smartrte-core 0.2.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 +7 -0
- package/README.md +5 -0
- package/dist/command.d.ts +13 -0
- package/dist/command.js +1 -0
- package/dist/commands/alignment.d.ts +18 -0
- package/dist/commands/alignment.js +34 -0
- package/dist/commands/code.d.ts +13 -0
- package/dist/commands/code.js +56 -0
- package/dist/commands/list.d.ts +20 -0
- package/dist/commands/list.js +52 -0
- package/dist/commands/marks.d.ts +17 -0
- package/dist/commands/marks.js +136 -0
- package/dist/html/compatibility.d.ts +21 -0
- package/dist/html/compatibility.js +68 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/markdown/compatibility.d.ts +7 -0
- package/dist/markdown/compatibility.js +21 -0
- package/dist/marks.d.ts +7 -0
- package/dist/marks.js +9 -0
- package/dist/model.d.ts +88 -0
- package/dist/model.js +14 -0
- package/dist/security/urlPolicy.d.ts +17 -0
- package/dist/security/urlPolicy.js +69 -0
- package/dist/selection.d.ts +30 -0
- package/dist/selection.js +1 -0
- package/dist/table.d.ts +11 -0
- package/dist/table.js +17 -0
- package/dist/transaction.d.ts +48 -0
- package/dist/transaction.js +73 -0
- package/dist/tree.d.ts +6 -0
- package/dist/tree.js +18 -0
- package/package.json +47 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SmartDocument } from "./model.js";
|
|
2
|
+
import type { SmartSelection } from "./selection.js";
|
|
3
|
+
import type { SmartTransaction } from "./transaction.js";
|
|
4
|
+
export interface CommandContext {
|
|
5
|
+
document: SmartDocument;
|
|
6
|
+
selection: SmartSelection;
|
|
7
|
+
now?: () => number;
|
|
8
|
+
}
|
|
9
|
+
export interface SmartCommand<Input = unknown> {
|
|
10
|
+
id: string;
|
|
11
|
+
isEnabled(context: CommandContext, input?: Input): boolean;
|
|
12
|
+
execute(context: CommandContext, input?: Input): SmartTransaction;
|
|
13
|
+
}
|
package/dist/command.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Path, type SmartDocument, type TextAlignment } from "../model.js";
|
|
2
|
+
import type { SmartSelection } from "../selection.js";
|
|
3
|
+
export interface SetTextAlignmentInput {
|
|
4
|
+
paths: readonly Path[];
|
|
5
|
+
alignment: TextAlignment | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const setTextAlignment: (document: SmartDocument, selection: SmartSelection, input: SetTextAlignmentInput) => {
|
|
8
|
+
document: SmartDocument;
|
|
9
|
+
transaction: {
|
|
10
|
+
id: string;
|
|
11
|
+
source: "user";
|
|
12
|
+
operations: import("../transaction.js").SmartOperation[];
|
|
13
|
+
selectionBefore: SmartSelection;
|
|
14
|
+
selectionAfter: SmartSelection;
|
|
15
|
+
addToHistory: boolean;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getNodeAtPath } from "../model.js";
|
|
2
|
+
import { replaceNodeAtPath } from "../tree.js";
|
|
3
|
+
export const setTextAlignment = (document, selection, input) => {
|
|
4
|
+
const uniquePaths = Array.from(new Map(input.paths.map((path) => [path.join("."), path])).values());
|
|
5
|
+
if (uniquePaths.length === 0)
|
|
6
|
+
throw new Error("At least one aligned block path is required.");
|
|
7
|
+
let nextDocument = document;
|
|
8
|
+
const operations = [];
|
|
9
|
+
uniquePaths.forEach((path) => {
|
|
10
|
+
const node = getNodeAtPath(nextDocument, path);
|
|
11
|
+
if (!node || !["paragraph", "heading", "listItem", "blockquote", "codeBlock"].includes(node.type || "")) {
|
|
12
|
+
throw new Error("Alignment path must resolve to an alignable block.");
|
|
13
|
+
}
|
|
14
|
+
const replacement = { ...node };
|
|
15
|
+
if (!input.alignment || input.alignment === "left")
|
|
16
|
+
delete replacement.alignment;
|
|
17
|
+
else
|
|
18
|
+
replacement.alignment = input.alignment;
|
|
19
|
+
nextDocument = replaceNodeAtPath(nextDocument, path, replacement);
|
|
20
|
+
operations.push({ type: "replaceNode", path, node: replacement });
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
document: nextDocument,
|
|
24
|
+
transaction: {
|
|
25
|
+
id: "set-text-alignment",
|
|
26
|
+
source: "user",
|
|
27
|
+
operations,
|
|
28
|
+
selectionBefore: selection,
|
|
29
|
+
selectionAfter: selection,
|
|
30
|
+
addToHistory: true,
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Path, type SmartDocument } from "../model.js";
|
|
2
|
+
import type { SmartSelection } from "../selection.js";
|
|
3
|
+
import type { SmartTransaction } from "../transaction.js";
|
|
4
|
+
export interface ToggleCodeBlocksInput {
|
|
5
|
+
parentPath: Path;
|
|
6
|
+
blockIndexes: readonly number[];
|
|
7
|
+
}
|
|
8
|
+
export interface CodeCommandResult {
|
|
9
|
+
document: SmartDocument;
|
|
10
|
+
transaction: SmartTransaction;
|
|
11
|
+
}
|
|
12
|
+
/** Converts selected sibling blocks only; neighbours are never inferred or changed. */
|
|
13
|
+
export declare const toggleCodeBlocks: (document: SmartDocument, selection: SmartSelection, input: ToggleCodeBlocksInput) => CodeCommandResult;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getNodeAtPath, paragraph } from "../model.js";
|
|
2
|
+
import { isSmartContainer, replaceNodeAtPath } from "../tree.js";
|
|
3
|
+
const toCodeBlock = (block) => {
|
|
4
|
+
if (block.type === "codeBlock")
|
|
5
|
+
return block;
|
|
6
|
+
if (block.type === "paragraph" || block.type === "heading") {
|
|
7
|
+
return {
|
|
8
|
+
type: "codeBlock",
|
|
9
|
+
text: block.children.map((child) => child.text).join(""),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
throw new Error(`Cannot convert ${block.type} to a code block.`);
|
|
13
|
+
};
|
|
14
|
+
const toParagraph = (block) => block.type === "codeBlock" ? paragraph(block.text) : block;
|
|
15
|
+
/** Converts selected sibling blocks only; neighbours are never inferred or changed. */
|
|
16
|
+
export const toggleCodeBlocks = (document, selection, input) => {
|
|
17
|
+
const parent = getNodeAtPath(document, input.parentPath);
|
|
18
|
+
if (!isSmartContainer(parent))
|
|
19
|
+
throw new Error("Expected a block container at parentPath.");
|
|
20
|
+
const indexes = [...new Set(input.blockIndexes)].sort((left, right) => left - right);
|
|
21
|
+
if (indexes.length === 0)
|
|
22
|
+
throw new Error("At least one block must be selected.");
|
|
23
|
+
if (indexes.some((index) => index < 0 || index >= parent.children.length)) {
|
|
24
|
+
throw new Error("Selected block index is out of bounds.");
|
|
25
|
+
}
|
|
26
|
+
const selected = indexes.map((index) => parent.children[index]);
|
|
27
|
+
const toggleOff = selected.every((block) => block.type === "codeBlock");
|
|
28
|
+
const selectedIndexes = new Set(indexes);
|
|
29
|
+
const nextParent = {
|
|
30
|
+
...parent,
|
|
31
|
+
children: parent.children.map((block, index) => {
|
|
32
|
+
if (!selectedIndexes.has(index))
|
|
33
|
+
return block;
|
|
34
|
+
return toggleOff
|
|
35
|
+
? toParagraph(block)
|
|
36
|
+
: toCodeBlock(block);
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
const nextDocument = replaceNodeAtPath(document, input.parentPath, nextParent);
|
|
40
|
+
const selectionAfter = {
|
|
41
|
+
type: "node",
|
|
42
|
+
path: [...input.parentPath, indexes[0]],
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
document: nextDocument,
|
|
46
|
+
transaction: {
|
|
47
|
+
id: "toggle-code-blocks",
|
|
48
|
+
source: "user",
|
|
49
|
+
operations: [{ type: "replaceNode", path: input.parentPath, node: nextParent }],
|
|
50
|
+
selectionBefore: selection,
|
|
51
|
+
selectionAfter,
|
|
52
|
+
addToHistory: true,
|
|
53
|
+
timestamp: Date.now(),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Path, type SmartDocument, type SmartListNode } from "../model.js";
|
|
2
|
+
import type { SmartSelection } from "../selection.js";
|
|
3
|
+
import type { SmartTransaction } from "../transaction.js";
|
|
4
|
+
export type ListStyle = SmartListNode["style"];
|
|
5
|
+
export interface ToggleTableCellListInput {
|
|
6
|
+
tablePath: Path;
|
|
7
|
+
row: number;
|
|
8
|
+
column: number;
|
|
9
|
+
blockIndexes: readonly number[];
|
|
10
|
+
style: ListStyle;
|
|
11
|
+
}
|
|
12
|
+
export interface CommandResult {
|
|
13
|
+
document: SmartDocument;
|
|
14
|
+
transaction: SmartTransaction;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Converts explicitly selected block nodes in one table cell into a single
|
|
18
|
+
* list. No DOM ranges, wrappers, or React state participate in this command.
|
|
19
|
+
*/
|
|
20
|
+
export declare const toggleTableCellList: (document: SmartDocument, selection: SmartSelection, input: ToggleTableCellListInput) => CommandResult;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getNodeAtPath, } from "../model.js";
|
|
2
|
+
import { listFromBlocks } from "../table.js";
|
|
3
|
+
import { replaceNodeAtPath } from "../tree.js";
|
|
4
|
+
/**
|
|
5
|
+
* Converts explicitly selected block nodes in one table cell into a single
|
|
6
|
+
* list. No DOM ranges, wrappers, or React state participate in this command.
|
|
7
|
+
*/
|
|
8
|
+
export const toggleTableCellList = (document, selection, input) => {
|
|
9
|
+
const table = getNodeAtPath(document, input.tablePath);
|
|
10
|
+
if (!table || table.type !== "table")
|
|
11
|
+
throw new Error("Expected a table at tablePath.");
|
|
12
|
+
const cell = table.children[input.row]?.children[input.column];
|
|
13
|
+
if (!cell || (cell.type !== "tableCell" && cell.type !== "tableHeaderCell")) {
|
|
14
|
+
throw new Error("Expected a table cell at the requested coordinates.");
|
|
15
|
+
}
|
|
16
|
+
const indexes = [...new Set(input.blockIndexes)].sort((left, right) => left - right);
|
|
17
|
+
if (indexes.length === 0)
|
|
18
|
+
throw new Error("At least one table-cell block must be selected.");
|
|
19
|
+
if (indexes.some((index) => index < 0 || index >= cell.children.length)) {
|
|
20
|
+
throw new Error("Selected table-cell block index is out of bounds.");
|
|
21
|
+
}
|
|
22
|
+
const selected = indexes.map((index) => cell.children[index]);
|
|
23
|
+
const selectedIndexes = new Set(indexes);
|
|
24
|
+
const firstIndex = indexes[0];
|
|
25
|
+
const list = listFromBlocks(selected, input.style);
|
|
26
|
+
const nextCell = {
|
|
27
|
+
...cell,
|
|
28
|
+
children: cell.children.reduce((children, block, index) => {
|
|
29
|
+
if (index === firstIndex)
|
|
30
|
+
children.push(list);
|
|
31
|
+
if (!selectedIndexes.has(index))
|
|
32
|
+
children.push(block);
|
|
33
|
+
return children;
|
|
34
|
+
}, []),
|
|
35
|
+
};
|
|
36
|
+
const cellPath = [...input.tablePath, input.row, input.column];
|
|
37
|
+
const nextDocument = replaceNodeAtPath(document, cellPath, nextCell);
|
|
38
|
+
const listPath = [...cellPath, firstIndex];
|
|
39
|
+
const selectionAfter = { type: "node", path: listPath };
|
|
40
|
+
return {
|
|
41
|
+
document: nextDocument,
|
|
42
|
+
transaction: {
|
|
43
|
+
id: "toggle-table-cell-list",
|
|
44
|
+
source: "user",
|
|
45
|
+
operations: [{ type: "replaceNode", path: cellPath, node: nextCell }],
|
|
46
|
+
selectionBefore: selection,
|
|
47
|
+
selectionAfter,
|
|
48
|
+
addToHistory: true,
|
|
49
|
+
timestamp: Date.now(),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SmartCommand } from "../command.js";
|
|
2
|
+
export declare const toggleBold: SmartCommand<void>;
|
|
3
|
+
export declare const toggleItalic: SmartCommand<void>;
|
|
4
|
+
export declare const toggleUnderline: SmartCommand<void>;
|
|
5
|
+
export declare const toggleSuperscript: SmartCommand<void>;
|
|
6
|
+
export declare const toggleSubscript: SmartCommand<void>;
|
|
7
|
+
export declare const applyTextColor: SmartCommand<string>;
|
|
8
|
+
export declare const applyBackgroundColor: SmartCommand<string>;
|
|
9
|
+
export declare const applyFontSize: SmartCommand<number>;
|
|
10
|
+
type LinkInput = {
|
|
11
|
+
href: string;
|
|
12
|
+
target?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const applyLink: SmartCommand<LinkInput>;
|
|
15
|
+
export declare const updateLink: SmartCommand<LinkInput>;
|
|
16
|
+
export declare const removeLink: SmartCommand<void>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { addMark, hasMark, removeMark } from "../marks.js";
|
|
2
|
+
import { getNodeAtPath } from "../model.js";
|
|
3
|
+
import { sanitizeLinkAttrs } from "../security/urlPolicy.js";
|
|
4
|
+
const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
|
|
5
|
+
const getTextRange = (context) => {
|
|
6
|
+
if (context.selection.type !== "text")
|
|
7
|
+
return null;
|
|
8
|
+
const { anchor, focus } = context.selection;
|
|
9
|
+
const parentPath = anchor.path.slice(0, -1);
|
|
10
|
+
if (!samePath(parentPath, focus.path.slice(0, -1)))
|
|
11
|
+
return null;
|
|
12
|
+
const anchorIndex = anchor.path[anchor.path.length - 1];
|
|
13
|
+
const focusIndex = focus.path[focus.path.length - 1];
|
|
14
|
+
const forward = anchorIndex < focusIndex || (anchorIndex === focusIndex && anchor.offset <= focus.offset);
|
|
15
|
+
const startIndex = forward ? anchorIndex : focusIndex;
|
|
16
|
+
const endIndex = forward ? focusIndex : anchorIndex;
|
|
17
|
+
const startOffset = forward ? anchor.offset : focus.offset;
|
|
18
|
+
const endOffset = forward ? focus.offset : anchor.offset;
|
|
19
|
+
const parent = getNodeAtPath(context.document, parentPath);
|
|
20
|
+
const nodes = parent?.children;
|
|
21
|
+
if (!nodes || startIndex < 0 || endIndex >= nodes.length || startOffset < 0 || endOffset < 0)
|
|
22
|
+
return null;
|
|
23
|
+
if (nodes.slice(startIndex, endIndex + 1).some((node) => node.type !== "text"))
|
|
24
|
+
return null;
|
|
25
|
+
if (startIndex === endIndex && (startOffset === endOffset || endOffset > nodes[startIndex].text.length))
|
|
26
|
+
return null;
|
|
27
|
+
if (startOffset > nodes[startIndex].text.length || endOffset > nodes[endIndex].text.length)
|
|
28
|
+
return null;
|
|
29
|
+
return { selection: context.selection, parentPath, startIndex, endIndex, startOffset, endOffset, nodes };
|
|
30
|
+
};
|
|
31
|
+
const replaceRange = (range, mutate) => {
|
|
32
|
+
const children = [];
|
|
33
|
+
let selectionStart = 0;
|
|
34
|
+
let selectionEnd = 0;
|
|
35
|
+
range.nodes.forEach((node, index) => {
|
|
36
|
+
if (index < range.startIndex || index > range.endIndex) {
|
|
37
|
+
children.push(node);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const from = index === range.startIndex ? range.startOffset : 0;
|
|
41
|
+
const to = index === range.endIndex ? range.endOffset : node.text.length;
|
|
42
|
+
if (from > 0)
|
|
43
|
+
children.push({ ...node, text: node.text.slice(0, from) });
|
|
44
|
+
if (index === range.startIndex)
|
|
45
|
+
selectionStart = children.length;
|
|
46
|
+
const selected = node.text.slice(from, to);
|
|
47
|
+
if (selected)
|
|
48
|
+
children.push({ type: "text", text: selected, marks: mutate(node.marks) });
|
|
49
|
+
if (index === range.endIndex)
|
|
50
|
+
selectionEnd = children.length - 1;
|
|
51
|
+
if (to < node.text.length)
|
|
52
|
+
children.push({ ...node, text: node.text.slice(to) });
|
|
53
|
+
});
|
|
54
|
+
return { children, start: selectionStart, end: selectionEnd };
|
|
55
|
+
};
|
|
56
|
+
const hasLink = (marks) => marks?.some((mark) => mark.type === "link") || false;
|
|
57
|
+
const createMarkTransaction = (id, context, input, mutate) => {
|
|
58
|
+
const range = getTextRange(context);
|
|
59
|
+
if (!range)
|
|
60
|
+
throw new Error(`${id} requires text in one inline container.`);
|
|
61
|
+
const result = replaceRange(range, (marks) => mutate(marks, range));
|
|
62
|
+
const parent = getNodeAtPath(context.document, range.parentPath);
|
|
63
|
+
const selectionAfter = {
|
|
64
|
+
type: "text",
|
|
65
|
+
anchor: { path: [...range.parentPath, result.start], offset: 0 },
|
|
66
|
+
focus: { path: [...range.parentPath, result.end], offset: result.children[result.end]?.text.length || 0 },
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
source: "user",
|
|
71
|
+
operations: [{ type: "replaceNode", path: range.parentPath, node: { ...parent, children: result.children } }],
|
|
72
|
+
selectionBefore: range.selection,
|
|
73
|
+
selectionAfter,
|
|
74
|
+
addToHistory: true,
|
|
75
|
+
timestamp: context.now?.() ?? Date.now(),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const createMarkCommand = (id, nextMark, toggle) => ({
|
|
79
|
+
id,
|
|
80
|
+
isEnabled: (context) => Boolean(getTextRange(context)),
|
|
81
|
+
execute: (context, input) => {
|
|
82
|
+
const range = getTextRange(context);
|
|
83
|
+
if (!range)
|
|
84
|
+
throw new Error(`${id} requires text in one inline container.`);
|
|
85
|
+
const mark = nextMark(input);
|
|
86
|
+
const selected = range.nodes.slice(range.startIndex, range.endIndex + 1);
|
|
87
|
+
const remove = toggle && selected.every((node) => hasMark(node.marks, mark.type));
|
|
88
|
+
return createMarkTransaction(id, context, input, (marks) => remove ? removeMark(marks, mark.type) : addMark(marks, mark));
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const toggle = (id, type) => createMarkCommand(id, () => ({ type }), true);
|
|
92
|
+
export const toggleBold = toggle("toggle-bold", "bold");
|
|
93
|
+
export const toggleItalic = toggle("toggle-italic", "italic");
|
|
94
|
+
export const toggleUnderline = toggle("toggle-underline", "underline");
|
|
95
|
+
export const toggleSuperscript = toggle("toggle-superscript", "superscript");
|
|
96
|
+
export const toggleSubscript = toggle("toggle-subscript", "subscript");
|
|
97
|
+
export const applyTextColor = createMarkCommand("apply-text-color", (value) => ({ type: "textColor", value }), false);
|
|
98
|
+
export const applyBackgroundColor = createMarkCommand("apply-background-color", (value) => ({ type: "backgroundColor", value }), false);
|
|
99
|
+
export const applyFontSize = createMarkCommand("apply-font-size", (valuePx) => ({ type: "fontSize", valuePx }), false);
|
|
100
|
+
export const applyLink = {
|
|
101
|
+
id: "apply-link",
|
|
102
|
+
isEnabled: (context, input) => Boolean(getTextRange(context) && input && sanitizeLinkAttrs(input)),
|
|
103
|
+
execute: (context, input) => {
|
|
104
|
+
const safe = sanitizeLinkAttrs(input || {});
|
|
105
|
+
if (!safe)
|
|
106
|
+
throw new Error("apply-link requires a safe href.");
|
|
107
|
+
return createMarkTransaction("apply-link", context, input, (marks) => addMark(marks, { type: "link", ...safe }));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
export const updateLink = {
|
|
111
|
+
id: "update-link",
|
|
112
|
+
isEnabled: (context, input) => {
|
|
113
|
+
const range = getTextRange(context);
|
|
114
|
+
return Boolean(range && input && sanitizeLinkAttrs(input) && range.nodes.slice(range.startIndex, range.endIndex + 1).some((node) => hasLink(node.marks)));
|
|
115
|
+
},
|
|
116
|
+
execute: (context, input) => {
|
|
117
|
+
const range = getTextRange(context);
|
|
118
|
+
const safe = sanitizeLinkAttrs(input || {});
|
|
119
|
+
if (!range || !safe || !range.nodes.slice(range.startIndex, range.endIndex + 1).some((node) => hasLink(node.marks))) {
|
|
120
|
+
throw new Error("update-link requires selected linked text and a safe href.");
|
|
121
|
+
}
|
|
122
|
+
return createMarkTransaction("update-link", context, input, (marks) => addMark(marks, { type: "link", ...safe }));
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
export const removeLink = {
|
|
126
|
+
id: "remove-link",
|
|
127
|
+
isEnabled: (context) => {
|
|
128
|
+
const range = getTextRange(context);
|
|
129
|
+
return Boolean(range && range.nodes.slice(range.startIndex, range.endIndex + 1).some((node) => hasLink(node.marks)));
|
|
130
|
+
},
|
|
131
|
+
execute: (context) => {
|
|
132
|
+
if (!removeLink.isEnabled(context))
|
|
133
|
+
throw new Error("remove-link requires selected linked text.");
|
|
134
|
+
return createMarkTransaction("remove-link", context, undefined, (marks) => removeMark(marks, "link"));
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type HtmlAttribute = {
|
|
2
|
+
name: string;
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
type HtmlNode = {
|
|
6
|
+
nodeName: string;
|
|
7
|
+
tagName?: string;
|
|
8
|
+
attrs?: HtmlAttribute[];
|
|
9
|
+
childNodes?: HtmlNode[];
|
|
10
|
+
parentNode?: HtmlNode;
|
|
11
|
+
namespaceURI?: string;
|
|
12
|
+
};
|
|
13
|
+
export interface HtmlCompatibilityDocument {
|
|
14
|
+
fragment: HtmlNode;
|
|
15
|
+
}
|
|
16
|
+
/** Parses legacy HTML into a canonical, editor-UI-free compatibility document. */
|
|
17
|
+
export declare const parseCompatibilityHtml: (html: string) => HtmlCompatibilityDocument;
|
|
18
|
+
/** Serializes canonical compatibility HTML for persisted value/onChange output. */
|
|
19
|
+
export declare const serializeCompatibilityHtml: (document: HtmlCompatibilityDocument) => string;
|
|
20
|
+
export declare const normalizeCompatibilityHtml: (html: string) => string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { parseFragment, serialize } from "parse5";
|
|
2
|
+
const BLOCK_TAGS = new Set([
|
|
3
|
+
"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li",
|
|
4
|
+
"blockquote", "pre", "table", "div",
|
|
5
|
+
]);
|
|
6
|
+
const attrsOf = (node) => node.attrs || [];
|
|
7
|
+
const hasAttr = (node, name, value) => attrsOf(node).some((attr) => attr.name === name && (value == null || attr.value === value));
|
|
8
|
+
const isTag = (node, tagName) => node.tagName === tagName;
|
|
9
|
+
const isWhitespaceText = (node) => node.nodeName === "#text" && !node.value?.trim();
|
|
10
|
+
const createParagraph = (children) => ({
|
|
11
|
+
nodeName: "p",
|
|
12
|
+
tagName: "p",
|
|
13
|
+
namespaceURI: "http://www.w3.org/1999/xhtml",
|
|
14
|
+
attrs: [],
|
|
15
|
+
childNodes: children,
|
|
16
|
+
});
|
|
17
|
+
const normalizeTableCell = (cell) => {
|
|
18
|
+
const children = cell.childNodes || [];
|
|
19
|
+
if (children.some((child) => child.tagName && BLOCK_TAGS.has(child.tagName)))
|
|
20
|
+
return;
|
|
21
|
+
const lines = [[]];
|
|
22
|
+
children.forEach((child) => {
|
|
23
|
+
if (isTag(child, "br")) {
|
|
24
|
+
lines.push([]);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
lines[lines.length - 1].push(child);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const nonEmptyLines = lines.filter((line) => line.some((child) => !isWhitespaceText(child)));
|
|
31
|
+
if (nonEmptyLines.length === 0)
|
|
32
|
+
return;
|
|
33
|
+
cell.childNodes = nonEmptyLines.map(createParagraph);
|
|
34
|
+
};
|
|
35
|
+
const normalizeNode = (node) => {
|
|
36
|
+
node.attrs = attrsOf(node).filter((attr) => {
|
|
37
|
+
if (attr.name === "data-table-wrapper")
|
|
38
|
+
return false;
|
|
39
|
+
if (attr.name === "data-row-index" || attr.name === "data-col-index")
|
|
40
|
+
return false;
|
|
41
|
+
return !attr.name.startsWith("data-srte-");
|
|
42
|
+
});
|
|
43
|
+
if (!node.childNodes)
|
|
44
|
+
return;
|
|
45
|
+
const normalizedChildren = [];
|
|
46
|
+
node.childNodes.forEach((child) => {
|
|
47
|
+
const isTableWrapper = hasAttr(child, "data-table-wrapper", "true");
|
|
48
|
+
normalizeNode(child);
|
|
49
|
+
if (isTableWrapper) {
|
|
50
|
+
(child.childNodes || []).forEach((nested) => normalizedChildren.push(nested));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
normalizedChildren.push(child);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
node.childNodes = normalizedChildren;
|
|
57
|
+
if (isTag(node, "td") || isTag(node, "th"))
|
|
58
|
+
normalizeTableCell(node);
|
|
59
|
+
};
|
|
60
|
+
/** Parses legacy HTML into a canonical, editor-UI-free compatibility document. */
|
|
61
|
+
export const parseCompatibilityHtml = (html) => {
|
|
62
|
+
const fragment = parseFragment(html);
|
|
63
|
+
normalizeNode(fragment);
|
|
64
|
+
return { fragment };
|
|
65
|
+
};
|
|
66
|
+
/** Serializes canonical compatibility HTML for persisted value/onChange output. */
|
|
67
|
+
export const serializeCompatibilityHtml = (document) => serialize(document.fragment);
|
|
68
|
+
export const normalizeCompatibilityHtml = (html) => serializeCompatibilityHtml(parseCompatibilityHtml(html));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "./model.js";
|
|
2
|
+
export * from "./command.js";
|
|
3
|
+
export * from "./marks.js";
|
|
4
|
+
export * from "./selection.js";
|
|
5
|
+
export * from "./table.js";
|
|
6
|
+
export * from "./transaction.js";
|
|
7
|
+
export * from "./tree.js";
|
|
8
|
+
export * from "./commands/list.js";
|
|
9
|
+
export * from "./commands/code.js";
|
|
10
|
+
export * from "./commands/marks.js";
|
|
11
|
+
export * from "./commands/alignment.js";
|
|
12
|
+
export * from "./html/compatibility.js";
|
|
13
|
+
export * from "./markdown/compatibility.js";
|
|
14
|
+
export * from "./security/urlPolicy.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "./model.js";
|
|
2
|
+
export * from "./command.js";
|
|
3
|
+
export * from "./marks.js";
|
|
4
|
+
export * from "./selection.js";
|
|
5
|
+
export * from "./table.js";
|
|
6
|
+
export * from "./transaction.js";
|
|
7
|
+
export * from "./tree.js";
|
|
8
|
+
export * from "./commands/list.js";
|
|
9
|
+
export * from "./commands/code.js";
|
|
10
|
+
export * from "./commands/marks.js";
|
|
11
|
+
export * from "./commands/alignment.js";
|
|
12
|
+
export * from "./html/compatibility.js";
|
|
13
|
+
export * from "./markdown/compatibility.js";
|
|
14
|
+
export * from "./security/urlPolicy.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts CommonMark/GFM input to canonical Smart RTE HTML.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally separate from HTML import. Markdown provides list
|
|
5
|
+
* indentation and table syntax that no HTML repair pass can recover once lost.
|
|
6
|
+
*/
|
|
7
|
+
export declare const markdownToCompatibilityHtml: (markdown: string) => string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import rehypeStringify from "rehype-stringify";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
import remarkParse from "remark-parse";
|
|
4
|
+
import remarkRehype from "remark-rehype";
|
|
5
|
+
import { unified } from "unified";
|
|
6
|
+
import { normalizeCompatibilityHtml } from "../html/compatibility.js";
|
|
7
|
+
/**
|
|
8
|
+
* Converts CommonMark/GFM input to canonical Smart RTE HTML.
|
|
9
|
+
*
|
|
10
|
+
* This is intentionally separate from HTML import. Markdown provides list
|
|
11
|
+
* indentation and table syntax that no HTML repair pass can recover once lost.
|
|
12
|
+
*/
|
|
13
|
+
export const markdownToCompatibilityHtml = (markdown) => {
|
|
14
|
+
const html = String(unified()
|
|
15
|
+
.use(remarkParse)
|
|
16
|
+
.use(remarkGfm)
|
|
17
|
+
.use(remarkRehype)
|
|
18
|
+
.use(rehypeStringify)
|
|
19
|
+
.processSync(markdown));
|
|
20
|
+
return normalizeCompatibilityHtml(html);
|
|
21
|
+
};
|
package/dist/marks.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SmartMark } from "./model.js";
|
|
2
|
+
export type InlineMarkType = Extract<SmartMark, {
|
|
3
|
+
type: string;
|
|
4
|
+
}>['type'];
|
|
5
|
+
export declare const hasMark: (marks: readonly SmartMark[] | undefined, type: InlineMarkType) => boolean;
|
|
6
|
+
export declare const addMark: (marks: readonly SmartMark[] | undefined, mark: SmartMark) => SmartMark[];
|
|
7
|
+
export declare const removeMark: (marks: readonly SmartMark[] | undefined, type: InlineMarkType) => SmartMark[] | undefined;
|
package/dist/marks.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const hasMark = (marks, type) => Boolean(marks?.some((mark) => mark.type === type));
|
|
2
|
+
export const addMark = (marks, mark) => [
|
|
3
|
+
...(marks || []).filter((existing) => existing.type !== mark.type),
|
|
4
|
+
mark,
|
|
5
|
+
];
|
|
6
|
+
export const removeMark = (marks, type) => {
|
|
7
|
+
const next = (marks || []).filter((mark) => mark.type !== type);
|
|
8
|
+
return next.length > 0 ? next : undefined;
|
|
9
|
+
};
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type Path = readonly number[];
|
|
2
|
+
export type TextAlignment = "left" | "center" | "right" | "justify";
|
|
3
|
+
export type SmartMark = {
|
|
4
|
+
type: "bold";
|
|
5
|
+
} | {
|
|
6
|
+
type: "italic";
|
|
7
|
+
} | {
|
|
8
|
+
type: "underline";
|
|
9
|
+
} | {
|
|
10
|
+
type: "strike";
|
|
11
|
+
} | {
|
|
12
|
+
type: "superscript";
|
|
13
|
+
} | {
|
|
14
|
+
type: "subscript";
|
|
15
|
+
} | {
|
|
16
|
+
type: "code";
|
|
17
|
+
} | {
|
|
18
|
+
type: "textColor";
|
|
19
|
+
value: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: "backgroundColor";
|
|
22
|
+
value: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: "fontSize";
|
|
25
|
+
valuePx: number;
|
|
26
|
+
} | {
|
|
27
|
+
type: "link";
|
|
28
|
+
href: string;
|
|
29
|
+
target?: string;
|
|
30
|
+
};
|
|
31
|
+
export interface SmartTextNode {
|
|
32
|
+
type: "text";
|
|
33
|
+
text: string;
|
|
34
|
+
marks?: SmartMark[];
|
|
35
|
+
}
|
|
36
|
+
export interface SmartParagraphNode {
|
|
37
|
+
type: "paragraph";
|
|
38
|
+
alignment?: TextAlignment;
|
|
39
|
+
children: SmartTextNode[];
|
|
40
|
+
}
|
|
41
|
+
export interface SmartHeadingNode {
|
|
42
|
+
type: "heading";
|
|
43
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
44
|
+
alignment?: TextAlignment;
|
|
45
|
+
children: SmartTextNode[];
|
|
46
|
+
}
|
|
47
|
+
export interface SmartListItemNode {
|
|
48
|
+
type: "listItem";
|
|
49
|
+
alignment?: TextAlignment;
|
|
50
|
+
children: SmartBlockNode[];
|
|
51
|
+
}
|
|
52
|
+
export interface SmartListNode {
|
|
53
|
+
type: "list";
|
|
54
|
+
style: "disc" | "circle" | "square" | "decimal" | "lower-alpha" | "upper-alpha" | "lower-roman" | "upper-roman";
|
|
55
|
+
children: SmartListItemNode[];
|
|
56
|
+
}
|
|
57
|
+
export interface SmartBlockquoteNode {
|
|
58
|
+
type: "blockquote";
|
|
59
|
+
alignment?: TextAlignment;
|
|
60
|
+
children: SmartBlockNode[];
|
|
61
|
+
}
|
|
62
|
+
export interface SmartCodeBlockNode {
|
|
63
|
+
type: "codeBlock";
|
|
64
|
+
alignment?: TextAlignment;
|
|
65
|
+
text: string;
|
|
66
|
+
language?: string;
|
|
67
|
+
}
|
|
68
|
+
export interface SmartTableCellNode {
|
|
69
|
+
type: "tableCell" | "tableHeaderCell";
|
|
70
|
+
colspan?: number;
|
|
71
|
+
rowspan?: number;
|
|
72
|
+
children: SmartBlockNode[];
|
|
73
|
+
}
|
|
74
|
+
export interface SmartTableRowNode {
|
|
75
|
+
type: "tableRow";
|
|
76
|
+
children: SmartTableCellNode[];
|
|
77
|
+
}
|
|
78
|
+
export interface SmartTableNode {
|
|
79
|
+
type: "table";
|
|
80
|
+
children: SmartTableRowNode[];
|
|
81
|
+
}
|
|
82
|
+
export type SmartBlockNode = SmartParagraphNode | SmartHeadingNode | SmartListNode | SmartBlockquoteNode | SmartCodeBlockNode | SmartTableNode;
|
|
83
|
+
export interface SmartDocument {
|
|
84
|
+
type: "doc";
|
|
85
|
+
children: SmartBlockNode[];
|
|
86
|
+
}
|
|
87
|
+
export declare const paragraph: (text?: string) => SmartParagraphNode;
|
|
88
|
+
export declare const getNodeAtPath: (document: SmartDocument, path: Path) => unknown;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const paragraph = (text = "") => ({
|
|
2
|
+
type: "paragraph",
|
|
3
|
+
children: [{ type: "text", text }],
|
|
4
|
+
});
|
|
5
|
+
export const getNodeAtPath = (document, path) => {
|
|
6
|
+
let node = document;
|
|
7
|
+
for (const index of path) {
|
|
8
|
+
if (!node || typeof node !== "object" || !Array.isArray(node.children)) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
node = node.children[index];
|
|
12
|
+
}
|
|
13
|
+
return node;
|
|
14
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SafeLinkAttrs {
|
|
2
|
+
href: string;
|
|
3
|
+
target?: string;
|
|
4
|
+
}
|
|
5
|
+
export type NormalizedLinkKind = "url" | "email" | "phone" | "anchor" | "invalid";
|
|
6
|
+
export interface NormalizedLinkResult {
|
|
7
|
+
kind: NormalizedLinkKind;
|
|
8
|
+
href: string | null;
|
|
9
|
+
input: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const normalizeLinkInput: (input: string | undefined | null) => NormalizedLinkResult;
|
|
12
|
+
export declare const sanitizeLinkHref: (href: string | undefined | null) => string | null;
|
|
13
|
+
export declare const sanitizeLinkTarget: (target: string | undefined) => string | undefined;
|
|
14
|
+
export declare const sanitizeLinkAttrs: (input: {
|
|
15
|
+
href?: string;
|
|
16
|
+
target?: string;
|
|
17
|
+
}) => SafeLinkAttrs | null;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const allowedProtocols = new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
2
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3
|
+
const bareDomainPattern = /^(?:www\.)?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+(?:[/?#][^\s]*)?$/;
|
|
4
|
+
const phonePattern = /^\+?[\d\s().-]{7,}$/;
|
|
5
|
+
const normalizePhone = (value) => {
|
|
6
|
+
const hasLeadingPlus = value.trim().startsWith("+");
|
|
7
|
+
const digits = value.replace(/[^\d]/g, "");
|
|
8
|
+
if (digits.length < 7)
|
|
9
|
+
return null;
|
|
10
|
+
return `${hasLeadingPlus ? "+" : ""}${digits}`;
|
|
11
|
+
};
|
|
12
|
+
const normalizeMailto = (value) => {
|
|
13
|
+
const email = value.slice("mailto:".length).trim();
|
|
14
|
+
return emailPattern.test(email) ? `mailto:${email}` : null;
|
|
15
|
+
};
|
|
16
|
+
const normalizeTel = (value) => {
|
|
17
|
+
const phone = normalizePhone(value.slice("tel:".length));
|
|
18
|
+
return phone ? `tel:${phone}` : null;
|
|
19
|
+
};
|
|
20
|
+
export const normalizeLinkInput = (input) => {
|
|
21
|
+
const trimmed = input?.trim() || "";
|
|
22
|
+
if (!trimmed)
|
|
23
|
+
return { kind: "invalid", href: null, input: "" };
|
|
24
|
+
if (/^mailto:/i.test(trimmed)) {
|
|
25
|
+
const href = normalizeMailto(trimmed);
|
|
26
|
+
return { kind: href ? "email" : "invalid", href, input: trimmed };
|
|
27
|
+
}
|
|
28
|
+
if (/^tel:/i.test(trimmed)) {
|
|
29
|
+
const href = normalizeTel(trimmed);
|
|
30
|
+
return { kind: href ? "phone" : "invalid", href, input: trimmed };
|
|
31
|
+
}
|
|
32
|
+
if (emailPattern.test(trimmed)) {
|
|
33
|
+
return { kind: "email", href: `mailto:${trimmed}`, input: trimmed };
|
|
34
|
+
}
|
|
35
|
+
const phone = normalizePhone(trimmed);
|
|
36
|
+
if (phone && (trimmed.startsWith("+") || phonePattern.test(trimmed))) {
|
|
37
|
+
return { kind: "phone", href: `tel:${phone}`, input: trimmed };
|
|
38
|
+
}
|
|
39
|
+
if (/^#[A-Za-z][\w:.-]*$/.test(trimmed)) {
|
|
40
|
+
return { kind: "anchor", href: trimmed, input: trimmed };
|
|
41
|
+
}
|
|
42
|
+
if (bareDomainPattern.test(trimmed)) {
|
|
43
|
+
return { kind: "url", href: `https://${trimmed}`, input: trimmed };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const parsed = new URL(trimmed);
|
|
47
|
+
return {
|
|
48
|
+
kind: allowedProtocols.has(parsed.protocol) ? "url" : "invalid",
|
|
49
|
+
href: allowedProtocols.has(parsed.protocol) ? trimmed : null,
|
|
50
|
+
input: trimmed,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { kind: "invalid", href: null, input: trimmed };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
export const sanitizeLinkHref = (href) => {
|
|
58
|
+
return normalizeLinkInput(href).href;
|
|
59
|
+
};
|
|
60
|
+
export const sanitizeLinkTarget = (target) => {
|
|
61
|
+
const trimmed = target?.trim();
|
|
62
|
+
return trimmed || undefined;
|
|
63
|
+
};
|
|
64
|
+
export const sanitizeLinkAttrs = (input) => {
|
|
65
|
+
const href = sanitizeLinkHref(input.href);
|
|
66
|
+
if (!href)
|
|
67
|
+
return null;
|
|
68
|
+
return { href, target: sanitizeLinkTarget(input.target) };
|
|
69
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Path } from "./model.js";
|
|
2
|
+
export interface SmartPoint {
|
|
3
|
+
path: Path;
|
|
4
|
+
offset: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SmartTextSelection {
|
|
7
|
+
type: "text";
|
|
8
|
+
anchor: SmartPoint;
|
|
9
|
+
focus: SmartPoint;
|
|
10
|
+
}
|
|
11
|
+
export interface SmartNodeSelection {
|
|
12
|
+
type: "node";
|
|
13
|
+
path: Path;
|
|
14
|
+
}
|
|
15
|
+
export interface SmartCellSelection {
|
|
16
|
+
type: "cell";
|
|
17
|
+
tablePath: Path;
|
|
18
|
+
start: {
|
|
19
|
+
row: number;
|
|
20
|
+
column: number;
|
|
21
|
+
};
|
|
22
|
+
end: {
|
|
23
|
+
row: number;
|
|
24
|
+
column: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface SmartAllSelection {
|
|
28
|
+
type: "all";
|
|
29
|
+
}
|
|
30
|
+
export type SmartSelection = SmartTextSelection | SmartNodeSelection | SmartCellSelection | SmartAllSelection;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SmartBlockNode, type SmartTableCellNode } from "./model.js";
|
|
2
|
+
/** Ensures table cells have blocks even when imported HTML supplied raw lines. */
|
|
3
|
+
export declare const normalizeTableCell: (cell: SmartTableCellNode, rawLines?: readonly string[]) => SmartTableCellNode;
|
|
4
|
+
export declare const listFromBlocks: (blocks: readonly SmartBlockNode[], style?: "disc" | "circle" | "square" | "decimal" | "lower-alpha" | "upper-alpha" | "lower-roman" | "upper-roman") => {
|
|
5
|
+
type: "list";
|
|
6
|
+
style: "disc" | "circle" | "square" | "decimal" | "lower-alpha" | "upper-alpha" | "lower-roman" | "upper-roman";
|
|
7
|
+
children: {
|
|
8
|
+
type: "listItem";
|
|
9
|
+
children: SmartBlockNode[];
|
|
10
|
+
}[];
|
|
11
|
+
};
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { paragraph } from "./model.js";
|
|
2
|
+
/** Ensures table cells have blocks even when imported HTML supplied raw lines. */
|
|
3
|
+
export const normalizeTableCell = (cell, rawLines = []) => {
|
|
4
|
+
if (cell.children.length > 0)
|
|
5
|
+
return cell;
|
|
6
|
+
return {
|
|
7
|
+
...cell,
|
|
8
|
+
children: rawLines.length > 0
|
|
9
|
+
? rawLines.map((line) => paragraph(line))
|
|
10
|
+
: [paragraph()],
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export const listFromBlocks = (blocks, style = "disc") => ({
|
|
14
|
+
type: "list",
|
|
15
|
+
style,
|
|
16
|
+
children: blocks.map((block) => ({ type: "listItem", children: [block] })),
|
|
17
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SmartDocument, Path, SmartMark } from "./model.js";
|
|
2
|
+
import type { SmartSelection } from "./selection.js";
|
|
3
|
+
import { type InlineMarkType } from "./marks.js";
|
|
4
|
+
export type SmartOperation = {
|
|
5
|
+
type: "insertNode";
|
|
6
|
+
path: Path;
|
|
7
|
+
node: unknown;
|
|
8
|
+
} | {
|
|
9
|
+
type: "removeNode";
|
|
10
|
+
path: Path;
|
|
11
|
+
} | {
|
|
12
|
+
type: "replaceNode";
|
|
13
|
+
path: Path;
|
|
14
|
+
node: unknown;
|
|
15
|
+
} | {
|
|
16
|
+
type: "setNodeAttrs";
|
|
17
|
+
path: Path;
|
|
18
|
+
attrs: Record<string, unknown>;
|
|
19
|
+
} | {
|
|
20
|
+
type: "addMark";
|
|
21
|
+
path: Path;
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
mark: SmartMark;
|
|
25
|
+
} | {
|
|
26
|
+
type: "removeMark";
|
|
27
|
+
path: Path;
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
markType: InlineMarkType;
|
|
31
|
+
} | {
|
|
32
|
+
type: "setSelection";
|
|
33
|
+
selection: SmartSelection;
|
|
34
|
+
};
|
|
35
|
+
export interface SmartTransaction {
|
|
36
|
+
id: string;
|
|
37
|
+
source: "user" | "paste" | "api" | "history" | "normalizer";
|
|
38
|
+
operations: SmartOperation[];
|
|
39
|
+
selectionBefore: SmartSelection;
|
|
40
|
+
selectionAfter: SmartSelection;
|
|
41
|
+
addToHistory: boolean;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
}
|
|
44
|
+
export interface SmartEditorState {
|
|
45
|
+
document: SmartDocument;
|
|
46
|
+
selection: SmartSelection;
|
|
47
|
+
}
|
|
48
|
+
export declare const applyTransaction: (state: SmartEditorState, transaction: SmartTransaction) => SmartEditorState;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { addMark, removeMark } from "./marks.js";
|
|
2
|
+
import { replaceNodeAtPath } from "./tree.js";
|
|
3
|
+
const splitTextNode = (node, start, end, mutateMarks) => {
|
|
4
|
+
if (start < 0 || end < start || end > node.text.length) {
|
|
5
|
+
throw new Error("Mark operation range is out of bounds.");
|
|
6
|
+
}
|
|
7
|
+
const result = [];
|
|
8
|
+
if (start > 0)
|
|
9
|
+
result.push({ ...node, text: node.text.slice(0, start) });
|
|
10
|
+
const selectedText = node.text.slice(start, end);
|
|
11
|
+
if (selectedText)
|
|
12
|
+
result.push({ type: "text", text: selectedText, marks: mutateMarks(node.marks) });
|
|
13
|
+
if (end < node.text.length)
|
|
14
|
+
result.push({ ...node, text: node.text.slice(end) });
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
const replaceTextAtPath = (document, path, replacement) => {
|
|
18
|
+
if (path.length === 0)
|
|
19
|
+
throw new Error("A text node path cannot be empty.");
|
|
20
|
+
const parentPath = path.slice(0, -1);
|
|
21
|
+
const textIndex = path[path.length - 1];
|
|
22
|
+
const parent = parentPath.reduce((node, index) => {
|
|
23
|
+
if (!node || typeof node !== "object" || !Array.isArray(node.children)) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return node.children[index];
|
|
27
|
+
}, document);
|
|
28
|
+
if (!parent || typeof parent !== "object" || !Array.isArray(parent.children)) {
|
|
29
|
+
throw new Error("Mark operation parent is not a text container.");
|
|
30
|
+
}
|
|
31
|
+
const children = parent.children;
|
|
32
|
+
const target = children[textIndex];
|
|
33
|
+
if (!target || target.type !== "text")
|
|
34
|
+
throw new Error("Mark operation path does not resolve to text.");
|
|
35
|
+
const nextParent = {
|
|
36
|
+
...parent,
|
|
37
|
+
children: [...children.slice(0, textIndex), ...replacement, ...children.slice(textIndex + 1)],
|
|
38
|
+
};
|
|
39
|
+
return replaceNodeAtPath(document, parentPath, nextParent);
|
|
40
|
+
};
|
|
41
|
+
export const applyTransaction = (state, transaction) => {
|
|
42
|
+
let document = state.document;
|
|
43
|
+
let selection = state.selection;
|
|
44
|
+
transaction.operations.forEach((operation) => {
|
|
45
|
+
if (operation.type === "replaceNode") {
|
|
46
|
+
document = replaceNodeAtPath(document, operation.path, operation.node);
|
|
47
|
+
}
|
|
48
|
+
else if (operation.type === "addMark") {
|
|
49
|
+
const node = operation.path.reduce((value, index) => {
|
|
50
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.children))
|
|
51
|
+
return undefined;
|
|
52
|
+
return value.children[index];
|
|
53
|
+
}, document);
|
|
54
|
+
if (!node || node.type !== "text")
|
|
55
|
+
throw new Error("Mark operation path does not resolve to text.");
|
|
56
|
+
document = replaceTextAtPath(document, operation.path, splitTextNode(node, operation.start, operation.end, (marks) => addMark(marks, operation.mark)));
|
|
57
|
+
}
|
|
58
|
+
else if (operation.type === "removeMark") {
|
|
59
|
+
const node = operation.path.reduce((value, index) => {
|
|
60
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.children))
|
|
61
|
+
return undefined;
|
|
62
|
+
return value.children[index];
|
|
63
|
+
}, document);
|
|
64
|
+
if (!node || node.type !== "text")
|
|
65
|
+
throw new Error("Mark operation path does not resolve to text.");
|
|
66
|
+
document = replaceTextAtPath(document, operation.path, splitTextNode(node, operation.start, operation.end, (marks) => removeMark(marks, operation.markType)));
|
|
67
|
+
}
|
|
68
|
+
else if (operation.type === "setSelection") {
|
|
69
|
+
selection = operation.selection;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return { document, selection: transaction.selectionAfter || selection };
|
|
73
|
+
};
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Path, SmartDocument } from "./model.js";
|
|
2
|
+
export interface SmartContainer {
|
|
3
|
+
children: unknown[];
|
|
4
|
+
}
|
|
5
|
+
export declare const isSmartContainer: (value: unknown) => value is SmartContainer;
|
|
6
|
+
export declare const replaceNodeAtPath: <T>(document: SmartDocument, path: Path, replacement: T) => SmartDocument;
|
package/dist/tree.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const isSmartContainer = (value) => Boolean(value) && typeof value === "object" && Array.isArray(value.children);
|
|
2
|
+
export const replaceNodeAtPath = (document, path, replacement) => {
|
|
3
|
+
if (path.length === 0)
|
|
4
|
+
return replacement;
|
|
5
|
+
const replace = (node, depth) => {
|
|
6
|
+
if (!isSmartContainer(node))
|
|
7
|
+
throw new Error("Path does not resolve to a document container.");
|
|
8
|
+
const index = path[depth];
|
|
9
|
+
if (index < 0 || index >= node.children.length)
|
|
10
|
+
throw new Error("Path is out of bounds.");
|
|
11
|
+
const children = [...node.children];
|
|
12
|
+
children[index] = depth === path.length - 1
|
|
13
|
+
? replacement
|
|
14
|
+
: replace(children[index], depth + 1);
|
|
15
|
+
return { ...node, children };
|
|
16
|
+
};
|
|
17
|
+
return replace(document, 0);
|
|
18
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smartrte-core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Framework-agnostic document model and editing primitives for Smart RTE",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/ayush1852017/smart-rte.git",
|
|
8
|
+
"directory": "packages/core"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/ayush1852017/smart-rte#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ayush1852017/smart-rte/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"module": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"!dist/**/*.test.js",
|
|
21
|
+
"!dist/**/*.test.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"prepublishOnly": "npm run build",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"lint": "tsc -p tsconfig.json --noEmit"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"parse5": "^7.2.1",
|
|
37
|
+
"rehype-stringify": "^10.0.1",
|
|
38
|
+
"remark-gfm": "^4.0.1",
|
|
39
|
+
"remark-parse": "^11.0.0",
|
|
40
|
+
"remark-rehype": "^11.1.1",
|
|
41
|
+
"unified": "^11.0.5"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "^5.6.2",
|
|
45
|
+
"vitest": "^2.1.4"
|
|
46
|
+
}
|
|
47
|
+
}
|