@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.
- package/CHANGELOG.md +19 -0
- package/LICENSE +1 -1
- package/README.md +13 -3
- package/dist/core/block-insert.d.ts +27 -0
- package/dist/core/block-insert.js +24 -0
- package/dist/core/caret-context.d.ts +24 -0
- package/dist/core/caret-context.js +73 -0
- package/dist/core/clipboard-text.d.ts +5 -0
- package/dist/core/clipboard-text.js +47 -0
- package/dist/core/commands.d.ts +65 -0
- package/dist/core/commands.js +299 -0
- package/dist/core/extensions.d.ts +29 -0
- package/dist/core/extensions.js +326 -0
- package/dist/core/find-replace.d.ts +34 -0
- package/dist/core/find-replace.js +67 -0
- package/dist/core/history-approval.d.ts +11 -0
- package/dist/core/history-approval.js +43 -0
- package/dist/core/host-value.d.ts +29 -0
- package/dist/core/host-value.js +10 -0
- package/dist/core/html-to-markdown.d.ts +3 -0
- package/dist/core/html-to-markdown.js +12 -0
- package/dist/core/markdown-format.d.ts +18 -0
- package/dist/core/markdown-format.js +354 -0
- package/dist/core/markdown-parse.d.ts +35 -0
- package/dist/core/markdown-parse.js +533 -0
- package/dist/core/markdown-serialize.d.ts +27 -0
- package/dist/core/markdown-serialize.js +394 -0
- package/dist/core/outline.d.ts +17 -0
- package/dist/core/outline.js +41 -0
- package/dist/core/paste-html.d.ts +10 -0
- package/dist/core/paste-html.js +198 -0
- package/dist/core/paste-markdown.d.ts +18 -0
- package/dist/core/paste-markdown.js +70 -0
- package/dist/core/placeholders.d.ts +22 -0
- package/dist/core/placeholders.js +60 -0
- package/dist/core/save-plan.d.ts +47 -0
- package/dist/core/save-plan.js +287 -0
- package/dist/core/shortcuts.d.ts +13 -0
- package/dist/core/shortcuts.js +50 -0
- package/dist/core/source-format.d.ts +28 -0
- package/dist/core/source-format.js +104 -0
- package/dist/core/text-metrics.d.ts +11 -0
- package/dist/core/text-metrics.js +35 -0
- package/dist/core/variable-name.d.ts +13 -0
- package/dist/core/variable-name.js +13 -0
- package/dist/core/variables.d.ts +37 -0
- package/dist/core/variables.js +77 -0
- package/dist/core/visual-document.d.ts +52 -0
- package/dist/core/visual-document.js +142 -0
- package/dist/core/visual-find.d.ts +27 -0
- package/dist/core/visual-find.js +93 -0
- package/dist/format/format-target.d.ts +39 -0
- package/dist/format/format-target.js +79 -0
- package/dist/in-place/caret-handoff.d.ts +3 -0
- package/dist/in-place/caret-handoff.js +14 -0
- package/dist/in-place/in-place-session.d.ts +24 -0
- package/dist/in-place/in-place-session.js +32 -0
- package/package.json +95 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core";
|
|
2
|
+
import { Fragment, Slice } from "@tiptap/pm/model";
|
|
3
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
4
|
+
import { buildVisualDocument } from "./visual-document.js";
|
|
5
|
+
import { mergedCellsNotice, normalizePastedHtml } from "./paste-html.js";
|
|
6
|
+
const IDENTITY_ATTRS = ["b", "mdId"];
|
|
7
|
+
function stripIdentity(node) {
|
|
8
|
+
const attrs = node.attrs ? { ...node.attrs } : void 0;
|
|
9
|
+
if (attrs) {
|
|
10
|
+
for (const key of IDENTITY_ATTRS) if (key in attrs) attrs[key] = null;
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
...node,
|
|
14
|
+
...attrs ? { attrs } : {},
|
|
15
|
+
...node.content ? { content: node.content.map(stripIdentity) } : {}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function markdownToSlice(text, schema) {
|
|
19
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
20
|
+
const { json } = buildVisualDocument(normalized, schema);
|
|
21
|
+
const doc = schema.nodeFromJSON(stripIdentity(json));
|
|
22
|
+
const blocks = [];
|
|
23
|
+
doc.forEach((node) => {
|
|
24
|
+
if (node.type.name === "sourceBlock") node.forEach((child) => blocks.push(child));
|
|
25
|
+
else blocks.push(node);
|
|
26
|
+
});
|
|
27
|
+
if (blocks.length === 0) return Slice.empty;
|
|
28
|
+
const first = blocks[0];
|
|
29
|
+
const last = blocks[blocks.length - 1];
|
|
30
|
+
const openStart = first.type.name === "paragraph" ? 1 : 0;
|
|
31
|
+
const openEnd = last.type.name === "paragraph" ? 1 : 0;
|
|
32
|
+
return new Slice(Fragment.from(blocks), openStart, openEnd);
|
|
33
|
+
}
|
|
34
|
+
function replaceSelectionWithMarkdown(tr, schema, text) {
|
|
35
|
+
const slice = markdownToSlice(text, schema);
|
|
36
|
+
if (slice.size === 0) tr.deleteSelection();
|
|
37
|
+
else tr.replaceSelection(slice);
|
|
38
|
+
}
|
|
39
|
+
const MarkdownTextPaste = Extension.create({
|
|
40
|
+
name: "richEditorMarkdownTextPaste",
|
|
41
|
+
addOptions() {
|
|
42
|
+
return { onNotice: null };
|
|
43
|
+
},
|
|
44
|
+
addProseMirrorPlugins() {
|
|
45
|
+
const schema = this.editor.schema;
|
|
46
|
+
const onNotice = this.options.onNotice;
|
|
47
|
+
return [
|
|
48
|
+
new Plugin({
|
|
49
|
+
key: new PluginKey("richEditorMarkdownTextPaste"),
|
|
50
|
+
props: {
|
|
51
|
+
clipboardTextParser: (text) => markdownToSlice(text, schema),
|
|
52
|
+
// Real-world tables (Wikipedia, Docs, Word, Sheets, Notion) squared and
|
|
53
|
+
// their cells flattened BEFORE the schema parses them — a second block
|
|
54
|
+
// in a cell must never become a second cell (paste-html.ts).
|
|
55
|
+
transformPastedHTML: (html) => {
|
|
56
|
+
const result = normalizePastedHtml(html);
|
|
57
|
+
const notice = mergedCellsNotice(result.mergedCellsSplit);
|
|
58
|
+
if (notice) onNotice?.(notice);
|
|
59
|
+
return result.html;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
export {
|
|
67
|
+
MarkdownTextPaste,
|
|
68
|
+
markdownToSlice,
|
|
69
|
+
replaceSelectionWithMarkdown
|
|
70
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SourceBlock, SourceIsland } from "@ai-matrx/content-ir/source";
|
|
2
|
+
/** True when the text holds a character from the range placeholders use. */
|
|
3
|
+
export declare function hasPrivateUseCharacter(text: string): boolean;
|
|
4
|
+
export interface PlaceholderText {
|
|
5
|
+
/** The block text with every inline island replaced by its placeholder. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** Islands by placeholder index. */
|
|
8
|
+
islands: readonly SourceIsland[];
|
|
9
|
+
}
|
|
10
|
+
/** Swap every inline island of a prose block for its placeholder. */
|
|
11
|
+
export declare function withPlaceholders(block: SourceBlock): PlaceholderText;
|
|
12
|
+
/** Put every placeholder in `text` back to its island's exact bytes. */
|
|
13
|
+
export declare function restorePlaceholders(text: string, islands: readonly SourceIsland[]): string;
|
|
14
|
+
export type PlaceholderPiece = {
|
|
15
|
+
kind: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "island";
|
|
19
|
+
island: SourceIsland;
|
|
20
|
+
};
|
|
21
|
+
/** Split text into literal runs and the islands its placeholders name. */
|
|
22
|
+
export declare function splitPlaceholders(text: string, islands: readonly SourceIsland[]): PlaceholderPiece[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const PH_OPEN = "\uE000";
|
|
2
|
+
const DIGIT_BASE = 57600;
|
|
3
|
+
const DIGIT_RANGE = 61439 - DIGIT_BASE + 1;
|
|
4
|
+
const PLACEHOLDER_RE = /([-])([-])/g;
|
|
5
|
+
function hasPrivateUseCharacter(text) {
|
|
6
|
+
return /[-]/.test(text);
|
|
7
|
+
}
|
|
8
|
+
function encodeIndex(index) {
|
|
9
|
+
const hi = Math.floor(index / DIGIT_RANGE);
|
|
10
|
+
const lo = index % DIGIT_RANGE;
|
|
11
|
+
if (hi >= DIGIT_RANGE) {
|
|
12
|
+
throw new Error(`too many inline islands in one block (${index})`);
|
|
13
|
+
}
|
|
14
|
+
return PH_OPEN + String.fromCharCode(DIGIT_BASE + hi) + String.fromCharCode(DIGIT_BASE + lo);
|
|
15
|
+
}
|
|
16
|
+
function decodeIndex(hi, lo) {
|
|
17
|
+
return (hi.charCodeAt(0) - DIGIT_BASE) * DIGIT_RANGE + (lo.charCodeAt(0) - DIGIT_BASE);
|
|
18
|
+
}
|
|
19
|
+
function withPlaceholders(block) {
|
|
20
|
+
if (block.inlines.length === 0) return { text: block.raw, islands: [] };
|
|
21
|
+
let out = "";
|
|
22
|
+
let cursor = block.start;
|
|
23
|
+
block.inlines.forEach((island, index) => {
|
|
24
|
+
out += block.raw.slice(cursor - block.start, island.start - block.start);
|
|
25
|
+
out += encodeIndex(index);
|
|
26
|
+
cursor = island.end;
|
|
27
|
+
});
|
|
28
|
+
out += block.raw.slice(cursor - block.start);
|
|
29
|
+
return { text: out, islands: block.inlines };
|
|
30
|
+
}
|
|
31
|
+
function restorePlaceholders(text, islands) {
|
|
32
|
+
if (islands.length === 0) return text;
|
|
33
|
+
return text.replace(PLACEHOLDER_RE, (whole, hi, lo) => {
|
|
34
|
+
const island = islands[decodeIndex(hi, lo)];
|
|
35
|
+
return island ? island.raw : whole;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function splitPlaceholders(text, islands) {
|
|
39
|
+
if (islands.length === 0 || !text.includes(PH_OPEN)) {
|
|
40
|
+
return text ? [{ kind: "text", text }] : [];
|
|
41
|
+
}
|
|
42
|
+
const pieces = [];
|
|
43
|
+
let last = 0;
|
|
44
|
+
for (const match of text.matchAll(PLACEHOLDER_RE)) {
|
|
45
|
+
const at = match.index ?? 0;
|
|
46
|
+
const island = islands[decodeIndex(match[1] ?? "", match[2] ?? "")];
|
|
47
|
+
if (!island) continue;
|
|
48
|
+
if (at > last) pieces.push({ kind: "text", text: text.slice(last, at) });
|
|
49
|
+
pieces.push({ kind: "island", island });
|
|
50
|
+
last = at + match[0].length;
|
|
51
|
+
}
|
|
52
|
+
if (last < text.length) pieces.push({ kind: "text", text: text.slice(last) });
|
|
53
|
+
return pieces;
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
hasPrivateUseCharacter,
|
|
57
|
+
restorePlaceholders,
|
|
58
|
+
splitPlaceholders,
|
|
59
|
+
withPlaceholders
|
|
60
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type MapOptions, type MappedPosition, type SourceChange, type SpliceIntegrity } from "@ai-matrx/content-ir/source";
|
|
2
|
+
export interface SaveRegion {
|
|
3
|
+
/** Range in the stored text (block-aligned). */
|
|
4
|
+
readonly start: number;
|
|
5
|
+
readonly end: number;
|
|
6
|
+
/** The replacement from the new text. */
|
|
7
|
+
readonly text: string;
|
|
8
|
+
}
|
|
9
|
+
export type IslandDeltaKind = "removed" | "changed" | "swallowed" | "moved" | "added";
|
|
10
|
+
export interface IslandDelta {
|
|
11
|
+
readonly kind: IslandDeltaKind;
|
|
12
|
+
readonly islandType: string;
|
|
13
|
+
/** The stored bytes (absent for "added"). */
|
|
14
|
+
readonly before: string | null;
|
|
15
|
+
/** The new bytes (absent for "removed"/"swallowed"). */
|
|
16
|
+
readonly after: string | null;
|
|
17
|
+
/** Offset of the stored island (or of the region, for "added"). */
|
|
18
|
+
readonly at: number;
|
|
19
|
+
}
|
|
20
|
+
export interface SavePlan {
|
|
21
|
+
readonly stored: string;
|
|
22
|
+
readonly text: string;
|
|
23
|
+
readonly changed: boolean;
|
|
24
|
+
readonly regions: readonly SaveRegion[];
|
|
25
|
+
/** Island changes in edited regions (see header). */
|
|
26
|
+
readonly islandDeltas: readonly IslandDelta[];
|
|
27
|
+
/** Removed / changed / swallowed islands the person did not approve. */
|
|
28
|
+
readonly needsConsent: readonly IslandDelta[];
|
|
29
|
+
/**
|
|
30
|
+
* The change sets of the save's splice steps, in order (island step, then
|
|
31
|
+
* prose step). Carry an anchor through with `mapSavePosition`.
|
|
32
|
+
*/
|
|
33
|
+
readonly changeSteps: ReadonlyArray<readonly SourceChange[]>;
|
|
34
|
+
readonly integrity: SpliceIntegrity | null;
|
|
35
|
+
/** Why the save could not be proven; the person may still save (validation offers, never blocks). */
|
|
36
|
+
readonly error: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface PlanSaveOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Stored island bytes the person explicitly edited or removed through an
|
|
41
|
+
* island's own editor — those changes need no second question.
|
|
42
|
+
*/
|
|
43
|
+
readonly approvedIslands?: ReadonlySet<string>;
|
|
44
|
+
}
|
|
45
|
+
/** Carry a stored-text position through a save (every step, in order). */
|
|
46
|
+
export declare function mapSavePosition(plan: SavePlan, pos: number, options?: MapOptions): MappedPosition;
|
|
47
|
+
export declare function planSave(stored: string, current: string, options?: PlanSaveOptions): SavePlan;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
islandEdit,
|
|
3
|
+
listIslands,
|
|
4
|
+
mapPosition,
|
|
5
|
+
spliceSave,
|
|
6
|
+
tokenizeSource,
|
|
7
|
+
SourceSpliceError
|
|
8
|
+
} from "@ai-matrx/content-ir/source";
|
|
9
|
+
import { findTableEnd, tableStartsAt } from "@ai-matrx/rich-content/display/markdown-classification/processors/utils/gfm-table-lines";
|
|
10
|
+
const MAX_LCS_CELLS = 4e6;
|
|
11
|
+
function blockKey(block) {
|
|
12
|
+
return `${block.kind}\0${block.islandType ?? ""}\0${block.raw}`;
|
|
13
|
+
}
|
|
14
|
+
function matchBlocks(a, b) {
|
|
15
|
+
const pairs = [];
|
|
16
|
+
let lo = 0;
|
|
17
|
+
while (lo < a.length && lo < b.length && a[lo] === b[lo]) {
|
|
18
|
+
pairs.push([lo, lo]);
|
|
19
|
+
lo += 1;
|
|
20
|
+
}
|
|
21
|
+
let hiA = a.length - 1;
|
|
22
|
+
let hiB = b.length - 1;
|
|
23
|
+
const tail = [];
|
|
24
|
+
while (hiA >= lo && hiB >= lo && a[hiA] === b[hiB]) {
|
|
25
|
+
tail.push([hiA, hiB]);
|
|
26
|
+
hiA -= 1;
|
|
27
|
+
hiB -= 1;
|
|
28
|
+
}
|
|
29
|
+
const n = hiA - lo + 1;
|
|
30
|
+
const m = hiB - lo + 1;
|
|
31
|
+
if (n > 0 && m > 0 && n * m <= MAX_LCS_CELLS) {
|
|
32
|
+
const width = m + 1;
|
|
33
|
+
const table = new Uint32Array((n + 1) * width);
|
|
34
|
+
for (let i2 = n - 1; i2 >= 0; i2 -= 1) {
|
|
35
|
+
for (let j2 = m - 1; j2 >= 0; j2 -= 1) {
|
|
36
|
+
table[i2 * width + j2] = a[lo + i2] === b[lo + j2] ? (table[(i2 + 1) * width + j2 + 1] ?? 0) + 1 : Math.max(table[(i2 + 1) * width + j2] ?? 0, table[i2 * width + j2 + 1] ?? 0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let i = 0;
|
|
40
|
+
let j = 0;
|
|
41
|
+
while (i < n && j < m) {
|
|
42
|
+
if (a[lo + i] === b[lo + j]) {
|
|
43
|
+
pairs.push([lo + i, lo + j]);
|
|
44
|
+
i += 1;
|
|
45
|
+
j += 1;
|
|
46
|
+
} else if ((table[(i + 1) * width + j] ?? 0) >= (table[i * width + j + 1] ?? 0)) {
|
|
47
|
+
i += 1;
|
|
48
|
+
} else {
|
|
49
|
+
j += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return pairs.concat(tail.reverse());
|
|
54
|
+
}
|
|
55
|
+
function islandKey(island) {
|
|
56
|
+
return `${island.islandType}\0${island.raw}`;
|
|
57
|
+
}
|
|
58
|
+
function countOccurrences(haystack, needle) {
|
|
59
|
+
if (!needle) return 0;
|
|
60
|
+
let count = 0;
|
|
61
|
+
let from = 0;
|
|
62
|
+
for (; ; ) {
|
|
63
|
+
const at = haystack.indexOf(needle, from);
|
|
64
|
+
if (at === -1) return count;
|
|
65
|
+
count += 1;
|
|
66
|
+
from = at + needle.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function islandDeltas(regions) {
|
|
70
|
+
const gone = [];
|
|
71
|
+
const added = [];
|
|
72
|
+
regions.forEach((region, index) => {
|
|
73
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
74
|
+
for (const island of region.after) {
|
|
75
|
+
const list = remaining.get(islandKey(island));
|
|
76
|
+
if (list) list.push(island);
|
|
77
|
+
else remaining.set(islandKey(island), [island]);
|
|
78
|
+
}
|
|
79
|
+
for (const island of region.before) {
|
|
80
|
+
const list = remaining.get(islandKey(island));
|
|
81
|
+
if (list && list.length) list.shift();
|
|
82
|
+
else gone.push({ island, region: index });
|
|
83
|
+
}
|
|
84
|
+
for (const list of remaining.values()) {
|
|
85
|
+
for (const island of list) added.push({ island, region: index });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
const deltas = [];
|
|
89
|
+
const unmatchedGone = [];
|
|
90
|
+
for (const entry of gone) {
|
|
91
|
+
const at = added.findIndex((candidate) => islandKey(candidate.island) === islandKey(entry.island));
|
|
92
|
+
if (at === -1) {
|
|
93
|
+
unmatchedGone.push(entry);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const [arrival] = added.splice(at, 1);
|
|
97
|
+
deltas.push({
|
|
98
|
+
kind: "moved",
|
|
99
|
+
islandType: entry.island.islandType,
|
|
100
|
+
before: entry.island.raw,
|
|
101
|
+
after: arrival?.island.raw ?? entry.island.raw,
|
|
102
|
+
at: entry.island.start
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const swallowedBudget = /* @__PURE__ */ new Map();
|
|
106
|
+
for (const { island, region } of unmatchedGone) {
|
|
107
|
+
const info = regions[region];
|
|
108
|
+
const key = `${region}\0${islandKey(island)}`;
|
|
109
|
+
if (!swallowedBudget.has(key) && info) {
|
|
110
|
+
const asIslands = info.after.filter((other) => islandKey(other) === islandKey(island)).length;
|
|
111
|
+
swallowedBudget.set(key, Math.max(0, countOccurrences(info.afterText, island.raw) - asIslands));
|
|
112
|
+
}
|
|
113
|
+
const budget = swallowedBudget.get(key) ?? 0;
|
|
114
|
+
if (budget > 0) {
|
|
115
|
+
swallowedBudget.set(key, budget - 1);
|
|
116
|
+
deltas.push({ kind: "swallowed", islandType: island.islandType, before: island.raw, after: null, at: island.start });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const replacement = added.findIndex(
|
|
120
|
+
(candidate) => candidate.region === region && candidate.island.islandType === island.islandType
|
|
121
|
+
);
|
|
122
|
+
if (replacement !== -1) {
|
|
123
|
+
const [next] = added.splice(replacement, 1);
|
|
124
|
+
deltas.push({
|
|
125
|
+
kind: "changed",
|
|
126
|
+
islandType: island.islandType,
|
|
127
|
+
before: island.raw,
|
|
128
|
+
after: next?.island.raw ?? null,
|
|
129
|
+
at: island.start
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
deltas.push({ kind: "removed", islandType: island.islandType, before: island.raw, after: null, at: island.start });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const { island, region } of added) {
|
|
136
|
+
deltas.push({
|
|
137
|
+
kind: "added",
|
|
138
|
+
islandType: island.islandType,
|
|
139
|
+
before: null,
|
|
140
|
+
after: island.raw,
|
|
141
|
+
at: regions[region]?.regionStart ?? 0
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return deltas;
|
|
145
|
+
}
|
|
146
|
+
function spliceInSteps(stored, current, deltas) {
|
|
147
|
+
const steps = [];
|
|
148
|
+
const islandEdits = deltas.filter((delta) => delta.kind !== "added" && delta.before !== null).map(
|
|
149
|
+
(delta) => islandEdit(
|
|
150
|
+
{ start: delta.at, end: delta.at + (delta.before ?? "").length },
|
|
151
|
+
delta.kind === "changed" ? delta.after ?? "" : ""
|
|
152
|
+
)
|
|
153
|
+
).sort((x, y) => x.start - y.start);
|
|
154
|
+
let text = stored;
|
|
155
|
+
if (islandEdits.length) {
|
|
156
|
+
const result = spliceSave(stored, islandEdits);
|
|
157
|
+
steps.push({ text: result.text, changes: result.changes, integrity: result.integrity });
|
|
158
|
+
text = result.text;
|
|
159
|
+
}
|
|
160
|
+
if (text !== current) {
|
|
161
|
+
const plan = regionsBetween(text, current);
|
|
162
|
+
const result = spliceSave(
|
|
163
|
+
text,
|
|
164
|
+
plan.regions.map((region) => ({ start: region.start, end: region.end, text: region.text })),
|
|
165
|
+
{ blocks: plan.storedBlocks }
|
|
166
|
+
);
|
|
167
|
+
steps.push({ text: result.text, changes: result.changes, integrity: result.integrity });
|
|
168
|
+
}
|
|
169
|
+
return steps;
|
|
170
|
+
}
|
|
171
|
+
function tableRanges(text) {
|
|
172
|
+
const lines = text.split("\n");
|
|
173
|
+
const starts = [];
|
|
174
|
+
let offset = 0;
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
starts.push(offset);
|
|
177
|
+
offset += line.length + 1;
|
|
178
|
+
}
|
|
179
|
+
const ranges = [];
|
|
180
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
181
|
+
if (!tableStartsAt(lines, i)) continue;
|
|
182
|
+
const end = findTableEnd(lines, i);
|
|
183
|
+
ranges.push([starts[i] ?? 0, (starts[end - 1] ?? 0) + (lines[end - 1] ?? "").length]);
|
|
184
|
+
i = end - 1;
|
|
185
|
+
}
|
|
186
|
+
return ranges;
|
|
187
|
+
}
|
|
188
|
+
const BR_TAG = /^<br\s*\/?>$/i;
|
|
189
|
+
function isCellLineBreak(delta, stored) {
|
|
190
|
+
if (delta.kind === "swallowed" || delta.before === null || !BR_TAG.test(delta.before)) return false;
|
|
191
|
+
if (delta.after !== null && !BR_TAG.test(delta.after)) return false;
|
|
192
|
+
return tableRanges(stored).some(([start, end]) => delta.at >= start && delta.at < end);
|
|
193
|
+
}
|
|
194
|
+
function regionsBetween(stored, current) {
|
|
195
|
+
const storedBlocks = tokenizeSource(stored);
|
|
196
|
+
const currentBlocks = tokenizeSource(current);
|
|
197
|
+
const a = storedBlocks.filter((block) => block.kind !== "gap");
|
|
198
|
+
const b = currentBlocks.filter((block) => block.kind !== "gap");
|
|
199
|
+
const anchors = matchBlocks(a.map(blockKey), b.map(blockKey));
|
|
200
|
+
const storedIslands = listIslands(storedBlocks);
|
|
201
|
+
const currentIslands = listIslands(currentBlocks);
|
|
202
|
+
const within = (islands, start, end) => islands.filter((island) => island.start >= start && island.end <= end);
|
|
203
|
+
const bounds = [];
|
|
204
|
+
let prevStored = 0;
|
|
205
|
+
let prevCurrent = 0;
|
|
206
|
+
for (const [ai, bi] of anchors) {
|
|
207
|
+
const sa = a[ai];
|
|
208
|
+
const sb = b[bi];
|
|
209
|
+
if (!sa || !sb) continue;
|
|
210
|
+
bounds.push([prevStored, sa.start, prevCurrent, sb.start]);
|
|
211
|
+
prevStored = sa.end;
|
|
212
|
+
prevCurrent = sb.end;
|
|
213
|
+
}
|
|
214
|
+
bounds.push([prevStored, stored.length, prevCurrent, current.length]);
|
|
215
|
+
const regions = [];
|
|
216
|
+
const regionIslands = [];
|
|
217
|
+
for (const [sStart, sEnd, cStart, cEnd] of bounds) {
|
|
218
|
+
const before = stored.slice(sStart, sEnd);
|
|
219
|
+
const after = current.slice(cStart, cEnd);
|
|
220
|
+
if (before === after) continue;
|
|
221
|
+
regions.push({ start: sStart, end: sEnd, text: after });
|
|
222
|
+
regionIslands.push({
|
|
223
|
+
before: within(storedIslands, sStart, sEnd),
|
|
224
|
+
after: within(currentIslands, cStart, cEnd),
|
|
225
|
+
afterText: after,
|
|
226
|
+
regionStart: sStart
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { storedBlocks, regions, regionIslands };
|
|
230
|
+
}
|
|
231
|
+
function mapSavePosition(plan, pos, options) {
|
|
232
|
+
let mapped = { pos, deleted: false };
|
|
233
|
+
for (const step of plan.changeSteps) {
|
|
234
|
+
const next = mapPosition(step, mapped.pos, options);
|
|
235
|
+
mapped = { pos: next.pos, deleted: mapped.deleted || next.deleted };
|
|
236
|
+
}
|
|
237
|
+
return mapped;
|
|
238
|
+
}
|
|
239
|
+
function planSave(stored, current, options = {}) {
|
|
240
|
+
const unchanged = {
|
|
241
|
+
stored,
|
|
242
|
+
text: stored,
|
|
243
|
+
changed: false,
|
|
244
|
+
regions: [],
|
|
245
|
+
islandDeltas: [],
|
|
246
|
+
needsConsent: [],
|
|
247
|
+
changeSteps: [],
|
|
248
|
+
integrity: { ok: true, bytesOutsideEditsIdentical: true, disturbed: [] },
|
|
249
|
+
error: null
|
|
250
|
+
};
|
|
251
|
+
if (stored === current) return unchanged;
|
|
252
|
+
const { regions, regionIslands } = regionsBetween(stored, current);
|
|
253
|
+
const deltas = islandDeltas(regionIslands);
|
|
254
|
+
const approved = options.approvedIslands ?? /* @__PURE__ */ new Set();
|
|
255
|
+
const needsConsent = deltas.filter(
|
|
256
|
+
(delta) => delta.kind !== "added" && delta.kind !== "moved" && !(delta.kind !== "swallowed" && delta.before !== null && approved.has(delta.before)) && // The splice still carries it as an island edit (proven); it just never asks.
|
|
257
|
+
!isCellLineBreak(delta, stored)
|
|
258
|
+
);
|
|
259
|
+
let error = null;
|
|
260
|
+
let changeSteps = [];
|
|
261
|
+
let integrity = null;
|
|
262
|
+
try {
|
|
263
|
+
const steps = spliceInSteps(stored, current, deltas);
|
|
264
|
+
changeSteps = steps.map((step) => step.changes);
|
|
265
|
+
integrity = steps[steps.length - 1]?.integrity ?? null;
|
|
266
|
+
if ((steps[steps.length - 1]?.text ?? stored) !== current) {
|
|
267
|
+
error = "The save could not be proven: splicing the edited blocks into the stored text did not reproduce what is on screen.";
|
|
268
|
+
}
|
|
269
|
+
} catch (caught) {
|
|
270
|
+
error = caught instanceof SourceSpliceError && caught.code === "integrity" ? "This edit would change protected content outside the part you edited (for example, an unclosed code block or tag swallowing what follows)." : caught instanceof SourceSpliceError && caught.code === "island_edit" ? `A protected block changed in a way the editor could not account for (${caught.message}).` : `The save could not be proven (${caught instanceof Error ? caught.message : String(caught)}).`;
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
stored,
|
|
274
|
+
text: current,
|
|
275
|
+
changed: true,
|
|
276
|
+
regions,
|
|
277
|
+
islandDeltas: deltas,
|
|
278
|
+
needsConsent,
|
|
279
|
+
changeSteps,
|
|
280
|
+
integrity,
|
|
281
|
+
error
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
export {
|
|
285
|
+
mapSavePosition,
|
|
286
|
+
planSave
|
|
287
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type ShortcutGroup = "Text" | "Blocks" | "Insert" | "Document" | "Views";
|
|
2
|
+
export interface ShortcutSpec {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
keys: readonly string[];
|
|
6
|
+
group: ShortcutGroup;
|
|
7
|
+
reference: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const RICH_EDITOR_SHORTCUTS: readonly ShortcutSpec[];
|
|
10
|
+
/** Keys that are typed characters, not chords (handled by input rules/menus). */
|
|
11
|
+
export declare const TYPED_TRIGGERS: Set<string>;
|
|
12
|
+
/** Human-readable key label for the platform. */
|
|
13
|
+
export declare function formatKeys(keys: string, apple: boolean): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const RICH_EDITOR_SHORTCUTS = [
|
|
2
|
+
{ id: "bold", label: "Bold", keys: ["Mod-b"], group: "Text", reference: "Docs \xB7 Notion" },
|
|
3
|
+
{ id: "italic", label: "Italic", keys: ["Mod-i"], group: "Text", reference: "Docs \xB7 Notion" },
|
|
4
|
+
{ id: "strike", label: "Strikethrough", keys: ["Mod-Shift-x", "Mod-Shift-s", "Alt-Shift-5"], group: "Text", reference: "Docs \xB7 Notion" },
|
|
5
|
+
{ id: "code", label: "Inline code", keys: ["Mod-e"], group: "Text", reference: "Notion" },
|
|
6
|
+
{ id: "link", label: "Add or edit link", keys: ["Mod-k"], group: "Text", reference: "Docs \xB7 Notion" },
|
|
7
|
+
{ id: "paragraph", label: "Normal text", keys: ["Mod-Alt-0", "Mod-Shift-0"], group: "Blocks", reference: "Docs \xB7 Notion" },
|
|
8
|
+
{ id: "heading1", label: "Heading 1", keys: ["Mod-Alt-1", "Mod-Shift-1"], group: "Blocks", reference: "Docs \xB7 Notion" },
|
|
9
|
+
{ id: "heading2", label: "Heading 2", keys: ["Mod-Alt-2", "Mod-Shift-2"], group: "Blocks", reference: "Docs \xB7 Notion" },
|
|
10
|
+
{ id: "heading3", label: "Heading 3", keys: ["Mod-Alt-3", "Mod-Shift-3"], group: "Blocks", reference: "Docs \xB7 Notion" },
|
|
11
|
+
{ id: "heading4", label: "Heading 4", keys: ["Mod-Alt-4"], group: "Blocks", reference: "Docs" },
|
|
12
|
+
{ id: "heading5", label: "Heading 5", keys: ["Mod-Alt-5"], group: "Blocks", reference: "Docs" },
|
|
13
|
+
{ id: "heading6", label: "Heading 6", keys: ["Mod-Alt-6"], group: "Blocks", reference: "Docs" },
|
|
14
|
+
{ id: "orderedList", label: "Numbered list", keys: ["Mod-Shift-7"], group: "Blocks", reference: "Docs \xB7 Notion" },
|
|
15
|
+
{ id: "bulletList", label: "Bulleted list", keys: ["Mod-Shift-8"], group: "Blocks", reference: "Docs \xB7 Notion" },
|
|
16
|
+
{ id: "taskList", label: "Checklist", keys: ["Mod-Shift-9"], group: "Blocks", reference: "Docs" },
|
|
17
|
+
{ id: "quote", label: "Quote", keys: ["Mod-Shift-."], group: "Blocks", reference: "AI Matrx" },
|
|
18
|
+
{ id: "moveUp", label: "Move block up", keys: ["Mod-Shift-ArrowUp"], group: "Blocks", reference: "Notion" },
|
|
19
|
+
{ id: "moveDown", label: "Move block down", keys: ["Mod-Shift-ArrowDown"], group: "Blocks", reference: "Notion" },
|
|
20
|
+
{ id: "codeBlock", label: "Code block", keys: ["Mod-Alt-c"], group: "Insert", reference: "Notion" },
|
|
21
|
+
{ id: "inlineMath", label: "Inline equation", keys: ["Mod-Shift-m"], group: "Insert", reference: "Notion (\u2318\u21E7E)" },
|
|
22
|
+
{ id: "slash", label: "Insert anything", keys: ["/"], group: "Insert", reference: "Notion" },
|
|
23
|
+
{ id: "variable", label: "Insert a variable", keys: ["{{"], group: "Insert", reference: "AI Matrx" },
|
|
24
|
+
{ id: "undo", label: "Undo", keys: ["Mod-z"], group: "Document", reference: "Docs \xB7 Notion" },
|
|
25
|
+
{ id: "redo", label: "Redo", keys: ["Mod-Shift-z", "Mod-y"], group: "Document", reference: "Docs \xB7 Notion" },
|
|
26
|
+
{ id: "find", label: "Find", keys: ["Mod-f"], group: "Document", reference: "Docs" },
|
|
27
|
+
{ id: "replace", label: "Find and replace", keys: ["Mod-h", "Mod-Shift-h"], group: "Document", reference: "Docs" },
|
|
28
|
+
{ id: "save", label: "Save", keys: ["Mod-s", "Mod-Enter"], group: "Document", reference: "Docs \xB7 Notion \xB7 Slack" },
|
|
29
|
+
{ id: "wordCount", label: "Word count", keys: ["Mod-Shift-c"], group: "Document", reference: "Docs" },
|
|
30
|
+
{ id: "outline", label: "Show outline", keys: ["Mod-Alt-h"], group: "Views", reference: "Docs" },
|
|
31
|
+
{ id: "focus", label: "Focus mode", keys: ["Mod-Shift-f"], group: "Views", reference: "iA Writer \xB7 Docs" },
|
|
32
|
+
{ id: "cycleView", label: "Visual \u2192 Source \u2192 Preview", keys: ["Mod-Shift-e"], group: "Views", reference: "AI Matrx" },
|
|
33
|
+
{ id: "help", label: "Keyboard shortcuts", keys: ["Mod-/"], group: "Views", reference: "Docs" }
|
|
34
|
+
];
|
|
35
|
+
const TYPED_TRIGGERS = /* @__PURE__ */ new Set(["/", "{{"]);
|
|
36
|
+
function formatKeys(keys, apple) {
|
|
37
|
+
return keys.split("-").map((part) => {
|
|
38
|
+
if (part === "Mod") return apple ? "\u2318" : "Ctrl";
|
|
39
|
+
if (part === "Alt") return apple ? "\u2325" : "Alt";
|
|
40
|
+
if (part === "Shift") return apple ? "\u21E7" : "Shift";
|
|
41
|
+
if (part === "ArrowUp") return "\u2191";
|
|
42
|
+
if (part === "ArrowDown") return "\u2193";
|
|
43
|
+
return part.length === 1 ? part.toUpperCase() : part;
|
|
44
|
+
}).join(apple ? "" : "+");
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
RICH_EDITOR_SHORTCUTS,
|
|
48
|
+
TYPED_TRIGGERS,
|
|
49
|
+
formatKeys
|
|
50
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SourceChange {
|
|
2
|
+
from: number;
|
|
3
|
+
to: number;
|
|
4
|
+
insert: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SourceEditResult {
|
|
7
|
+
changes: SourceChange[];
|
|
8
|
+
/** New selection, in the text AFTER the changes. */
|
|
9
|
+
anchor: number;
|
|
10
|
+
head: number;
|
|
11
|
+
}
|
|
12
|
+
/** Wrap the selection in `marker`, or unwrap it when it already is. */
|
|
13
|
+
export declare function toggleWrap(text: string, from: number, to: number, marker: string): SourceEditResult;
|
|
14
|
+
/**
|
|
15
|
+
* Give every line the selection touches `prefix` (replacing any heading /
|
|
16
|
+
* list / quote prefix it has). `null` clears it. Numbered prefixes count up.
|
|
17
|
+
*/
|
|
18
|
+
export declare function setLinePrefix(text: string, from: number, to: number, prefix: string | null): SourceEditResult;
|
|
19
|
+
/** `[selection](url)` — the cursor lands in the url when none is given. */
|
|
20
|
+
export declare function makeLink(text: string, from: number, to: number, url?: string): SourceEditResult;
|
|
21
|
+
/** Apply a result to a string (tests and non-CodeMirror hosts). */
|
|
22
|
+
export declare function applySourceEdit(text: string, result: SourceEditResult): string;
|
|
23
|
+
/**
|
|
24
|
+
* Enter at the end of a list item or quote line continues it (next bullet,
|
|
25
|
+
* next number, an open checkbox, the quote prefix); Enter on an EMPTY item
|
|
26
|
+
* ends the list. Returns null when Enter should just insert a newline.
|
|
27
|
+
*/
|
|
28
|
+
export declare function continueMarkupOnEnter(text: string, pos: number): SourceEditResult | null;
|