@ai-matrx/rich-editor 0.0.0 → 0.1.1

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 (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +1 -1
  3. package/README.md +13 -3
  4. package/dist/core/block-insert.d.ts +27 -0
  5. package/dist/core/block-insert.js +24 -0
  6. package/dist/core/caret-context.d.ts +24 -0
  7. package/dist/core/caret-context.js +73 -0
  8. package/dist/core/clipboard-text.d.ts +5 -0
  9. package/dist/core/clipboard-text.js +47 -0
  10. package/dist/core/commands.d.ts +65 -0
  11. package/dist/core/commands.js +299 -0
  12. package/dist/core/extensions.d.ts +29 -0
  13. package/dist/core/extensions.js +326 -0
  14. package/dist/core/find-replace.d.ts +34 -0
  15. package/dist/core/find-replace.js +67 -0
  16. package/dist/core/history-approval.d.ts +11 -0
  17. package/dist/core/history-approval.js +43 -0
  18. package/dist/core/host-value.d.ts +29 -0
  19. package/dist/core/host-value.js +10 -0
  20. package/dist/core/html-to-markdown.d.ts +3 -0
  21. package/dist/core/html-to-markdown.js +12 -0
  22. package/dist/core/markdown-format.d.ts +18 -0
  23. package/dist/core/markdown-format.js +354 -0
  24. package/dist/core/markdown-parse.d.ts +35 -0
  25. package/dist/core/markdown-parse.js +533 -0
  26. package/dist/core/markdown-serialize.d.ts +27 -0
  27. package/dist/core/markdown-serialize.js +394 -0
  28. package/dist/core/outline.d.ts +17 -0
  29. package/dist/core/outline.js +41 -0
  30. package/dist/core/paste-html.d.ts +10 -0
  31. package/dist/core/paste-html.js +198 -0
  32. package/dist/core/paste-markdown.d.ts +18 -0
  33. package/dist/core/paste-markdown.js +70 -0
  34. package/dist/core/placeholders.d.ts +22 -0
  35. package/dist/core/placeholders.js +60 -0
  36. package/dist/core/save-plan.d.ts +47 -0
  37. package/dist/core/save-plan.js +287 -0
  38. package/dist/core/shortcuts.d.ts +13 -0
  39. package/dist/core/shortcuts.js +50 -0
  40. package/dist/core/source-format.d.ts +28 -0
  41. package/dist/core/source-format.js +104 -0
  42. package/dist/core/text-metrics.d.ts +11 -0
  43. package/dist/core/text-metrics.js +35 -0
  44. package/dist/core/variable-name.d.ts +13 -0
  45. package/dist/core/variable-name.js +13 -0
  46. package/dist/core/variables.d.ts +37 -0
  47. package/dist/core/variables.js +77 -0
  48. package/dist/core/visual-document.d.ts +52 -0
  49. package/dist/core/visual-document.js +142 -0
  50. package/dist/core/visual-find.d.ts +27 -0
  51. package/dist/core/visual-find.js +93 -0
  52. package/dist/format/format-target.d.ts +39 -0
  53. package/dist/format/format-target.js +79 -0
  54. package/dist/in-place/caret-handoff.d.ts +3 -0
  55. package/dist/in-place/caret-handoff.js +14 -0
  56. package/dist/in-place/in-place-session.d.ts +24 -0
  57. package/dist/in-place/in-place-session.js +32 -0
  58. package/package.json +95 -5
@@ -0,0 +1,104 @@
1
+ function toggleWrap(text, from, to, marker) {
2
+ const size = marker.length;
3
+ const before = text.slice(Math.max(0, from - size), from);
4
+ const after = text.slice(to, to + size);
5
+ if (before === marker && after === marker) {
6
+ return {
7
+ changes: [
8
+ { from: from - size, to: from, insert: "" },
9
+ { from: to, to: to + size, insert: "" }
10
+ ],
11
+ anchor: from - size,
12
+ head: to - size
13
+ };
14
+ }
15
+ const selected = text.slice(from, to);
16
+ if (selected.length >= size * 2 && selected.startsWith(marker) && selected.endsWith(marker)) {
17
+ return {
18
+ changes: [{ from, to, insert: selected.slice(size, selected.length - size) }],
19
+ anchor: from,
20
+ head: to - size * 2
21
+ };
22
+ }
23
+ return {
24
+ changes: [{ from, to, insert: `${marker}${selected}${marker}` }],
25
+ anchor: from + size,
26
+ head: to + size
27
+ };
28
+ }
29
+ const LINE_PREFIX = /^( {0,3})(#{1,6}[ \t]+|[-*+][ \t]+\[[ xX]\][ \t]+|[-*+][ \t]+|\d{1,9}[.)][ \t]+|>[ \t]?)?/;
30
+ function setLinePrefix(text, from, to, prefix) {
31
+ const start = text.lastIndexOf("\n", from - 1) + 1;
32
+ const endBreak = text.indexOf("\n", to);
33
+ const end = endBreak === -1 ? text.length : endBreak;
34
+ const lines = text.slice(start, end).split("\n");
35
+ let number = 1;
36
+ let delta = 0;
37
+ const changes = [];
38
+ let offset = start;
39
+ let firstDelta = 0;
40
+ lines.forEach((line, index) => {
41
+ const match = LINE_PREFIX.exec(line);
42
+ const existing = match?.[0] ?? "";
43
+ const indent = match?.[1] ?? "";
44
+ const next = prefix === null ? indent : `${indent}${prefix === "1. " ? `${number++}. ` : prefix}`;
45
+ if (existing !== next) {
46
+ changes.push({ from: offset, to: offset + existing.length, insert: next });
47
+ delta += next.length - existing.length;
48
+ if (index === 0) firstDelta = next.length - existing.length;
49
+ }
50
+ offset += line.length + 1;
51
+ });
52
+ return { changes, anchor: Math.max(start, from + firstDelta), head: to + delta };
53
+ }
54
+ function makeLink(text, from, to, url = "") {
55
+ const label = text.slice(from, to) || "link";
56
+ const insert = `[${label}](${url})`;
57
+ const urlStart = from + label.length + 3;
58
+ return { changes: [{ from, to, insert }], anchor: urlStart, head: urlStart + url.length };
59
+ }
60
+ function applySourceEdit(text, result) {
61
+ let out = text;
62
+ for (const change of [...result.changes].sort((a, b) => b.from - a.from)) {
63
+ out = out.slice(0, change.from) + change.insert + out.slice(change.to);
64
+ }
65
+ return out;
66
+ }
67
+ const CONTINUABLE = /^([ \t]*)(?:([-*+])|(\d{1,9})([.)]))([ \t]+)(\[[ xX]\][ \t]+)?(.*)$/;
68
+ const QUOTE = /^([ \t]*(?:>[ \t]?)+)(.*)$/;
69
+ function continueMarkupOnEnter(text, pos) {
70
+ const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
71
+ const lineEndBreak = text.indexOf("\n", pos);
72
+ const lineEnd = lineEndBreak === -1 ? text.length : lineEndBreak;
73
+ if (text.slice(pos, lineEnd).trim() !== "") return null;
74
+ const line = text.slice(lineStart, lineEnd);
75
+ const item = CONTINUABLE.exec(line);
76
+ if (item) {
77
+ const [, indent = "", bullet, number, delimiter, gap = " ", task, rest = ""] = item;
78
+ if (!rest.trim()) {
79
+ return { changes: [{ from: lineStart, to: lineEnd, insert: "" }], anchor: lineStart, head: lineStart };
80
+ }
81
+ const marker = bullet ?? `${Number(number) + 1}${delimiter}`;
82
+ const insert = `
83
+ ${indent}${marker}${gap}${task ? "[ ] " : ""}`;
84
+ return { changes: [{ from: pos, to: pos, insert }], anchor: pos + insert.length, head: pos + insert.length };
85
+ }
86
+ const quote = QUOTE.exec(line);
87
+ if (quote && line.trimStart().startsWith(">")) {
88
+ const [, prefix = "> ", rest = ""] = quote;
89
+ if (!rest.trim()) {
90
+ return { changes: [{ from: lineStart, to: lineEnd, insert: "" }], anchor: lineStart, head: lineStart };
91
+ }
92
+ const insert = `
93
+ ${prefix}`;
94
+ return { changes: [{ from: pos, to: pos, insert }], anchor: pos + insert.length, head: pos + insert.length };
95
+ }
96
+ return null;
97
+ }
98
+ export {
99
+ applySourceEdit,
100
+ continueMarkupOnEnter,
101
+ makeLink,
102
+ setLinePrefix,
103
+ toggleWrap
104
+ };
@@ -0,0 +1,11 @@
1
+ export interface TextMetrics {
2
+ words: number;
3
+ /** Characters of prose, spaces included (what "characters" means in Docs). */
4
+ characters: number;
5
+ /** Whole minutes at 238 words per minute (the adult silent-reading average), minimum 1 when there is any prose. */
6
+ readingMinutes: number;
7
+ /** Protected blocks and inline islands the counts leave out. */
8
+ islands: number;
9
+ }
10
+ export declare const READING_WORDS_PER_MINUTE = 238;
11
+ export declare function measureText(text: string): TextMetrics;
@@ -0,0 +1,35 @@
1
+ import { tokenizeSource } from "@ai-matrx/content-ir/source";
2
+ import { isGfmDelimiterRow } from "@ai-matrx/rich-content/display/markdown-classification/processors/utils/gfm-table-lines";
3
+ const READING_WORDS_PER_MINUTE = 238;
4
+ function proseOf(raw) {
5
+ return raw.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<https?:[^>]+>/g, " ").replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])[ \t]+/gm, "").replace(/^ {0,3}[-*_](?:[ \t]*[-*_]){2,}[ \t]*$/gm, "").split("\n").map((line) => isGfmDelimiterRow(line) ? "" : line).join("\n").replace(/\[[ xX]\][ \t]/g, "").replace(/[*_~`|]+/g, "");
6
+ }
7
+ function measureText(text) {
8
+ let prose = "";
9
+ let islands = 0;
10
+ for (const block of tokenizeSource(text)) {
11
+ if (block.kind === "island") {
12
+ islands += 1;
13
+ continue;
14
+ }
15
+ if (block.kind !== "prose") continue;
16
+ let cursor = block.start;
17
+ let raw = "";
18
+ for (const island of block.inlines) {
19
+ raw += `${text.slice(cursor, island.start)} `;
20
+ cursor = island.end;
21
+ islands += 1;
22
+ }
23
+ raw += text.slice(cursor, block.end);
24
+ prose += `${proseOf(raw)}
25
+ `;
26
+ }
27
+ const words = prose.match(/[\p{L}\p{N}][\p{L}\p{N}'’.-]*/gu)?.length ?? 0;
28
+ const characters = prose.replace(/\s+/g, " ").trim().length;
29
+ const readingMinutes = words === 0 ? 0 : Math.max(1, Math.round(words / READING_WORDS_PER_MINUTE));
30
+ return { words, characters, readingMinutes, islands };
31
+ }
32
+ export {
33
+ READING_WORDS_PER_MINUTE,
34
+ measureText
35
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * THE variable-name rule for `{{name}}` in the editor: what a declarable variable name looks like.
3
+ * (Ported from @ai-matrx/chat agents/utils/variable-utils; chat imports it from here at rich-content
4
+ * phase 3 so there is one rule.)
5
+ *
6
+ * - Lowercase, spaces/hyphens → underscores
7
+ * - Remove non-alphanumeric/underscore characters
8
+ * - Collapse consecutive underscores to one
9
+ * - Strip leading/trailing underscores
10
+ */
11
+ export declare const sanitizeVariableName: (input: string) => string;
12
+ /** A name that survives `sanitizeVariableName` unchanged can be declared as a variable. */
13
+ export declare const isDeclarableVariableName: (name: string) => boolean;
@@ -0,0 +1,13 @@
1
+ const sanitizeVariableName = (input) => {
2
+ const trimmed = input.trim();
3
+ const lowercased = trimmed.toLowerCase();
4
+ const underscored = lowercased.replace(/[\s-]+/g, "_");
5
+ const cleaned = underscored.replace(/[^a-z0-9_]/g, "");
6
+ const single = cleaned.replace(/_+/g, "_");
7
+ return single.replace(/^_+|_+$/g, "");
8
+ };
9
+ const isDeclarableVariableName = (name) => name.length > 0 && sanitizeVariableName(name) === name;
10
+ export {
11
+ isDeclarableVariableName,
12
+ sanitizeVariableName
13
+ };
@@ -0,0 +1,37 @@
1
+ export type VariableState = "declared" | "undeclared" | "literal" | "unbound";
2
+ /** A variable the surface declares, with its type when the surface knows it. */
3
+ export interface DeclaredVariable {
4
+ name: string;
5
+ type?: string;
6
+ description?: string;
7
+ }
8
+ export interface VariableInfo {
9
+ name: string;
10
+ state: VariableState;
11
+ declared: DeclaredVariable | null;
12
+ /** Hover text in plain English. */
13
+ title: string;
14
+ }
15
+ /** The name inside `{{name}}`, or null when the raw text is not a variable. */
16
+ export declare function variableName(raw: string): string | null;
17
+ export declare function classifyVariable(raw: string, declared: readonly DeclaredVariable[] | null | undefined): VariableInfo;
18
+ /** Tailwind classes per state (the HighlightedText palette). */
19
+ export declare const VARIABLE_STATE_CLASS: Record<VariableState, string>;
20
+ /** A typed name for a new variable: letters, digits and underscores. */
21
+ export declare function toVariableName(input: string): string;
22
+ /** Every distinct `{{name}}` in a text, in order of first appearance. */
23
+ export declare function variableNamesInText(text: string): string[];
24
+ export interface VariableSuggestion {
25
+ name: string;
26
+ type?: string;
27
+ description?: string;
28
+ /** Declared by the surface, already used in this document, or a new name from what was typed. */
29
+ source: "declared" | "document" | "new";
30
+ }
31
+ /**
32
+ * THE `{{` menu's rows, for both views: the surface's declared variables, then
33
+ * the variables this document already uses (so a prompt's own `{{site_name}}`
34
+ * is one keystroke away even where the host declares nothing), then the typed
35
+ * name as a new variable.
36
+ */
37
+ export declare function variableSuggestions(declared: readonly DeclaredVariable[], documentNames: readonly string[], query: string): VariableSuggestion[];
@@ -0,0 +1,77 @@
1
+ import { isDeclarableVariableName } from "./variable-name.js";
2
+ const VARIABLE_RE = /^\{\{([^}]+)\}\}$/;
3
+ function variableName(raw) {
4
+ return VARIABLE_RE.exec(raw)?.[1] ?? null;
5
+ }
6
+ function classifyVariable(raw, declared) {
7
+ const name = variableName(raw) ?? raw;
8
+ const trimmed = name.trim();
9
+ if (!declared) {
10
+ return {
11
+ name,
12
+ state: isDeclarableVariableName(trimmed) ? "unbound" : "literal",
13
+ declared: null,
14
+ title: isDeclarableVariableName(trimmed) ? `Variable: ${name}` : `Literal text \u2014 "${name}" is not a valid variable name, so it is sent as written`
15
+ };
16
+ }
17
+ const match = declared.find((variable) => variable.name === name) ?? null;
18
+ if (match) {
19
+ return {
20
+ name,
21
+ state: "declared",
22
+ declared: match,
23
+ title: `Variable: ${name}${match.type ? ` (${match.type})` : ""}${match.description ? ` \u2014 ${match.description}` : ""}`
24
+ };
25
+ }
26
+ if (!isDeclarableVariableName(trimmed)) {
27
+ return {
28
+ name,
29
+ state: "literal",
30
+ declared: null,
31
+ title: `Literal text \u2014 "${name}" is not a valid variable name, so it is sent as written`
32
+ };
33
+ }
34
+ return {
35
+ name,
36
+ state: "undeclared",
37
+ declared: null,
38
+ title: `Undefined variable: ${name} \u2014 it resolves to nothing until it is declared`
39
+ };
40
+ }
41
+ const VARIABLE_STATE_CLASS = {
42
+ declared: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800",
43
+ undeclared: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800",
44
+ literal: "bg-muted text-muted-foreground border-border",
45
+ unbound: "bg-primary/10 text-primary-ink border-primary/30"
46
+ };
47
+ function toVariableName(input) {
48
+ return input.trim().replace(/[^\p{L}\p{N}_]+/gu, "_").replace(/^_+|_+$/g, "");
49
+ }
50
+ const VARIABLE_IN_TEXT_RE = /\{\{\s*([\p{L}\p{N}_][\p{L}\p{N}_.-]*)\s*\}\}/gu;
51
+ function variableNamesInText(text) {
52
+ const seen = /* @__PURE__ */ new Set();
53
+ for (const match of text.matchAll(VARIABLE_IN_TEXT_RE)) seen.add(match[1]);
54
+ return [...seen];
55
+ }
56
+ function variableSuggestions(declared, documentNames, query) {
57
+ const q = query.replace(/\}+$/, "").trim().toLowerCase();
58
+ const matches = (name) => !q || name.toLowerCase().includes(q);
59
+ const out = declared.filter((variable) => matches(variable.name)).map((variable) => ({ ...variable, source: "declared" }));
60
+ const known = new Set(declared.map((variable) => variable.name));
61
+ for (const name of documentNames) {
62
+ if (known.has(name) || !matches(name)) continue;
63
+ known.add(name);
64
+ out.push({ name, source: "document" });
65
+ }
66
+ const fresh = toVariableName(query.replace(/\}+$/, ""));
67
+ if (fresh && !known.has(fresh)) out.push({ name: fresh, source: "new" });
68
+ return out;
69
+ }
70
+ export {
71
+ VARIABLE_STATE_CLASS,
72
+ classifyVariable,
73
+ toVariableName,
74
+ variableName,
75
+ variableNamesInText,
76
+ variableSuggestions
77
+ };
@@ -0,0 +1,52 @@
1
+ import type { JSONContent } from "@tiptap/core";
2
+ import type { Node as PMNode, Schema } from "@tiptap/pm/model";
3
+ import { type SourceBlock } from "@ai-matrx/content-ir/source";
4
+ import { type Adjacency } from "./markdown-serialize.js";
5
+ export interface VisualLoadStats {
6
+ /** Stored prose blocks. */
7
+ proseBlocks: number;
8
+ /** Prose blocks held whole as source (see markdown-parse.ts). */
9
+ lockedBlocks: number;
10
+ /** Constructs inside editable prose blocks held as source. */
11
+ lockedChildren: number;
12
+ /** Constructs editable as rich text. */
13
+ editableChildren: number;
14
+ /** Stored islands (kinds, XML, fences, math, HTML, anchors…). */
15
+ islandBlocks: number;
16
+ /** Inline islands ({{variables}}, citations, inline math, tags…). */
17
+ inlineIslands: number;
18
+ }
19
+ export interface VisualPlan {
20
+ /** The text this document was built from. */
21
+ readonly text: string;
22
+ readonly blocks: readonly SourceBlock[];
23
+ /** For each non-gap block index, the next non-gap block index. */
24
+ readonly nextNonGap: ReadonlyMap<number, number>;
25
+ /** Text before the first block / after the last (document-edge spacing). */
26
+ readonly leading: string;
27
+ readonly trailing: string;
28
+ readonly adjacency: ReadonlyMap<string, Adjacency>;
29
+ /** Exact stored bytes of every direct child of a prose block, by id. */
30
+ readonly childRaw: ReadonlyMap<string, string>;
31
+ readonly stats: VisualLoadStats;
32
+ }
33
+ export interface VisualLoad {
34
+ json: JSONContent;
35
+ plan: VisualPlan;
36
+ }
37
+ export declare function buildVisualDocument(text: string, schema: Schema): VisualLoad;
38
+ export interface VisualBaseline {
39
+ readonly plan: VisualPlan;
40
+ /** The editor's own top-level node for each stored block, by block index. */
41
+ readonly topNodes: ReadonlyMap<number, PMNode>;
42
+ /** The editor's own node for each direct child of a prose block, by id. */
43
+ readonly children: ReadonlyMap<string, PMNode>;
44
+ }
45
+ /**
46
+ * Record the loaded document as the "unchanged" reference. Call it with the
47
+ * document AS THE EDITOR HOLDS IT after load, never with the JSON — whatever
48
+ * the editor did on load is then part of the baseline, not an edit.
49
+ */
50
+ export declare function captureBaseline(doc: PMNode, plan: VisualPlan): VisualBaseline;
51
+ /** The current document → source text. Unchanged blocks are their stored bytes. */
52
+ export declare function serializeVisualDocument(doc: PMNode, baseline: VisualBaseline): string;
@@ -0,0 +1,142 @@
1
+ import { collectLinkDefinitions, tokenizeSource } from "@ai-matrx/content-ir/source";
2
+ import { parseProseBlock } from "./markdown-parse.js";
3
+ import {
4
+ createSerializeContext,
5
+ serializeBlock,
6
+ serializeChildren
7
+ } from "./markdown-serialize.js";
8
+ function buildVisualDocument(text, schema) {
9
+ const blocks = tokenizeSource(text);
10
+ const linkDefinitions = collectLinkDefinitions(text);
11
+ const adjacency = /* @__PURE__ */ new Map();
12
+ const childRaw = /* @__PURE__ */ new Map();
13
+ const nextNonGap = /* @__PURE__ */ new Map();
14
+ const stats = {
15
+ proseBlocks: 0,
16
+ lockedBlocks: 0,
17
+ lockedChildren: 0,
18
+ editableChildren: 0,
19
+ islandBlocks: 0,
20
+ inlineIslands: 0
21
+ };
22
+ let counter = 0;
23
+ const nextId = () => `m${counter += 1}`;
24
+ const content = [];
25
+ let prevIndex = null;
26
+ let firstStart = null;
27
+ let lastEnd = null;
28
+ blocks.forEach((block, index) => {
29
+ if (block.kind === "gap") return;
30
+ if (prevIndex !== null) nextNonGap.set(prevIndex, index);
31
+ prevIndex = index;
32
+ if (firstStart === null) firstStart = block.start;
33
+ lastEnd = block.end;
34
+ if (block.kind === "island") {
35
+ stats.islandBlocks += 1;
36
+ content.push({
37
+ type: "islandBlock",
38
+ attrs: {
39
+ raw: block.raw,
40
+ islandType: block.islandType ?? "fence",
41
+ complete: block.complete,
42
+ b: index
43
+ }
44
+ });
45
+ return;
46
+ }
47
+ stats.proseBlocks += 1;
48
+ stats.inlineIslands += block.inlines.length;
49
+ const parsed = parseProseBlock(block, schema, adjacency, nextId, linkDefinitions);
50
+ if (parsed.lockedReason !== null) {
51
+ stats.lockedBlocks += 1;
52
+ content.push({
53
+ type: "sourceLocked",
54
+ attrs: { raw: block.raw, reason: parsed.lockedReason, b: index }
55
+ });
56
+ return;
57
+ }
58
+ for (const child of parsed.children) {
59
+ childRaw.set(child.id, child.raw);
60
+ if (child.lockedReason) stats.lockedChildren += 1;
61
+ else stats.editableChildren += 1;
62
+ }
63
+ content.push({
64
+ type: "sourceBlock",
65
+ attrs: { b: index },
66
+ content: parsed.children.map((child) => child.json)
67
+ });
68
+ });
69
+ if (content.length === 0) content.push({ type: "paragraph" });
70
+ const leading = firstStart === null ? text : text.slice(0, firstStart);
71
+ const trailing = lastEnd === null ? "" : text.slice(lastEnd);
72
+ return {
73
+ json: { type: "doc", content },
74
+ plan: { text, blocks, nextNonGap, leading, trailing, adjacency, childRaw, stats }
75
+ };
76
+ }
77
+ function captureBaseline(doc, plan) {
78
+ const topNodes = /* @__PURE__ */ new Map();
79
+ const children = /* @__PURE__ */ new Map();
80
+ doc.forEach((node) => {
81
+ const b = node.attrs.b;
82
+ if (typeof b === "number" && !topNodes.has(b)) topNodes.set(b, node);
83
+ if (node.type.name === "sourceBlock") {
84
+ node.forEach((child) => {
85
+ const id = child.attrs.mdId;
86
+ if (typeof id === "string" && !children.has(id)) children.set(id, child);
87
+ });
88
+ }
89
+ });
90
+ return { plan, topNodes, children };
91
+ }
92
+ function claimDescendantIds(node, claimed) {
93
+ node.descendants((child) => {
94
+ const id = child.attrs.mdId;
95
+ if (typeof id === "string") claimed.add(id);
96
+ return true;
97
+ });
98
+ }
99
+ function serializeVisualDocument(doc, baseline) {
100
+ const { plan } = baseline;
101
+ const ctx = createSerializeContext(plan.adjacency, (id, node) => {
102
+ const stored = plan.childRaw.get(id);
103
+ if (stored === void 0) return null;
104
+ return baseline.children.get(id)?.eq(node) ? stored : null;
105
+ });
106
+ const claimedBlocks = /* @__PURE__ */ new Set();
107
+ let out = plan.leading;
108
+ let prevB = null;
109
+ let first = true;
110
+ doc.forEach((node) => {
111
+ let b = typeof node.attrs.b === "number" ? node.attrs.b : null;
112
+ if (b !== null) {
113
+ if (claimedBlocks.has(b)) b = null;
114
+ else claimedBlocks.add(b);
115
+ }
116
+ let body;
117
+ const block = b !== null ? plan.blocks[b] : void 0;
118
+ if (b !== null && block && baseline.topNodes.get(b)?.eq(node)) {
119
+ body = block.raw;
120
+ claimDescendantIds(node, ctx.claimed);
121
+ } else if (node.type.name === "sourceBlock") {
122
+ body = serializeChildren(node, "wrapper", ctx);
123
+ } else {
124
+ body = serializeBlock(node, ctx);
125
+ }
126
+ if (body === "") return;
127
+ if (!first) {
128
+ const prevBlock = prevB !== null ? plan.blocks[prevB] : void 0;
129
+ const originallyAdjacent = prevB !== null && b !== null && plan.nextNonGap.get(prevB) === b;
130
+ out += originallyAdjacent && prevBlock && block ? plan.text.slice(prevBlock.end, block.start) : "\n\n";
131
+ }
132
+ out += body;
133
+ first = false;
134
+ prevB = b;
135
+ });
136
+ return out + plan.trailing;
137
+ }
138
+ export {
139
+ buildVisualDocument,
140
+ captureBaseline,
141
+ serializeVisualDocument
142
+ };
@@ -0,0 +1,27 @@
1
+ import type { Node as PMNode } from "@tiptap/pm/model";
2
+ import type { Transaction } from "@tiptap/pm/state";
3
+ import { type FindOptions } from "./find-replace.js";
4
+ export interface VisualMatch {
5
+ /** Document range for text matches; the island's own position for island matches. */
6
+ from: number;
7
+ to: number;
8
+ island: null | {
9
+ pos: number;
10
+ start: number;
11
+ end: number;
12
+ raw: string;
13
+ };
14
+ }
15
+ export interface VisualFindResult {
16
+ matches: VisualMatch[];
17
+ skippedProtected: number;
18
+ error: string | null;
19
+ }
20
+ export declare function findInDoc(doc: PMNode, query: string, options?: FindOptions): VisualFindResult;
21
+ /**
22
+ * Replace the given matches in one transaction, back to front so earlier
23
+ * positions stay valid. Island matches rewrite the island's bytes; the caller
24
+ * records them as approved island edits. Returns the stored bytes of every
25
+ * island it changed.
26
+ */
27
+ export declare function replaceInDoc(tr: Transaction, matches: readonly VisualMatch[], query: string, replacement: string, options?: FindOptions): string[];
@@ -0,0 +1,93 @@
1
+ import { buildFindRegex, replacementFor } from "./find-replace.js";
2
+ const ATOM = "\uFFFC";
3
+ function findInDoc(doc, query, options = {}) {
4
+ const { regex, error } = buildFindRegex(query, options);
5
+ if (!regex) return { matches: [], skippedProtected: 0, error };
6
+ const matches = [];
7
+ let skippedProtected = 0;
8
+ const searchIsland = (node, pos) => {
9
+ const raw = String(node.attrs.raw ?? "");
10
+ for (const match of raw.matchAll(regex)) {
11
+ if (!match[0]) continue;
12
+ if (!options.includeProtected) {
13
+ skippedProtected += 1;
14
+ continue;
15
+ }
16
+ const start = match.index ?? 0;
17
+ matches.push({ from: pos, to: pos + node.nodeSize, island: { pos, start, end: start + match[0].length, raw } });
18
+ }
19
+ };
20
+ doc.descendants((node, pos) => {
21
+ if (node.type.name === "islandBlock" || node.type.name === "sourceLocked") {
22
+ searchIsland(node, pos);
23
+ return false;
24
+ }
25
+ if (!node.isTextblock) return true;
26
+ let text = "";
27
+ const map = [];
28
+ node.forEach((child, offset) => {
29
+ const childPos = pos + 1 + offset;
30
+ if (child.isText) {
31
+ for (let i = 0; i < (child.text ?? "").length; i += 1) map.push(childPos + i);
32
+ text += child.text ?? "";
33
+ } else {
34
+ if (child.type.name === "inlineIsland") searchIsland(child, childPos);
35
+ map.push(childPos);
36
+ text += ATOM;
37
+ }
38
+ });
39
+ for (const match of text.matchAll(regex)) {
40
+ if (!match[0] || match[0].includes(ATOM)) continue;
41
+ const start = match.index ?? 0;
42
+ const from = map[start];
43
+ const last = map[start + match[0].length - 1];
44
+ if (from === void 0 || last === void 0) continue;
45
+ matches.push({ from, to: last + 1, island: null });
46
+ }
47
+ return false;
48
+ });
49
+ matches.sort((a, b) => a.from - b.from || (a.island?.start ?? 0) - (b.island?.start ?? 0));
50
+ return { matches, skippedProtected, error: null };
51
+ }
52
+ function replaceInDoc(tr, matches, query, replacement, options = {}) {
53
+ const changedIslands = [];
54
+ const islandEdits = /* @__PURE__ */ new Map();
55
+ const textMatches = [];
56
+ for (const match of matches) {
57
+ if (match.island) {
58
+ const entry = islandEdits.get(match.island.pos) ?? { raw: match.island.raw, edits: [] };
59
+ entry.edits.push({ start: match.island.start, end: match.island.end });
60
+ islandEdits.set(match.island.pos, entry);
61
+ } else {
62
+ textMatches.push(match);
63
+ }
64
+ }
65
+ const ordered = [
66
+ ...textMatches.map((match) => ({ pos: match.from, apply: () => {
67
+ const found = tr.doc.textBetween(match.from, match.to);
68
+ tr.insertText(replacementFor(found, query, replacement, options), match.from, match.to);
69
+ } })),
70
+ ...[...islandEdits.entries()].map(([pos, entry]) => ({ pos, apply: () => {
71
+ const node = tr.doc.nodeAt(pos);
72
+ if (!node) return;
73
+ let next = "";
74
+ let cursor = 0;
75
+ for (const edit of entry.edits.sort((a, b) => a.start - b.start)) {
76
+ next += entry.raw.slice(cursor, edit.start);
77
+ next += replacementFor(entry.raw.slice(edit.start, edit.end), query, replacement, options);
78
+ cursor = edit.end;
79
+ }
80
+ next += entry.raw.slice(cursor);
81
+ if (next !== entry.raw) {
82
+ changedIslands.push(entry.raw);
83
+ tr.setNodeMarkup(pos, void 0, { ...node.attrs, raw: next });
84
+ }
85
+ } }))
86
+ ].sort((a, b) => b.pos - a.pos);
87
+ for (const step of ordered) step.apply();
88
+ return changedIslands;
89
+ }
90
+ export {
91
+ findInDoc,
92
+ replaceInDoc
93
+ };
@@ -0,0 +1,39 @@
1
+ import { type FormatCommandId } from "../core/markdown-format.js";
2
+ import { type SourceEditResult } from "../core/source-format.js";
3
+ export type FormatEngine = "visual" | "source" | "textarea";
4
+ export interface FormatTarget {
5
+ engine: FormatEngine;
6
+ /** Run a command. False when the editor refused (read-only, destroyed). */
7
+ run(command: FormatCommandId): boolean;
8
+ /** Is the command already on for the selection (the toolbar's pressed state)? */
9
+ isActive(command: FormatCommandId): boolean;
10
+ /** Is there something to format (an editable, non-blank selection)? */
11
+ canFormat(): boolean;
12
+ /** Call `onChange` when the selection or text moves (pressed state follows). */
13
+ subscribe?(onChange: () => void): () => void;
14
+ }
15
+ /** The selection-zone host half every non-visual engine contributes. */
16
+ export declare const MARKDOWN_FORMAT_HOST_KEY = "markdownFormat";
17
+ export interface MarkdownFormatHost {
18
+ kind: "markdown-format";
19
+ target: FormatTarget;
20
+ }
21
+ export declare function markdownFormatHost(target: FormatTarget): Record<string, unknown>;
22
+ export interface TextSelectionState {
23
+ text: string;
24
+ from: number;
25
+ to: number;
26
+ editable: boolean;
27
+ }
28
+ /** Any engine whose document is markdown text. `apply` writes the minimal change. */
29
+ export declare function textFormatTarget(engine: "source" | "textarea", read: () => TextSelectionState | null, apply: (result: SourceEditResult, text: string) => void, subscribe?: (onChange: () => void) => () => void): FormatTarget;
30
+ /**
31
+ * Write a text edit into a <textarea> so the browser's own undo takes it back
32
+ * and React's onChange sees it: the changes collapse to ONE replaced span (the
33
+ * bytes between them are re-inserted unchanged), inserted with
34
+ * `execCommand("insertText")` — the editing path that records undo — with
35
+ * `setRangeText` + an input event where that path is unavailable.
36
+ */
37
+ export declare function applyEditToTextarea(el: HTMLTextAreaElement, result: SourceEditResult): void;
38
+ /** The textarea engine. */
39
+ export declare function textareaFormatTarget(el: HTMLTextAreaElement): FormatTarget;