summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/directives/DocumentEditor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/directives/DocumentEditor.tsx')
-rw-r--r--makima/frontend/src/components/directives/DocumentEditor.tsx334
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"