@elabs-ai/components-editor 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/chunk-LBC5VJBD.js +3748 -0
- package/dist/chunk-LBC5VJBD.js.map +1 -0
- package/dist/index.css +233 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +362 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/monaco-environment.d.ts +2 -0
- package/dist/lib/monaco-environment.js +44 -0
- package/dist/lib/monaco-environment.js.map +1 -0
- package/dist/markdown/frontmatter.d.ts +11 -0
- package/dist/markdown/frontmatter.js +33 -0
- package/dist/markdown/frontmatter.js.map +1 -0
- package/dist/markdown/index.css +233 -0
- package/dist/markdown/index.css.map +1 -0
- package/dist/markdown/index.d.ts +1486 -0
- package/dist/markdown/index.js +4882 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/parse.d.ts +25 -0
- package/dist/markdown/parse.js +14 -0
- package/dist/markdown/parse.js.map +1 -0
- package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
- package/package.json +101 -0
- package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
- package/src/ai-objects/ai-objects.stories.tsx +233 -0
- package/src/ai-objects/ai-objects.test.tsx +205 -0
- package/src/ai-objects/decision-card.tsx +178 -0
- package/src/ai-objects/directives.ts +136 -0
- package/src/ai-objects/entity.tsx +181 -0
- package/src/ai-objects/index.ts +34 -0
- package/src/ai-objects/knowledge-card.tsx +143 -0
- package/src/calc-block/calc-block.stories.tsx +297 -0
- package/src/calc-block/calc-block.test.tsx +164 -0
- package/src/calc-block/calc-block.tsx +398 -0
- package/src/calc-block/calc-editor-monaco.ts +214 -0
- package/src/calc-block/calc-editor-prose.ts +128 -0
- package/src/calc-block/calc-editor.css +80 -0
- package/src/calc-block/calc-editor.stories.tsx +272 -0
- package/src/calc-block/calc-editor.test.ts +228 -0
- package/src/calc-block/calc-editor.ts +270 -0
- package/src/calc-block/calc-inline.stories.tsx +44 -0
- package/src/calc-block/calc-inline.test.tsx +44 -0
- package/src/calc-block/calc-inline.tsx +75 -0
- package/src/calc-block/index.ts +20 -0
- package/src/calc-block/types.ts +164 -0
- package/src/code-editor/code-editor.stories.tsx +218 -0
- package/src/code-editor/code-editor.test.tsx +134 -0
- package/src/code-editor/code-editor.tsx +250 -0
- package/src/code-editor/index.ts +6 -0
- package/src/code-workspace/code-workspace.stories.tsx +51 -0
- package/src/code-workspace/code-workspace.test.tsx +146 -0
- package/src/code-workspace/code-workspace.tsx +253 -0
- package/src/code-workspace/index.ts +6 -0
- package/src/copy-button/copy-button.tsx +51 -0
- package/src/copy-button/index.ts +1 -0
- package/src/css.d.ts +3 -0
- package/src/diff-editor/diff-editor.stories.tsx +44 -0
- package/src/diff-editor/diff-editor.test.tsx +68 -0
- package/src/diff-editor/diff-editor.tsx +151 -0
- package/src/diff-editor/index.ts +1 -0
- package/src/editor-context-menu/editor-context-menu.tsx +120 -0
- package/src/editor-context-menu/index.ts +1 -0
- package/src/editor-toolbar/editor-toolbar.tsx +83 -0
- package/src/editor-toolbar/index.ts +1 -0
- package/src/index.ts +48 -0
- package/src/lib/editor-completions-monaco.test.ts +206 -0
- package/src/lib/editor-completions-monaco.ts +127 -0
- package/src/lib/editor-completions.test.ts +156 -0
- package/src/lib/editor-completions.ts +153 -0
- package/src/lib/editor-content-access-prose.test.ts +411 -0
- package/src/lib/editor-content-access-prose.ts +172 -0
- package/src/lib/editor-content-access.test.ts +182 -0
- package/src/lib/editor-content-access.ts +109 -0
- package/src/lib/languages.ts +32 -0
- package/src/lib/markdown/diff.test.ts +80 -0
- package/src/lib/markdown/diff.ts +194 -0
- package/src/lib/markdown/directives.ts +342 -0
- package/src/lib/markdown/frontmatter.ts +50 -0
- package/src/lib/markdown/markdown-scale.test.ts +65 -0
- package/src/lib/markdown/markdown-scale.ts +62 -0
- package/src/lib/markdown/merge.test.ts +64 -0
- package/src/lib/markdown/merge.ts +158 -0
- package/src/lib/markdown/slugify.test.ts +156 -0
- package/src/lib/markdown/slugify.ts +47 -0
- package/src/lib/monaco-environment.ts +103 -0
- package/src/lib/monaco-theme-bridge.ts +293 -0
- package/src/lib/use-data-theme.ts +61 -0
- package/src/markdown/frontmatter.ts +14 -0
- package/src/markdown/index.ts +323 -0
- package/src/markdown/parse.test.ts +60 -0
- package/src/markdown/parse.ts +38 -0
- package/src/markdown-academic/citations.test.ts +77 -0
- package/src/markdown-academic/citations.tsx +442 -0
- package/src/markdown-academic/footnotes.tsx +235 -0
- package/src/markdown-academic/index.ts +32 -0
- package/src/markdown-academic/math.tsx +163 -0
- package/src/markdown-academic/toc.tsx +88 -0
- package/src/markdown-editor/completions/completions-menu.tsx +96 -0
- package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
- package/src/markdown-editor/completions/completions-prose.ts +313 -0
- package/src/markdown-editor/completions/completions-widget.tsx +75 -0
- package/src/markdown-editor/completions/index.ts +37 -0
- package/src/markdown-editor/directive-nodes.ts +151 -0
- package/src/markdown-editor/directive-views.test.tsx +189 -0
- package/src/markdown-editor/directive-views.tsx +795 -0
- package/src/markdown-editor/exit-keymap.test.ts +88 -0
- package/src/markdown-editor/exit-keymap.ts +87 -0
- package/src/markdown-editor/index.ts +6 -0
- package/src/markdown-editor/markdown-editor.css +250 -0
- package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
- package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
- package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
- package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
- package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
- package/src/markdown-editor/markdown-editor.tsx +560 -0
- package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
- package/src/markdown-editor/milkdown-react/index.ts +8 -0
- package/src/markdown-editor/milkdown-react/types.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
- package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
- package/src/markdown-editor/paste-embed.ts +355 -0
- package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
- package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
- package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
- package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
- package/src/markdown-editor/slash/index.ts +107 -0
- package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
- package/src/markdown-editor/slash/insert-directive.ts +345 -0
- package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
- package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
- package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
- package/src/markdown-editor/slash/shortcut.test.ts +157 -0
- package/src/markdown-editor/slash/shortcut.ts +57 -0
- package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
- package/src/markdown-editor/slash/slash-menu.tsx +115 -0
- package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
- package/src/markdown-editor/slash/slash-widget.tsx +97 -0
- package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
- package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
- package/src/markdown-editor/table-view.tsx +249 -0
- package/src/markdown-iteration/directive.tsx +92 -0
- package/src/markdown-iteration/edit-context.ts +56 -0
- package/src/markdown-iteration/index.ts +24 -0
- package/src/markdown-iteration/iteration-block.test.tsx +138 -0
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
- package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
- package/src/markdown-iteration/iteration-builder.test.ts +252 -0
- package/src/markdown-iteration/iteration-builder.ts +303 -0
- package/src/markdown-iteration/iteration.test.ts +56 -0
- package/src/markdown-iteration/iteration.tsx +333 -0
- package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
- package/src/markdown-iteration/template-dialog.test.tsx +104 -0
- package/src/markdown-iteration/template-dialog.tsx +116 -0
- package/src/markdown-outline/document-outline.stories.tsx +76 -0
- package/src/markdown-outline/document-outline.tsx +83 -0
- package/src/markdown-outline/index.ts +2 -0
- package/src/markdown-outline/markdown-outline.test.ts +49 -0
- package/src/markdown-outline/markdown-outline.ts +60 -0
- package/src/markdown-preview/code-fence.tsx +193 -0
- package/src/markdown-preview/index.ts +8 -0
- package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
- package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
- package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
- package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
- package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
- package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
- package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
- package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
- package/src/markdown-preview/markdown-preview.test.tsx +411 -0
- package/src/markdown-preview/markdown-preview.tsx +1541 -0
- package/src/markdown-toolbar/index.ts +8 -0
- package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
- package/src/markdown-toolbar/markdown-commands.ts +104 -0
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
- package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
- package/src/markdown-workspace/focus-writing.test.ts +52 -0
- package/src/markdown-workspace/focus-writing.ts +38 -0
- package/src/markdown-workspace/index.ts +6 -0
- package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
- package/src/markdown-workspace/markdown-workspace.tsx +787 -0
- package/src/mermaid-diagram/index.ts +1 -0
- package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
- package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
- package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
- package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
- package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
- package/src/mermaid-diagram/real-parse.test.ts +42 -0
- package/src/mermaid-diagram/remediate.test.ts +60 -0
- package/src/mermaid-diagram/remediate.ts +124 -0
- package/src/mermaid-workspace/index.ts +1 -0
- package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
- package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
- package/src/metric-block/index.ts +1 -0
- package/src/metric-block/metric-block.stories.tsx +40 -0
- package/src/metric-block/metric-block.tsx +14 -0
- package/src/monaco-workers.d.ts +10 -0
- package/src/prose/index.ts +14 -0
- package/src/prose/prose.stories.tsx +44 -0
- package/src/prose/prose.test.tsx +44 -0
- package/src/prose/prose.tsx +21 -0
- package/src/timeline/index.ts +18 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MarkdownWorkspace — the hybrid markdown authoring surface for the Workbench.
|
|
5
|
+
*
|
|
6
|
+
* One markdown value, three modes (a @elabs-ai/components-ui ToggleGroup):
|
|
7
|
+
* - "source" : Monaco CodeEditor(markdown) + the MarkdownToolbar
|
|
8
|
+
* - "wysiwyg" : the Milkdown MarkdownEditor (direct manipulation)
|
|
9
|
+
* - "split" : source ↔ the branded MarkdownPreview (drag-resizable)
|
|
10
|
+
*
|
|
11
|
+
* The value is shared across modes, so switching is lossless. Controlled
|
|
12
|
+
* (`value`/`onChange`) or uncontrolled (`defaultValue`); same for `mode`.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
ResizableHandle,
|
|
16
|
+
ResizablePanel,
|
|
17
|
+
ResizablePanelGroup,
|
|
18
|
+
Toggle,
|
|
19
|
+
ToggleGroup,
|
|
20
|
+
ToggleGroupItem,
|
|
21
|
+
Tooltip,
|
|
22
|
+
TooltipContent,
|
|
23
|
+
TooltipProvider,
|
|
24
|
+
TooltipTrigger,
|
|
25
|
+
} from "@elabs-ai/components-ui";
|
|
26
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
27
|
+
import { Columns2, Eye, Focus, SquareCode } from "lucide-react";
|
|
28
|
+
import {
|
|
29
|
+
forwardRef,
|
|
30
|
+
useEffect,
|
|
31
|
+
useImperativeHandle,
|
|
32
|
+
useMemo,
|
|
33
|
+
useRef,
|
|
34
|
+
useState,
|
|
35
|
+
type HTMLAttributes,
|
|
36
|
+
type ReactNode,
|
|
37
|
+
} from "react";
|
|
38
|
+
|
|
39
|
+
import { attachCalcMonaco } from "../calc-block/calc-editor-monaco";
|
|
40
|
+
import type { CalcEditorHooks } from "../calc-block/types";
|
|
41
|
+
import { CodeEditor, type EditorAction, type MonacoCodeEditor } from "../code-editor";
|
|
42
|
+
import { attachCompletionsMonaco } from "../lib/editor-completions-monaco";
|
|
43
|
+
import type { EditorCompletionProvider } from "../lib/editor-completions";
|
|
44
|
+
import {
|
|
45
|
+
monacoContentAccess,
|
|
46
|
+
type EditorContentAccess,
|
|
47
|
+
type EditorSelection,
|
|
48
|
+
} from "../lib/editor-content-access";
|
|
49
|
+
import { parseFrontmatter } from "../lib/markdown/frontmatter";
|
|
50
|
+
import { mergeNormalizedEdit } from "../lib/markdown/merge";
|
|
51
|
+
import { MarkdownEditor, type MarkdownEditorHandle, type EmbedAssetFn } from "../markdown-editor";
|
|
52
|
+
import { parseMarkdownOutline } from "../markdown-outline";
|
|
53
|
+
import { BRAND_SLASH_COMMANDS, type SlashCommand } from "../markdown-editor/slash";
|
|
54
|
+
// MonacoSlashMenu + parseShortcut are imported from their files (NOT the slash
|
|
55
|
+
// barrel), which pull the Monaco runtime — the workspace already does too. Keeps
|
|
56
|
+
// the Milkdown-facing slash barrel Monaco-free. The pure `shortcut.ts` holds the
|
|
57
|
+
// default; `shortcut-monaco.ts` holds the Monaco-keybinding parser.
|
|
58
|
+
import { MonacoSlashMenu } from "../markdown-editor/slash/monaco-slash-menu";
|
|
59
|
+
import {
|
|
60
|
+
slashTriggerRange,
|
|
61
|
+
type SlashTriggerRange,
|
|
62
|
+
} from "../markdown-editor/slash/source-slash-trigger";
|
|
63
|
+
import { DEFAULT_SLASH_SHORTCUT } from "../markdown-editor/slash/shortcut";
|
|
64
|
+
import { parseShortcut } from "../markdown-editor/slash/shortcut-monaco";
|
|
65
|
+
import { MarkdownPreview } from "../markdown-preview";
|
|
66
|
+
import { MarkdownToolbar } from "../markdown-toolbar";
|
|
67
|
+
import { topLevelBlockOf, typewriterDelta } from "./focus-writing";
|
|
68
|
+
|
|
69
|
+
export type MarkdownWorkspaceMode = "source" | "wysiwyg" | "split";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Imperative handle exposed via `MarkdownWorkspace`'s `ref` (#273, DECISION A).
|
|
73
|
+
*
|
|
74
|
+
* Migration note: the forwarded ref type changed from `HTMLDivElement` to this
|
|
75
|
+
* handle. Replace any `ref.current` DOM access with `ref.current?.getElement()`.
|
|
76
|
+
*
|
|
77
|
+
* Extends {@link EditorContentAccess} — all AI content-access methods delegate to
|
|
78
|
+
* the active engine: Monaco (source/split) or the Milkdown WYSIWYG handle.
|
|
79
|
+
*
|
|
80
|
+
* **`onSelectionChange` caveat:** the subscription is scoped to the engine active at
|
|
81
|
+
* call time. A mode switch (source ↔ wysiwyg) does NOT auto-rebind the listener —
|
|
82
|
+
* re-subscribe from an effect whose deps include the mode. A self-rebinding v2 is a
|
|
83
|
+
* noted future enhancement, out of v1 scope.
|
|
84
|
+
*/
|
|
85
|
+
export interface MarkdownWorkspaceHandle extends EditorContentAccess {
|
|
86
|
+
/**
|
|
87
|
+
* Scroll the active editor so FULL-SOURCE 1-based `line` is visible (Monaco
|
|
88
|
+
* coordinates). No-op (never throws) while the engine is booting or `line` is
|
|
89
|
+
* out of range. In WYSIWYG it is best-effort: resolves the nearest preceding
|
|
90
|
+
* heading via `parseMarkdownOutline` + `fmOffset`, then delegates to
|
|
91
|
+
* `scrollToHeading`. No-op if no preceding heading found.
|
|
92
|
+
*/
|
|
93
|
+
revealLine(line: number, opts?: { center?: boolean }): void;
|
|
94
|
+
/**
|
|
95
|
+
* Scroll to a heading by its outline slug (same slugs as `DocumentOutline` /
|
|
96
|
+
* `useMarkdownOutline` / `parseMarkdownOutline`). In Source/Split mode
|
|
97
|
+
* resolves the line via `parseMarkdownOutline` then calls `revealLine`. In
|
|
98
|
+
* WYSIWYG delegates to the `MarkdownEditorHandle.scrollToHeading`.
|
|
99
|
+
*/
|
|
100
|
+
scrollToHeading(slug: string): void;
|
|
101
|
+
/**
|
|
102
|
+
* The live Monaco source editor instance, or `null` when not mounted or when
|
|
103
|
+
* the active mode is `"wysiwyg"` (source pane not rendered).
|
|
104
|
+
*/
|
|
105
|
+
getEditor(): MonacoCodeEditor | null;
|
|
106
|
+
/**
|
|
107
|
+
* The workspace root DOM element. Preserves the old `HTMLDivElement` ref
|
|
108
|
+
* access that existed before DECISION A (ref type change in #273).
|
|
109
|
+
*/
|
|
110
|
+
getElement(): HTMLDivElement | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface MarkdownWorkspaceProps extends Omit<
|
|
114
|
+
HTMLAttributes<HTMLDivElement>,
|
|
115
|
+
"onChange" | "defaultValue"
|
|
116
|
+
> {
|
|
117
|
+
value?: string;
|
|
118
|
+
defaultValue?: string;
|
|
119
|
+
onChange?: (markdown: string) => void;
|
|
120
|
+
mode?: MarkdownWorkspaceMode;
|
|
121
|
+
defaultMode?: MarkdownWorkspaceMode;
|
|
122
|
+
onModeChange?: (mode: MarkdownWorkspaceMode) => void;
|
|
123
|
+
/**
|
|
124
|
+
* Start with FOCUS WRITING on (wysiwyg mode): typewriter scrolling keeps
|
|
125
|
+
* the caret vertically centered and inactive paragraphs dim. Toggleable in
|
|
126
|
+
* the editor's mode row.
|
|
127
|
+
*/
|
|
128
|
+
defaultFocusWriting?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* The `/` command menu in the WYSIWYG (preview-edit) pane AND the Monaco source
|
|
131
|
+
* pane. `true` (default) uses the built-in brand commands; pass a config to
|
|
132
|
+
* extend/replace them, or `false` to disable. Forwarded to {@link MarkdownEditor}.
|
|
133
|
+
*
|
|
134
|
+
* `shortcut` (default `"Mod-Shift-O"`) opens the menu at the caret in BOTH panes —
|
|
135
|
+
* in the WYSIWYG pane via the ProseMirror plugin's `handleKeyDown`, and in the
|
|
136
|
+
* source/split pane via a `CodeEditor` action (no `/` is inserted into the
|
|
137
|
+
* doc). (#271)
|
|
138
|
+
*/
|
|
139
|
+
slashMenu?: boolean | { commands?: SlashCommand[]; trigger?: string; shortcut?: string };
|
|
140
|
+
/**
|
|
141
|
+
* Customize the source / split toolbar's **Insert** menu (A4). Defaults to the
|
|
142
|
+
* same commands as the WYSIWYG slash menu (so `/calc`, `/iterate`, `/pivot` and
|
|
143
|
+
* any consumer commands are insertable in source mode too). Only commands with
|
|
144
|
+
* a `snippet` appear; pass your own list to override.
|
|
145
|
+
*/
|
|
146
|
+
insertCommands?: SlashCommand[];
|
|
147
|
+
/**
|
|
148
|
+
* Opt-in calc authoring inside ```calc fences (off by default). Wired to BOTH
|
|
149
|
+
* surfaces: the Monaco source pane (highlight + autocomplete + result inlays)
|
|
150
|
+
* and the WYSIWYG pane (highlight + result inlays). Supply the consumer's
|
|
151
|
+
* `tokenize` / `evaluate` / `complete` hooks; the library bundles no calc engine.
|
|
152
|
+
*/
|
|
153
|
+
calc?: CalcEditorHooks;
|
|
154
|
+
/**
|
|
155
|
+
* Declarative completion providers (#283) — e.g. `[[wikilink]]` autocomplete.
|
|
156
|
+
* Off by default; mirrors the `slashMenu`/`calc` opt-in pattern. The library
|
|
157
|
+
* owns the Monaco `registerCompletionItemProvider` registration lifecycle
|
|
158
|
+
* (registered once, refcounted across mounted workspaces, disposed with the
|
|
159
|
+
* last one — see `lib/editor-completions-monaco.ts`) for the Source/Split
|
|
160
|
+
* panes, and mirrors providers into the WYSIWYG pane via `MarkdownEditor`'s
|
|
161
|
+
* `completions` prop (a deliberately minimal mirror — see
|
|
162
|
+
* `markdown-editor/completions/completions-prose.ts` for the exact gaps).
|
|
163
|
+
* Zero `monaco-editor` imports needed in consumer code.
|
|
164
|
+
*/
|
|
165
|
+
completions?: EditorCompletionProvider[];
|
|
166
|
+
/**
|
|
167
|
+
* Host-provided callback for image paste/drop embedding in the WYSIWYG pane.
|
|
168
|
+
* Forwarded to {@link MarkdownEditor}. See `MarkdownEditorProps.onEmbedAsset`
|
|
169
|
+
* for the full contract.
|
|
170
|
+
*/
|
|
171
|
+
onEmbedAsset?: EmbedAssetFn;
|
|
172
|
+
/**
|
|
173
|
+
* Show the built-in "Focus writing" toggle in the WYSIWYG (preview-edit)
|
|
174
|
+
* toolbar row. `false` HIDES and DISABLES it (no keyboard path) and forces
|
|
175
|
+
* focus-writing OFF. `undefined`/`true` keep current behavior. The INITIAL
|
|
176
|
+
* on/off state still comes from `defaultFocusWriting`. (#270)
|
|
177
|
+
*/
|
|
178
|
+
focusWriting?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Show the built-in Source / Split / Preview-edit mode switch. `false` hides
|
|
181
|
+
* it in both toolbar branches so the host owns the view switch (controlled
|
|
182
|
+
* `mode`/`onModeChange` still drive the panes). Default `true`. (#272)
|
|
183
|
+
*/
|
|
184
|
+
modeSwitch?: boolean;
|
|
185
|
+
/**
|
|
186
|
+
* Host-supplied controls rendered in the toolbar's trailing slot (where the
|
|
187
|
+
* built-in mode switch sits). Use with `modeSwitch={false}` to supply your
|
|
188
|
+
* own switch / actions. (#272)
|
|
189
|
+
*/
|
|
190
|
+
toolbarActions?: ReactNode;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const MODES: { value: MarkdownWorkspaceMode; label: string; icon: typeof Eye }[] = [
|
|
194
|
+
{ value: "source", label: "Source", icon: SquareCode },
|
|
195
|
+
{ value: "split", label: "Split", icon: Columns2 },
|
|
196
|
+
{ value: "wysiwyg", label: "Preview-edit", icon: Eye },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
export const MarkdownWorkspace = forwardRef<MarkdownWorkspaceHandle, MarkdownWorkspaceProps>(
|
|
200
|
+
function MarkdownWorkspace(
|
|
201
|
+
{
|
|
202
|
+
value,
|
|
203
|
+
defaultValue,
|
|
204
|
+
onChange,
|
|
205
|
+
mode,
|
|
206
|
+
defaultMode = "split",
|
|
207
|
+
onModeChange,
|
|
208
|
+
defaultFocusWriting = false,
|
|
209
|
+
focusWriting,
|
|
210
|
+
modeSwitch = true,
|
|
211
|
+
toolbarActions,
|
|
212
|
+
slashMenu = true,
|
|
213
|
+
insertCommands,
|
|
214
|
+
calc,
|
|
215
|
+
completions,
|
|
216
|
+
onEmbedAsset,
|
|
217
|
+
className,
|
|
218
|
+
...props
|
|
219
|
+
},
|
|
220
|
+
ref,
|
|
221
|
+
) {
|
|
222
|
+
// The source/split Insert menu defaults to the SAME commands as the WYSIWYG
|
|
223
|
+
// slash menu, so both surfaces insert the same blocks (A4).
|
|
224
|
+
const slashCommandList =
|
|
225
|
+
typeof slashMenu === "object" && slashMenu.commands
|
|
226
|
+
? slashMenu.commands
|
|
227
|
+
: BRAND_SLASH_COMMANDS;
|
|
228
|
+
const toolbarInsertCommands = insertCommands ?? slashCommandList;
|
|
229
|
+
const isControlled = value !== undefined;
|
|
230
|
+
const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? "");
|
|
231
|
+
const markdown = isControlled ? value : internalValue;
|
|
232
|
+
|
|
233
|
+
const [internalMode, setInternalMode] = useState<MarkdownWorkspaceMode>(defaultMode);
|
|
234
|
+
const activeMode = mode ?? internalMode;
|
|
235
|
+
|
|
236
|
+
const [monaco, setMonaco] = useState<MonacoCodeEditor | null>(null);
|
|
237
|
+
// Stable set of onSelectionChange listeners (engine-agnostic). The handle adds
|
|
238
|
+
// here; a binding effect forwards the active engine's selection events. (#AI)
|
|
239
|
+
const [selectionListeners] = useState(() => new Set<(sel: EditorSelection) => void>());
|
|
240
|
+
|
|
241
|
+
/* ------------------------- calc authoring (#220) ------------------------ */
|
|
242
|
+
// Read the calc hooks through a ref so a fresh `calc` object identity never
|
|
243
|
+
// re-attaches the Monaco layer; only toggling the feature on/off does.
|
|
244
|
+
const calcRef = useRef<CalcEditorHooks | undefined>(calc);
|
|
245
|
+
calcRef.current = calc;
|
|
246
|
+
const calcEnabled = calc != null;
|
|
247
|
+
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!monaco || !calcEnabled) return;
|
|
250
|
+
// Let calc completions surface as you type inside the fence (markdown
|
|
251
|
+
// otherwise suppresses quick suggestions outside comments/strings).
|
|
252
|
+
monaco.updateOptions({
|
|
253
|
+
quickSuggestions: { other: true, comments: false, strings: false },
|
|
254
|
+
});
|
|
255
|
+
return attachCalcMonaco(monaco, () => calcRef.current);
|
|
256
|
+
}, [monaco, calcEnabled]);
|
|
257
|
+
|
|
258
|
+
/* --------------------- completion providers (#283) ----------------------- */
|
|
259
|
+
// Read through a ref so a fresh `completions` array identity (a re-render)
|
|
260
|
+
// never re-attaches — the Monaco lifecycle (`attachCompletionsMonaco`) reads
|
|
261
|
+
// the LIVE list on every suggestion request; only mount/unmount and
|
|
262
|
+
// enabling/disabling the feature touch the effect.
|
|
263
|
+
const completionsRef = useRef<EditorCompletionProvider[] | undefined>(completions);
|
|
264
|
+
completionsRef.current = completions;
|
|
265
|
+
const completionsEnabled = completions != null;
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!monaco || !completionsEnabled) return;
|
|
269
|
+
return attachCompletionsMonaco(monaco, () => completionsRef.current);
|
|
270
|
+
}, [monaco, completionsEnabled]);
|
|
271
|
+
|
|
272
|
+
// The source CodeEditor unmounts when the active mode becomes WYSIWYG, but its
|
|
273
|
+
// `onMount` only fires on (re)mount — nothing clears the held instance. Drop it
|
|
274
|
+
// here so `getEditor()` honors its documented `null` contract in WYSIWYG and
|
|
275
|
+
// `revealLine`/`scrollToHeading` delegate to the WYSIWYG handle instead of
|
|
276
|
+
// acting on a disposed Monaco editor. (#271 review)
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (activeMode === "wysiwyg") setMonaco(null);
|
|
279
|
+
}, [activeMode]);
|
|
280
|
+
|
|
281
|
+
/* --------- source-pane slash menu (#271) -------------------------------- */
|
|
282
|
+
const slashEnabled = slashMenu !== false;
|
|
283
|
+
// Honor an explicitly disabled shortcut (`shortcut: ""` / `shortcut: undefined`)
|
|
284
|
+
// the SAME way the WYSIWYG plugin does (`"shortcut" in options`), so both panes
|
|
285
|
+
// agree: only fall back to the default when the key is absent entirely. (#271 review)
|
|
286
|
+
const shortcut =
|
|
287
|
+
typeof slashMenu === "object" && "shortcut" in slashMenu
|
|
288
|
+
? slashMenu.shortcut
|
|
289
|
+
: DEFAULT_SLASH_SHORTCUT;
|
|
290
|
+
|
|
291
|
+
const [sourceSlashOpen, setSourceSlashOpen] = useState(false);
|
|
292
|
+
// The model range of a typed `/` when the menu was opened by typing (not the
|
|
293
|
+
// hotkey). null for the hotkey path. Drives MonacoSlashMenu's `triggerRange`:
|
|
294
|
+
// on select the `/` is replaced by the block; on cancel it is removed.
|
|
295
|
+
const [typedTrigger, setTypedTrigger] = useState<SlashTriggerRange | null>(null);
|
|
296
|
+
|
|
297
|
+
// Close handler: clear the typed-trigger whenever the menu closes so a later
|
|
298
|
+
// hotkey-open doesn't inherit a stale range.
|
|
299
|
+
const handleSourceSlashOpenChange = (next: boolean) => {
|
|
300
|
+
setSourceSlashOpen(next);
|
|
301
|
+
if (!next) setTypedTrigger(null);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Typing `/` at a line start (or after whitespace) opens the menu in the
|
|
305
|
+
// source pane — the conflict-free trigger that mirrors the WYSIWYG `/`. We
|
|
306
|
+
// watch model edits for a lone `/` insertion at a valid spot; our own
|
|
307
|
+
// insert/cancel edits are multi-char or empty, so they never re-trigger.
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (!monaco || !slashEnabled) return;
|
|
310
|
+
const sub = monaco.onDidChangeModelContent((e) => {
|
|
311
|
+
if (sourceSlashOpen || e.changes.length !== 1) return;
|
|
312
|
+
const change = e.changes[0];
|
|
313
|
+
if (!change || change.text !== "/") return;
|
|
314
|
+
const model = monaco.getModel();
|
|
315
|
+
if (!model) return;
|
|
316
|
+
const line = change.range.startLineNumber;
|
|
317
|
+
const range = slashTriggerRange(line, model.getLineContent(line), change.range.startColumn);
|
|
318
|
+
if (!range) return;
|
|
319
|
+
setTypedTrigger(range);
|
|
320
|
+
setSourceSlashOpen(true);
|
|
321
|
+
});
|
|
322
|
+
return () => sub.dispose();
|
|
323
|
+
}, [monaco, slashEnabled, sourceSlashOpen]);
|
|
324
|
+
|
|
325
|
+
// A command works in the source pane when it has a text snippet OR an
|
|
326
|
+
// explicit source-pane handler (#299) — a run-only command whose ONLY
|
|
327
|
+
// handler is Milkdown's `run` (needs a Ctx unavailable in Monaco) is
|
|
328
|
+
// excluded; `runInSource` is exactly the escape hatch for that case.
|
|
329
|
+
const sourceCommands = useMemo(
|
|
330
|
+
() =>
|
|
331
|
+
slashCommandList.filter((c) => c.snippet != null || typeof c.runInSource === "function"),
|
|
332
|
+
[slashCommandList],
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
// A Layer-1 CodeEditor action that opens the source slash popup when fired
|
|
336
|
+
// (via its keybinding or the command palette). All KeyMod/KeyCode references
|
|
337
|
+
// live inside parseShortcut (slash/shortcut.ts) — NEVER reference a bare
|
|
338
|
+
// monaco.KeyMod here, because `monaco` is a state variable (the editor
|
|
339
|
+
// instance), not the namespace.
|
|
340
|
+
const sourceActions = useMemo<EditorAction[]>(
|
|
341
|
+
() =>
|
|
342
|
+
slashEnabled && shortcut
|
|
343
|
+
? [
|
|
344
|
+
{
|
|
345
|
+
id: "brand.openSlashMenu",
|
|
346
|
+
label: "Insert block…",
|
|
347
|
+
keybindings: [parseShortcut(shortcut)],
|
|
348
|
+
// Hotkey open inserts at the caret (no typed `/` to replace).
|
|
349
|
+
run: () => {
|
|
350
|
+
setTypedTrigger(null);
|
|
351
|
+
setSourceSlashOpen(true);
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
]
|
|
355
|
+
: [],
|
|
356
|
+
[slashEnabled, shortcut],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
/* ------------------- split-view scroll synchronization ------------------ */
|
|
360
|
+
// Line-accurate (not percentage) sync: preview blocks carry
|
|
361
|
+
// `data-sourcepos` in frontmatter-STRIPPED coordinates; Monaco lines are
|
|
362
|
+
// full-source — bridge with the stripped-line offset.
|
|
363
|
+
const previewPaneRef = useRef<HTMLDivElement | null>(null);
|
|
364
|
+
const scrollLock = useRef<"editor" | "preview" | null>(null);
|
|
365
|
+
const lockTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
366
|
+
|
|
367
|
+
const fmOffset = useMemo(() => {
|
|
368
|
+
try {
|
|
369
|
+
const body = parseFrontmatter(markdown).content;
|
|
370
|
+
return markdown.split("\n").length - body.split("\n").length;
|
|
371
|
+
} catch {
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
}, [markdown]);
|
|
375
|
+
|
|
376
|
+
const lock = (owner: "editor" | "preview") => {
|
|
377
|
+
scrollLock.current = owner;
|
|
378
|
+
if (lockTimer.current) clearTimeout(lockTimer.current);
|
|
379
|
+
lockTimer.current = setTimeout(() => {
|
|
380
|
+
scrollLock.current = null;
|
|
381
|
+
}, 150);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Editor → preview.
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
if (!monaco || activeMode !== "split") return;
|
|
387
|
+
const disposable = monaco.onDidScrollChange(() => {
|
|
388
|
+
if (scrollLock.current === "preview") return;
|
|
389
|
+
const range = monaco.getVisibleRanges()[0];
|
|
390
|
+
const host = previewPaneRef.current;
|
|
391
|
+
if (!range || !host) return;
|
|
392
|
+
const line = range.startLineNumber - fmOffset;
|
|
393
|
+
let target: HTMLElement | null = null;
|
|
394
|
+
for (const el of host.querySelectorAll<HTMLElement>("[data-sourcepos]")) {
|
|
395
|
+
const end = Number(el.dataset.sourcepos?.split(":")[1]);
|
|
396
|
+
if (end >= line) {
|
|
397
|
+
target = el;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (!target) return;
|
|
402
|
+
lock("editor");
|
|
403
|
+
host.scrollTop =
|
|
404
|
+
target.getBoundingClientRect().top -
|
|
405
|
+
host.getBoundingClientRect().top +
|
|
406
|
+
host.scrollTop -
|
|
407
|
+
12;
|
|
408
|
+
});
|
|
409
|
+
return () => disposable.dispose();
|
|
410
|
+
}, [monaco, activeMode, fmOffset]);
|
|
411
|
+
|
|
412
|
+
// Preview → editor.
|
|
413
|
+
const onPreviewScroll = () => {
|
|
414
|
+
if (scrollLock.current === "editor" || !monaco || activeMode !== "split") return;
|
|
415
|
+
const host = previewPaneRef.current;
|
|
416
|
+
if (!host) return;
|
|
417
|
+
const hostTop = host.getBoundingClientRect().top;
|
|
418
|
+
for (const el of host.querySelectorAll<HTMLElement>("[data-sourcepos]")) {
|
|
419
|
+
if (el.getBoundingClientRect().bottom >= hostTop) {
|
|
420
|
+
const start = Number(el.dataset.sourcepos?.split(":")[0]);
|
|
421
|
+
if (!Number.isNaN(start)) {
|
|
422
|
+
lock("preview");
|
|
423
|
+
monaco.setScrollTop(monaco.getTopForLineNumber(Math.max(1, start + fmOffset)));
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const setMarkdown = (next: string) => {
|
|
431
|
+
if (!isControlled) setInternalValue(next);
|
|
432
|
+
onChange?.(next);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/* ------------------ lossless WYSIWYG editing (WI-1) ------------------ */
|
|
436
|
+
// Milkdown re-serializes the WHOLE document on every edit, normalizing
|
|
437
|
+
// formatting the user never touched — a one-line edit became a whole-file
|
|
438
|
+
// diff. Capture the editor's pre-edit serialization as a BASELINE and
|
|
439
|
+
// merge each emission back onto the byte-exact original: unedited blocks
|
|
440
|
+
// keep their original bytes.
|
|
441
|
+
const wysiwygRef = useRef<MarkdownEditorHandle | null>(null);
|
|
442
|
+
const wysiwygBase = useRef<{ original: string; baseline: string | null } | null>(null);
|
|
443
|
+
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
if (activeMode !== "wysiwyg") {
|
|
446
|
+
wysiwygBase.current = null;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// Capture at mode entry; the editor is uncontrolled while in wysiwyg,
|
|
450
|
+
// so the workspace buffer is the only writer.
|
|
451
|
+
const original = markdown;
|
|
452
|
+
wysiwygBase.current = { original, baseline: null };
|
|
453
|
+
const poll = setInterval(() => {
|
|
454
|
+
const base = wysiwygBase.current;
|
|
455
|
+
if (!base || base.baseline !== null) {
|
|
456
|
+
clearInterval(poll);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const s = wysiwygRef.current?.serialized();
|
|
460
|
+
if (s != null) {
|
|
461
|
+
base.baseline = s;
|
|
462
|
+
clearInterval(poll);
|
|
463
|
+
}
|
|
464
|
+
}, 50);
|
|
465
|
+
const stop = setTimeout(() => clearInterval(poll), 5000);
|
|
466
|
+
return () => {
|
|
467
|
+
clearInterval(poll);
|
|
468
|
+
clearTimeout(stop);
|
|
469
|
+
};
|
|
470
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- capture markdown at ENTRY only
|
|
471
|
+
}, [activeMode]);
|
|
472
|
+
|
|
473
|
+
const onWysiwygChange = (emitted: string) => {
|
|
474
|
+
const base = wysiwygBase.current;
|
|
475
|
+
// Baseline captured before the first keystroke → merge; otherwise fall
|
|
476
|
+
// back to the raw emission (rare boot race — old behavior, never worse).
|
|
477
|
+
const next =
|
|
478
|
+
base && base.baseline !== null
|
|
479
|
+
? mergeNormalizedEdit(base.original, base.baseline, emitted)
|
|
480
|
+
: emitted;
|
|
481
|
+
setMarkdown(next);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/* ---------------- focus writing (Ulysses Phase A, wysiwyg) ---------------- */
|
|
485
|
+
// Typewriter scrolling + paragraph focus, driven from OUTSIDE the engine:
|
|
486
|
+
// selectionchange marks the active top-level block (CSS dims the rest);
|
|
487
|
+
// right after typing, the pane re-centers the caret into a middle band.
|
|
488
|
+
const focusWritingEnabled = focusWriting !== false;
|
|
489
|
+
const [focusWritingOn, setFocusWritingOn] = useState(
|
|
490
|
+
focusWritingEnabled ? defaultFocusWriting : false,
|
|
491
|
+
);
|
|
492
|
+
const wysiwygPaneRef = useRef<HTMLDivElement | null>(null);
|
|
493
|
+
const lastInputAt = useRef(0);
|
|
494
|
+
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
if (!(focusWritingEnabled && focusWritingOn && activeMode === "wysiwyg")) return;
|
|
497
|
+
const pane = wysiwygPaneRef.current;
|
|
498
|
+
if (!pane) return;
|
|
499
|
+
let active: Element | null = null;
|
|
500
|
+
|
|
501
|
+
const onSelectionChange = () => {
|
|
502
|
+
const root = pane.querySelector<HTMLElement>(".ProseMirror");
|
|
503
|
+
if (!root) return;
|
|
504
|
+
const sel = document.getSelection();
|
|
505
|
+
const node = sel?.anchorNode ?? null;
|
|
506
|
+
if (!node || !root.contains(node)) return;
|
|
507
|
+
const block = topLevelBlockOf(root, node);
|
|
508
|
+
if (block !== active) {
|
|
509
|
+
active?.classList.remove("wb-fw-active");
|
|
510
|
+
block?.classList.add("wb-fw-active");
|
|
511
|
+
active = block;
|
|
512
|
+
}
|
|
513
|
+
// Re-center only right after typing — a mouse click must not yank
|
|
514
|
+
// the viewport (Ulysses recenters while WRITING, not while aiming).
|
|
515
|
+
if (Date.now() - lastInputAt.current < 200 && sel && sel.rangeCount > 0) {
|
|
516
|
+
const range = sel.getRangeAt(0).getBoundingClientRect();
|
|
517
|
+
const caret = range.height > 0 ? range : (active?.getBoundingClientRect() ?? range);
|
|
518
|
+
const host = pane.getBoundingClientRect();
|
|
519
|
+
const delta = typewriterDelta(caret.top, caret.height, host.top, host.height);
|
|
520
|
+
if (delta !== 0) pane.scrollTop += delta;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
const onInput = () => {
|
|
524
|
+
lastInputAt.current = Date.now();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
528
|
+
pane.addEventListener("input", onInput, true);
|
|
529
|
+
onSelectionChange();
|
|
530
|
+
return () => {
|
|
531
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
532
|
+
pane.removeEventListener("input", onInput, true);
|
|
533
|
+
active?.classList.remove("wb-fw-active");
|
|
534
|
+
};
|
|
535
|
+
}, [focusWritingEnabled, focusWritingOn, activeMode]);
|
|
536
|
+
|
|
537
|
+
/* ------------------- imperative handle (#273, DECISION A) --------------- */
|
|
538
|
+
// The forwarded ref is now a MarkdownWorkspaceHandle (not the div).
|
|
539
|
+
// The root <div> gets rootRef; getElement() returns rootRef.current.
|
|
540
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
541
|
+
|
|
542
|
+
useImperativeHandle(
|
|
543
|
+
ref,
|
|
544
|
+
() => {
|
|
545
|
+
const revealLine = (n: number, opts?: { center?: boolean }) => {
|
|
546
|
+
if (monaco) {
|
|
547
|
+
// Source / Split: Monaco exact-line reveal.
|
|
548
|
+
const max = monaco.getModel()?.getLineCount() ?? 0;
|
|
549
|
+
if (n < 1 || n > max) return;
|
|
550
|
+
if (opts?.center === false) {
|
|
551
|
+
monaco.revealLine(n);
|
|
552
|
+
} else {
|
|
553
|
+
monaco.revealLineInCenter(n);
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
// WYSIWYG: best-effort — find the nearest preceding heading whose
|
|
557
|
+
// (stripped) line + fmOffset ≤ n, then delegate to scrollToHeading.
|
|
558
|
+
const items = parseMarkdownOutline(markdown);
|
|
559
|
+
// items.line is frontmatter-stripped (1-based); full-source = line + fmOffset
|
|
560
|
+
const preceding = items.filter((item) => item.line + fmOffset <= n).at(-1);
|
|
561
|
+
if (!preceding) return;
|
|
562
|
+
wysiwygRef.current?.scrollToHeading(preceding.id);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const scrollToHeading = (slug: string) => {
|
|
567
|
+
if (monaco) {
|
|
568
|
+
// Source / Split: resolve stripped line via outline, lift to full-source.
|
|
569
|
+
const item = parseMarkdownOutline(markdown).find((i) => i.id === slug);
|
|
570
|
+
if (!item) return;
|
|
571
|
+
revealLine(item.line + fmOffset, { center: true });
|
|
572
|
+
} else {
|
|
573
|
+
// WYSIWYG: delegate to the Milkdown handle.
|
|
574
|
+
wysiwygRef.current?.scrollToHeading(slug);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// Content-access delegation: monaco (source/split) → monacoContentAccess;
|
|
579
|
+
// wysiwyg → the MarkdownEditorHandle (which now IS an EditorContentAccess).
|
|
580
|
+
// If both are null (booting), fall back to best-effort no-ops (never throw).
|
|
581
|
+
const getAccess = (): EditorContentAccess | null => {
|
|
582
|
+
if (monaco) return monacoContentAccess(monaco);
|
|
583
|
+
if (wysiwygRef.current) return wysiwygRef.current;
|
|
584
|
+
return null;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
revealLine,
|
|
589
|
+
scrollToHeading,
|
|
590
|
+
getEditor: () => monaco,
|
|
591
|
+
getElement: () => rootRef.current,
|
|
592
|
+
|
|
593
|
+
// EditorContentAccess — read/write delegate to the active engine at call
|
|
594
|
+
// time (the AI acts after mount, so a call-time snapshot is correct here).
|
|
595
|
+
getText: () => getAccess()?.getText() ?? "",
|
|
596
|
+
getSelection: () => getAccess()?.getSelection() ?? { text: "", empty: true },
|
|
597
|
+
replaceSelection: (text: string) => getAccess()?.replaceSelection(text),
|
|
598
|
+
insertAtCursor: (text: string) => getAccess()?.insertAtCursor(text),
|
|
599
|
+
focus: () => getAccess()?.focus(),
|
|
600
|
+
// onSelectionChange uses the STABLE listener set (not getAccess()) so a
|
|
601
|
+
// subscribe-in-mount-effect survives the editor's async mount + mode
|
|
602
|
+
// switches; the binding effect below forwards the active engine's events.
|
|
603
|
+
onSelectionChange: (listener) => {
|
|
604
|
+
selectionListeners.add(listener);
|
|
605
|
+
return () => selectionListeners.delete(listener);
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
// Re-create when the things the methods close over change.
|
|
610
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
611
|
+
[monaco, markdown, fmOffset, activeMode, selectionListeners],
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
// Forward the ACTIVE engine's selection changes into the stable listener set,
|
|
615
|
+
// re-binding when the engine (monaco/wysiwyg) or mode changes. This is what
|
|
616
|
+
// makes the handle's onSelectionChange robust to the editor's async mount.
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
let unsub: (() => void) | undefined;
|
|
619
|
+
if (monaco) {
|
|
620
|
+
unsub = monacoContentAccess(monaco).onSelectionChange((sel) =>
|
|
621
|
+
selectionListeners.forEach((l) => l(sel)),
|
|
622
|
+
);
|
|
623
|
+
} else if (activeMode === "wysiwyg" && wysiwygRef.current) {
|
|
624
|
+
unsub = wysiwygRef.current.onSelectionChange((sel) =>
|
|
625
|
+
selectionListeners.forEach((l) => l(sel)),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return () => unsub?.();
|
|
629
|
+
}, [monaco, activeMode, selectionListeners]);
|
|
630
|
+
|
|
631
|
+
const setMode = (next: string) => {
|
|
632
|
+
if (next !== "source" && next !== "split" && next !== "wysiwyg") return;
|
|
633
|
+
if (!mode) setInternalMode(next);
|
|
634
|
+
onModeChange?.(next);
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const modeToggle = (
|
|
638
|
+
<TooltipProvider delayDuration={300}>
|
|
639
|
+
{/* Segmented mode switch — same recessed-track / raised-segment
|
|
640
|
+
grammar as every other mode control (Tabs, Read/Write). */}
|
|
641
|
+
<ToggleGroup
|
|
642
|
+
type="single"
|
|
643
|
+
value={activeMode}
|
|
644
|
+
onValueChange={setMode}
|
|
645
|
+
variant="segmented"
|
|
646
|
+
size="sm"
|
|
647
|
+
className="rounded-md p-0.5"
|
|
648
|
+
>
|
|
649
|
+
{MODES.map(({ value: m, label, icon: Icon }) => (
|
|
650
|
+
<Tooltip key={m}>
|
|
651
|
+
<TooltipTrigger asChild>
|
|
652
|
+
<ToggleGroupItem
|
|
653
|
+
value={m}
|
|
654
|
+
aria-label={label}
|
|
655
|
+
className="h-6 min-w-7 rounded-[5px] px-2"
|
|
656
|
+
>
|
|
657
|
+
<Icon className="size-4" />
|
|
658
|
+
</ToggleGroupItem>
|
|
659
|
+
</TooltipTrigger>
|
|
660
|
+
<TooltipContent>{label}</TooltipContent>
|
|
661
|
+
</Tooltip>
|
|
662
|
+
))}
|
|
663
|
+
</ToggleGroup>
|
|
664
|
+
</TooltipProvider>
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
const trailing = (
|
|
668
|
+
<>
|
|
669
|
+
{modeSwitch ? modeToggle : null}
|
|
670
|
+
{toolbarActions}
|
|
671
|
+
</>
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
const sourcePane = (
|
|
675
|
+
<CodeEditor
|
|
676
|
+
language="markdown"
|
|
677
|
+
value={markdown}
|
|
678
|
+
onChange={setMarkdown}
|
|
679
|
+
actions={sourceActions}
|
|
680
|
+
onMount={(editor) => {
|
|
681
|
+
// Markdown is prose: soft-wrap so the source pane lays out like the
|
|
682
|
+
// preview (line-based scroll sync stays accurate either way, but
|
|
683
|
+
// matching layouts keep the two panes visually in step).
|
|
684
|
+
editor.updateOptions({ wordWrap: "on" });
|
|
685
|
+
setMonaco(editor);
|
|
686
|
+
}}
|
|
687
|
+
/>
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
return (
|
|
691
|
+
<div
|
|
692
|
+
ref={rootRef}
|
|
693
|
+
data-testid="markdown-workspace"
|
|
694
|
+
className={cn("flex h-full min-h-0 flex-col overflow-hidden", className)}
|
|
695
|
+
{...props}
|
|
696
|
+
>
|
|
697
|
+
{activeMode === "wysiwyg" ? (
|
|
698
|
+
<div className="flex h-10 shrink-0 items-center justify-end gap-2 border-b border-border bg-surface px-2">
|
|
699
|
+
{focusWritingEnabled ? (
|
|
700
|
+
<TooltipProvider delayDuration={300}>
|
|
701
|
+
<Tooltip>
|
|
702
|
+
<TooltipTrigger asChild>
|
|
703
|
+
<Toggle
|
|
704
|
+
size="sm"
|
|
705
|
+
pressed={focusWritingOn}
|
|
706
|
+
onPressedChange={setFocusWritingOn}
|
|
707
|
+
aria-label="Focus writing"
|
|
708
|
+
className="h-6 gap-1.5 px-2 text-caption"
|
|
709
|
+
>
|
|
710
|
+
<Focus className="size-3.5" aria-hidden="true" /> Focus
|
|
711
|
+
</Toggle>
|
|
712
|
+
</TooltipTrigger>
|
|
713
|
+
<TooltipContent>Typewriter scrolling · inactive paragraphs dim</TooltipContent>
|
|
714
|
+
</Tooltip>
|
|
715
|
+
</TooltipProvider>
|
|
716
|
+
) : null}
|
|
717
|
+
{trailing}
|
|
718
|
+
</div>
|
|
719
|
+
) : (
|
|
720
|
+
<MarkdownToolbar
|
|
721
|
+
editor={monaco}
|
|
722
|
+
actions={trailing}
|
|
723
|
+
insertCommands={toolbarInsertCommands}
|
|
724
|
+
/>
|
|
725
|
+
)}
|
|
726
|
+
|
|
727
|
+
<div className="min-h-0 flex-1">
|
|
728
|
+
{activeMode === "source" ? sourcePane : null}
|
|
729
|
+
|
|
730
|
+
{activeMode === "wysiwyg" ? (
|
|
731
|
+
<div
|
|
732
|
+
ref={wysiwygPaneRef}
|
|
733
|
+
data-focus-writing={focusWritingEnabled && focusWritingOn ? "" : undefined}
|
|
734
|
+
className="h-full overflow-auto p-4"
|
|
735
|
+
>
|
|
736
|
+
{/* UNCONTROLLED while in wysiwyg: feeding the merged buffer back
|
|
737
|
+
would replaceAll on every keystroke (cursor loss + echo
|
|
738
|
+
loops). The buffer receives merged text via onWysiwygChange;
|
|
739
|
+
mode switches remount the editor from the buffer. */}
|
|
740
|
+
<MarkdownEditor
|
|
741
|
+
ref={wysiwygRef}
|
|
742
|
+
defaultValue={markdown}
|
|
743
|
+
onChange={onWysiwygChange}
|
|
744
|
+
slashMenu={slashMenu}
|
|
745
|
+
calc={calc}
|
|
746
|
+
completions={completions}
|
|
747
|
+
onEmbedAsset={onEmbedAsset}
|
|
748
|
+
className="border-0"
|
|
749
|
+
/>
|
|
750
|
+
</div>
|
|
751
|
+
) : null}
|
|
752
|
+
|
|
753
|
+
{activeMode === "split" ? (
|
|
754
|
+
<ResizablePanelGroup direction="horizontal">
|
|
755
|
+
<ResizablePanel defaultSize={50} minSize={25}>
|
|
756
|
+
{sourcePane}
|
|
757
|
+
</ResizablePanel>
|
|
758
|
+
<ResizableHandle withHandle />
|
|
759
|
+
<ResizablePanel defaultSize={50} minSize={25}>
|
|
760
|
+
<div
|
|
761
|
+
ref={previewPaneRef}
|
|
762
|
+
onScroll={onPreviewScroll}
|
|
763
|
+
className="h-full overflow-auto p-5"
|
|
764
|
+
>
|
|
765
|
+
<MarkdownPreview>{markdown}</MarkdownPreview>
|
|
766
|
+
</div>
|
|
767
|
+
</ResizablePanel>
|
|
768
|
+
</ResizablePanelGroup>
|
|
769
|
+
) : null}
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
{/* Source-pane slash popup (#271): shown when the Layer-1 action fires
|
|
773
|
+
in source or split mode. Uses fixed positioning anchored to the caret
|
|
774
|
+
via getScrolledVisiblePosition, so it works in both layouts. */}
|
|
775
|
+
{monaco && (activeMode === "source" || activeMode === "split") && slashEnabled ? (
|
|
776
|
+
<MonacoSlashMenu
|
|
777
|
+
editor={monaco}
|
|
778
|
+
commands={sourceCommands}
|
|
779
|
+
open={sourceSlashOpen}
|
|
780
|
+
onOpenChange={handleSourceSlashOpenChange}
|
|
781
|
+
triggerRange={typedTrigger}
|
|
782
|
+
/>
|
|
783
|
+
) : null}
|
|
784
|
+
</div>
|
|
785
|
+
);
|
|
786
|
+
},
|
|
787
|
+
);
|