@aicayzer/inkkit 0.0.5 → 0.0.7
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/README.md +4 -0
- package/dist/callouts.d.ts +2 -0
- package/dist/callouts.js +11 -0
- package/dist/dialect.js +24 -4
- package/dist/editor.d.ts +27 -8
- package/dist/editor.js +460 -96
- package/dist/index.d.ts +3 -2
- package/dist/outline.d.ts +22 -0
- package/dist/outline.js +79 -0
- package/dist/paste.js +56 -6
- package/dist/preserve.js +38 -11
- package/dist/search.d.ts +14 -0
- package/dist/search.js +171 -0
- package/dist/source.d.ts +17 -0
- package/dist/source.js +117 -0
- package/dist/spreadsheet.d.ts +3 -0
- package/dist/spreadsheet.js +275 -0
- package/dist/table-alignment.d.ts +2 -0
- package/dist/table-alignment.js +46 -0
- package/dist/tables.d.ts +15 -4
- package/dist/tables.js +168 -2
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { InkKitEditor } from './editor.js';
|
|
2
|
-
export type { EditorEvents, CaretState, Mark, Block, FormatCommand, Keymap, } from './editor.js';
|
|
3
|
-
export type { TableCommand } from './tables.js';
|
|
2
|
+
export type { EditorEvents, EditingMode, CaretState, Mark, Block, FormatCommand, Keymap, } from './editor.js';
|
|
3
|
+
export type { TableCommand, TableOptions } from './tables.js';
|
|
4
|
+
export type { Heading } from './outline.js';
|
|
4
5
|
export { InkKitError } from './types.js';
|
|
5
6
|
export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, PrintableDocument, PrintableWarning, } from './types.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Ctx } from '@milkdown/kit/ctx';
|
|
2
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
3
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
4
|
+
export interface Heading {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly level: number;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly documentId: string;
|
|
9
|
+
readonly generation: number;
|
|
10
|
+
readonly revision: number;
|
|
11
|
+
}
|
|
12
|
+
export interface OutlineContext {
|
|
13
|
+
documentId: string;
|
|
14
|
+
generation: number;
|
|
15
|
+
revision: number;
|
|
16
|
+
epoch: number;
|
|
17
|
+
mode: 'source' | 'formatted';
|
|
18
|
+
}
|
|
19
|
+
export declare function collectFormattedHeadings(doc: ProseNode, context: OutlineContext): readonly Heading[];
|
|
20
|
+
export declare function collectSourceHeadings(ctx: Ctx, source: string, context: OutlineContext): readonly Heading[];
|
|
21
|
+
export declare function sourceHeadingPosition(ctx: Ctx, source: string, requested: Heading, context: OutlineContext): number;
|
|
22
|
+
export declare function navigateFormattedHeading(view: EditorView, requested: Heading, context: OutlineContext): boolean;
|
package/dist/outline.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
2
|
+
import { TextSelection } from '@milkdown/kit/prose/state';
|
|
3
|
+
import { clipboardText } from './clipboard.js';
|
|
4
|
+
import { revealCalloutAncestors } from './callouts.js';
|
|
5
|
+
import { InkKitError } from './types.js';
|
|
6
|
+
function formattedLocations(doc) {
|
|
7
|
+
const headings = [];
|
|
8
|
+
doc.descendants((node, position) => {
|
|
9
|
+
if (node.type.name === 'heading')
|
|
10
|
+
headings.push({ node, position });
|
|
11
|
+
});
|
|
12
|
+
return headings;
|
|
13
|
+
}
|
|
14
|
+
function sourceLocations(ctx, source) {
|
|
15
|
+
const processor = ctx.get(remarkCtx);
|
|
16
|
+
const tree = processor.runSync(processor.parse(source), {
|
|
17
|
+
value: source,
|
|
18
|
+
});
|
|
19
|
+
const positions = [];
|
|
20
|
+
const bom = source.startsWith('\uFEFF') ? 1 : 0;
|
|
21
|
+
const visit = (node) => {
|
|
22
|
+
if (node.type === 'heading' && node.position?.start.offset != null)
|
|
23
|
+
positions.push({
|
|
24
|
+
position: node.position.start.offset + bom,
|
|
25
|
+
level: node.depth,
|
|
26
|
+
});
|
|
27
|
+
node.children?.forEach(visit);
|
|
28
|
+
};
|
|
29
|
+
visit(tree);
|
|
30
|
+
const parsed = formattedLocations(ctx.get(parserCtx)(source));
|
|
31
|
+
if (positions.length !== parsed.length ||
|
|
32
|
+
positions.some((item, index) => item.level !== parsed[index].node.attrs.level))
|
|
33
|
+
throw new InkKitError('preservation', 'The current Markdown headings cannot be located safely');
|
|
34
|
+
return parsed.map((heading, index) => ({
|
|
35
|
+
...heading,
|
|
36
|
+
position: positions[index].position,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
function entry(heading, context) {
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
id: `heading:${context.epoch}:${context.mode}:${context.generation}:${context.revision}:${heading.position}`,
|
|
42
|
+
level: Number(heading.node.attrs.level),
|
|
43
|
+
text: clipboardText(heading.node.content),
|
|
44
|
+
documentId: context.documentId,
|
|
45
|
+
generation: context.generation,
|
|
46
|
+
revision: context.revision,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function collectFormattedHeadings(doc, context) {
|
|
50
|
+
return Object.freeze(formattedLocations(doc).map((item) => entry(item, context)));
|
|
51
|
+
}
|
|
52
|
+
export function collectSourceHeadings(ctx, source, context) {
|
|
53
|
+
return Object.freeze(sourceLocations(ctx, source).map((item) => entry(item, context)));
|
|
54
|
+
}
|
|
55
|
+
function currentLocation(locations, requested, context) {
|
|
56
|
+
for (const location of locations) {
|
|
57
|
+
const current = entry(location, context);
|
|
58
|
+
if (current.id === requested.id &&
|
|
59
|
+
current.documentId === requested.documentId &&
|
|
60
|
+
current.generation === requested.generation &&
|
|
61
|
+
current.revision === requested.revision &&
|
|
62
|
+
current.level === requested.level &&
|
|
63
|
+
current.text === requested.text)
|
|
64
|
+
return location.position;
|
|
65
|
+
}
|
|
66
|
+
throw new InkKitError('stale-document', 'The heading is no longer current');
|
|
67
|
+
}
|
|
68
|
+
export function sourceHeadingPosition(ctx, source, requested, context) {
|
|
69
|
+
return currentLocation(sourceLocations(ctx, source), requested, context);
|
|
70
|
+
}
|
|
71
|
+
export function navigateFormattedHeading(view, requested, context) {
|
|
72
|
+
const position = currentLocation(formattedLocations(view.state.doc), requested, context);
|
|
73
|
+
revealCalloutAncestors(view, position + 1);
|
|
74
|
+
view.dispatch(view.state.tr
|
|
75
|
+
.setSelection(TextSelection.create(view.state.doc, position + 1))
|
|
76
|
+
.scrollIntoView());
|
|
77
|
+
view.focus();
|
|
78
|
+
return true;
|
|
79
|
+
}
|
package/dist/paste.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { editorViewCtx, parserCtx } from '@milkdown/kit/core';
|
|
2
|
-
import { DOMParser as ProseParser, Fragment, Slice, } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { DOMParser as ProseParser, DOMSerializer, Fragment, Slice, } from '@milkdown/kit/prose/model';
|
|
3
3
|
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
4
4
|
import { $prose } from '@milkdown/kit/utils';
|
|
5
|
+
import { pasteSpreadsheet } from './spreadsheet.js';
|
|
5
6
|
import { avoidReferenceCollisions, markdownFromHTML, } from './reference-clipboard.js';
|
|
6
7
|
import { InkKitError, } from './types.js';
|
|
7
8
|
export class PasteController {
|
|
@@ -114,6 +115,10 @@ export class PasteController {
|
|
|
114
115
|
const plainText = input.plainText ||
|
|
115
116
|
this.options.literalText?.() ||
|
|
116
117
|
view.state.selection.$from.parent.type.spec.code;
|
|
118
|
+
if (!plainText &&
|
|
119
|
+
input.markdown == null &&
|
|
120
|
+
pasteSpreadsheet(ctx, { ...input, images }))
|
|
121
|
+
return;
|
|
117
122
|
const markdown = plainText
|
|
118
123
|
? undefined
|
|
119
124
|
: (input.markdown ??
|
|
@@ -136,6 +141,16 @@ export class PasteController {
|
|
|
136
141
|
const template = document.createElement('template');
|
|
137
142
|
template.innerHTML = input.html;
|
|
138
143
|
const container = template.content;
|
|
144
|
+
// Preserve whole unsupported tables before rewriting their descendants,
|
|
145
|
+
// so comments retain their source and ordinary-copy privacy.
|
|
146
|
+
for (const table of [...container.querySelectorAll('table')]) {
|
|
147
|
+
if (table.parentElement?.closest('table'))
|
|
148
|
+
continue;
|
|
149
|
+
if (schema.nodes.table && !unsupportedClipboardTable(table))
|
|
150
|
+
continue;
|
|
151
|
+
const preserved = ctx.get(parserCtx)(table.outerHTML);
|
|
152
|
+
table.replaceWith(DOMSerializer.fromSchema(schema).serializeFragment(preserved.content));
|
|
153
|
+
}
|
|
139
154
|
for (const warning of container.querySelectorAll('[data-inkkit-diagram-error]'))
|
|
140
155
|
warning.remove();
|
|
141
156
|
for (const diagram of container.querySelectorAll('figure[data-inkkit-mermaid]')) {
|
|
@@ -252,11 +267,7 @@ export class PasteController {
|
|
|
252
267
|
if (table.parentElement?.closest('table'))
|
|
253
268
|
continue;
|
|
254
269
|
const rows = [...table.querySelectorAll('tr')];
|
|
255
|
-
const
|
|
256
|
-
const unsupported = !schema.nodes.table ||
|
|
257
|
-
widths.length === 0 ||
|
|
258
|
-
widths.some((width) => !width || width !== widths[0]) ||
|
|
259
|
-
table.querySelector('table,[rowspan]:not([rowspan="1"]),[colspan]:not([colspan="1"]),td > p ~ p,th > p ~ p,td > div,th > div,td > ul,td > ol,td > pre,td > blockquote,th > ul,th > ol,th > pre,th > blockquote');
|
|
270
|
+
const unsupported = !schema.nodes.table || unsupportedClipboardTable(table);
|
|
260
271
|
if (!unsupported && !table.querySelector('th')) {
|
|
261
272
|
for (const cell of rows[0]?.querySelectorAll(':scope > td') ?? []) {
|
|
262
273
|
const heading = document.createElement('th');
|
|
@@ -422,6 +433,45 @@ export class PasteController {
|
|
|
422
433
|
}
|
|
423
434
|
}
|
|
424
435
|
}
|
|
436
|
+
function unsupportedClipboardTable(table) {
|
|
437
|
+
const rows = [...table.querySelectorAll('tr')];
|
|
438
|
+
const widths = rows.map((row) => row.querySelectorAll(':scope > th,:scope > td').length);
|
|
439
|
+
const supported = new Set([
|
|
440
|
+
'THEAD',
|
|
441
|
+
'TBODY',
|
|
442
|
+
'TFOOT',
|
|
443
|
+
'TR',
|
|
444
|
+
'TH',
|
|
445
|
+
'TD',
|
|
446
|
+
'P',
|
|
447
|
+
'STRONG',
|
|
448
|
+
'B',
|
|
449
|
+
'EM',
|
|
450
|
+
'I',
|
|
451
|
+
'S',
|
|
452
|
+
'DEL',
|
|
453
|
+
'STRIKE',
|
|
454
|
+
'CODE',
|
|
455
|
+
'A',
|
|
456
|
+
'MARK',
|
|
457
|
+
'SPAN',
|
|
458
|
+
'SUP',
|
|
459
|
+
'FONT',
|
|
460
|
+
'IMG',
|
|
461
|
+
]);
|
|
462
|
+
return (widths.length === 0 ||
|
|
463
|
+
widths.some((width) => !width || width !== widths[0]) ||
|
|
464
|
+
!!document.createTreeWalker(table, NodeFilter.SHOW_COMMENT).nextNode() ||
|
|
465
|
+
[...table.querySelectorAll('*')].some((element) => !supported.has(element.tagName)) ||
|
|
466
|
+
[...table.querySelectorAll('p')].some((paragraph) => {
|
|
467
|
+
const cell = paragraph.parentElement;
|
|
468
|
+
return (!cell ||
|
|
469
|
+
!['TH', 'TD'].includes(cell.tagName) ||
|
|
470
|
+
cell.children.length !== 1 ||
|
|
471
|
+
[...cell.childNodes].some((node) => node !== paragraph && !!node.textContent?.trim()));
|
|
472
|
+
}) ||
|
|
473
|
+
!!table.querySelector('[rowspan]:not([rowspan="1"]),[colspan]:not([colspan="1"])'));
|
|
474
|
+
}
|
|
425
475
|
function dataImage(source) {
|
|
426
476
|
const match = /^data:(image\/(?:png|jpeg|gif|webp|avif|bmp|tiff));base64,([A-Za-z0-9+/=\s]+)$/.exec(source);
|
|
427
477
|
if (!match)
|
package/dist/preserve.js
CHANGED
|
@@ -2,16 +2,14 @@ import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
|
2
2
|
import { serialize } from './dialect.js';
|
|
3
3
|
import { restoreCodeEndings, finalCodeEnding } from './code-fences.js';
|
|
4
4
|
function signature(node) {
|
|
5
|
-
return JSON.stringify(node, (key, value) => ['position', 'data', '
|
|
6
|
-
? undefined
|
|
7
|
-
: value);
|
|
5
|
+
return JSON.stringify(node, (key, value) => ['position', 'data', 'marker'].includes(key) ? undefined : value);
|
|
8
6
|
}
|
|
9
7
|
function ownShape(node) {
|
|
10
8
|
return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
|
|
11
9
|
? undefined
|
|
12
10
|
: value);
|
|
13
11
|
}
|
|
14
|
-
function retainText(raw, before, after, quoteIndent = false) {
|
|
12
|
+
function retainText(raw, before, after, quoteIndent = false, inTable = false) {
|
|
15
13
|
before = before.replace(/\r\n?/g, '\n');
|
|
16
14
|
after = after.replace(/\r\n?/g, '\n');
|
|
17
15
|
let prefix = 0;
|
|
@@ -64,7 +62,7 @@ function retainText(raw, before, after, quoteIndent = false) {
|
|
|
64
62
|
}
|
|
65
63
|
if (decoded !== before) {
|
|
66
64
|
// Continuation indentation is excluded from a quoted paragraph's text.
|
|
67
|
-
return quoteIndent ? after : retainText(raw, before, after, true);
|
|
65
|
+
return quoteIndent ? after : retainText(raw, before, after, true, inTable);
|
|
68
66
|
}
|
|
69
67
|
const inserted = after
|
|
70
68
|
.slice(prefix, after.length - suffix)
|
|
@@ -73,7 +71,9 @@ function retainText(raw, before, after, quoteIndent = false) {
|
|
|
73
71
|
const at = prefix + index;
|
|
74
72
|
const delimiter = (character === '%' || character === '=') &&
|
|
75
73
|
(after[at - 1] === character || after[at + 1] === character);
|
|
76
|
-
return delimiter ||
|
|
74
|
+
return delimiter ||
|
|
75
|
+
/[\\`*_[\]<>]/.test(character) ||
|
|
76
|
+
(inTable && character === '|')
|
|
77
77
|
? `\\${character}`
|
|
78
78
|
: character;
|
|
79
79
|
})
|
|
@@ -108,7 +108,7 @@ function retainBoundaryWhitespace(raw, canonical) {
|
|
|
108
108
|
return raw;
|
|
109
109
|
}
|
|
110
110
|
// Patch changed leaves so an adjacent edit retains authored delimiters and labels.
|
|
111
|
-
function retainSource(before, after, source, canonical, lineEnding) {
|
|
111
|
+
function retainSource(before, after, source, canonical, lineEnding, inTable = false) {
|
|
112
112
|
const start = before.position?.start.offset;
|
|
113
113
|
const end = before.position?.end.offset;
|
|
114
114
|
const newStart = after.position?.start.offset;
|
|
@@ -163,7 +163,7 @@ function retainSource(before, after, source, canonical, lineEnding) {
|
|
|
163
163
|
destination[3]);
|
|
164
164
|
}
|
|
165
165
|
if (before.type === 'text' && after.type === 'text')
|
|
166
|
-
return retainBoundaryWhitespace(retainText(raw, before.value, after.value), canonical.slice(newStart, newEnd));
|
|
166
|
+
return retainBoundaryWhitespace(retainText(raw, before.value, after.value, false, inTable), canonical.slice(newStart, newEnd));
|
|
167
167
|
if (signature(before) === signature(after) &&
|
|
168
168
|
ownShape(before) === ownShape(after))
|
|
169
169
|
return raw;
|
|
@@ -173,13 +173,40 @@ function retainSource(before, after, source, canonical, lineEnding) {
|
|
|
173
173
|
after.children &&
|
|
174
174
|
before.children.length === after.children.length) {
|
|
175
175
|
let output = '', previous = start;
|
|
176
|
+
const retained = new Set();
|
|
176
177
|
before.children.forEach((child, index) => {
|
|
178
|
+
const incoming = after.children[index];
|
|
179
|
+
const movedIndex = before.type === 'table' || before.type === 'tableRow'
|
|
180
|
+
? before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) &&
|
|
181
|
+
signature(candidate) === signature(incoming) &&
|
|
182
|
+
ownShape(candidate) === ownShape(incoming))
|
|
183
|
+
: -1;
|
|
184
|
+
const original = movedIndex < 0 ? child : before.children[movedIndex];
|
|
185
|
+
if (movedIndex >= 0)
|
|
186
|
+
retained.add(movedIndex);
|
|
177
187
|
const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
|
|
178
188
|
if (childStart == null || childEnd == null)
|
|
179
189
|
throw new PreservationError('A source token has no location');
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
190
|
+
let retainedSource = retainSource(original, incoming, source, canonical, lineEnding, inTable || before.type === 'tableCell');
|
|
191
|
+
if (before.type === 'tableRow' && movedIndex >= 0) {
|
|
192
|
+
// Cell positions own different pipe boundaries at the row edges.
|
|
193
|
+
const originalFrom = original.children?.[0]?.position?.start.offset;
|
|
194
|
+
const originalTo = original.children?.at(-1)?.position?.end.offset;
|
|
195
|
+
const from = child.children?.[0]?.position?.start.offset;
|
|
196
|
+
const to = child.children?.at(-1)?.position?.end.offset;
|
|
197
|
+
retainedSource =
|
|
198
|
+
originalFrom != null &&
|
|
199
|
+
originalTo != null &&
|
|
200
|
+
from != null &&
|
|
201
|
+
to != null
|
|
202
|
+
? source.slice(childStart, from) +
|
|
203
|
+
source.slice(originalFrom, originalTo) +
|
|
204
|
+
source.slice(to, childEnd)
|
|
205
|
+
: canonical
|
|
206
|
+
.slice(incoming.position.start.offset, incoming.position.end.offset)
|
|
207
|
+
.replaceAll('\n', lineEnding);
|
|
208
|
+
}
|
|
209
|
+
output += source.slice(previous, childStart) + retainedSource;
|
|
183
210
|
previous = childEnd;
|
|
184
211
|
});
|
|
185
212
|
return output + source.slice(previous, end);
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
3
|
+
export interface TextMatch {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LiteralReplacement extends TextMatch {
|
|
8
|
+
text: string;
|
|
9
|
+
count: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function findLiteral(text: string, search: string, from?: number): TextMatch | undefined;
|
|
12
|
+
export declare function replaceLiteral(text: string, search: string, replacement: string, selection: TextMatch, all?: boolean): LiteralReplacement;
|
|
13
|
+
export declare function findFormatted(view: EditorView, text: string): void;
|
|
14
|
+
export declare function replaceFormatted(view: EditorView, search: string, replacement: string, preflight: (doc: ProseNode) => void, all?: boolean): number;
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { closeHistory } from '@milkdown/kit/prose/history';
|
|
3
|
+
import { Selection, TextSelection } from '@milkdown/kit/prose/state';
|
|
4
|
+
import { SearchQuery, setSearchState, getSearchState } from 'prosemirror-search';
|
|
5
|
+
import { InkKitError } from './types.js';
|
|
6
|
+
function escapedPattern(text) {
|
|
7
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
8
|
+
}
|
|
9
|
+
function literalMatches(text, search) {
|
|
10
|
+
if (!search)
|
|
11
|
+
return [];
|
|
12
|
+
const pattern = new RegExp(escapedPattern(search), 'giu');
|
|
13
|
+
return [...text.matchAll(pattern)].map((match) => ({
|
|
14
|
+
from: match.index,
|
|
15
|
+
to: match.index + match[0].length,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
export function findLiteral(text, search, from = 0) {
|
|
19
|
+
const matches = literalMatches(text, search);
|
|
20
|
+
return matches.find((match) => match.from >= from) ?? matches[0];
|
|
21
|
+
}
|
|
22
|
+
export function replaceLiteral(text, search, replacement, selection, all = false) {
|
|
23
|
+
const matches = literalMatches(text, search);
|
|
24
|
+
const selected = matches.find((match) => match.from === selection.from && match.to === selection.to) ??
|
|
25
|
+
matches.find((match) => match.from >= selection.to) ??
|
|
26
|
+
matches[0];
|
|
27
|
+
const changed = (all ? matches : selected ? [selected] : []).filter((match) => text.slice(match.from, match.to) !== replacement);
|
|
28
|
+
if (!changed.length)
|
|
29
|
+
return { ...selection, text, count: 0 };
|
|
30
|
+
let output = text;
|
|
31
|
+
for (const match of [...changed].reverse())
|
|
32
|
+
output = output.slice(0, match.from) + replacement + output.slice(match.to);
|
|
33
|
+
if (!all)
|
|
34
|
+
return {
|
|
35
|
+
text: output,
|
|
36
|
+
count: 1,
|
|
37
|
+
from: changed[0].from,
|
|
38
|
+
to: changed[0].from + replacement.length,
|
|
39
|
+
};
|
|
40
|
+
const map = (position) => {
|
|
41
|
+
let difference = 0;
|
|
42
|
+
for (const match of changed) {
|
|
43
|
+
if (position < match.from)
|
|
44
|
+
break;
|
|
45
|
+
if (position <= match.to)
|
|
46
|
+
return (match.from +
|
|
47
|
+
difference +
|
|
48
|
+
(position === match.from ? 0 : replacement.length));
|
|
49
|
+
difference += replacement.length - (match.to - match.from);
|
|
50
|
+
}
|
|
51
|
+
return position + difference;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
text: output,
|
|
55
|
+
count: changed.length,
|
|
56
|
+
from: map(selection.from),
|
|
57
|
+
to: map(selection.to),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function formattedQuery(text) {
|
|
61
|
+
// Unicode regexp matching retains offsets when lowercasing would expand a character.
|
|
62
|
+
return new SearchQuery({
|
|
63
|
+
search: escapedPattern(text),
|
|
64
|
+
regexp: true,
|
|
65
|
+
literal: true,
|
|
66
|
+
filter: (state, match) => {
|
|
67
|
+
const from = state.doc.resolve(match.from);
|
|
68
|
+
const to = state.doc.resolve(match.to);
|
|
69
|
+
if (from.parent !== to.parent || !from.parent.inlineContent)
|
|
70
|
+
return false;
|
|
71
|
+
const content = state.doc.slice(match.from, match.to).content;
|
|
72
|
+
let textOnly = true;
|
|
73
|
+
content.forEach((node) => {
|
|
74
|
+
if (!node.isText)
|
|
75
|
+
textOnly = false;
|
|
76
|
+
});
|
|
77
|
+
return textOnly;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function findFormatted(view, text) {
|
|
82
|
+
const query = formattedQuery(text);
|
|
83
|
+
const previous = getSearchState(view.state)?.query.search;
|
|
84
|
+
let tr = setSearchState(view.state.tr, query);
|
|
85
|
+
if (text) {
|
|
86
|
+
const start = previous === query.search
|
|
87
|
+
? view.state.selection.to
|
|
88
|
+
: view.state.selection.from;
|
|
89
|
+
const match = query.findNext(view.state, start) ?? query.findNext(view.state, 0);
|
|
90
|
+
if (match)
|
|
91
|
+
tr = tr
|
|
92
|
+
.setSelection(TextSelection.create(tr.doc, match.from, match.to))
|
|
93
|
+
.scrollIntoView();
|
|
94
|
+
else
|
|
95
|
+
tr = tr.setSelection(Selection.near(view.state.selection.$to));
|
|
96
|
+
}
|
|
97
|
+
else if (previous)
|
|
98
|
+
tr = tr.setSelection(Selection.near(view.state.selection.$to));
|
|
99
|
+
view.dispatch(tr);
|
|
100
|
+
}
|
|
101
|
+
export function replaceFormatted(view, search, replacement, preflight, all = false) {
|
|
102
|
+
if (!search)
|
|
103
|
+
return 0;
|
|
104
|
+
const state = view.state;
|
|
105
|
+
const query = formattedQuery(search);
|
|
106
|
+
if (!query.valid)
|
|
107
|
+
return 0;
|
|
108
|
+
const matches = [];
|
|
109
|
+
if (all) {
|
|
110
|
+
for (let position = 0;;) {
|
|
111
|
+
const match = query.findNext(state, position);
|
|
112
|
+
if (!match)
|
|
113
|
+
break;
|
|
114
|
+
matches.push(match);
|
|
115
|
+
position = match.to;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const selection = state.selection;
|
|
120
|
+
const current = query.findNext(state, selection.from, selection.to);
|
|
121
|
+
const match = (current?.from === selection.from && current.to === selection.to
|
|
122
|
+
? current
|
|
123
|
+
: undefined) ??
|
|
124
|
+
query.findNext(state, selection.to) ??
|
|
125
|
+
query.findNext(state, 0);
|
|
126
|
+
if (match)
|
|
127
|
+
matches.push(match);
|
|
128
|
+
}
|
|
129
|
+
const value = replacement.replace(/\r\n?/g, '\n');
|
|
130
|
+
const changed = matches.filter((match) => state.doc.textBetween(match.from, match.to) !== value);
|
|
131
|
+
if (!changed.length)
|
|
132
|
+
return 0;
|
|
133
|
+
let tr = setSearchState(state.tr, query);
|
|
134
|
+
for (const match of [...changed].reverse()) {
|
|
135
|
+
const from = state.doc.resolve(match.from);
|
|
136
|
+
const to = state.doc.resolve(match.to);
|
|
137
|
+
const marks = from.marksAcross(to) ?? from.marks();
|
|
138
|
+
let content = Fragment.empty;
|
|
139
|
+
if (from.parent.type.spec.code) {
|
|
140
|
+
if (value)
|
|
141
|
+
content = Fragment.from(state.schema.text(value, marks));
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const nodes = [];
|
|
145
|
+
const lines = value.split('\n');
|
|
146
|
+
lines.forEach((line, index) => {
|
|
147
|
+
if (index)
|
|
148
|
+
nodes.push(state.schema.nodes.hardbreak.create({ isHTML: index === lines.length - 1 && line === '' }, null, marks));
|
|
149
|
+
if (line)
|
|
150
|
+
nodes.push(state.schema.text(line, marks));
|
|
151
|
+
});
|
|
152
|
+
content = Fragment.fromArray(nodes);
|
|
153
|
+
}
|
|
154
|
+
tr.replace(match.from, match.to, new Slice(content, 0, 0));
|
|
155
|
+
}
|
|
156
|
+
if (!all) {
|
|
157
|
+
const match = changed[0];
|
|
158
|
+
tr = tr.setSelection(TextSelection.create(tr.doc, match.from, tr.mapping.map(match.to, 1)));
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
preflight(tr.doc);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof InkKitError)
|
|
165
|
+
throw error;
|
|
166
|
+
throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot preserve replacement');
|
|
167
|
+
}
|
|
168
|
+
view.dispatch(closeHistory(tr).scrollIntoView());
|
|
169
|
+
view.dispatch(closeHistory(view.state.tr).setMeta('addToHistory', false));
|
|
170
|
+
return changed.length;
|
|
171
|
+
}
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
+
export declare const sourceAttribute = "inkkitSource";
|
|
3
|
+
export interface SourceProvenance {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly format: 'md' | 'txt';
|
|
6
|
+
readonly from: number;
|
|
7
|
+
readonly to: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function cleanSourceDoc(doc: ProseNode): ProseNode;
|
|
10
|
+
export declare function rawOffset(source: string, normalized: number): number;
|
|
11
|
+
export declare function normalizedOffset(source: string, raw: number): number;
|
|
12
|
+
export declare function editedPlainSource(previous: string, value: string): string;
|
|
13
|
+
export declare function sourceSelection(source: string, start: number, end: number): {
|
|
14
|
+
markdown: string;
|
|
15
|
+
shareable: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function revealSourceCaret(plain: HTMLTextAreaElement): void;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { literalCommentSpans } from './comments.js';
|
|
2
|
+
export const sourceAttribute = 'inkkitSource';
|
|
3
|
+
export function cleanSourceDoc(doc) {
|
|
4
|
+
return doc.attrs[sourceAttribute] == null
|
|
5
|
+
? doc
|
|
6
|
+
: doc.type.create({ ...doc.attrs, [sourceAttribute]: null }, doc.content);
|
|
7
|
+
}
|
|
8
|
+
export function rawOffset(source, normalized) {
|
|
9
|
+
let position = 0, count = 0;
|
|
10
|
+
while (position < source.length && count < normalized) {
|
|
11
|
+
if (source[position] === '\r' && source[position + 1] === '\n')
|
|
12
|
+
position++;
|
|
13
|
+
position++;
|
|
14
|
+
count++;
|
|
15
|
+
}
|
|
16
|
+
return position;
|
|
17
|
+
}
|
|
18
|
+
export function normalizedOffset(source, raw) {
|
|
19
|
+
return source.slice(0, raw).replace(/\r\n?/g, '\n').length;
|
|
20
|
+
}
|
|
21
|
+
export function editedPlainSource(previous, value) {
|
|
22
|
+
const normalized = previous.replace(/\r\n?/g, '\n');
|
|
23
|
+
if (normalized === value)
|
|
24
|
+
return previous;
|
|
25
|
+
let prefix = 0, suffix = 0;
|
|
26
|
+
while (prefix < normalized.length &&
|
|
27
|
+
prefix < value.length &&
|
|
28
|
+
normalized[prefix] === value[prefix])
|
|
29
|
+
prefix++;
|
|
30
|
+
while (suffix < normalized.length - prefix &&
|
|
31
|
+
suffix < value.length - prefix &&
|
|
32
|
+
normalized[normalized.length - suffix - 1] ===
|
|
33
|
+
value[value.length - suffix - 1])
|
|
34
|
+
suffix++;
|
|
35
|
+
const ending = /\r\n|\r|\n/.exec(previous)?.[0] ?? '\n';
|
|
36
|
+
return (previous.slice(0, rawOffset(previous, prefix)) +
|
|
37
|
+
value
|
|
38
|
+
.slice(prefix, value.length - suffix)
|
|
39
|
+
.replace(/\r\n?/g, '\n')
|
|
40
|
+
.replaceAll('\n', ending) +
|
|
41
|
+
previous.slice(rawOffset(previous, normalized.length - suffix)));
|
|
42
|
+
}
|
|
43
|
+
export function sourceSelection(source, start, end) {
|
|
44
|
+
const from = rawOffset(source, start), to = rawOffset(source, end);
|
|
45
|
+
let shareable = '', cursor = from;
|
|
46
|
+
const bom = source.startsWith('\uFEFF') ? 1 : 0;
|
|
47
|
+
for (const parsed of literalCommentSpans(source.slice(bom))) {
|
|
48
|
+
const span = { start: parsed.start + bom, end: parsed.end + bom };
|
|
49
|
+
if (span.end <= from || span.start >= to)
|
|
50
|
+
continue;
|
|
51
|
+
if (span.start > cursor)
|
|
52
|
+
shareable += source.slice(cursor, Math.min(span.start, to));
|
|
53
|
+
cursor = Math.max(cursor, Math.min(span.end, to));
|
|
54
|
+
}
|
|
55
|
+
shareable += source.slice(cursor, to);
|
|
56
|
+
return { markdown: source.slice(from, to), shareable };
|
|
57
|
+
}
|
|
58
|
+
export function revealSourceCaret(plain) {
|
|
59
|
+
if (!plain.clientWidth || !plain.clientHeight)
|
|
60
|
+
return;
|
|
61
|
+
const document = plain.ownerDocument;
|
|
62
|
+
const style = document.defaultView.getComputedStyle(plain);
|
|
63
|
+
const mirror = document.createElement('div');
|
|
64
|
+
for (const property of [
|
|
65
|
+
'font',
|
|
66
|
+
'font-family',
|
|
67
|
+
'font-size',
|
|
68
|
+
'font-weight',
|
|
69
|
+
'font-style',
|
|
70
|
+
'font-stretch',
|
|
71
|
+
'font-variant',
|
|
72
|
+
'line-height',
|
|
73
|
+
'letter-spacing',
|
|
74
|
+
'word-spacing',
|
|
75
|
+
'tab-size',
|
|
76
|
+
'padding',
|
|
77
|
+
'text-indent',
|
|
78
|
+
'text-transform',
|
|
79
|
+
'direction',
|
|
80
|
+
'word-break',
|
|
81
|
+
])
|
|
82
|
+
mirror.style.setProperty(property, style.getPropertyValue(property));
|
|
83
|
+
Object.assign(mirror.style, {
|
|
84
|
+
position: 'fixed',
|
|
85
|
+
visibility: 'hidden',
|
|
86
|
+
left: '-10000px',
|
|
87
|
+
top: '0',
|
|
88
|
+
width: `${plain.clientWidth}px`,
|
|
89
|
+
boxSizing: 'border-box',
|
|
90
|
+
whiteSpace: plain.wrap === 'off' ? 'pre' : 'pre-wrap',
|
|
91
|
+
overflowWrap: plain.wrap === 'off' ? 'normal' : 'break-word',
|
|
92
|
+
});
|
|
93
|
+
mirror.setAttribute('aria-hidden', 'true');
|
|
94
|
+
const caret = document.createElement('span');
|
|
95
|
+
caret.setAttribute('data-inkkit-source-caret', '');
|
|
96
|
+
caret.textContent = plain.value.slice(plain.selectionStart) || '\u200b';
|
|
97
|
+
mirror.append(document.createTextNode(plain.value.slice(0, plain.selectionStart)), caret);
|
|
98
|
+
document.body.append(mirror);
|
|
99
|
+
try {
|
|
100
|
+
// Textareas expose selection offsets but no caret geometry; matching their layout also accounts for wrapped lines.
|
|
101
|
+
const rect = caret.getClientRects()[0] ?? caret.getBoundingClientRect();
|
|
102
|
+
const top = rect.top - mirror.getBoundingClientRect().top;
|
|
103
|
+
const lineHeight = Math.max(rect.height, Number.parseFloat(style.lineHeight) || rect.height);
|
|
104
|
+
const insetTop = Number.parseFloat(style.paddingTop) || 0;
|
|
105
|
+
const insetBottom = Number.parseFloat(style.paddingBottom) || 0;
|
|
106
|
+
if (plain.selectionStart === 0)
|
|
107
|
+
plain.scrollTop = 0;
|
|
108
|
+
else if (top < plain.scrollTop + insetTop)
|
|
109
|
+
plain.scrollTop = Math.max(0, top - insetTop);
|
|
110
|
+
else if (top + lineHeight >
|
|
111
|
+
plain.scrollTop + plain.clientHeight - insetBottom)
|
|
112
|
+
plain.scrollTop = Math.max(0, top + lineHeight - plain.clientHeight + insetBottom);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
mirror.remove();
|
|
116
|
+
}
|
|
117
|
+
}
|