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
Something went wrong. Try again.
14 kB · 428 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429import type { WorkspaceEditorProps, WorkspacePanelProps, WorkspaceWelcomeProps,} from "$components/AppLayout/WorkspacePanel";import { StatusBarProps } from "$components/StatusBar";import type { StyleMatch } from "$editor/types";import { useDocumentSessionEffects } from "$hooks/app/useDocumentSessionEffects";import { useEditorPreviewEffects } from "$hooks/app/useEditorPreviewEffects";import { useSettingsSync } from "$hooks/app/useSettingsSync";import { useAtProtoController } from "$hooks/controllers/useAtProtoController";import { useStandardSiteController } from "$hooks/controllers/useStandardSiteController";import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController";import { useDocumentActions } from "$hooks/useDocumentActions";import { useEditor } from "$hooks/useEditor";import { useEditorBridge } from "$hooks/useEditorBridge";import { useEditorImageHandlers } from "$hooks/useEditorImageHandlers";import { useHelpSheetHotkey } from "$hooks/useHelpSheetHotkey";import { useLayoutHotkeys } from "$hooks/useLayoutHotkeys";import { usePdfExport, usePdfExportUI } from "$hooks/usePdfExport";import { usePreview } from "$hooks/usePreview";import { useRoutedSheet } from "$hooks/useRoutedSheet";import { useWorkspaceSync } from "$hooks/useWorkspaceSync";import type { PdfExportOptions, PdfRenderResult } from "$pdf/types";import { useEditorPresentationState } from "$state/selectors";import type { DocRef, EditorFontFamily, Maybe, RenderedFontFamily, SaveStatus } from "$types";import { isImageContentType, isImagePath } from "$utils/documents";import { useCallback, useEffect, useMemo, useState } from "react";
export type FocusModePanelProps = { editor: WorkspaceEditorProps; statusBar: StatusBarProps; saveStatus: SaveStatus; hasActiveDocument: boolean; onSave: () => void;};
export type WorkspaceViewController = { workspacePanelProps: WorkspacePanelProps; focusModePanelProps: FocusModePanelProps; handleExportPdf: (options: PdfExportOptions) => Promise<void>; previewResult: PdfRenderResult | null; activeDocLocationId: number | undefined; activeDocRelPath: string | undefined; editorFontFamily: EditorFontFamily; renderedFontFamily: RenderedFontFamily; editorText: string; atProto: ReturnType<typeof useAtProtoController>; standardSite: ReturnType<typeof useStandardSiteController>;};
export function deriveWordCount(text: string, renderWordCount: number | undefined): number { if (typeof renderWordCount === "number") { return renderWordCount; }
const trimmedText = text.trim(); return trimmedText ? trimmedText.split(/\s+/).length : 0;}
function isSameDocRef(left: Maybe<DocRef>, right: Maybe<DocRef>): boolean { if (!left || !right) { return false; }
return left.location_id === right.location_id && left.rel_path === right.rel_path;}
export function useWorkspaceViewController(): WorkspaceViewController { const { model: editorModel, dispatch: editorDispatch, openDoc } = useEditor(); const { model: previewModel, render: renderPreview, syncLine: syncPreviewLine, setDoc: setPreviewDoc } = usePreview(); const exportPdf = usePdfExport();
useWorkspaceSync(); useLayoutHotkeys(); useHelpSheetHotkey();
const editorPresentation = useEditorPresentationState(); const { isOpen: diagnosticsVisible, close: closeDiagnostics } = useRoutedSheet("/diagnostics"); const { open: openSettingsRoute } = useRoutedSheet("/settings"); const [styleMatches, setStyleMatches] = useState<StyleMatch[]>([]); const [styleSelection, setStyleSelection] = useState<{ from: number; to: number; requestId: number } | null>(null); const [isWelcomeTabOpen, setWelcomeTabOpen] = useState(false); const { locations, documents, documentsByLocation, selectedLocationId, isSidebarLoading, isSessionHydrated, tabs, activeTab, markActiveTabModified, handleSelectDocument, handleCreateDraftTab, handleCreateNewDocument, handleAddLocation, handleRefreshSidebar, } = useWorkspaceController(); const atProto = useAtProtoController({ locations, selectedLocationId, refreshSidebar: handleRefreshSidebar }); const standardSite = useStandardSiteController({ locations, selectedLocationId, refreshSidebar: handleRefreshSidebar, });
const { handleSave, handleNewDocument } = useDocumentActions({ editorDocRef: editorModel.docRef, selectedLocationId, documents, tabs, dispatchEditor: editorDispatch, createDraftTab: handleCreateDraftTab, createNewDocument: handleCreateNewDocument, }); const { handleEditorChange, handleCursorMove, handleSelectionChange } = useEditorBridge({ dispatchEditor: editorDispatch, syncPreviewLine, });
const imageLocationId = editorModel.docRef?.location_id ?? null; const imageDocRelPath = editorModel.docRef?.rel_path ?? null; const { insertAt, handleImageFilePaste, handlePickAndInsertImage } = useEditorImageHandlers( imageLocationId, imageDocRelPath, ); const { handleOpenPdfExport, handleExportPdf, previewResult, activeDocLocationId, activeDocRelPath } = usePdfExportUI( { activeTab, text: editorModel.text, renderedFontFamily: editorPresentation.renderedFontFamily, exportPdf }, );
const hasOpenDocument = useMemo(() => isSameDocRef(activeTab?.docRef, editorModel.docRef), [ activeTab, editorModel.docRef, ]); const isImageDocument = useMemo( () => isImageContentType(editorModel.contentType) || (editorModel.docRef ? isImagePath(editorModel.docRef.rel_path) : false), [editorModel.contentType, editorModel.docRef], ); const hasEditableDocument = hasOpenDocument && !isImageDocument; const hasLocations = useMemo(() => locations.length > 0, [locations.length]); const openLocationDocumentCount = useMemo(() => locations.reduce((count, location) => { const cachedDocuments = documentsByLocation[location.id]; if (cachedDocuments) { return count + cachedDocuments.length; }
if (selectedLocationId === location.id) { return count + documents.filter((doc) => doc.location_id === location.id).length; }
return count; }, 0), [documents, documentsByLocation, locations, selectedLocationId]); const showWelcomeScreen = useMemo(() => isSessionHydrated && (!activeTab || isWelcomeTabOpen), [ activeTab, isSessionHydrated, isWelcomeTabOpen, ]);
useEffect(() => { if (activeTab) { setWelcomeTabOpen(false); } }, [activeTab]);
const handleOpenWelcomeTab = useCallback(() => { setWelcomeTabOpen(true); }, []);
const cursorPosition = useMemo( () => ({ cursorLine: editorModel.cursorLine, cursorColumn: editorModel.cursorColumn }), [editorModel.cursorLine, editorModel.cursorColumn], );
const renderWordCount = previewModel.renderResult?.metadata.word_count; const { wordCount, charCount, selectionCount } = useMemo(() => { const { text } = editorModel;
return { wordCount: deriveWordCount(text, renderWordCount), charCount: text.length, selectionCount: editorModel.selectionFrom !== null && editorModel.selectionTo !== null ? editorModel.selectionTo - editorModel.selectionFrom : undefined, }; }, [editorModel.selectionFrom, editorModel.selectionTo, editorModel.text, renderWordCount]);
const editorStats = useMemo(() => ({ ...cursorPosition, wordCount, charCount, selectionCount }), [ cursorPosition, wordCount, charCount, selectionCount, ]);
const activeDocMeta = useMemo(() => { if (!activeTab) { return null; }
const activeDoc = documents.find((doc) => doc.location_id === activeTab.docRef.location_id && doc.rel_path === activeTab.docRef.rel_path ); return activeDoc ?? null; }, [activeTab, documents]);
const activeDocRef = activeTab?.docRef ?? null;
useEffect(() => { setStyleMatches([]); setStyleSelection(null); }, [activeTab?.id]);
useEffect(() => { if (!editorPresentation.styleCheckSettings.enabled) { setStyleMatches([]); } }, [editorPresentation.styleCheckSettings.enabled]);
useDocumentSessionEffects({ isSidebarLoading, isSessionHydrated, locations, selectedLocationId, tabs, activeTab, documentsCount: documents.length, activeDocRef, openDoc, handleSelectDocument, });
useEditorPreviewEffects({ activeTab, text: editorModel.text, saveStatus: editorModel.saveStatus, markActiveTabModified, setPreviewDoc, renderPreview, });
useSettingsSync();
const handleStyleMatchesChange = useCallback((matches: StyleMatch[]) => { setStyleMatches(matches); }, []);
const handleSelectStyleMatch = useCallback((match: StyleMatch) => { setStyleSelection((previous) => ({ from: match.from, to: match.to, requestId: (previous?.requestId ?? 0) + 1 })); }, []);
const toolbarProps = useMemo( () => ({ saveStatus: editorModel.saveStatus, atProtoSession: atProto.session, hasActiveDocument: hasEditableDocument, onSave: handleSave, onAtProtoAuth: atProto.openAuthSheet, onNewDocument: handleNewDocument, isNewDocumentDisabled: !hasLocations, onExportPdf: handleOpenPdfExport, isPdfExportDisabled: !activeTab || isImageDocument, onInsertImage: hasEditableDocument ? handlePickAndInsertImage : undefined, onRefresh: handleRefreshSidebar, }), [ editorModel.saveStatus, atProto.openAuthSheet, atProto.session, activeTab, handleOpenPdfExport, handleNewDocument, handleRefreshSidebar, hasLocations, handleSave, hasEditableDocument, isImageDocument, handlePickAndInsertImage, ], );
const editorProps = useMemo( () => ({ initialText: editorModel.text, onChange: handleEditorChange, onSave: handleSave, onCursorMove: handleCursorMove, onSelectionChange: handleSelectionChange, onStyleMatchesChange: handleStyleMatchesChange, styleSelection, imageHandlers: { insertAt, onImageFilePaste: handleImageFilePaste }, }), [ editorModel.text, handleEditorChange, handleSave, handleCursorMove, handleSelectionChange, handleStyleMatchesChange, styleSelection, insertAt, handleImageFilePaste, ], );
const statusBarProps = useMemo(() => ({ docMeta: activeDocMeta, stats: editorStats }), [activeDocMeta, editorStats]);
const previewProps = useMemo( () => ({ renderResult: previewModel.renderResult, theme: editorPresentation.theme, editorLine: editorModel.cursorLine, previewStyle: editorPresentation.markdownPreviewStyle, renderedFontFamily: editorPresentation.renderedFontFamily, locationId: previewModel.docRef?.location_id, docRelPath: previewModel.docRef?.rel_path, blobDid: atProto.session?.did, onScrollToLine: syncPreviewLine, }), [ previewModel.renderResult, previewModel.docRef?.location_id, previewModel.docRef?.rel_path, atProto.session?.did, editorPresentation.theme, editorPresentation.markdownPreviewStyle, editorPresentation.renderedFontFamily, editorModel.cursorLine, syncPreviewLine, ], );
const focusModePanelProps = useMemo( () => ({ editor: { initialText: editorModel.text, onChange: handleEditorChange, onSave: handleSave, onCursorMove: handleCursorMove, onSelectionChange: handleSelectionChange, }, statusBar: { docMeta: activeDocMeta, stats: editorStats }, saveStatus: editorModel.saveStatus, hasActiveDocument: hasEditableDocument, onSave: handleSave, }), [ activeDocMeta, editorStats, editorModel.text, editorModel.saveStatus, hasEditableDocument, handleEditorChange, handleSave, handleCursorMove, handleSelectionChange, ], );
const welcomeProps = useMemo<WorkspaceWelcomeProps>( () => ({ isVisible: showWelcomeScreen, hasLocations, locationCount: locations.length, documentCount: openLocationDocumentCount, onAddLocation: handleAddLocation, }), [showWelcomeScreen, hasLocations, locations.length, openLocationDocumentCount, handleAddLocation], );
const workspacePanelProps = useMemo( () => ({ toolbar: toolbarProps, onOpenWelcomeTab: handleOpenWelcomeTab, onOpenImportSheet: atProto.openImportSheet, onOpenGithubImportSheet: atProto.openGithubImportSheet, onOpenStandardSiteImportSheet: standardSite.openImportSheet, editor: editorProps, preview: previewProps, statusBar: statusBarProps, diagnostics: { isVisible: diagnosticsVisible, styleCheckEnabled: editorPresentation.styleCheckSettings.enabled, matches: styleMatches, onSelectMatch: handleSelectStyleMatch, onClose: closeDiagnostics, onOpenSettings: openSettingsRoute, }, welcome: welcomeProps, activeDocRelPath: editorModel.docRef?.rel_path, activeDocContentType: editorModel.contentType, }), [ toolbarProps, handleOpenWelcomeTab, atProto.openImportSheet, atProto.openGithubImportSheet, standardSite.openImportSheet, editorProps, previewProps, statusBarProps, diagnosticsVisible, editorPresentation.styleCheckSettings.enabled, styleMatches, handleSelectStyleMatch, closeDiagnostics, openSettingsRoute, welcomeProps, editorModel.docRef?.rel_path, editorModel.contentType, ], );
return { workspacePanelProps, focusModePanelProps, handleExportPdf, previewResult, activeDocLocationId, activeDocRelPath, editorFontFamily: editorPresentation.fontFamily, renderedFontFamily: editorPresentation.renderedFontFamily, editorText: editorModel.text, atProto, standardSite, };}