Something went wrong. Try again.
[mirror] A local-first desktop markdown writer with grammar checking, at proto integration, and more github.com/stormlightlabs/writer/releases
atproto codemirror markdown tauri rust react
Something went wrong. Try again.
16 kB · 489 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490import { WelcomeScreen } from "$components/AppLayout/WelcomeScreen";import { DocumentTabs } from "$components/DocumentTabs";import { type EditorProps, EditorWithContainer } from "$components/Editor";import { ImageDocumentView } from "$components/ImageDocumentView";import { Preview, type PreviewProps } from "$components/Preview";import { Sidebar } from "$components/Sidebar";import { StatusBar, type StatusBarProps } from "$components/StatusBar";import { Toolbar, type ToolbarProps } from "$components/Toolbar";import { CHROME_SECTION, NO_MOTION_TRANSITION } from "$constants";import type { StyleMatch } from "$editor/types";import { useSkipAnimation } from "$hooks/useMotion";import { useResizable } from "$hooks/useResizable";import { useViewportTier } from "$hooks/useViewportTier";import { EyeIcon } from "$icons";import { useEditorPresentationActions, useLayoutChromeActions, useWorkspacePanelModeState, useWorkspacePanelSidebarState, useWorkspacePanelStatusBarCollapsed, useWorkspacePanelTopBarsCollapsed,} from "$state/selectors";import { PanelMode } from "$types";import { isImageContentType, isImagePath } from "$utils/documents";import { cn } from "$utils/tw";import { AnimatePresence, EasingDefinition, motion } from "motion/react";import { type PointerEventHandler, useCallback, useEffect, useMemo } from "react";import { DiagnosticsPanel } from "./DiagnosticsPanel";
type K = | "initialText" | "presentation" | "onChange" | "onSave" | "onCursorMove" | "onSelectionChange" | "onStyleMatchesChange" | "styleSelection" | "imageHandlers";export type WorkspaceEditorProps = Pick<EditorProps, K>;
type PK = | "renderResult" | "theme" | "editorLine" | "previewStyle" | "renderedFontFamily" | "locationId" | "docRelPath" | "blobDid" | "onScrollToLine";export type WorkspacePreviewProps = Pick<PreviewProps, PK>;
export type WorkspaceDiagnosticsProps = { isVisible: boolean; styleCheckEnabled: boolean; matches: StyleMatch[]; onSelectMatch: (match: StyleMatch) => void; onClose: () => void; onOpenSettings: () => void;};
export type WorkspaceWelcomeProps = { isVisible: boolean; hasLocations: boolean; locationCount: number; documentCount: number; onAddLocation: () => void;};
export type WorkspacePanelProps = { toolbar: Pick< ToolbarProps, | "saveStatus" | "atProtoSession" | "hasActiveDocument" | "onSave" | "onAtProtoAuth" | "onNewDocument" | "isNewDocumentDisabled" | "onExportPdf" | "isExportingPdf" | "isPdfExportDisabled" | "onInsertImage" | "onRefresh" >; onOpenWelcomeTab?: () => void; onOpenImportSheet?: () => void; onOpenGithubImportSheet?: () => void; onOpenStandardSiteImportSheet?: () => void; editor: WorkspaceEditorProps; preview: WorkspacePreviewProps; statusBar: StatusBarProps; diagnostics: WorkspaceDiagnosticsProps; welcome?: WorkspaceWelcomeProps; activeDocRelPath?: string; activeDocContentType?: string | null;};
const SPLIT_PANEL_MIN_WIDTH = 280;const SPLIT_PANEL_THRESHOLD = SPLIT_PANEL_MIN_WIDTH * 2 + 96;const SIDEBAR_MIN_WIDTH = 220;const SIDEBAR_MAX_WIDTH = 480;const FALLBACK_VIEWPORT_WIDTH = 1280;
type PreviewMode = "reading" | "web";
function previewModeToStyle(mode: PreviewMode): WorkspacePreviewProps["previewStyle"] { return mode === "reading" ? "github" : "pdf";}
function previewStyleToMode(style: WorkspacePreviewProps["previewStyle"]): PreviewMode { return style === "pdf" ? "web" : "reading";}
function PreviewModeButton( { mode, isActive, onSelect }: { mode: PreviewMode; isActive: boolean; onSelect: (m: PreviewMode) => void },) { const handleClick = useCallback(() => onSelect(mode), [mode, onSelect]); return ( <button type="button" onClick={handleClick} className={cn( "px-2 py-1 text-[10px] uppercase tracking-widest bg-transparent border-none cursor-pointer transition-colors duration-200 rounded-sm", isActive ? "text-accent-blue border-b border-accent-blue" : "text-text-secondary hover:text-text-primary", )}> {mode} </button> );}
function PreviewHeader( { previewStyle, onSelectMode }: { previewStyle: WorkspacePreviewProps["previewStyle"]; onSelectMode: (mode: PreviewMode) => void; },) { const activeMode = useMemo(() => previewStyleToMode(previewStyle), [previewStyle]);
return ( <div className="h-10 flex items-center justify-between px-4 bg-layer-01/50 border-b border-stroke-subtle/5 shrink-0"> <div className="flex items-center gap-2 text-text-secondary"> <EyeIcon size="sm" /> <span className="text-[10px] uppercase tracking-widest">Live Preview</span> </div> <div className="flex items-center gap-0.5"> <PreviewModeButton mode="reading" isActive={activeMode === "reading"} onSelect={onSelectMode} /> <PreviewModeButton mode="web" isActive={activeMode === "web"} onSelect={onSelectMode} /> </div> </div> );}
type MainPanelProps = { panelMode: PanelMode; editor: WorkspaceEditorProps; preview: WorkspacePreviewProps; welcome?: WorkspaceWelcomeProps; onCreateNewDocument?: () => void; onOpenExistingDocument: () => void; onOpenImportSheet?: () => void; onOpenStandardSiteImportSheet?: () => void; onSelectPreviewMode: (mode: PreviewMode) => void; splitEditorWidth: number; isSplitResizing: boolean; onSplitResizeStart: PointerEventHandler<HTMLDivElement>; activeDocRelPath?: string; activeDocContentType?: string | null;};
function getPanelMode(isSplitView: boolean, isPreviewVisible: boolean): PanelMode { if (isSplitView && isPreviewVisible) { return "split"; }
if (isPreviewVisible) { return "preview"; }
return "editor";}
function MainPanel( { panelMode, editor, preview, welcome, onCreateNewDocument, onOpenExistingDocument, onOpenImportSheet, onOpenStandardSiteImportSheet, onSelectPreviewMode, splitEditorWidth, isSplitResizing, onSplitResizeStart, activeDocRelPath, activeDocContentType, }: MainPanelProps,) { const isImageDocument = useMemo( () => isImageContentType(activeDocContentType) || (activeDocRelPath ? isImagePath(activeDocRelPath) : false), [activeDocContentType, activeDocRelPath], );
const container = useMemo(() => { if (panelMode === "split") { return { className: "flex min-h-0 min-w-0 shrink-0 flex-col border-r border-stroke-subtle/10", style: { width: `${splitEditorWidth}px` }, }; } return { className: "flex min-h-0 min-w-0 flex-col w-full" }; }, [panelMode, splitEditorWidth]);
if (welcome?.isVisible) { return ( <WelcomeScreen hasLocations={welcome.hasLocations} locationCount={welcome.locationCount} documentCount={welcome.documentCount} onAddLocation={welcome.onAddLocation} onCreateNewDocument={onCreateNewDocument} onOpenExisting={onOpenExistingDocument} onOpenImportSheet={onOpenImportSheet} onOpenStandardSiteImportSheet={onOpenStandardSiteImportSheet} /> ); }
if (isImageDocument) { return <ImageDocumentView locationId={preview.locationId} relPath={activeDocRelPath} className="w-full" />; }
if (panelMode === "split") { return ( <div className="flex flex-1 min-h-0 overflow-hidden"> <EditorWithContainer {...editor} container={container} /> <div role="separator" aria-label="Resize split panes" aria-orientation="vertical" onPointerDown={onSplitResizeStart} className={`relative z-10 w-1 shrink-0 cursor-col-resize transition-colors ${ isSplitResizing ? "bg-stroke-interactive" : "bg-stroke-subtle/10 hover:bg-stroke-strong" }`} /> <div className="flex flex-col min-h-0 min-w-0 flex-1"> <PreviewHeader previewStyle={preview.previewStyle} onSelectMode={onSelectPreviewMode} /> <Preview className="min-h-0 min-w-0 flex-1" {...preview} /> </div> </div> ); }
return ( <div className="flex-1 min-h-0 flex overflow-hidden"> {panelMode === "editor" && <EditorWithContainer {...editor} container={container} />} {panelMode === "preview" && ( <div className="flex flex-col min-h-0 min-w-0 flex-1 w-full"> <PreviewHeader previewStyle={preview.previewStyle} onSelectMode={onSelectPreviewMode} /> <Preview className="min-h-0 min-w-0 flex-1 w-full" {...preview} /> </div> )} </div> );}
type SectionProps = { children: React.ReactNode; initial: { opacity: number; x?: number; y?: number; height?: number | string }; animate: { opacity: number; x?: number; y?: number; height?: number | string }; exit: { opacity: number; x?: number; y?: number; height?: number | string }; transition: { duration: number; ease?: EasingDefinition }; className: string; style?: React.CSSProperties; isVisible: boolean;};
function Section({ children, initial, animate, exit, transition, className, style, isVisible }: SectionProps) { return ( <AnimatePresence initial={false}> {isVisible && ( <motion.div initial={initial} animate={animate} exit={exit} transition={transition} className={className} style={style}> {children} </motion.div> )} </AnimatePresence> );}
export function WorkspacePanel( { toolbar, onOpenWelcomeTab, onOpenImportSheet, onOpenGithubImportSheet, onOpenStandardSiteImportSheet, editor, preview, statusBar, diagnostics, welcome, activeDocRelPath, activeDocContentType, }: WorkspacePanelProps,) { const skipAnimation = useSkipAnimation(); const { viewportWidth } = useViewportTier(FALLBACK_VIEWPORT_WIDTH); const { setMarkdownPreviewStyle } = useEditorPresentationActions(); const { setShowSearch } = useLayoutChromeActions(); const { sidebarCollapsed } = useWorkspacePanelSidebarState(); const { isSplitView, isPreviewVisible } = useWorkspacePanelModeState(); const topBarsCollapsed = useWorkspacePanelTopBarsCollapsed(); const statusBarCollapsed = useWorkspacePanelStatusBarCollapsed(); const panelMode = useMemo(() => getPanelMode(isSplitView, isPreviewVisible), [isSplitView, isPreviewVisible]); const sidebarMaxWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, viewportWidth - 280));
const effectiveSidebarVisible = !sidebarCollapsed; const effectiveTabBarVisible = !topBarsCollapsed; const effectiveStatusBarVisible = !statusBarCollapsed;
const { size: sidebarWidth, isResizing, startResizing, setSize: setSidebarWidth } = useResizable({ initialSize: 256, minSize: SIDEBAR_MIN_WIDTH, maxSize: sidebarMaxWidth, axis: "x", });
const splitAvailableWidth = Math.max( SPLIT_PANEL_MIN_WIDTH, viewportWidth - (effectiveSidebarVisible ? sidebarWidth : 0), );
const splitMaxWidth = Math.max(SPLIT_PANEL_MIN_WIDTH, splitAvailableWidth - SPLIT_PANEL_MIN_WIDTH); const splitFeasible = splitAvailableWidth >= SPLIT_PANEL_THRESHOLD; const effectivePanelMode = panelMode === "split" && !splitFeasible ? "editor" : panelMode;
const { size: splitEditorWidth, isResizing: isSplitResizing, startResizing: startSplitResizing, setSize: setSplitEditorWidth, } = useResizable({ initialSize: Math.round(viewportWidth / 2), minSize: SPLIT_PANEL_MIN_WIDTH, maxSize: splitMaxWidth, axis: "x", });
useEffect(() => { if (sidebarWidth > sidebarMaxWidth) { setSidebarWidth(sidebarMaxWidth); } }, [sidebarWidth, sidebarMaxWidth, setSidebarWidth]);
useEffect(() => { if (splitEditorWidth > splitMaxWidth) { setSplitEditorWidth(splitMaxWidth); return; }
if (splitEditorWidth < SPLIT_PANEL_MIN_WIDTH) { setSplitEditorWidth(SPLIT_PANEL_MIN_WIDTH); } }, [splitEditorWidth, splitMaxWidth, setSplitEditorWidth]);
const handleSidebarResizeStart: PointerEventHandler<HTMLDivElement> = useCallback((event) => { event.preventDefault(); startResizing(event.clientX); }, [startResizing]);
const handleSplitResizeStart: PointerEventHandler<HTMLDivElement> = useCallback((event) => { event.preventDefault(); startSplitResizing(event.clientX); }, [startSplitResizing]);
const handleSelectPreviewMode = useCallback((mode: PreviewMode) => { setMarkdownPreviewStyle(previewModeToStyle(mode)); }, [setMarkdownPreviewStyle]);
const handleOpenExistingDocument = useCallback(() => { setShowSearch(true); }, [setShowSearch]);
const sidebarStyle = useMemo(() => ({ width: `${sidebarWidth}px` }), [sidebarWidth]);
const sectionTransition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : CHROME_SECTION.TRANSITION, [ skipAnimation, ]);
const newDocumentHandler = useMemo(() => toolbar.isNewDocumentDisabled ? void 0 : toolbar.onNewDocument, [ toolbar.isNewDocumentDisabled, toolbar.onNewDocument, ]);
const sidebarMotionProps = useMemo( () => ({ initial: { opacity: 0, x: -16 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: -12 }, transition: sectionTransition, }), [sectionTransition], );
const tabBarMotionProps = useMemo( () => ({ initial: { opacity: 0, y: -8, height: 0 }, animate: { opacity: 1, y: 0, height: "auto" }, exit: { opacity: 0, y: -8, height: 0 }, transition: sectionTransition, }), [sectionTransition], );
const statusBarMotionProps = useMemo( () => ({ initial: { opacity: 0, y: 8, height: 0 }, animate: { opacity: 1, y: 0, height: "auto" }, exit: { opacity: 0, y: 8, height: 0 }, transition: sectionTransition, }), [sectionTransition], );
return ( <div className="flex flex-1 min-h-0 overflow-hidden"> <Section isVisible={effectiveSidebarVisible} {...sidebarMotionProps} className="relative flex h-full shrink-0" style={sidebarStyle}> <Sidebar onNewDocument={newDocumentHandler} onOpenImportSheet={onOpenImportSheet} onOpenGithubImportSheet={onOpenGithubImportSheet} onOpenStandardSiteImportSheet={onOpenStandardSiteImportSheet} /> <div role="separator" aria-label="Resize sidebar" aria-orientation="vertical" onPointerDown={handleSidebarResizeStart} className={`absolute inset-y-0 right-0 w-1 cursor-col-resize transition-colors ${ isResizing ? "bg-stroke-interactive" : "hover:bg-stroke-subtle" }`} /> </Section>
<div className="flex min-h-0 min-w-0 flex-1 flex-col"> <Toolbar {...toolbar} /> <Section isVisible={effectiveTabBarVisible} {...tabBarMotionProps} className="overflow-hidden"> <DocumentTabs onNewTab={onOpenWelcomeTab} /> </Section> <MainPanel panelMode={effectivePanelMode} editor={editor} preview={preview} welcome={welcome} onCreateNewDocument={newDocumentHandler} onOpenExistingDocument={handleOpenExistingDocument} onOpenImportSheet={onOpenImportSheet} onOpenStandardSiteImportSheet={onOpenStandardSiteImportSheet} onSelectPreviewMode={handleSelectPreviewMode} splitEditorWidth={splitEditorWidth} isSplitResizing={isSplitResizing} onSplitResizeStart={handleSplitResizeStart} activeDocRelPath={activeDocRelPath} activeDocContentType={activeDocContentType} />
<Section isVisible={effectiveStatusBarVisible} {...statusBarMotionProps} className="overflow-hidden"> <StatusBar {...statusBar} /> </Section> </div>
<DiagnosticsPanel isVisible={diagnostics.isVisible} styleCheckEnabled={diagnostics.styleCheckEnabled} matches={diagnostics.matches} onSelectMatch={diagnostics.onSelectMatch} onClose={diagnostics.onClose} onOpenSettings={diagnostics.onOpenSettings} /> </div> );}