diff options
Diffstat (limited to 'makima/frontend/src/components')
| -rw-r--r-- | makima/frontend/src/components/directives/DocumentEditor.tsx | 334 |
1 files changed, 304 insertions, 30 deletions
diff --git a/makima/frontend/src/components/directives/DocumentEditor.tsx b/makima/frontend/src/components/directives/DocumentEditor.tsx index d953d45..270f5c3 100644 --- a/makima/frontend/src/components/directives/DocumentEditor.tsx +++ b/makima/frontend/src/components/directives/DocumentEditor.tsx @@ -22,11 +22,18 @@ import { $createParagraphNode, $createTextNode, $getRoot, + $getSelection, $isElementNode, + $isRangeSelection, + $isTextNode, COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, KEY_ESCAPE_COMMAND, + SELECTION_CHANGE_COMMAND, UNDO_COMMAND, type LexicalEditor, + type ElementNode, + type TextFormatType, } from "lexical"; import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; import { ListNode, ListItemNode } from "@lexical/list"; @@ -36,7 +43,12 @@ import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + TEXT_FORMAT_TRANSFORMERS, + type TextFormatTransformer, +} from "@lexical/markdown"; import type { DirectiveWithSteps } from "../../lib/api"; import { $createStepsBlockNode, @@ -60,11 +72,146 @@ const COUNTDOWN_RUNNING_MS = 10_000; /** The countdown bar only appears once we're inside this many ms from firing. */ const BAR_VISIBLE_MS = 10_000; const SAVED_TOAST_MS = 1200; -/** Debounce for writing the in-progress draft to localStorage (no backend hit). */ -const DRAFT_PERSIST_DEBOUNCE_MS = 250; +/** + * Drafts are written synchronously to localStorage on every keystroke. We used + * to debounce these by 250ms, but that lost the most recent edits whenever the + * user navigated away within the debounce window — the cleanup effect cleared + * the pending timer before it could flush. localStorage.setItem on a small + * string is sub-millisecond, so debouncing was a premature optimisation. + */ const DRAFT_KEY = (directiveId: string) => `makima:directive-goal-draft:${directiveId}`; const LIVE_START_KEY = "makima:liveStartEnabled"; +// ============================================================================= +// Inline-only markdown round-trip for the goal paragraph. +// +// The directive goal is a single paragraph node in the editor (children[1]). +// We support inline formatting (bold, italic, underline, code, strikethrough) +// and persist it as inline markdown in `directive.goal`. We deliberately do +// NOT handle headings, lists, or blocks here — those would change the document +// shape and the goal column is just TEXT on the backend. +// +// Supported markers (single-format, no nesting except bold+italic): +// `code` → format: code +// ***x*** → format: bold + italic +// **x** → format: bold +// *x* / _x_ → format: italic +// ~~x~~ → format: strikethrough +// +// Underline is preserved at the editor level via the Cmd+U shortcut and the +// toolbar, but is intentionally not emitted in markdown (it has no native +// markdown syntax). It will silently round-trip as plain text. +// ============================================================================= + +interface InlineToken { + text: string; + bold: boolean; + italic: boolean; + code: boolean; + strike: boolean; +} + +const INLINE_MARKERS: Array<[RegExp, Partial<InlineToken>]> = [ + // Code wins first — content inside backticks is literal. + [/^`([^`]+)`/, { code: true }], + // ***bold italic*** + [/^\*\*\*([^*]+)\*\*\*/, { bold: true, italic: true }], + // **bold** + [/^\*\*([^*]+)\*\*/, { bold: true }], + // *italic* or _italic_ + [/^\*([^*]+)\*/, { italic: true }], + [/^_([^_]+)_/, { italic: true }], + // ~~strikethrough~~ + [/^~~([^~]+)~~/, { strike: true }], +]; + +function tokenizeInlineMarkdown(input: string): InlineToken[] { + const tokens: InlineToken[] = []; + let buf = ""; + let i = 0; + const flushBuf = () => { + if (buf.length > 0) { + tokens.push({ text: buf, bold: false, italic: false, code: false, strike: false }); + buf = ""; + } + }; + while (i < input.length) { + const slice = input.slice(i); + let matched = false; + for (const [re, fmt] of INLINE_MARKERS) { + const m = re.exec(slice); + if (m) { + flushBuf(); + tokens.push({ + text: m[1], + bold: !!fmt.bold, + italic: !!fmt.italic, + code: !!fmt.code, + strike: !!fmt.strike, + }); + i += m[0].length; + matched = true; + break; + } + } + if (!matched) { + buf += input[i]; + i++; + } + } + flushBuf(); + return tokens; +} + +function appendInlineMarkdownTo(parent: ElementNode, markdown: string): void { + const tokens = tokenizeInlineMarkdown(markdown); + for (const t of tokens) { + const node = $createTextNode(t.text); + if (t.bold) node.toggleFormat("bold" as TextFormatType); + if (t.italic) node.toggleFormat("italic" as TextFormatType); + if (t.code) node.toggleFormat("code" as TextFormatType); + if (t.strike) node.toggleFormat("strikethrough" as TextFormatType); + parent.append(node); + } +} + +/** + * Walk the goal paragraph's children and emit inline markdown. Only TextNodes + * are emitted — anything else falls back to its plain text content. We always + * wrap in the most specific marker pair available so the round-trip is stable. + */ +function serializeInlineMarkdown(parent: ElementNode): string { + let out = ""; + for (const child of parent.getChildren()) { + if (!$isTextNode(child)) { + out += child.getTextContent(); + continue; + } + let text = child.getTextContent(); + if (text.length === 0) continue; + // Code is exclusive — applying any other marker would break the literal + // semantics, so wrap once and skip the rest. + if (child.hasFormat("code")) { + out += "`" + text + "`"; + continue; + } + const bold = child.hasFormat("bold"); + const italic = child.hasFormat("italic"); + const strike = child.hasFormat("strikethrough"); + if (bold && italic) text = `***${text}***`; + else if (bold) text = `**${text}**`; + else if (italic) text = `*${text}*`; + if (strike) text = `~~${text}~~`; + out += text; + } + return out; +} + +// Keep only the inline transformers; stripping the block-level ones keeps the +// MarkdownShortcutPlugin from auto-converting `# ` to a heading or `- ` to a +// list inside the goal paragraph (which would break the document shape). +const INLINE_TRANSFORMERS: TextFormatTransformer[] = TEXT_FORMAT_TRANSFORMERS; + function isLiveStartEnabled(): boolean { if (typeof window === "undefined") return true; const raw = window.localStorage.getItem(LIVE_START_KEY); @@ -149,10 +296,12 @@ function SeedContentPlugin({ heading.append($createTextNode(directive.title)); root.append(heading); - // Paragraph: goal (editable). + // Paragraph: goal (editable). The persisted goal may contain inline + // markdown — parse it into formatted TextNodes so users see their + // bold/italic/code formatting on load. const goalPara = $createParagraphNode(); if (initialGoal.length > 0) { - goalPara.append($createTextNode(initialGoal)); + appendInlineMarkdownTo(goalPara, initialGoal); } root.append(goalPara); @@ -243,8 +392,11 @@ function GoalChangePlugin({ const children = root.getChildren(); // The goal lives at index 1 (after the H1 title). const goalNode = children[1]; - if (!goalNode) return; - onGoalChange(goalNode.getTextContent()); + if (!goalNode || !$isElementNode(goalNode)) return; + // Serialize the goal paragraph as INLINE MARKDOWN so bold/italic/code + // formatting round-trips through `directive.goal` (a plain TEXT + // column on the backend). + onGoalChange(serializeInlineMarkdown(goalNode)); }); }} /> @@ -289,6 +441,129 @@ function CountdownKeyBridge({ } // ============================================================================= +// Floating formatting toolbar +// +// Appears just above the current text selection when the selection covers any +// text inside the goal paragraph. Buttons dispatch FORMAT_TEXT_COMMAND which +// toggles the corresponding format flag on every covered TextNode — Lexical's +// built-in behaviour. Keyboard shortcuts (Cmd/Ctrl+B/I/U) also work via the +// RichTextPlugin even when the toolbar isn't shown. +// ============================================================================= + +function FloatingFormatToolbar() { + const [editor] = useLexicalComposerContext(); + const [coords, setCoords] = useState<{ x: number; y: number } | null>(null); + const [active, setActive] = useState({ + bold: false, + italic: false, + underline: false, + code: false, + strike: false, + }); + + useEffect(() => { + const update = () => { + editor.getEditorState().read(() => { + const sel = $getSelection(); + if (!$isRangeSelection(sel) || sel.isCollapsed()) { + setCoords(null); + return; + } + // Only show inside the goal paragraph (children[1] of root). + const anchorTop = sel.anchor.getNode().getTopLevelElement(); + const focusTop = sel.focus.getNode().getTopLevelElement(); + if (!anchorTop || !focusTop) { + setCoords(null); + return; + } + const root = $getRoot(); + const goalNode = root.getChildren()[1]; + if (anchorTop.getKey() !== goalNode?.getKey()) { + setCoords(null); + return; + } + // Selection rect from the DOM. + const domSel = window.getSelection(); + if (!domSel || domSel.rangeCount === 0) { + setCoords(null); + return; + } + const rect = domSel.getRangeAt(0).getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + setCoords(null); + return; + } + setCoords({ x: rect.left + rect.width / 2, y: rect.top }); + setActive({ + bold: sel.hasFormat("bold"), + italic: sel.hasFormat("italic"), + underline: sel.hasFormat("underline"), + code: sel.hasFormat("code"), + strike: sel.hasFormat("strikethrough"), + }); + }); + }; + + const unselect = editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + update(); + return false; + }, + COMMAND_PRIORITY_LOW, + ); + const unupdate = editor.registerUpdateListener(({ editorState }) => { + // Use the just-committed state for format flags. + void editorState; + update(); + }); + return () => { + unselect(); + unupdate(); + }; + }, [editor]); + + if (!coords) return null; + + const fmt = (type: TextFormatType) => () => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, type); + }; + + const button = (label: string, onClick: () => void, isActive: boolean, hint: string) => ( + <button + type="button" + onMouseDown={(e) => e.preventDefault()} + onClick={onClick} + title={hint} + className={`px-2 py-1 text-[11px] font-mono uppercase tracking-wide border-r border-[rgba(117,170,252,0.2)] last:border-r-0 transition-colors ${ + isActive + ? "bg-[#75aafc] text-[#0a1628]" + : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]" + }`} + > + {label} + </button> + ); + + return ( + <div + className="fixed z-40 flex items-stretch bg-[#0a1628] border border-[rgba(117,170,252,0.35)] shadow-lg pointer-events-auto" + style={{ + left: coords.x, + top: coords.y - 8, + transform: "translate(-50%, -100%)", + }} + > + {button("B", fmt("bold"), active.bold, "Bold (⌘B)")} + {button("I", fmt("italic"), active.italic, "Italic (⌘I)")} + {button("U", fmt("underline"), active.underline, "Underline (⌘U)")} + {button("S", fmt("strikethrough"), active.strike, "Strikethrough")} + {button("</>", fmt("code"), active.code, "Inline code")} + </div> + ); +} + +// ============================================================================= // Right-click context menu // ============================================================================= @@ -559,7 +834,6 @@ export function DocumentEditor({ const timerRef = useRef<number | null>(null); const tickRef = useRef<number | null>(null); const deadlineRef = useRef<number>(0); - const draftDebounceRef = useRef<number | null>(null); const editorRef = useRef<LexicalEditor | null>(null); function cancelTimers() { @@ -656,7 +930,9 @@ export function DocumentEditor({ /* ignore */ } // Also revert the editor's goal paragraph back to the persisted value, so - // the user sees the rollback. + // the user sees the rollback. The persisted value may contain inline + // markdown — re-parse it so formatting comes back styled, not as raw + // asterisks. const editor = editorRef.current; if (editor) { editor.update( @@ -666,7 +942,7 @@ export function DocumentEditor({ if (!goalNode || !$isElementNode(goalNode)) return; goalNode.getChildren().forEach((c) => c.remove()); if (directive.goal.length > 0) { - goalNode.append($createTextNode(directive.goal)); + appendInlineMarkdownTo(goalNode, directive.goal); } }, { tag: "history-merge" }, @@ -678,10 +954,6 @@ export function DocumentEditor({ useEffect(() => { return () => { cancelTimers(); - if (draftDebounceRef.current != null) { - window.clearTimeout(draftDebounceRef.current); - draftDebounceRef.current = null; - } }; }, []); @@ -689,24 +961,20 @@ export function DocumentEditor({ (goal: string) => { pendingGoalRef.current = goal; - // 1. Always persist work-in-progress to localStorage (debounced) so - // leaving the page does not lose typing. This is independent of - // whether we will trigger a backend save. - if (draftDebounceRef.current != null) { - window.clearTimeout(draftDebounceRef.current); - } - draftDebounceRef.current = window.setTimeout(() => { - try { - if (goal === directive.goal) { - window.localStorage.removeItem(DRAFT_KEY(directive.id)); - } else { - window.localStorage.setItem(DRAFT_KEY(directive.id), goal); - } - } catch { - /* localStorage may be unavailable / full; ignore */ + // 1. Always persist work-in-progress to localStorage IMMEDIATELY so + // leaving the page does not lose typing. We previously debounced + // this write by 250ms, but unmount could clear the pending timer + // before it flushed — losing the most recent edits exactly when + // we needed them most. + try { + if (goal === directive.goal) { + window.localStorage.removeItem(DRAFT_KEY(directive.id)); + } else { + window.localStorage.setItem(DRAFT_KEY(directive.id), goal); } - draftDebounceRef.current = null; - }, DRAFT_PERSIST_DEBOUNCE_MS); + } catch { + /* localStorage may be unavailable / full; ignore */ + } // 2. State-machine. if (goal === directive.goal) { @@ -759,6 +1027,12 @@ export function DocumentEditor({ <HistoryPlugin /> <GoalChangePlugin onGoalChange={handleGoalChange} /> <CountdownKeyBridge onEsc={cancelCountdown} onUndo={cancelCountdown} /> + {/* Inline markdown shortcuts: typing **foo** auto-formats as bold, + `foo` as code, etc. We pass only TEXT_FORMAT_TRANSFORMERS so + block-level shortcuts (# heading, - list) don't fire and + accidentally restructure the document. */} + <MarkdownShortcutPlugin transformers={INLINE_TRANSFORMERS} /> + <FloatingFormatToolbar /> <div className="flex-1 overflow-auto" |
