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.
20 kB · 600 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601import { focusDimming, focusDimmingTheme } from "$editor/focus-dimming";import { posHighlighting, posHighlightingTheme } from "$editor/pos-highlighting";import { styleCheck, styleCheckTheme } from "$editor/style-check";import { oxocarbonDark, oxocarbonLight } from "$editor/themes";import type { StyleMatch } from "$editor/types";import { typewriterScroll } from "$editor/typewriter-scroll";import { useEditorPresentationState } from "$state/selectors";import type { AppTheme, EditorFontFamily, FocusDimmingMode, StyleCheckPattern, StyleCheckSettings } from "$types";import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";import { markdown, markdownLanguage } from "@codemirror/lang-markdown";import { Compartment, EditorState as CMEditorState, type Extension } from "@codemirror/state";import { EditorView, keymap, lineNumbers } from "@codemirror/view";import type { ViewUpdate } from "@codemirror/view";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import type { CSSProperties } from "react";
export type EditorTheme = AppTheme;
export type EditorPresentationOverrides = Partial< { theme: EditorTheme; showLineNumbers: boolean; textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; fontSize: number; fontFamily: EditorFontFamily; typewriterScrollingEnabled: boolean; focusDimmingMode: FocusDimmingMode; posHighlightingEnabled: boolean; styleCheckSettings: StyleCheckSettings; }>;
export type EditorImageHandlers = { insertAt?: { text: string; requestId: number } | null; onImageFilePaste?: (file: File) => void;};
export type EditorProps = { initialText?: string; disabled?: boolean; placeholder?: string; debounceMs?: number; styleSelection?: { from: number; to: number; requestId: number } | null; imageHandlers?: EditorImageHandlers; presentation?: EditorPresentationOverrides; onChange?: (text: string) => void; onSave?: () => void; onCursorMove?: (line: number, column: number) => void; onSelectionChange?: (from: number, to: number | null) => void; onStyleMatchesChange?: (matches: StyleMatch[]) => void; className?: string;};
type EditorCallbacks = Pick< EditorProps, "onChange" | "onSave" | "onCursorMove" | "onSelectionChange" | "onStyleMatchesChange" | "imageHandlers">;
type CreateEditorStateOptions = { doc: string; presentation: PresentationSnapshot; placeholder?: string; updateListener: ReturnType<typeof EditorView.updateListener.of>; onSave: () => void; onStyleMatchesChange: (matches: StyleMatch[]) => void; compartments: EditorCompartments;};
type PresentationSnapshot = { theme: EditorTheme; disabled: boolean; showLineNumbers: boolean; textWrappingEnabled: boolean; syntaxHighlightingEnabled: boolean; typewriterScrollingEnabled: boolean; focusDimmingMode: FocusDimmingMode; posHighlightingEnabled: boolean; styleCheckSettings: StyleCheckSettings;};
type EditorCompartments = { theme: Compartment; editable: Compartment; lineNumbers: Compartment; wrapping: Compartment; syntax: Compartment; placeholder: Compartment; typewriter: Compartment; focusDimming: Compartment; posHighlighting: Compartment; styleCheck: Compartment;};
const EDITOR_FONT_FAMILY_MAP: Record<EditorFontFamily, string> = { "IBM Plex Mono": "\"Commonplace IBM Plex Mono\", \"IBM Plex Mono\", \"SF Mono\", Monaco, \"Cascadia Code\", monospace", "IBM Plex Sans Variable": "\"Commonplace IBM Plex Sans\", \"IBM Plex Sans\", -apple-system, BlinkMacSystemFont, sans-serif", "IBM Plex Serif": "\"Commonplace IBM Plex Serif\", \"IBM Plex Serif\", Georgia, \"Times New Roman\", serif", "Maple Mono": "\"Commonplace Maple Mono\", \"Maple Mono\", \"SF Mono\", Monaco, monospace", "Monaspace Argon": "\"Commonplace Monaspace Argon\", \"Commonplace IBM Plex Mono\", monospace", "Monaspace Krypton": "\"Commonplace Monaspace Krypton\", \"Commonplace IBM Plex Mono\", monospace", "Monaspace Neon": "\"Commonplace Monaspace Neon\", \"Commonplace IBM Plex Mono\", monospace", "Monaspace Radon": "\"Commonplace Monaspace Radon\", \"Commonplace IBM Plex Mono\", monospace", "Monaspace Xenon": "\"Commonplace Monaspace Xenon\", \"Commonplace IBM Plex Mono\", monospace",};
function getThemeExtension(theme: EditorTheme): Extension { const themeExtension = theme === "dark" ? oxocarbonDark : oxocarbonLight; return themeExtension;}
function getSyntaxExtension(enabled: boolean): Extension { if (!enabled) { return []; }
return markdown({ base: markdownLanguage, codeLanguages: [], addKeymap: true });}
function getFocusDimmingExtension(mode: FocusDimmingMode): Extension { if (mode === "off") { return []; }
return [focusDimming(mode), focusDimmingTheme];}
function getStyleCheckExtension( settings: StyleCheckSettings, onStyleMatchesChange: (matches: StyleMatch[]) => void,): Extension { if (!settings.enabled) { return []; }
return [ styleCheck({ enabled: settings.enabled, categories: settings.categories, customPatterns: settings.customPatterns.map((p) => ({ text: p.text, category: p.category, replacement: p.replacement, })), markerStyle: settings.markerStyle, onMatchesChange: onStyleMatchesChange, }), styleCheckTheme, ];}
function getPlaceholderExtension(placeholder?: string): Extension { if (!placeholder) { return []; }
return EditorView.theme({ ".cm-placeholder": { color: "#888" } });}
function createEditorState( { doc, presentation, placeholder, updateListener, onSave, onStyleMatchesChange, compartments }: CreateEditorStateOptions,): CMEditorState { const saveKeymap = keymap.of([{ key: "Mod-s", preventDefault: true, run: () => { onSave(); return true; }, }]);
return CMEditorState.create({ doc, extensions: [ history(), updateListener, keymap.of([...defaultKeymap, ...historyKeymap]), saveKeymap, compartments.theme.of(getThemeExtension(presentation.theme)), compartments.editable.of(EditorView.editable.of(!presentation.disabled)), compartments.lineNumbers.of(presentation.showLineNumbers ? lineNumbers() : []), compartments.wrapping.of(presentation.textWrappingEnabled ? EditorView.lineWrapping : []), compartments.syntax.of(getSyntaxExtension(presentation.syntaxHighlightingEnabled)), compartments.placeholder.of(getPlaceholderExtension(placeholder)), compartments.typewriter.of(presentation.typewriterScrollingEnabled ? typewriterScroll() : []), compartments.focusDimming.of(getFocusDimmingExtension(presentation.focusDimmingMode)), compartments.posHighlighting.of( presentation.posHighlightingEnabled ? [posHighlighting(), posHighlightingTheme] : [], ), compartments.styleCheck.of(getStyleCheckExtension(presentation.styleCheckSettings, onStyleMatchesChange)), ], });}
function areCustomPatternsEqual(left: StyleCheckPattern[], right: StyleCheckPattern[]): boolean { if (left.length !== right.length) { return false; }
for (let index = 0; index < left.length; index += 1) { const a = left[index]; const b = right[index]; if (a.text !== b.text || a.category !== b.category || a.replacement !== b.replacement) { return false; } }
return true;}
export function Editor( { initialText = "", disabled = false, placeholder, debounceMs = 500, styleSelection = null, imageHandlers, presentation, onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange, className = "", }: EditorProps,) { const defaults = useEditorPresentationState(); const theme = presentation?.theme ?? defaults.theme; const showLineNumbers = presentation?.showLineNumbers ?? defaults.showLineNumbers; const textWrappingEnabled = presentation?.textWrappingEnabled ?? defaults.textWrappingEnabled; const syntaxHighlightingEnabled = presentation?.syntaxHighlightingEnabled ?? defaults.syntaxHighlightingEnabled; const fontSize = presentation?.fontSize ?? defaults.fontSize; const fontFamily = presentation?.fontFamily ?? defaults.fontFamily; const typewriterScrollingEnabled = presentation?.typewriterScrollingEnabled ?? defaults.typewriterScrollingEnabled; const focusDimmingMode = presentation?.focusDimmingMode ?? defaults.focusDimmingMode; const posHighlightingEnabled = presentation?.posHighlightingEnabled ?? defaults.posHighlightingEnabled; const styleCheckSettings = presentation?.styleCheckSettings ?? defaults.styleCheckSettings;
const containerRef = useRef<HTMLDivElement>(null); const viewRef = useRef<EditorView | null>(null); const callbacksRef = useRef<EditorCallbacks>({ onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange, imageHandlers, }); const debounceMsRef = useRef(debounceMs); const onChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const initialTextRef = useRef(initialText); const presentationRef = useRef({ theme, disabled, showLineNumbers, textWrappingEnabled, syntaxHighlightingEnabled, placeholder, typewriterScrollingEnabled, focusDimmingMode, posHighlightingEnabled, styleCheckSettings, }); const compartmentsRef = useRef<EditorCompartments>({ theme: new Compartment(), editable: new Compartment(), lineNumbers: new Compartment(), wrapping: new Compartment(), syntax: new Compartment(), placeholder: new Compartment(), typewriter: new Compartment(), focusDimming: new Compartment(), posHighlighting: new Compartment(), styleCheck: new Compartment(), }); const [isReady, setIsReady] = useState(false);
useEffect(() => { callbacksRef.current = { onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange, imageHandlers }; }, [onChange, onSave, onCursorMove, onSelectionChange, onStyleMatchesChange, imageHandlers]);
useEffect(() => { debounceMsRef.current = debounceMs; }, [debounceMs]);
const emitChange = useCallback((text: string) => { if (onChangeTimeoutRef.current) { clearTimeout(onChangeTimeoutRef.current); onChangeTimeoutRef.current = null; }
if (debounceMsRef.current <= 0) { callbacksRef.current.onChange?.(text); return; }
onChangeTimeoutRef.current = setTimeout(() => { callbacksRef.current.onChange?.(text); }, debounceMsRef.current); }, []);
const flushPendingChange = useCallback(() => { const view = viewRef.current; if (!view) { return; }
if (onChangeTimeoutRef.current) { clearTimeout(onChangeTimeoutRef.current); onChangeTimeoutRef.current = null; }
callbacksRef.current.onChange?.(view.state.doc.toString()); }, []);
const createUpdateListener = useCallback(() => EditorView.updateListener.of((update: ViewUpdate) => { if (update.docChanged) { emitChange(update.state.doc.toString()); }
if (update.selectionSet) { const selection = update.state.selection.main; const { from } = selection; const to = selection.empty ? null : selection.to; callbacksRef.current.onSelectionChange?.(from, to); }
if (update.selectionSet || update.docChanged) { const pos = update.state.selection.main.head; const line = update.state.doc.lineAt(pos); const column = pos - line.from; callbacksRef.current.onCursorMove?.(line.number, column); } }), [emitChange]);
const createView = useCallback((doc: string, selection?: CMEditorState["selection"]) => { if (!containerRef.current) { return null; }
const currentPresentation = presentationRef.current; const state = createEditorState({ doc, presentation: currentPresentation, placeholder: currentPresentation.placeholder, updateListener: createUpdateListener(), onSave: () => { flushPendingChange(); callbacksRef.current.onSave?.(); }, onStyleMatchesChange: (matches) => callbacksRef.current.onStyleMatchesChange?.(matches), compartments: compartmentsRef.current, });
const view = new EditorView({ state, parent: containerRef.current });
if (selection) { view.dispatch({ selection }); }
return view; }, [createUpdateListener, flushPendingChange]);
useEffect(() => { if (viewRef.current || !containerRef.current) { return; }
const view = createView(initialTextRef.current); if (!view) { return; }
viewRef.current = view; setIsReady(true);
return () => { if (onChangeTimeoutRef.current) { clearTimeout(onChangeTimeoutRef.current); }
view.destroy(); viewRef.current = null; setIsReady(false); }; }, [createView]);
useEffect(() => { const previousPresentation = presentationRef.current;
if ( previousPresentation.theme === theme && previousPresentation.disabled === disabled && previousPresentation.showLineNumbers === showLineNumbers && previousPresentation.textWrappingEnabled === textWrappingEnabled && previousPresentation.syntaxHighlightingEnabled === syntaxHighlightingEnabled && previousPresentation.placeholder === placeholder && previousPresentation.typewriterScrollingEnabled === typewriterScrollingEnabled && previousPresentation.focusDimmingMode === focusDimmingMode && previousPresentation.posHighlightingEnabled === posHighlightingEnabled && previousPresentation.styleCheckSettings.enabled === styleCheckSettings.enabled && previousPresentation.styleCheckSettings.categories.filler === styleCheckSettings.categories.filler && previousPresentation.styleCheckSettings.categories.redundancy === styleCheckSettings.categories.redundancy && previousPresentation.styleCheckSettings.categories.cliche === styleCheckSettings.categories.cliche && previousPresentation.styleCheckSettings.markerStyle === styleCheckSettings.markerStyle && areCustomPatternsEqual( previousPresentation.styleCheckSettings.customPatterns, styleCheckSettings.customPatterns, ) ) { return; }
presentationRef.current = { theme, disabled, showLineNumbers, textWrappingEnabled, syntaxHighlightingEnabled, placeholder, typewriterScrollingEnabled, focusDimmingMode, posHighlightingEnabled, styleCheckSettings, };
const view = viewRef.current; if (!view) { return; } const effects = []; const compartments = compartmentsRef.current;
if (previousPresentation.theme !== theme) { effects.push(compartments.theme.reconfigure(getThemeExtension(theme))); } if (previousPresentation.disabled !== disabled) { effects.push(compartments.editable.reconfigure(EditorView.editable.of(!disabled))); } if (previousPresentation.showLineNumbers !== showLineNumbers) { effects.push(compartments.lineNumbers.reconfigure(showLineNumbers ? lineNumbers() : [])); } if (previousPresentation.textWrappingEnabled !== textWrappingEnabled) { effects.push(compartments.wrapping.reconfigure(textWrappingEnabled ? EditorView.lineWrapping : [])); } if (previousPresentation.syntaxHighlightingEnabled !== syntaxHighlightingEnabled) { effects.push(compartments.syntax.reconfigure(getSyntaxExtension(syntaxHighlightingEnabled))); } if (previousPresentation.placeholder !== placeholder) { effects.push(compartments.placeholder.reconfigure(getPlaceholderExtension(placeholder))); } if (previousPresentation.typewriterScrollingEnabled !== typewriterScrollingEnabled) { effects.push(compartments.typewriter.reconfigure(typewriterScrollingEnabled ? typewriterScroll() : [])); } if (previousPresentation.focusDimmingMode !== focusDimmingMode) { effects.push(compartments.focusDimming.reconfigure(getFocusDimmingExtension(focusDimmingMode))); } if (previousPresentation.posHighlightingEnabled !== posHighlightingEnabled) { effects.push( compartments.posHighlighting.reconfigure( posHighlightingEnabled ? [posHighlighting(), posHighlightingTheme] : [], ), ); } if ( previousPresentation.styleCheckSettings.enabled !== styleCheckSettings.enabled || previousPresentation.styleCheckSettings.categories.filler !== styleCheckSettings.categories.filler || previousPresentation.styleCheckSettings.categories.redundancy !== styleCheckSettings.categories.redundancy || previousPresentation.styleCheckSettings.categories.cliche !== styleCheckSettings.categories.cliche || previousPresentation.styleCheckSettings.markerStyle !== styleCheckSettings.markerStyle || !areCustomPatternsEqual( previousPresentation.styleCheckSettings.customPatterns, styleCheckSettings.customPatterns, ) ) { effects.push( compartments.styleCheck.reconfigure(getStyleCheckExtension(styleCheckSettings, (matches) => callbacksRef.current.onStyleMatchesChange?.(matches))), ); }
if (effects.length > 0) { view.dispatch({ effects }); } }, [ disabled, placeholder, showLineNumbers, textWrappingEnabled, syntaxHighlightingEnabled, theme, typewriterScrollingEnabled, focusDimmingMode, posHighlightingEnabled, styleCheckSettings, ]);
useEffect(() => { const view = viewRef.current; if (!view) { return; }
const currentText = view.state.doc.toString(); if (currentText === initialText) { return; }
view.dispatch({ changes: { from: 0, to: currentText.length, insert: initialText } }); }, [initialText]);
useEffect(() => { const view = viewRef.current; const styleSelectionFrom = styleSelection?.from ?? null; const styleSelectionTo = styleSelection?.to ?? null; if (!view || styleSelectionFrom === null || styleSelectionTo === null) { return; }
const docLength = view.state.doc.length; const from = Math.max(0, Math.min(docLength, styleSelectionFrom)); const to = Math.max(from, Math.min(docLength, styleSelectionTo));
view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true }); view.focus(); }, [styleSelection, styleSelection?.requestId]);
const insertAt = imageHandlers?.insertAt ?? null; useEffect(() => { const view = viewRef.current; if (!view || !insertAt) { return; }
const { from } = view.state.selection.main; view.dispatch({ changes: { from, insert: insertAt.text }, selection: { anchor: from + insertAt.text.length } }); view.focus(); }, [insertAt, insertAt?.requestId]);
const handlePaste = useCallback((event: React.ClipboardEvent<HTMLDivElement>) => { const items = event.clipboardData?.items; if (!items) { return; }
for (const item of items) { if (item.type.startsWith("image/")) { event.preventDefault(); const file = item.getAsFile(); if (file) { callbacksRef.current.imageHandlers?.onImageFilePaste?.(file); } return; } } }, []);
const focus = useCallback(() => { viewRef.current?.focus(); }, []);
const containerStyle: CSSProperties = useMemo( () => ({ height: "100%", overflow: "hidden", ["--editor-font-family" as string]: EDITOR_FONT_FAMILY_MAP[fontFamily], ["--editor-font-size" as string]: `${Math.max(12, Math.min(24, Math.round(fontSize)))}px`, }), [fontFamily, fontSize], );
return ( <div ref={containerRef} className={`editor-container ${ textWrappingEnabled ? "editor-container--wrapped" : "editor-container--scroll" } ${className}`} data-theme={theme} data-ready={isReady} style={containerStyle} onClick={focus} onPaste={handlePaste} data-testid="editor-container" /> );}
export const EditorWithContainer = ( { container, ...props }: EditorProps & { container: { className?: string; style?: CSSProperties } },) => ( <div className={`${container.className}`} style={container.style}> <Editor {...props} /> </div>);
export default Editor;