@tangle-network/ui 11.33.0 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/editor/document-editor-pane.d.ts +7 -1
  3. package/dist/editor/document-editor-pane.js +27 -12
  4. package/dist/editor/markdown-document-editor.js +30 -12
  5. package/dist/editor/source-markdown-editor.js +256 -0
  6. package/dist/editor/source-markdown.js +495 -0
  7. package/dist/files/file-artifact-pane.d.ts +2 -0
  8. package/dist/files/file-artifact-pane.js +1 -0
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/markdown/markdown-table.js +1 -1
  12. package/dist/run/inline-thinking-item.d.ts +7 -1
  13. package/dist/run/inline-thinking-item.js +6 -5
  14. package/dist/utils/format.d.ts +6 -0
  15. package/dist/utils/format.js +9 -1
  16. package/dist/utils.d.ts +2 -2
  17. package/dist/utils.js +2 -2
  18. package/package.json +2 -2
  19. package/src/editor/document-editor-pane.tsx +54 -16
  20. package/src/editor/markdown-document-editor.tsx +33 -18
  21. package/src/editor/source-markdown-editor.test.tsx +156 -0
  22. package/src/editor/source-markdown-editor.ts +296 -0
  23. package/src/editor/source-markdown.fixtures.ts +90 -0
  24. package/src/editor/source-markdown.test.ts +235 -0
  25. package/src/editor/source-markdown.ts +627 -0
  26. package/src/files/file-artifact-pane.test.tsx +34 -1
  27. package/src/files/file-artifact-pane.tsx +3 -0
  28. package/src/markdown/markdown-table.test.tsx +9 -0
  29. package/src/markdown/markdown-table.tsx +5 -1
  30. package/src/run/inline-thinking-item.test.tsx +29 -0
  31. package/src/run/inline-thinking-item.tsx +11 -4
  32. package/src/utils/format.test.ts +16 -1
  33. package/src/utils/format.ts +17 -0
  34. package/src/utils/index.ts +1 -1
@@ -1,6 +1,12 @@
1
1
  //#region src/utils/format.d.ts
2
2
  /** Format a duration in milliseconds to a human-readable string. */
3
3
  export declare function formatDuration(ms: number): string;
4
+ /**
5
+ * Markdown read as plain text, for a one-line preview: code fences, emphasis,
6
+ * headings, list and quote markers, and link syntax are removed, so a preview
7
+ * never shows `**` or `#`. Underscores inside words (`snake_case`) are kept.
8
+ */
9
+ export declare function plainTextFromMarkdown(text: string): string;
4
10
  /** Truncate text to `max` characters, appending "..." if truncated. */
5
11
  export declare function truncateText(text: string, max: number): string;
6
12
  /**
@@ -8,6 +8,14 @@ function formatDuration(ms) {
8
8
  const remaining = seconds % 60;
9
9
  return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`;
10
10
  }
11
+ /**
12
+ * Markdown read as plain text, for a one-line preview: code fences, emphasis,
13
+ * headings, list and quote markers, and link syntax are removed, so a preview
14
+ * never shows `**` or `#`. Underscores inside words (`snake_case`) are kept.
15
+ */
16
+ function plainTextFromMarkdown(text) {
17
+ return text.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^[ \t]{0,3}(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)/gm, "").replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2").replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2").replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2").replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1");
18
+ }
11
19
  /** Truncate text to `max` characters, appending "..." if truncated. */
12
20
  function truncateText(text, max) {
13
21
  const cleaned = text.replace(/\s+/g, " ").trim();
@@ -51,4 +59,4 @@ function formatBytes(bytes) {
51
59
  return `${gb < 10 ? gb.toFixed(2) : gb.toFixed(1)} GB`;
52
60
  }
53
61
  //#endregion
54
- export { formatBytes, formatDuration, formatUptime, truncateText };
62
+ export { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText };
package/dist/utils.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { copyText } from "./utils/copy-text.js";
2
- import { formatBytes, formatDuration, formatUptime, truncateText } from "./utils/format.js";
2
+ import { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
3
3
  import { timeAgo } from "./utils/time-ago.js";
4
4
  import { TOOL_CATEGORY_ICONS, getToolCategory, getToolDisplayMetadata, getToolErrorText } from "./utils/tool-display.js";
5
5
  import { cn } from "./lib/utils.js";
6
6
  import { focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset } from "./lib/focus.js";
7
7
  import { ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline } from "./lib/interaction.js";
8
- export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, timeAgo, truncateText };
8
+ export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
package/dist/utils.js CHANGED
@@ -2,7 +2,7 @@ import { ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, i
2
2
  import { focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset } from "./lib/focus.js";
3
3
  import { cn } from "./lib/utils.js";
4
4
  import { TOOL_CATEGORY_ICONS, getToolCategory, getToolDisplayMetadata, getToolErrorText } from "./utils/tool-display.js";
5
- import { formatBytes, formatDuration, formatUptime, truncateText } from "./utils/format.js";
5
+ import { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
6
6
  import { copyText } from "./utils/copy-text.js";
7
7
  import { timeAgo } from "./utils/time-ago.js";
8
- export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, timeAgo, truncateText };
8
+ export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.33.0",
3
+ "version": "11.34.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -150,7 +150,7 @@
150
150
  "peerDependencies": {
151
151
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
152
152
  "@nanostores/react": ">=0.8.4 <3.0.0",
153
- "@tangle-network/brand": "^1.23.0",
153
+ "@tangle-network/brand": "^1.24.1",
154
154
  "@tiptap/core": ">=3.20.4 <4.0.0",
155
155
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
156
156
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { type ReactNode, useEffect, useMemo, useState } from "react";
3
+ import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useState } from "react";
4
4
  import type { UrlTransform } from "react-markdown";
5
5
  import { PencilLine, Save, Users, Wifi, WifiOff } from "lucide-react";
6
6
  import { Markdown } from "../markdown/markdown";
@@ -39,6 +39,12 @@ export interface DocumentEditorPaneProps
39
39
  markdown?: string;
40
40
  /** Persisted baseline when the host controls the current markdown draft. */
41
41
  savedMarkdown?: string;
42
+ /**
43
+ * Whether the draft has unsaved edits, when the host tracks that itself
44
+ * (for example against the file's full bytes, frontmatter included). It
45
+ * takes precedence over comparing the draft with `savedMarkdown`.
46
+ */
47
+ dirty?: boolean;
42
48
  /** Transform Markdown links and images through the shared URL policy. */
43
49
  urlTransform?: UrlTransform;
44
50
  mode?: DocumentEditorMode;
@@ -194,6 +200,7 @@ export function DocumentEditorPane({
194
200
  toolbar,
195
201
  markdown = "",
196
202
  savedMarkdown,
203
+ dirty,
197
204
  urlTransform,
198
205
  mode,
199
206
  defaultMode = "preview",
@@ -215,16 +222,32 @@ export function DocumentEditorPane({
215
222
  useState<DocumentEditorMode>(defaultMode);
216
223
  const activeMode = mode ?? uncontrolledMode;
217
224
  const isCollaborative = backend === "collaborative" && Boolean(collaboration);
218
- const isDirty = normalizeMarkdown(draft) !== normalizeMarkdown(savedMarkdown ?? markdown);
225
+ const isDirty = dirty ?? normalizeMarkdown(draft) !== normalizeMarkdown(savedMarkdown ?? markdown);
226
+ const canSave = Boolean(onSave) && !readOnly && isDirty && !saving;
219
227
  const saveStateLabel = readOnly
220
228
  ? "Read only"
221
229
  : isCollaborative
222
230
  ? isDirty
223
231
  ? "Snapshot pending"
224
232
  : "Live document current"
225
- : isDirty
226
- ? "Unsaved changes"
227
- : "Saved";
233
+ : saving
234
+ ? "Saving…"
235
+ : isDirty
236
+ ? "Unsaved changes"
237
+ : "Saved";
238
+
239
+ const save = () => {
240
+ if (canSave) void onSave?.(draft);
241
+ };
242
+
243
+ // Ctrl+S / Cmd+S saves from anywhere in the pane, the editor included.
244
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
245
+ if (!onSave || readOnly) return;
246
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
247
+ if (event.key.toLowerCase() !== "s") return;
248
+ event.preventDefault();
249
+ save();
250
+ };
228
251
 
229
252
  useEffect(() => {
230
253
  setDraft(markdown);
@@ -271,26 +294,40 @@ export function DocumentEditorPane({
271
294
  </TabsTrigger>
272
295
  </TabsList>
273
296
 
274
- <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
297
+ {/* One save state: the status says whether the draft is saved, and the
298
+ Save button exists only while there is something to save. */}
299
+ <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
275
300
  {toolbar}
276
- <span className="rounded-full border border-border bg-card px-2.5 py-1 font-medium">
277
- {isCollaborative ? "Live document" : "Local draft"}
278
- </span>
279
- <span className="rounded-full border border-border bg-background px-2.5 py-1">
301
+ {isCollaborative && (
302
+ <span className="rounded-full border border-border bg-card px-2.5 py-1 font-medium">
303
+ Live document
304
+ </span>
305
+ )}
306
+ <span
307
+ role="status"
308
+ aria-live="polite"
309
+ className={cn(
310
+ "rounded-full border px-2.5 py-1",
311
+ isDirty && !readOnly
312
+ ? "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)]"
313
+ : "border-border bg-background",
314
+ )}
315
+ >
280
316
  {saveStateLabel}
281
317
  </span>
282
- {onSave && !readOnly && (
318
+ {onSave && !readOnly && (isDirty || saving) && (
283
319
  <button
284
320
  type="button"
285
- onClick={() => void onSave(draft)}
286
- disabled={saving || !isDirty}
321
+ onClick={save}
322
+ disabled={!canSave}
323
+ title="Save changes (Ctrl+S)"
287
324
  className={cn(
288
- "inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-border bg-card px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:border-[var(--border-strong)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",
325
+ "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",
289
326
  focusRing,
290
327
  )}
291
328
  >
292
- <Save className="h-3.5 w-3.5" />
293
- {saving ? "Saving..." : saveLabel}
329
+ <Save className="h-4 w-4" />
330
+ {saveLabel}
294
331
  </button>
295
332
  )}
296
333
  </div>
@@ -341,6 +378,7 @@ export function DocumentEditorPane({
341
378
  <Tabs
342
379
  value={activeMode}
343
380
  onValueChange={(nextValue) => setMode(nextValue as DocumentEditorMode)}
381
+ onKeyDown={handleKeyDown}
344
382
  className="h-full"
345
383
  >
346
384
  <ArtifactPane
@@ -7,11 +7,8 @@ import { retryableLazyEditor } from "./editor-lazy";
7
7
  import { EditorLoadingPlaceholder } from "./editor-loading";
8
8
  import { type DocumentEditorPeers, loadDocumentEditorPeers } from "./editor-peers";
9
9
  import { EditorToolbar } from "./editor-toolbar";
10
- import {
11
- htmlToMarkdown,
12
- markdownToHtml,
13
- normalizeMarkdown,
14
- } from "./markdown-conversion";
10
+ import { normalizeMarkdown } from "./markdown-conversion";
11
+ import { createSourceMarkdownExtensions, SourceMarkdownSession } from "./source-markdown-editor";
15
12
 
16
13
  export interface MarkdownDocumentEditorProps {
17
14
  value?: string;
@@ -28,12 +25,18 @@ export interface MarkdownDocumentEditorProps {
28
25
  * Builds the local markdown editor against loaded tiptap namespaces. The
29
26
  * peers arrive as an argument so that this module reaches them through
30
27
  * type-only imports, which a bundler erases.
28
+ *
29
+ * The editor writes back the file's own bytes wherever the reader changed
30
+ * nothing (see `source-markdown.ts`): opening a file and leaving it reports no
31
+ * change, and an edit changes only the edited span. Blocks it cannot represent,
32
+ * such as tables, are shown read-only and kept verbatim.
31
33
  */
32
34
  export function createMarkdownDocumentEditor(
33
35
  peers: DocumentEditorPeers,
34
36
  ): ComponentType<MarkdownDocumentEditorProps> {
35
37
  const { EditorContent, useEditor } = peers.react;
36
38
  const StarterKit = peers.starterKit.default;
39
+ const sourceExtensions = createSourceMarkdownExtensions(peers.react);
37
40
 
38
41
  return function MarkdownDocumentEditor({
39
42
  value = "",
@@ -45,8 +48,13 @@ export function createMarkdownDocumentEditor(
45
48
  onChange,
46
49
  onReady,
47
50
  }: MarkdownDocumentEditorProps) {
48
- const initialHtml = useMemo(() => markdownToHtml(value), []);
49
- const lastAppliedMarkdownRef = useRef(normalizeMarkdown(value));
51
+ const sessionRef = useRef<SourceMarkdownSession | null>(null);
52
+ if (!sessionRef.current) sessionRef.current = new SourceMarkdownSession(peers.react, value);
53
+ const initialHtml = useMemo(() => sessionRef.current!.html, []);
54
+ // The Markdown last written by this editor or applied from `value`.
55
+ const lastMarkdownRef = useRef(value);
56
+ const onChangeRef = useRef(onChange);
57
+ onChangeRef.current = onChange;
50
58
 
51
59
  const editor = useEditor({
52
60
  extensions: [
@@ -56,7 +64,10 @@ export function createMarkdownDocumentEditor(
56
64
  class: "hljs",
57
65
  },
58
66
  },
67
+ // Markdown has no underline, so an underline could not be saved.
68
+ underline: false,
59
69
  }),
70
+ ...sourceExtensions,
60
71
  ],
61
72
  content: initialHtml,
62
73
  editable: !readOnly,
@@ -73,33 +84,37 @@ export function createMarkdownDocumentEditor(
73
84
  },
74
85
  },
75
86
  onUpdate: ({ editor: currentEditor }) => {
76
- const nextMarkdown = normalizeMarkdown(htmlToMarkdown(currentEditor.getHTML()));
77
- lastAppliedMarkdownRef.current = nextMarkdown;
78
- onChange?.(nextMarkdown, currentEditor);
87
+ const nextMarkdown = sessionRef.current!.serialize(currentEditor.state.doc);
88
+ // A transaction that changes no Markdown (a selection, a trailing empty
89
+ // paragraph) is not an edit.
90
+ if (nextMarkdown === lastMarkdownRef.current) return;
91
+ lastMarkdownRef.current = nextMarkdown;
92
+ onChangeRef.current?.(nextMarkdown, currentEditor);
79
93
  },
80
94
  onCreate: ({ editor: currentEditor }) => {
95
+ sessionRef.current!.attach(currentEditor);
81
96
  onReady?.(currentEditor);
82
97
  },
83
98
  });
84
99
 
85
100
  useEffect(() => {
86
- if (editor) {
87
- editor.setEditable(!readOnly);
88
- }
101
+ // No update event: toggling read-only is not an edit.
102
+ editor?.setEditable(!readOnly, false);
89
103
  }, [editor, readOnly]);
90
104
 
91
105
  useEffect(() => {
92
106
  if (!editor) {
93
107
  return;
94
108
  }
95
-
96
- const normalizedValue = normalizeMarkdown(value);
97
- if (normalizedValue === lastAppliedMarkdownRef.current) {
109
+ if (normalizeMarkdown(value) === normalizeMarkdown(lastMarkdownRef.current)) {
98
110
  return;
99
111
  }
100
112
 
101
- editor.commands.setContent(markdownToHtml(value), { emitUpdate: false });
102
- lastAppliedMarkdownRef.current = normalizedValue;
113
+ const session = new SourceMarkdownSession(peers.react, value);
114
+ sessionRef.current = session;
115
+ editor.commands.setContent(session.html, { emitUpdate: false });
116
+ session.attach(editor);
117
+ lastMarkdownRef.current = value;
103
118
  }, [editor, value]);
104
119
 
105
120
  return (
@@ -0,0 +1,156 @@
1
+ import { act, render, waitFor } from "@testing-library/react";
2
+ import type { Editor } from "@tiptap/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { MarkdownDocumentEditor } from "./markdown-document-editor";
5
+ import { CALL_SHEET, KITCHEN_SINK } from "./source-markdown.fixtures";
6
+
7
+ // The first render imports tiptap for real; a cold import on a busy runner
8
+ // takes several seconds.
9
+ const PEERS_LOADED = { timeout: 15_000 };
10
+ const LOADS_PEERS = 30_000;
11
+
12
+ async function openEditor(value: string) {
13
+ const onChange = vi.fn<(markdown: string) => void>();
14
+ let editor: Editor | null = null;
15
+ const view = render(
16
+ <MarkdownDocumentEditor
17
+ value={value}
18
+ onChange={(markdown) => onChange(markdown)}
19
+ onReady={(ready) => {
20
+ editor = ready;
21
+ }}
22
+ />,
23
+ );
24
+ // The first mount after the peers load can replace its editor instance, so
25
+ // wait for a live one and hand back the latest.
26
+ await waitFor(() => {
27
+ expect(editor).not.toBeNull();
28
+ expect((editor as Editor | null)?.isDestroyed).toBe(false);
29
+ }, PEERS_LOADED);
30
+ return {
31
+ view,
32
+ get editor() {
33
+ return editor! as Editor;
34
+ },
35
+ onChange,
36
+ /** The Markdown the editor last reported, or the value it opened. */
37
+ saved: () => onChange.mock.calls.at(-1)?.[0] ?? value,
38
+ };
39
+ }
40
+
41
+ /** Document position at the start or end of the first text node containing `needle`. */
42
+ function textPosition(editor: Editor, needle: string, edge: "start" | "end" = "end"): number {
43
+ let found = -1;
44
+ editor.state.doc.descendants((node, pos) => {
45
+ if (found >= 0) return false;
46
+ const index = node.isText ? (node.text ?? "").indexOf(needle) : -1;
47
+ if (index >= 0) found = pos + index + (edge === "end" ? needle.length : 0);
48
+ return true;
49
+ });
50
+ if (found < 0) throw new Error(`Not in the document: ${needle}`);
51
+ return found;
52
+ }
53
+
54
+ function insertAfter(editor: Editor, needle: string, text: string) {
55
+ act(() => {
56
+ editor.commands.insertContentAt(textPosition(editor, needle), text);
57
+ });
58
+ }
59
+
60
+ function tableRows(markdown: string) {
61
+ return markdown.split("\n").filter((line) => line.startsWith("|"));
62
+ }
63
+
64
+ describe("MarkdownDocumentEditor source preservation", () => {
65
+ it("opens every Markdown construct without reporting a change", async () => {
66
+ const { editor, onChange, view } = await openEditor(KITCHEN_SINK);
67
+ act(() => {
68
+ editor.commands.setTextSelection(1);
69
+ editor.setEditable(false);
70
+ editor.setEditable(true);
71
+ });
72
+ expect(onChange).not.toHaveBeenCalled();
73
+ // The table is shown as a table, read-only, instead of a run-on paragraph.
74
+ const table = view.container.querySelector('[data-md-kind="table"] table');
75
+ expect(table?.querySelectorAll("tbody tr")).toHaveLength(1);
76
+ expect(table?.textContent).toContain("Center");
77
+ }, LOADS_PEERS);
78
+
79
+ it.each([
80
+ ["a paragraph with escapes", "snake_case_name", " (edited)"],
81
+ ["a star bullet", "star bullet two", "!"],
82
+ ["a nested bullet", "nested four-space", " item"],
83
+ ["a paren-ordered item", "paren ordered", " list"],
84
+ ["a setext heading", "Setext heading", " again"],
85
+ ["a fenced code block", "const x = 1", "0"],
86
+ ["a tilde fence", "tilde fence", "d"],
87
+ ["a nested quote", "nested quote", "d"],
88
+ ["the last paragraph", "next line", "."],
89
+ ])("changes only the edited span when typing into %s", async (_name, needle, text) => {
90
+ const { editor, saved } = await openEditor(KITCHEN_SINK);
91
+ insertAfter(editor, needle, text);
92
+ expect(saved()).toBe(KITCHEN_SINK.replace(needle, needle + text));
93
+ }, LOADS_PEERS);
94
+
95
+ it("keeps every table row when the title is edited, the failure founders hit", async () => {
96
+ const { editor, saved } = await openEditor(CALL_SHEET);
97
+ act(() => {
98
+ editor.commands.insertContentAt(textPosition(editor, "Plausible Analytics:", "start"), "Draft: ");
99
+ });
100
+ expect(saved()).toBe(CALL_SHEET.replace("# Plausible", "# Draft: Plausible"));
101
+ expect(tableRows(saved())).toEqual(tableRows(CALL_SHEET));
102
+ expect(tableRows(saved())).toHaveLength(5);
103
+ }, LOADS_PEERS);
104
+
105
+ it("separates a paragraph added with Enter by a blank line", async () => {
106
+ const { editor, saved } = await openEditor(CALL_SHEET);
107
+ act(() => {
108
+ editor.chain().setTextSelection(textPosition(editor, "positioning Plausible.")).splitBlock().run();
109
+ editor.commands.insertContent("A new line of notes.");
110
+ });
111
+ expect(saved()).toBe(
112
+ CALL_SHEET.replace("positioning Plausible.\n", "positioning Plausible.\n\nA new line of notes.\n"),
113
+ );
114
+ }, LOADS_PEERS);
115
+
116
+ it("adds only the delimiters when a word is bolded", async () => {
117
+ const { editor, saved } = await openEditor(CALL_SHEET);
118
+ const end = textPosition(editor, "Who signs off");
119
+ act(() => {
120
+ editor.chain().setTextSelection({ from: end - "signs off".length, to: end }).toggleBold().run();
121
+ });
122
+ expect(saved()).toBe(CALL_SHEET.replace("Who signs off", "Who **signs off**"));
123
+ }, LOADS_PEERS);
124
+
125
+ it("adds an item in the list's own marker style", async () => {
126
+ const { editor, saved } = await openEditor(CALL_SHEET);
127
+ act(() => {
128
+ editor.chain().setTextSelection(textPosition(editor, "after the call")).splitListItem("listItem").run();
129
+ editor.commands.insertContent("Log the objections");
130
+ });
131
+ expect(saved()).toBe(CALL_SHEET.replace("after the call\n", "after the call\n* Log the objections\n"));
132
+ }, LOADS_PEERS);
133
+
134
+ it("removes only the table when its block is deleted", async () => {
135
+ const { editor, saved } = await openEditor(CALL_SHEET);
136
+ let tablePos = -1;
137
+ editor.state.doc.forEach((node, offset) => {
138
+ if (node.type.name === "markdownSource") tablePos = offset;
139
+ });
140
+ act(() => {
141
+ editor.chain().setNodeSelection(tablePos).deleteSelection().run();
142
+ });
143
+ const start = CALL_SHEET.indexOf("| Competitor");
144
+ const end = CALL_SHEET.indexOf("## Qualifying");
145
+ expect(saved()).toBe(CALL_SHEET.slice(0, start) + CALL_SHEET.slice(end));
146
+ }, LOADS_PEERS);
147
+
148
+ it("reports the original bytes again once an edit is undone", async () => {
149
+ const { editor, saved } = await openEditor(KITCHEN_SINK);
150
+ insertAfter(editor, "star bullet one", "?");
151
+ act(() => {
152
+ editor.commands.undo();
153
+ });
154
+ expect(saved()).toBe(KITCHEN_SINK);
155
+ }, LOADS_PEERS);
156
+ });