@tangle-network/ui 11.32.2 → 11.34.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/CHANGELOG.md +23 -0
- package/dist/editor/document-editor-pane.d.ts +7 -1
- package/dist/editor/document-editor-pane.js +27 -12
- package/dist/editor/markdown-document-editor.js +30 -12
- package/dist/editor/source-markdown-editor.js +256 -0
- package/dist/editor/source-markdown.js +495 -0
- package/dist/files/file-artifact-pane.d.ts +2 -0
- package/dist/files/file-artifact-pane.js +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/lib/control-presentation.js +2 -1
- package/dist/lib/focus.d.ts +5 -1
- package/dist/lib/focus.js +6 -1
- package/dist/lib/interaction.d.ts +41 -0
- package/dist/lib/interaction.js +42 -0
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/button.js +19 -18
- package/dist/primitives/card.d.ts +14 -1
- package/dist/primitives/card.js +15 -5
- package/dist/primitives/dropdown-menu.js +7 -6
- package/dist/primitives/segmented-control.d.ts +5 -1
- package/dist/primitives/segmented-control.js +12 -5
- package/dist/primitives/select.js +4 -3
- package/dist/primitives/tabs.js +4 -3
- package/dist/run/inline-thinking-item.d.ts +7 -1
- package/dist/run/inline-thinking-item.js +6 -5
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils.d.ts +3 -2
- package/dist/utils.js +3 -2
- package/package.json +2 -2
- package/src/editor/document-editor-pane.tsx +54 -16
- package/src/editor/markdown-document-editor.tsx +33 -18
- package/src/editor/source-markdown-editor.test.tsx +156 -0
- package/src/editor/source-markdown-editor.ts +296 -0
- package/src/editor/source-markdown.fixtures.ts +90 -0
- package/src/editor/source-markdown.test.ts +235 -0
- package/src/editor/source-markdown.ts +627 -0
- package/src/files/file-artifact-pane.test.tsx +38 -3
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/lib/control-presentation.ts +10 -4
- package/src/lib/focus.ts +7 -1
- package/src/lib/interaction.ts +64 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/button.tsx +29 -10
- package/src/primitives/card.tsx +23 -6
- package/src/primitives/control-presentation.test.tsx +6 -3
- package/src/primitives/date-picker.test.tsx +1 -1
- package/src/primitives/dropdown-menu.tsx +15 -10
- package/src/primitives/interaction-states.test.tsx +285 -0
- package/src/primitives/segmented-control.test.tsx +4 -2
- package/src/primitives/segmented-control.tsx +13 -14
- package/src/primitives/select.tsx +11 -5
- package/src/primitives/tabs.tsx +7 -3
- package/src/run/inline-thinking-item.test.tsx +29 -0
- package/src/run/inline-thinking-item.tsx +11 -4
- package/src/utils/format.test.ts +16 -1
- package/src/utils/format.ts +17 -0
- package/src/utils/index.ts +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.34.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d81b6ec: The rich Markdown editor writes back the file's own bytes. Opening a file and leaving it reports no change; an edit changes only the edited span, keeping list markers, emphasis delimiters, escapes, setext headings, reference links and blank lines as written. Blocks the editor has no node for (GFM tables, raw HTML, images, task lists, link definitions) are shown read-only and kept verbatim, so opening Edit no longer flattens a table into one paragraph.
|
|
8
|
+
|
|
9
|
+
`DocumentEditorPane` and `FileArtifactPane`'s `editor` option take `dirty`, so a host that tracks unsaved edits itself drives the save state and the Save button. The pane shows one save state at 14px ("Unsaved changes", "Saving…" or "Saved"), shows Save only while there is something to save, and saves on Ctrl+S / Cmd+S.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 0b915b2: Markdown tables contain their copy control's screen-reader status line. The card is now `relative`, so a table deep inside a scrolling panel no longer stretches the page past the viewport and lets a wheel scroll the whole app away.
|
|
14
|
+
- 2d96408: InlineThinkingItem previews reasoning as plain text (no `**` or `#` marks), and takes `openWhileActive` (default true) so a product can keep a live reasoning row closed until the reader opens it. Adds `plainTextFromMarkdown` to the utils.
|
|
15
|
+
|
|
16
|
+
## 11.33.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- 03c7da0: Surface ladder and one interaction language.
|
|
21
|
+
|
|
22
|
+
Brand: Hospitality gets a stepped ladder (light page 91.6, white cards with a two-layer shadow, raised 95.6; dark page 16.9, card 31.1, raised 37.9 with a top highlight, popover 42.0), field wells that are always the opposite step from their parent, a 3:1 `--field-border`, darker muted text, and status fills retuned for its planes (success, warning, danger, neutral). Dark accent text (#b2dbc2) and the dark destructive red (0 80% 88%) are lifted so they keep 4.5:1 on the lighter raised and popover planes. New tokens `--hsl-raised`, `--hsl-raised-2`, `--well-page`, `--well-card`, `--well-raised`, `--well-raised-2`, `--well`, `--field-border`, `--shadow-raised`, `--shadow-well` and `--edge-highlight` default to the plain spine, so other themes look as before. Shared `--ix-hover`, `--ix-hover-border`, `--ix-press`, `--ix-after`, `--ix-sel-ring`, `--ix-sel-bg` and `--ix-hover-bg` derive from `--hsl-ring`. A `[data-slot="card"]` rule re-declares `--card` and `--well` per card level.
|
|
23
|
+
|
|
24
|
+
UI: new `ixControl`, `ixControlAfter`, `ixControlSoft`, `ixField`, `ixSelected`, `ixTabUnderline` and `ixRow` (exported from `@tangle-network/ui/utils`) give hover, press, after-click and selected states in the focus ring hue; the keyboard focus glow is unchanged. Button, Input, Textarea, `focusFieldWithin`, SelectTrigger/Item/Content, DropdownMenu content and items, Card, SegmentedControl and TabsTrigger apply them. Card renders `data-slot="card"` with `--edge-highlight` + `--shadow-card`, gains `interactive`, and CardDescription clamps at two lines. Select and DropdownMenu content paint the popover level. See docs/surfaces-and-states.md.
|
|
25
|
+
|
|
3
26
|
## 11.32.2
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
|
@@ -12,6 +12,12 @@ export interface DocumentEditorPaneProps extends Omit<ArtifactPaneProps, "childr
|
|
|
12
12
|
markdown?: string;
|
|
13
13
|
/** Persisted baseline when the host controls the current markdown draft. */
|
|
14
14
|
savedMarkdown?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the draft has unsaved edits, when the host tracks that itself
|
|
17
|
+
* (for example against the file's full bytes, frontmatter included). It
|
|
18
|
+
* takes precedence over comparing the draft with `savedMarkdown`.
|
|
19
|
+
*/
|
|
20
|
+
dirty?: boolean;
|
|
15
21
|
/** Transform Markdown links and images through the shared URL policy. */
|
|
16
22
|
urlTransform?: UrlTransform;
|
|
17
23
|
mode?: DocumentEditorMode;
|
|
@@ -33,5 +39,5 @@ export interface DocumentEditorPaneProps extends Omit<ArtifactPaneProps, "childr
|
|
|
33
39
|
* DocumentEditorPane — reusable markdown document surface with preview/edit
|
|
34
40
|
* modes and optional collaborative editing backed by Yjs/Hocuspocus.
|
|
35
41
|
*/
|
|
36
|
-
export declare function DocumentEditorPane({ eyebrow, title, subtitle, meta, headerActions, footer, className, contentClassName, headerClassName, hideTitleBlock, tabs, toolbar, markdown, savedMarkdown, urlTransform, mode, defaultMode, onModeChange, backend, placeholder, autoFocus, readOnly, onChange, onSave, saving, saveLabel, previewClassName, editorClassName, collaboration }: DocumentEditorPaneProps): import("react").JSX.Element;
|
|
42
|
+
export declare function DocumentEditorPane({ eyebrow, title, subtitle, meta, headerActions, footer, className, contentClassName, headerClassName, hideTitleBlock, tabs, toolbar, markdown, savedMarkdown, dirty, urlTransform, mode, defaultMode, onModeChange, backend, placeholder, autoFocus, readOnly, onChange, onSave, saving, saveLabel, previewClassName, editorClassName, collaboration }: DocumentEditorPaneProps): import("react").JSX.Element;
|
|
37
43
|
//#endregion
|
|
@@ -79,13 +79,24 @@ function CollaborativeDocumentSurface({ markdown, placeholder, autoFocus, readOn
|
|
|
79
79
|
* DocumentEditorPane — reusable markdown document surface with preview/edit
|
|
80
80
|
* modes and optional collaborative editing backed by Yjs/Hocuspocus.
|
|
81
81
|
*/
|
|
82
|
-
function DocumentEditorPane({ eyebrow, title, subtitle, meta, headerActions, footer, className, contentClassName, headerClassName, hideTitleBlock, tabs, toolbar, markdown = "", savedMarkdown, urlTransform, mode, defaultMode = "preview", onModeChange, backend = "local", placeholder = "Start writing...", autoFocus = false, readOnly = false, onChange, onSave, saving = false, saveLabel = "Save changes", previewClassName, editorClassName, collaboration }) {
|
|
82
|
+
function DocumentEditorPane({ eyebrow, title, subtitle, meta, headerActions, footer, className, contentClassName, headerClassName, hideTitleBlock, tabs, toolbar, markdown = "", savedMarkdown, dirty, urlTransform, mode, defaultMode = "preview", onModeChange, backend = "local", placeholder = "Start writing...", autoFocus = false, readOnly = false, onChange, onSave, saving = false, saveLabel = "Save changes", previewClassName, editorClassName, collaboration }) {
|
|
83
83
|
const [draft, setDraft] = useState(markdown);
|
|
84
84
|
const [uncontrolledMode, setUncontrolledMode] = useState(defaultMode);
|
|
85
85
|
const activeMode = mode ?? uncontrolledMode;
|
|
86
86
|
const isCollaborative = backend === "collaborative" && Boolean(collaboration);
|
|
87
|
-
const isDirty = normalizeMarkdown(draft) !== normalizeMarkdown(savedMarkdown ?? markdown);
|
|
88
|
-
const
|
|
87
|
+
const isDirty = dirty ?? normalizeMarkdown(draft) !== normalizeMarkdown(savedMarkdown ?? markdown);
|
|
88
|
+
const canSave = Boolean(onSave) && !readOnly && isDirty && !saving;
|
|
89
|
+
const saveStateLabel = readOnly ? "Read only" : isCollaborative ? isDirty ? "Snapshot pending" : "Live document current" : saving ? "Saving…" : isDirty ? "Unsaved changes" : "Saved";
|
|
90
|
+
const save = () => {
|
|
91
|
+
if (canSave) onSave?.(draft);
|
|
92
|
+
};
|
|
93
|
+
const handleKeyDown = (event) => {
|
|
94
|
+
if (!onSave || readOnly) return;
|
|
95
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
|
|
96
|
+
if (event.key.toLowerCase() !== "s") return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
save();
|
|
99
|
+
};
|
|
89
100
|
useEffect(() => {
|
|
90
101
|
setDraft(markdown);
|
|
91
102
|
}, [markdown]);
|
|
@@ -117,23 +128,26 @@ function DocumentEditorPane({ eyebrow, title, subtitle, meta, headerActions, foo
|
|
|
117
128
|
children: [/* @__PURE__ */ jsx(PencilLine, { className: "h-3.5 w-3.5" }), isCollaborative ? "Live edit" : "Edit"]
|
|
118
129
|
})]
|
|
119
130
|
}), /* @__PURE__ */ jsxs("div", {
|
|
120
|
-
className: "flex flex-wrap items-center gap-2 text-
|
|
131
|
+
className: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground",
|
|
121
132
|
children: [
|
|
122
133
|
toolbar,
|
|
123
|
-
/* @__PURE__ */ jsx("span", {
|
|
134
|
+
isCollaborative && /* @__PURE__ */ jsx("span", {
|
|
124
135
|
className: "rounded-full border border-border bg-card px-2.5 py-1 font-medium",
|
|
125
|
-
children:
|
|
136
|
+
children: "Live document"
|
|
126
137
|
}),
|
|
127
138
|
/* @__PURE__ */ jsx("span", {
|
|
128
|
-
|
|
139
|
+
role: "status",
|
|
140
|
+
"aria-live": "polite",
|
|
141
|
+
className: cn("rounded-full border px-2.5 py-1", isDirty && !readOnly ? "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)]" : "border-border bg-background"),
|
|
129
142
|
children: saveStateLabel
|
|
130
143
|
}),
|
|
131
|
-
onSave && !readOnly && /* @__PURE__ */ jsxs("button", {
|
|
144
|
+
onSave && !readOnly && (isDirty || saving) && /* @__PURE__ */ jsxs("button", {
|
|
132
145
|
type: "button",
|
|
133
|
-
onClick:
|
|
134
|
-
disabled:
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
onClick: save,
|
|
147
|
+
disabled: !canSave,
|
|
148
|
+
title: "Save changes (Ctrl+S)",
|
|
149
|
+
className: cn("inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50", focusRing),
|
|
150
|
+
children: [/* @__PURE__ */ jsx(Save, { className: "h-4 w-4" }), saveLabel]
|
|
137
151
|
})
|
|
138
152
|
]
|
|
139
153
|
})]
|
|
@@ -169,6 +183,7 @@ function DocumentEditorPane({ eyebrow, title, subtitle, meta, headerActions, foo
|
|
|
169
183
|
return /* @__PURE__ */ jsx(Tabs, {
|
|
170
184
|
value: activeMode,
|
|
171
185
|
onValueChange: (nextValue) => setMode(nextValue),
|
|
186
|
+
onKeyDown: handleKeyDown,
|
|
172
187
|
className: "h-full",
|
|
173
188
|
children: /* @__PURE__ */ jsxs(ArtifactPane, {
|
|
174
189
|
eyebrow,
|
|
@@ -4,7 +4,8 @@ import { loadDocumentEditorPeers } from "./editor-peers.js";
|
|
|
4
4
|
import { retryableLazyEditor } from "./editor-lazy.js";
|
|
5
5
|
import { EditorLoadingPlaceholder } from "./editor-loading.js";
|
|
6
6
|
import { EditorToolbar } from "./editor-toolbar.js";
|
|
7
|
-
import {
|
|
7
|
+
import { normalizeMarkdown } from "./markdown-conversion.js";
|
|
8
|
+
import { SourceMarkdownSession, createSourceMarkdownExtensions } from "./source-markdown-editor.js";
|
|
8
9
|
import { Suspense, useEffect, useMemo, useRef } from "react";
|
|
9
10
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
11
|
//#region src/editor/markdown-document-editor.tsx
|
|
@@ -12,15 +13,28 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
12
13
|
* Builds the local markdown editor against loaded tiptap namespaces. The
|
|
13
14
|
* peers arrive as an argument so that this module reaches them through
|
|
14
15
|
* type-only imports, which a bundler erases.
|
|
16
|
+
*
|
|
17
|
+
* The editor writes back the file's own bytes wherever the reader changed
|
|
18
|
+
* nothing (see `source-markdown.ts`): opening a file and leaving it reports no
|
|
19
|
+
* change, and an edit changes only the edited span. Blocks it cannot represent,
|
|
20
|
+
* such as tables, are shown read-only and kept verbatim.
|
|
15
21
|
*/
|
|
16
22
|
function createMarkdownDocumentEditor(peers) {
|
|
17
23
|
const { EditorContent, useEditor } = peers.react;
|
|
18
24
|
const StarterKit = peers.starterKit.default;
|
|
25
|
+
const sourceExtensions = createSourceMarkdownExtensions(peers.react);
|
|
19
26
|
return function MarkdownDocumentEditor({ value = "", placeholder = "Start writing...", readOnly = false, autoFocus = false, className, contentClassName, onChange, onReady }) {
|
|
20
|
-
const
|
|
21
|
-
|
|
27
|
+
const sessionRef = useRef(null);
|
|
28
|
+
if (!sessionRef.current) sessionRef.current = new SourceMarkdownSession(peers.react, value);
|
|
29
|
+
const initialHtml = useMemo(() => sessionRef.current.html, []);
|
|
30
|
+
const lastMarkdownRef = useRef(value);
|
|
31
|
+
const onChangeRef = useRef(onChange);
|
|
32
|
+
onChangeRef.current = onChange;
|
|
22
33
|
const editor = useEditor({
|
|
23
|
-
extensions: [StarterKit.configure({
|
|
34
|
+
extensions: [StarterKit.configure({
|
|
35
|
+
codeBlock: { HTMLAttributes: { class: "hljs" } },
|
|
36
|
+
underline: false
|
|
37
|
+
}), ...sourceExtensions],
|
|
24
38
|
content: initialHtml,
|
|
25
39
|
editable: !readOnly,
|
|
26
40
|
autofocus: autoFocus,
|
|
@@ -29,23 +43,27 @@ function createMarkdownDocumentEditor(peers) {
|
|
|
29
43
|
"data-placeholder": placeholder
|
|
30
44
|
} },
|
|
31
45
|
onUpdate: ({ editor: currentEditor }) => {
|
|
32
|
-
const nextMarkdown =
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
const nextMarkdown = sessionRef.current.serialize(currentEditor.state.doc);
|
|
47
|
+
if (nextMarkdown === lastMarkdownRef.current) return;
|
|
48
|
+
lastMarkdownRef.current = nextMarkdown;
|
|
49
|
+
onChangeRef.current?.(nextMarkdown, currentEditor);
|
|
35
50
|
},
|
|
36
51
|
onCreate: ({ editor: currentEditor }) => {
|
|
52
|
+
sessionRef.current.attach(currentEditor);
|
|
37
53
|
onReady?.(currentEditor);
|
|
38
54
|
}
|
|
39
55
|
});
|
|
40
56
|
useEffect(() => {
|
|
41
|
-
|
|
57
|
+
editor?.setEditable(!readOnly, false);
|
|
42
58
|
}, [editor, readOnly]);
|
|
43
59
|
useEffect(() => {
|
|
44
60
|
if (!editor) return;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
if (normalizeMarkdown(value) === normalizeMarkdown(lastMarkdownRef.current)) return;
|
|
62
|
+
const session = new SourceMarkdownSession(peers.react, value);
|
|
63
|
+
sessionRef.current = session;
|
|
64
|
+
editor.commands.setContent(session.html, { emitUpdate: false });
|
|
65
|
+
session.attach(editor);
|
|
66
|
+
lastMarkdownRef.current = value;
|
|
49
67
|
}, [editor, value]);
|
|
50
68
|
return /* @__PURE__ */ jsxs("div", {
|
|
51
69
|
className: cn("flex min-h-[14rem] w-full flex-col overflow-hidden rounded-lg border border-border bg-background", className),
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { BLOCK_ID_ATTRIBUTE, SOURCE_KIND_ATTRIBUTE, SOURCE_RAW_ATTRIBUTE, asReadOnlyDocument, blockHtml, blockMarkdown, createBlockMerger, parseSourceMarkdown, serializeSourceMarkdown, withReadOnlyBlocks } from "./source-markdown.js";
|
|
2
|
+
import { marked } from "marked";
|
|
3
|
+
//#region src/editor/source-markdown-editor.ts
|
|
4
|
+
/** Name of the read-only node that holds a block's raw Markdown. */
|
|
5
|
+
const SOURCE_NODE = "markdownSource";
|
|
6
|
+
/** Top-level node types that can carry a block id. */
|
|
7
|
+
const BLOCK_TYPES = [
|
|
8
|
+
"paragraph",
|
|
9
|
+
"heading",
|
|
10
|
+
"bulletList",
|
|
11
|
+
"orderedList",
|
|
12
|
+
"blockquote",
|
|
13
|
+
"codeBlock",
|
|
14
|
+
"horizontalRule",
|
|
15
|
+
SOURCE_NODE
|
|
16
|
+
];
|
|
17
|
+
const CAPTIONS = {
|
|
18
|
+
table: "Table",
|
|
19
|
+
html: "HTML",
|
|
20
|
+
image: "Image",
|
|
21
|
+
checklist: "Checklist",
|
|
22
|
+
definition: "Link definition",
|
|
23
|
+
markdown: "Markdown",
|
|
24
|
+
paragraph: "Markdown",
|
|
25
|
+
heading: "Markdown",
|
|
26
|
+
code: "Markdown",
|
|
27
|
+
blockquote: "Markdown",
|
|
28
|
+
list: "Markdown",
|
|
29
|
+
hr: "Markdown"
|
|
30
|
+
};
|
|
31
|
+
function inlineText(tokens) {
|
|
32
|
+
return (tokens ?? []).map((token) => {
|
|
33
|
+
switch (token.type) {
|
|
34
|
+
case "image": return token.text;
|
|
35
|
+
case "br": return " ";
|
|
36
|
+
case "html": return "";
|
|
37
|
+
default: {
|
|
38
|
+
const children = token.tokens;
|
|
39
|
+
return children ? inlineText(children) : token.text ?? "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}).join("");
|
|
43
|
+
}
|
|
44
|
+
function tablePreview(raw) {
|
|
45
|
+
const table = marked.lexer(raw, { gfm: true }).find((token) => token.type === "table");
|
|
46
|
+
if (!table) return null;
|
|
47
|
+
const cell = (tag, value) => [
|
|
48
|
+
tag,
|
|
49
|
+
{},
|
|
50
|
+
inlineText(value.tokens)
|
|
51
|
+
];
|
|
52
|
+
return [
|
|
53
|
+
"table",
|
|
54
|
+
{},
|
|
55
|
+
[
|
|
56
|
+
"thead",
|
|
57
|
+
{},
|
|
58
|
+
[
|
|
59
|
+
"tr",
|
|
60
|
+
{},
|
|
61
|
+
...table.header.map((value) => cell("th", value))
|
|
62
|
+
]
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
"tbody",
|
|
66
|
+
{},
|
|
67
|
+
...table.rows.map((row) => [
|
|
68
|
+
"tr",
|
|
69
|
+
{},
|
|
70
|
+
...row.map((value) => cell("td", value))
|
|
71
|
+
])
|
|
72
|
+
]
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
function sourcePreview(raw, kind) {
|
|
76
|
+
const caption = [
|
|
77
|
+
"p",
|
|
78
|
+
{ class: "not-prose m-0 mb-2 text-sm text-muted-foreground" },
|
|
79
|
+
`${CAPTIONS[kind] ?? "Markdown"} kept exactly as written. Edit it in the Markdown source.`
|
|
80
|
+
];
|
|
81
|
+
const table = kind === "table" ? tablePreview(raw) : null;
|
|
82
|
+
if (table) return [caption, [
|
|
83
|
+
"div",
|
|
84
|
+
{ class: "overflow-x-auto" },
|
|
85
|
+
table
|
|
86
|
+
]];
|
|
87
|
+
return [caption, [
|
|
88
|
+
"pre",
|
|
89
|
+
{ class: "m-0 whitespace-pre-wrap" },
|
|
90
|
+
[
|
|
91
|
+
"code",
|
|
92
|
+
{},
|
|
93
|
+
raw
|
|
94
|
+
]
|
|
95
|
+
]];
|
|
96
|
+
}
|
|
97
|
+
/** The read-only source node and the block-id attribute, built from the loaded tiptap. */
|
|
98
|
+
function createSourceMarkdownExtensions(core) {
|
|
99
|
+
return [core.Node.create({
|
|
100
|
+
name: SOURCE_NODE,
|
|
101
|
+
group: "block",
|
|
102
|
+
atom: true,
|
|
103
|
+
selectable: true,
|
|
104
|
+
draggable: false,
|
|
105
|
+
addAttributes() {
|
|
106
|
+
return {
|
|
107
|
+
raw: {
|
|
108
|
+
default: "",
|
|
109
|
+
parseHTML: (element) => element.getAttribute("data-md-raw") ?? "",
|
|
110
|
+
renderHTML: (attributes) => ({ [SOURCE_RAW_ATTRIBUTE]: attributes.raw })
|
|
111
|
+
},
|
|
112
|
+
kind: {
|
|
113
|
+
default: "markdown",
|
|
114
|
+
parseHTML: (element) => element.getAttribute("data-md-kind") ?? "markdown",
|
|
115
|
+
renderHTML: (attributes) => ({ [SOURCE_KIND_ATTRIBUTE]: attributes.kind })
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
parseHTML() {
|
|
120
|
+
return [{ tag: `div[${SOURCE_RAW_ATTRIBUTE}]` }];
|
|
121
|
+
},
|
|
122
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
123
|
+
return [
|
|
124
|
+
"div",
|
|
125
|
+
core.mergeAttributes(HTMLAttributes, { class: "my-3 rounded-lg border border-dashed border-border bg-muted/40 p-3" }),
|
|
126
|
+
...sourcePreview(String(node.attrs.raw ?? ""), node.attrs.kind)
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
}), core.Extension.create({
|
|
130
|
+
name: "markdownSourceBlockId",
|
|
131
|
+
addGlobalAttributes() {
|
|
132
|
+
return [{
|
|
133
|
+
types: BLOCK_TYPES,
|
|
134
|
+
attributes: { mdBlock: {
|
|
135
|
+
default: null,
|
|
136
|
+
keepOnSplit: false,
|
|
137
|
+
parseHTML: (element) => element.getAttribute(BLOCK_ID_ATTRIBUTE),
|
|
138
|
+
renderHTML: (attributes) => attributes.mdBlock ? { [BLOCK_ID_ATTRIBUTE]: attributes.mdBlock } : {}
|
|
139
|
+
} }
|
|
140
|
+
}];
|
|
141
|
+
}
|
|
142
|
+
})];
|
|
143
|
+
}
|
|
144
|
+
function blockIdOf(node) {
|
|
145
|
+
const id = node.attrs.mdBlock;
|
|
146
|
+
return typeof id === "string" && id ? id : null;
|
|
147
|
+
}
|
|
148
|
+
function isEmptyParagraph(node) {
|
|
149
|
+
return node.type.name === "paragraph" && node.content.size === 0;
|
|
150
|
+
}
|
|
151
|
+
function withoutBlockIds(value) {
|
|
152
|
+
if (Array.isArray(value)) return value.map(withoutBlockIds);
|
|
153
|
+
if (!value || typeof value !== "object") return value;
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
156
|
+
if (key === "mdBlock") continue;
|
|
157
|
+
out[key] = withoutBlockIds(entry);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* One open file in the rich editor: its source blocks, the nodes they were
|
|
163
|
+
* first parsed into, and the Markdown written back for the current document.
|
|
164
|
+
*/
|
|
165
|
+
var SourceMarkdownSession = class {
|
|
166
|
+
source;
|
|
167
|
+
core;
|
|
168
|
+
markdown;
|
|
169
|
+
initial = /* @__PURE__ */ new Map();
|
|
170
|
+
mergers = /* @__PURE__ */ new Map();
|
|
171
|
+
constructor(core, markdown) {
|
|
172
|
+
this.core = core;
|
|
173
|
+
this.markdown = markdown;
|
|
174
|
+
this.source = parseSourceMarkdown(markdown);
|
|
175
|
+
}
|
|
176
|
+
get html() {
|
|
177
|
+
return this.source.html;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Records the nodes the editor parsed. A block the editor document does not
|
|
181
|
+
* hold as exactly one top-level node (dropped, split, or merged by the
|
|
182
|
+
* schema) is shown read-only instead, so no source byte can be lost.
|
|
183
|
+
*/
|
|
184
|
+
attach(editor) {
|
|
185
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
186
|
+
const unmapped = this.capture(editor.state.doc);
|
|
187
|
+
if (unmapped.size === 0) return;
|
|
188
|
+
this.source = attempt < 2 ? withReadOnlyBlocks(this.source, unmapped) : asReadOnlyDocument(this.markdown);
|
|
189
|
+
editor.commands.setContent(this.source.html, { emitUpdate: false });
|
|
190
|
+
}
|
|
191
|
+
this.capture(editor.state.doc);
|
|
192
|
+
}
|
|
193
|
+
capture(doc) {
|
|
194
|
+
this.initial.clear();
|
|
195
|
+
this.mergers.clear();
|
|
196
|
+
const unmapped = /* @__PURE__ */ new Set();
|
|
197
|
+
let previous = null;
|
|
198
|
+
doc.forEach((node, _offset, index) => {
|
|
199
|
+
const id = blockIdOf(node);
|
|
200
|
+
if (id && this.source.byId.has(id)) {
|
|
201
|
+
if (this.initial.has(id)) unmapped.add(id);
|
|
202
|
+
else this.initial.set(id, node);
|
|
203
|
+
previous = id;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (index === doc.childCount - 1 && isEmptyParagraph(node)) return;
|
|
207
|
+
const owner = previous ?? this.source.blocks[0]?.id;
|
|
208
|
+
if (owner) unmapped.add(owner);
|
|
209
|
+
});
|
|
210
|
+
for (const block of this.source.blocks) if (!this.initial.has(block.id)) unmapped.add(block.id);
|
|
211
|
+
return unmapped;
|
|
212
|
+
}
|
|
213
|
+
toMarkdown(node, raw) {
|
|
214
|
+
const fragment = node.type.schema.topNodeType.create(null, node).content;
|
|
215
|
+
return blockMarkdown(this.core.getHTMLFromFragment(fragment, node.type.schema), raw);
|
|
216
|
+
}
|
|
217
|
+
reparses(markdown, node, schema) {
|
|
218
|
+
const html = blockHtml(markdown, this.source.links);
|
|
219
|
+
if (html === null) return false;
|
|
220
|
+
const parsed = this.core.createDocument(html, schema);
|
|
221
|
+
const nodes = [];
|
|
222
|
+
parsed.forEach((child) => {
|
|
223
|
+
if (!isEmptyParagraph(child)) nodes.push(child);
|
|
224
|
+
});
|
|
225
|
+
return nodes.length === 1 && JSON.stringify(withoutBlockIds(nodes[0].toJSON())) === JSON.stringify(withoutBlockIds(node.toJSON()));
|
|
226
|
+
}
|
|
227
|
+
mergerFor(id, initial, raw) {
|
|
228
|
+
let merge = this.mergers.get(id);
|
|
229
|
+
if (!merge) {
|
|
230
|
+
merge = createBlockMerger(raw, this.toMarkdown(initial, raw));
|
|
231
|
+
this.mergers.set(id, merge);
|
|
232
|
+
}
|
|
233
|
+
return merge;
|
|
234
|
+
}
|
|
235
|
+
/** The file's Markdown for `doc`: original bytes wherever the reader changed nothing. */
|
|
236
|
+
serialize(doc) {
|
|
237
|
+
const nodes = [];
|
|
238
|
+
doc.forEach((node) => {
|
|
239
|
+
const id = blockIdOf(node);
|
|
240
|
+
const block = id ? this.source.byId.get(id) : void 0;
|
|
241
|
+
const initial = id ? this.initial.get(id) : void 0;
|
|
242
|
+
const raw = node.type.name === "markdownSource" ? String(node.attrs.raw ?? "") : null;
|
|
243
|
+
nodes.push({
|
|
244
|
+
block: initial ? block : void 0,
|
|
245
|
+
unchanged: Boolean(initial?.eq(node)),
|
|
246
|
+
sameType: initial?.type === node.type,
|
|
247
|
+
markdown: () => raw ?? this.toMarkdown(node, block?.raw ?? ""),
|
|
248
|
+
merge: (edited) => initial && block ? this.mergerFor(block.id, initial, block.raw)(edited) : null,
|
|
249
|
+
reparses: (markdown) => this.reparses(markdown, node, node.type.schema)
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
return serializeSourceMarkdown(this.source, nodes);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
//#endregion
|
|
256
|
+
export { SOURCE_NODE, SourceMarkdownSession, createSourceMarkdownExtensions };
|