@aicayzer/inkkit 0.0.8 → 0.0.9
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 +3 -3
- package/dist/editor.d.ts +21 -1
- package/dist/editor.js +376 -17
- package/dist/index.d.ts +1 -1
- package/dist/search.d.ts +2 -1
- package/dist/search.js +53 -22
- package/dist/source.d.ts +0 -1
- package/dist/source.js +0 -60
- package/dist/text-ranges.d.ts +26 -0
- package/dist/text-ranges.js +228 -0
- package/dist/types.d.ts +40 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
InkKit is a reusable Markdown editor for simple writing tools and richer document apps. It provides formatted editing, source preservation, portable clipboard content and optional host-managed images, while your app controls its appearance, files and interface.
|
|
4
4
|
|
|
5
|
-
Write in formatted or source mode with shared undo/redo. Edit tables, task lists, footnotes, reference links, highlights and callouts. Preview bounded Mermaid diagrams offline. Copy readable text and semantic HTML, explicitly copy Markdown source, or capture a complete printable document. Unsupported syntax remains editable literal Markdown.
|
|
5
|
+
Write in formatted or source mode with shared undo/redo. Edit tables, task lists, footnotes, reference links, highlights and callouts. Preview bounded Mermaid diagrams offline. Copy readable text and semantic HTML, explicitly copy Markdown source, or capture a complete printable document. Native hosts can use scoped readable-text ranges and demand-driven geometry with their own search controls and viewport insets. Unsupported syntax remains editable literal Markdown.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Host controls, read-only and text-input policies, command availability, scoped appearance, labels and shortcuts are available from 0.0.8. Check the [releases](https://github.com/aicayzer/inkkit/releases) for published versions and verification evidence.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install @aicayzer/inkkit
|
|
@@ -43,6 +43,6 @@ InkKit runs on the web and in offline web views. Hosts use its public TypeScript
|
|
|
43
43
|
|
|
44
44
|
Start with the [feature guide](docs/supported-syntax.md) and [simple and rich integration examples](docs/integration.md). The [documentation index](docs/README.md) links to precise API, preservation, playground, verification and publication guidance.
|
|
45
45
|
|
|
46
|
-
Scope and release status live in the [InkKit Project](https://github.com/users/aicayzer/projects/3) and [version milestones](https://github.com/aicayzer/inkkit/milestones).
|
|
46
|
+
Scope and release status live in the [InkKit Project](https://github.com/users/aicayzer/projects/3) and [version milestones](https://github.com/aicayzer/inkkit/milestones). Optional media extensions are tracked there. Only documented, released features form the supported package contract.
|
|
47
47
|
|
|
48
48
|
MIT licensed. InkKit builds on the public PadPad and Memos editors; see [NOTICE](NOTICE).
|
package/dist/editor.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type EditorLabels } from './labels.js';
|
|
2
2
|
import { type TableCommand, type TableOptions } from './tables.js';
|
|
3
|
-
import { type DocumentInput, type DocumentSnapshot, type ImageAdapter, type ClipboardInput, type ClipboardOutput, type PrintableDocument } from './types.js';
|
|
3
|
+
import { type DocumentInput, type DocumentSnapshot, type ImageAdapter, type ClipboardInput, type ClipboardOutput, type PrintableDocument, type TextRange, type ReadableTextSnapshot, type TextRect, type ViewportOptions, type ViewportSnapshot } from './types.js';
|
|
4
4
|
import { type Heading } from './outline.js';
|
|
5
5
|
export type Mark = 'bold' | 'italic' | 'strikethrough' | 'highlight' | 'code' | 'link';
|
|
6
6
|
export type Block = {
|
|
@@ -110,6 +110,10 @@ export declare class InkKitEditor {
|
|
|
110
110
|
private generation;
|
|
111
111
|
private reportingSuppressed;
|
|
112
112
|
private loading;
|
|
113
|
+
private readonly textIdentity;
|
|
114
|
+
private textScope?;
|
|
115
|
+
private viewportContainer?;
|
|
116
|
+
private viewportInsets;
|
|
113
117
|
private writable;
|
|
114
118
|
private inputPreferences;
|
|
115
119
|
private policyEpoch;
|
|
@@ -155,6 +159,22 @@ export declare class InkKitEditor {
|
|
|
155
159
|
static mount(root: HTMLElement, events: EditorEvents, options?: EditorOptions): Promise<InkKitEditor>;
|
|
156
160
|
loadDocument(input: DocumentInput): void;
|
|
157
161
|
reloadDocument(input: DocumentInput): void;
|
|
162
|
+
setViewport(options: ViewportOptions): void;
|
|
163
|
+
viewport(): ViewportSnapshot;
|
|
164
|
+
private readableScope;
|
|
165
|
+
textSnapshot(expectedGeneration?: number): ReadableTextSnapshot;
|
|
166
|
+
private validateTextRange;
|
|
167
|
+
selectTextRange(range: TextRange, options?: {
|
|
168
|
+
focus?: boolean;
|
|
169
|
+
reveal?: boolean;
|
|
170
|
+
}): void;
|
|
171
|
+
replaceTextRange(range: TextRange, text: string): boolean;
|
|
172
|
+
textRangeRects(range: TextRange): readonly TextRect[];
|
|
173
|
+
visibleTextRanges(snapshotId: string): readonly TextRange[];
|
|
174
|
+
revealTextRange(range: TextRange): void;
|
|
175
|
+
private revealRects;
|
|
176
|
+
private revealCurrentSelection;
|
|
177
|
+
private revealLiteralSelection;
|
|
158
178
|
private load;
|
|
159
179
|
find(text: string, expectedGeneration?: number): void;
|
|
160
180
|
replace(search: string, replacement: string, expectedGeneration?: number): boolean;
|
package/dist/editor.js
CHANGED
|
@@ -17,19 +17,20 @@ import { codeCopyPlugin, placeholderPlugin } from './decorations.js';
|
|
|
17
17
|
import { labelsCtx, defaultLabels } from './labels.js';
|
|
18
18
|
import { createDialect, stringifyOptions } from './dialect.js';
|
|
19
19
|
import { Preservation } from './preserve.js';
|
|
20
|
-
import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection,
|
|
20
|
+
import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, rawOffset, normalizedOffset, } from './source.js';
|
|
21
21
|
import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
|
|
22
22
|
import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
|
|
23
23
|
import { imageView } from './images.js';
|
|
24
24
|
import { tablePlugins, tableCommand, tableCommands, tableAvailability, tableContext, configureTableMovement, } from './tables.js';
|
|
25
25
|
import { InkKitError, } from './types.js';
|
|
26
|
+
import { readableProjection, projectionPosition, projectionOffset, validateOffsets, formattedRects, literalRects, literalVisibleRanges, intersectsViewport, textRect, } from './text-ranges.js';
|
|
26
27
|
import { highlightPlugin } from './highlight.js';
|
|
27
28
|
import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
|
|
28
29
|
import { commentSelectionContent, setCommentVisibility } from './comments.js';
|
|
29
30
|
import { PasteController } from './paste.js';
|
|
30
31
|
import { selectionPlugin } from './selection.js';
|
|
31
32
|
import { search, SearchQuery, setSearchState } from 'prosemirror-search';
|
|
32
|
-
import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, } from './search.js';
|
|
33
|
+
import { findFormatted, findLiteral, replaceFormatted, replaceLiteral, replaceFormattedRange, } from './search.js';
|
|
33
34
|
import { collectFormattedHeadings, collectSourceHeadings, navigateFormattedHeading, sourceHeadingPosition, } from './outline.js';
|
|
34
35
|
import { taskListPlugin, toggleTaskList } from './tasks.js';
|
|
35
36
|
import { isMermaid, mermaidPreview } from './mermaid.js';
|
|
@@ -239,6 +240,7 @@ function joinLists(tr, start, index) {
|
|
|
239
240
|
if (before && after && isList(before) && after.type === before.type)
|
|
240
241
|
tr.join($start.posAtIndex(index));
|
|
241
242
|
}
|
|
243
|
+
let editorSequence = 0;
|
|
242
244
|
export class InkKitEditor {
|
|
243
245
|
events;
|
|
244
246
|
options;
|
|
@@ -270,6 +272,15 @@ export class InkKitEditor {
|
|
|
270
272
|
generation = 0;
|
|
271
273
|
reportingSuppressed = false;
|
|
272
274
|
loading = false;
|
|
275
|
+
textIdentity = ++editorSequence;
|
|
276
|
+
textScope;
|
|
277
|
+
viewportContainer;
|
|
278
|
+
viewportInsets = {
|
|
279
|
+
top: 0,
|
|
280
|
+
right: 0,
|
|
281
|
+
bottom: 0,
|
|
282
|
+
left: 0,
|
|
283
|
+
};
|
|
273
284
|
writable = true;
|
|
274
285
|
inputPreferences = {};
|
|
275
286
|
policyEpoch = 0;
|
|
@@ -300,7 +311,7 @@ export class InkKitEditor {
|
|
|
300
311
|
this.plain.value = markdown;
|
|
301
312
|
if (!this.plainComposing) {
|
|
302
313
|
this.plain.setSelectionRange(Math.min(from, this.plain.value.length), Math.min(to, this.plain.value.length));
|
|
303
|
-
|
|
314
|
+
this.revealLiteralSelection();
|
|
304
315
|
}
|
|
305
316
|
}
|
|
306
317
|
if (markdown === this.lastMarkdown)
|
|
@@ -574,6 +585,12 @@ export class InkKitEditor {
|
|
|
574
585
|
throw new RangeError('Editing mode must be source or formatted');
|
|
575
586
|
if (this.formatType === 'txt' || this.mode === mode)
|
|
576
587
|
return false;
|
|
588
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
589
|
+
const focused = this.literalSurface
|
|
590
|
+
? this.plain.ownerDocument.activeElement === this.plain
|
|
591
|
+
: view.hasFocus();
|
|
592
|
+
const container = this.viewportContainer ?? this.root;
|
|
593
|
+
const top = container.scrollTop, left = container.scrollLeft;
|
|
577
594
|
const text = this.snapshot().text;
|
|
578
595
|
this.mode = mode;
|
|
579
596
|
this.outlineEpoch += 1;
|
|
@@ -591,6 +608,14 @@ export class InkKitEditor {
|
|
|
591
608
|
finally {
|
|
592
609
|
this.reportingSuppressed = suppressed;
|
|
593
610
|
}
|
|
611
|
+
if (focused) {
|
|
612
|
+
if (this.literalSurface)
|
|
613
|
+
this.plain.focus({ preventScroll: true });
|
|
614
|
+
else
|
|
615
|
+
view.focus();
|
|
616
|
+
}
|
|
617
|
+
container.scrollTop = top;
|
|
618
|
+
container.scrollLeft = left;
|
|
594
619
|
this.events.stateChanged(this.literalSurface
|
|
595
620
|
? { marks: [], block: { type: 'paragraph' }, quoted: false }
|
|
596
621
|
: caretState(this.editor.ctx.get(editorViewCtx).state));
|
|
@@ -1013,11 +1038,13 @@ export class InkKitEditor {
|
|
|
1013
1038
|
unbind(codeBlockKeymap);
|
|
1014
1039
|
unbind(bulletListKeymap);
|
|
1015
1040
|
unbind(orderedListKeymap);
|
|
1016
|
-
// The caret is kept above the fade under the formatting bar.
|
|
1017
1041
|
ctx.update(editorViewOptionsCtx, (options) => ({
|
|
1018
1042
|
...options,
|
|
1019
|
-
|
|
1020
|
-
|
|
1043
|
+
handleScrollToSelection: () => {
|
|
1044
|
+
if (!instance.loading && instance.ready)
|
|
1045
|
+
instance.revealCurrentSelection();
|
|
1046
|
+
return true;
|
|
1047
|
+
},
|
|
1021
1048
|
}));
|
|
1022
1049
|
})
|
|
1023
1050
|
.use(labelsCtx)
|
|
@@ -1242,6 +1269,11 @@ export class InkKitEditor {
|
|
|
1242
1269
|
}
|
|
1243
1270
|
loadDocument(input) {
|
|
1244
1271
|
this.assertAlive();
|
|
1272
|
+
const container = this.viewportContainer ?? this.root;
|
|
1273
|
+
const top = container.scrollTop, left = container.scrollLeft;
|
|
1274
|
+
const focused = this.literalSurface
|
|
1275
|
+
? this.plain.ownerDocument.activeElement === this.plain
|
|
1276
|
+
: this.editor.ctx.get(editorViewCtx).hasFocus();
|
|
1245
1277
|
this.ready = false;
|
|
1246
1278
|
this.pasteController.cancelPending();
|
|
1247
1279
|
this.documentEpoch += 1;
|
|
@@ -1256,11 +1288,20 @@ export class InkKitEditor {
|
|
|
1256
1288
|
this.formatType = input.format;
|
|
1257
1289
|
this.revision = 0;
|
|
1258
1290
|
this.plain.value = input.text;
|
|
1291
|
+
this.plain.setSelectionRange(this.plain.value.length, this.plain.value.length);
|
|
1259
1292
|
this.load(input.format === 'txt' ? '' : input.text, input.generation);
|
|
1260
1293
|
this.originalSource = input.text;
|
|
1261
1294
|
this.lastMarkdown = input.text;
|
|
1262
1295
|
this.updateSurface();
|
|
1263
1296
|
this.ready = true;
|
|
1297
|
+
if (focused) {
|
|
1298
|
+
if (this.literalSurface)
|
|
1299
|
+
this.plain.focus({ preventScroll: true });
|
|
1300
|
+
else
|
|
1301
|
+
this.editor.ctx.get(editorViewCtx).focus();
|
|
1302
|
+
}
|
|
1303
|
+
container.scrollTop = top;
|
|
1304
|
+
container.scrollLeft = left;
|
|
1264
1305
|
this.events.stateChanged(this.literalSurface
|
|
1265
1306
|
? { marks: [], block: { type: 'paragraph' }, quoted: false }
|
|
1266
1307
|
: caretState(this.editor.ctx.get(editorViewCtx).state));
|
|
@@ -1272,11 +1313,19 @@ export class InkKitEditor {
|
|
|
1272
1313
|
const wasLiteral = this.literalSurface;
|
|
1273
1314
|
const previousMode = this.editingMode;
|
|
1274
1315
|
const previousFormat = this.formatType;
|
|
1316
|
+
const selectedNode = !wasLiteral && view.state.selection instanceof NodeSelection
|
|
1317
|
+
? view.state.selection.node
|
|
1318
|
+
: undefined;
|
|
1275
1319
|
const at = wasLiteral
|
|
1276
1320
|
? this.plain.selectionStart
|
|
1277
|
-
: view.state.selection.
|
|
1278
|
-
const end = wasLiteral ? this.plain.selectionEnd :
|
|
1279
|
-
const
|
|
1321
|
+
: view.state.selection.anchor;
|
|
1322
|
+
const end = wasLiteral ? this.plain.selectionEnd : view.state.selection.head;
|
|
1323
|
+
const direction = this.plain.selectionDirection;
|
|
1324
|
+
const container = this.viewportContainer ?? this.root;
|
|
1325
|
+
const top = container.scrollTop, left = container.scrollLeft;
|
|
1326
|
+
const focused = wasLiteral
|
|
1327
|
+
? document.activeElement === this.plain
|
|
1328
|
+
: view.hasFocus();
|
|
1280
1329
|
const plainTop = this.plain.scrollTop, plainLeft = this.plain.scrollLeft;
|
|
1281
1330
|
this.reportingSuppressed = true;
|
|
1282
1331
|
try {
|
|
@@ -1286,10 +1335,24 @@ export class InkKitEditor {
|
|
|
1286
1335
|
previousMode === 'source')
|
|
1287
1336
|
this.setEditingMode('source', input.generation);
|
|
1288
1337
|
if (this.literalSurface)
|
|
1289
|
-
this.plain.setSelectionRange(Math.min(at, this.plain.value.length), Math.min(end, this.plain.value.length));
|
|
1290
|
-
else
|
|
1291
|
-
|
|
1292
|
-
|
|
1338
|
+
this.plain.setSelectionRange(Math.min(at, this.plain.value.length), Math.min(end, this.plain.value.length), direction);
|
|
1339
|
+
else {
|
|
1340
|
+
const anchor = Math.min(at, view.state.doc.content.size);
|
|
1341
|
+
const node = view.state.doc.nodeAt(anchor);
|
|
1342
|
+
view.dispatch(view.state.tr.setSelection(selectedNode &&
|
|
1343
|
+
node?.type === selectedNode.type &&
|
|
1344
|
+
NodeSelection.isSelectable(node)
|
|
1345
|
+
? NodeSelection.create(view.state.doc, anchor)
|
|
1346
|
+
: TextSelection.between(view.state.doc.resolve(anchor), view.state.doc.resolve(Math.min(end, view.state.doc.content.size)))));
|
|
1347
|
+
}
|
|
1348
|
+
if (focused) {
|
|
1349
|
+
if (this.literalSurface)
|
|
1350
|
+
this.plain.focus({ preventScroll: true });
|
|
1351
|
+
else
|
|
1352
|
+
view.focus();
|
|
1353
|
+
}
|
|
1354
|
+
container.scrollTop = top;
|
|
1355
|
+
container.scrollLeft = left;
|
|
1293
1356
|
if (wasLiteral && this.literalSurface) {
|
|
1294
1357
|
this.plain.scrollTop = plainTop;
|
|
1295
1358
|
this.plain.scrollLeft = plainLeft;
|
|
@@ -1300,6 +1363,301 @@ export class InkKitEditor {
|
|
|
1300
1363
|
this.publishCommandState();
|
|
1301
1364
|
}
|
|
1302
1365
|
}
|
|
1366
|
+
setViewport(options) {
|
|
1367
|
+
this.assertAlive();
|
|
1368
|
+
const container = options.scrollContainer ?? this.root;
|
|
1369
|
+
if (container !== this.root && !container.contains(this.root))
|
|
1370
|
+
throw new RangeError('The scroll container must be the editor root or an ancestor');
|
|
1371
|
+
const insets = { top: 0, right: 0, bottom: 0, left: 0, ...options.insets };
|
|
1372
|
+
if (Object.values(insets).some((value) => !Number.isFinite(value) || value < 0))
|
|
1373
|
+
throw new RangeError('Viewport insets must be finite non-negative CSS pixel values');
|
|
1374
|
+
this.viewportContainer = container;
|
|
1375
|
+
this.viewportInsets = insets;
|
|
1376
|
+
}
|
|
1377
|
+
viewport() {
|
|
1378
|
+
this.assertAlive();
|
|
1379
|
+
const container = this.viewportContainer ?? this.root;
|
|
1380
|
+
const bounds = container.getBoundingClientRect();
|
|
1381
|
+
const root = this.root.getBoundingClientRect();
|
|
1382
|
+
const insets = this.viewportInsets;
|
|
1383
|
+
const window = this.root.ownerDocument.defaultView;
|
|
1384
|
+
const left = Math.max(bounds.left + container.clientLeft, root.left, 0);
|
|
1385
|
+
const top = Math.max(bounds.top + container.clientTop, root.top, 0);
|
|
1386
|
+
const right = Math.max(left, Math.min(bounds.left + container.clientLeft + container.clientWidth, root.right, window.innerWidth));
|
|
1387
|
+
const bottom = Math.max(top, Math.min(bounds.top + container.clientTop + container.clientHeight, root.bottom, window.innerHeight));
|
|
1388
|
+
let rect = textRect({ left, top, right, bottom });
|
|
1389
|
+
if (this.literalSurface) {
|
|
1390
|
+
const plain = this.plain.getBoundingClientRect();
|
|
1391
|
+
const literalLeft = Math.max(rect.left, plain.left + this.plain.clientLeft);
|
|
1392
|
+
const literalTop = Math.max(rect.top, plain.top + this.plain.clientTop);
|
|
1393
|
+
rect = textRect({
|
|
1394
|
+
left: literalLeft,
|
|
1395
|
+
top: literalTop,
|
|
1396
|
+
right: Math.max(literalLeft, Math.min(rect.right, plain.left + this.plain.clientLeft + this.plain.clientWidth)),
|
|
1397
|
+
bottom: Math.max(literalTop, Math.min(rect.bottom, plain.top + this.plain.clientTop + this.plain.clientHeight)),
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
const insetLeft = Math.min(rect.right, rect.left + insets.left);
|
|
1401
|
+
const insetTop = Math.min(rect.bottom, rect.top + insets.top);
|
|
1402
|
+
rect = textRect({
|
|
1403
|
+
left: insetLeft,
|
|
1404
|
+
top: insetTop,
|
|
1405
|
+
right: Math.max(insetLeft, rect.right - insets.right),
|
|
1406
|
+
bottom: Math.max(insetTop, rect.bottom - insets.bottom),
|
|
1407
|
+
});
|
|
1408
|
+
return Object.freeze({
|
|
1409
|
+
rect: Object.freeze(rect),
|
|
1410
|
+
insets: Object.freeze({ ...insets }),
|
|
1411
|
+
scrollTop: container.scrollTop + (this.literalSurface ? this.plain.scrollTop : 0),
|
|
1412
|
+
scrollLeft: container.scrollLeft +
|
|
1413
|
+
(this.literalSurface ? this.plain.scrollLeft : 0),
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
readableScope() {
|
|
1417
|
+
const key = `${this.documentEpoch}:${this.revision}:${this.outlineEpoch}:${this.editingMode}`;
|
|
1418
|
+
if (this.textScope?.key !== key)
|
|
1419
|
+
this.textScope = {
|
|
1420
|
+
key,
|
|
1421
|
+
id: `${this.textIdentity}:${key}`,
|
|
1422
|
+
projection: this.literalSurface
|
|
1423
|
+
? { text: this.plain.value, spans: [] }
|
|
1424
|
+
: readableProjection(this.editor.ctx.get(editorViewCtx).state.doc),
|
|
1425
|
+
};
|
|
1426
|
+
return this.textScope;
|
|
1427
|
+
}
|
|
1428
|
+
textSnapshot(expectedGeneration) {
|
|
1429
|
+
this.assertCurrent(expectedGeneration);
|
|
1430
|
+
const scope = this.readableScope();
|
|
1431
|
+
const selection = this.editor.ctx.get(editorViewCtx).state.selection;
|
|
1432
|
+
return Object.freeze({
|
|
1433
|
+
snapshotId: scope.id,
|
|
1434
|
+
documentId: this.documentId,
|
|
1435
|
+
generation: this.generation,
|
|
1436
|
+
revision: this.revision,
|
|
1437
|
+
format: this.formatType,
|
|
1438
|
+
mode: this.editingMode,
|
|
1439
|
+
text: scope.projection.text,
|
|
1440
|
+
selection: Object.freeze({
|
|
1441
|
+
snapshotId: scope.id,
|
|
1442
|
+
from: this.literalSurface
|
|
1443
|
+
? this.plain.selectionStart
|
|
1444
|
+
: projectionOffset(scope.projection, selection.from),
|
|
1445
|
+
to: this.literalSurface
|
|
1446
|
+
? this.plain.selectionEnd
|
|
1447
|
+
: projectionOffset(scope.projection, selection.to),
|
|
1448
|
+
}),
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
validateTextRange(range) {
|
|
1452
|
+
this.assertCurrent();
|
|
1453
|
+
const scope = this.readableScope();
|
|
1454
|
+
if (range.snapshotId !== scope.id)
|
|
1455
|
+
throw new InkKitError('stale-document', 'The readable text snapshot changed');
|
|
1456
|
+
validateOffsets(scope.projection.text, range.from, range.to);
|
|
1457
|
+
return scope.projection;
|
|
1458
|
+
}
|
|
1459
|
+
selectTextRange(range, options = {}) {
|
|
1460
|
+
const projection = this.validateTextRange(range);
|
|
1461
|
+
if (this.literalSurface)
|
|
1462
|
+
this.plain.setSelectionRange(range.from, range.to);
|
|
1463
|
+
else {
|
|
1464
|
+
for (const span of projection.spans)
|
|
1465
|
+
if (span.kind === 'embed' &&
|
|
1466
|
+
((range.from > span.from && range.from < span.to) ||
|
|
1467
|
+
(range.to > span.from && range.to < span.to)))
|
|
1468
|
+
throw new InkKitError('invalid-range', 'Embedded labels must be selected as complete ranges');
|
|
1469
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
1470
|
+
const from = projectionPosition(projection, range.from, 1);
|
|
1471
|
+
const to = projectionPosition(projection, range.to, range.from === range.to ? 1 : -1);
|
|
1472
|
+
const embed = projection.spans.find((span) => span.kind === 'embed' &&
|
|
1473
|
+
span.from === range.from &&
|
|
1474
|
+
span.to === range.to);
|
|
1475
|
+
view.dispatch(view.state.tr.setSelection(embed && view.state.doc.nodeAt(embed.start)
|
|
1476
|
+
? NodeSelection.create(view.state.doc, embed.start)
|
|
1477
|
+
: TextSelection.between(view.state.doc.resolve(from), view.state.doc.resolve(to))));
|
|
1478
|
+
}
|
|
1479
|
+
if (options.focus) {
|
|
1480
|
+
if (this.literalSurface)
|
|
1481
|
+
this.plain.focus({ preventScroll: true });
|
|
1482
|
+
else
|
|
1483
|
+
this.editor.ctx.get(editorViewCtx).focus();
|
|
1484
|
+
}
|
|
1485
|
+
if (options.reveal)
|
|
1486
|
+
this.revealTextRange(range);
|
|
1487
|
+
this.publishCommandState();
|
|
1488
|
+
}
|
|
1489
|
+
replaceTextRange(range, text) {
|
|
1490
|
+
const projection = this.validateTextRange(range);
|
|
1491
|
+
this.assertMutation();
|
|
1492
|
+
if (typeof text !== 'string')
|
|
1493
|
+
throw new TypeError('Replacement must be a string');
|
|
1494
|
+
if (this.literalSurface) {
|
|
1495
|
+
const raw = this.currentText();
|
|
1496
|
+
const from = rawOffset(raw, range.from), to = rawOffset(raw, range.to);
|
|
1497
|
+
const ending = /\r\n|\r|\n/.exec(raw)?.[0] ?? '\n';
|
|
1498
|
+
const value = text.replace(/\r\n?/g, '\n');
|
|
1499
|
+
const replaced = raw.slice(0, from) + value.replaceAll('\n', ending) + raw.slice(to);
|
|
1500
|
+
return this.applySource(replaced, true, range.from, range.from + value.length);
|
|
1501
|
+
}
|
|
1502
|
+
if (projection.spans.some((span) => (span.kind === 'embed' &&
|
|
1503
|
+
range.from > span.from &&
|
|
1504
|
+
range.from < span.to) ||
|
|
1505
|
+
(span.kind === 'embed' &&
|
|
1506
|
+
range.to > span.from &&
|
|
1507
|
+
range.to < span.to) ||
|
|
1508
|
+
(span.to > range.from &&
|
|
1509
|
+
span.from < range.to &&
|
|
1510
|
+
span.kind !== 'text')))
|
|
1511
|
+
throw new InkKitError('invalid-range', 'Replacement cannot remove embedded content or structural separators');
|
|
1512
|
+
const from = projectionPosition(projection, range.from, 1);
|
|
1513
|
+
const to = projectionPosition(projection, range.to, range.from === range.to ? 1 : -1);
|
|
1514
|
+
return replaceFormattedRange(this.editor.ctx.get(editorViewCtx), from, to, text, (doc) => {
|
|
1515
|
+
this.currentText(doc);
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
textRangeRects(range) {
|
|
1519
|
+
const projection = this.validateTextRange(range);
|
|
1520
|
+
const rects = this.literalSurface
|
|
1521
|
+
? literalRects(this.plain, range.from, range.to)
|
|
1522
|
+
: formattedRects(this.editor.ctx.get(editorViewCtx), projection, range.from, range.to);
|
|
1523
|
+
return Object.freeze(rects.map((rect) => Object.freeze(rect)));
|
|
1524
|
+
}
|
|
1525
|
+
visibleTextRanges(snapshotId) {
|
|
1526
|
+
const snapshot = this.textSnapshot();
|
|
1527
|
+
this.validateTextRange({ snapshotId, from: 0, to: 0 });
|
|
1528
|
+
const viewport = this.viewport().rect;
|
|
1529
|
+
if (this.literalSurface)
|
|
1530
|
+
return Object.freeze(literalVisibleRanges(this.plain, viewport).map((range) => Object.freeze({ snapshotId, ...range })));
|
|
1531
|
+
const visible = (rect) => intersectsViewport(rect, viewport);
|
|
1532
|
+
const ranges = [];
|
|
1533
|
+
const projection = this.readableScope().projection;
|
|
1534
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
1535
|
+
for (const span of projection.spans) {
|
|
1536
|
+
const current = { text: projection.text, spans: [span] };
|
|
1537
|
+
if (span.kind === 'separator' ||
|
|
1538
|
+
!formattedRects(view, current, span.from, span.to).some(visible))
|
|
1539
|
+
continue;
|
|
1540
|
+
for (let from = span.from; from < span.to;) {
|
|
1541
|
+
const to = from + (snapshot.text.codePointAt(from) > 0xffff ? 2 : 1);
|
|
1542
|
+
if (formattedRects(view, current, from, to).some(visible)) {
|
|
1543
|
+
const previous = ranges.at(-1);
|
|
1544
|
+
if (previous?.to === from)
|
|
1545
|
+
previous.to = to;
|
|
1546
|
+
else
|
|
1547
|
+
ranges.push({ snapshotId, from, to });
|
|
1548
|
+
}
|
|
1549
|
+
from = to;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return Object.freeze(ranges.map((range) => Object.freeze(range)));
|
|
1553
|
+
}
|
|
1554
|
+
revealTextRange(range) {
|
|
1555
|
+
const projection = this.validateTextRange(range);
|
|
1556
|
+
if (!this.literalSurface) {
|
|
1557
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
1558
|
+
const position = projectionPosition(projection, range.from, 1);
|
|
1559
|
+
const at = view.domAtPos(position).node;
|
|
1560
|
+
let element = at instanceof Element ? at : at.parentElement;
|
|
1561
|
+
while (element && this.root.contains(element)) {
|
|
1562
|
+
if (element.matches('.inkkit-callout[data-inkkit-folded="true"]'))
|
|
1563
|
+
element
|
|
1564
|
+
.querySelector(':scope > .inkkit-callout-header [data-inkkit-callout-toggle]')
|
|
1565
|
+
?.click();
|
|
1566
|
+
element = element.parentElement;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
this.revealRects(this.textRangeRects(range));
|
|
1570
|
+
}
|
|
1571
|
+
revealRects(rects) {
|
|
1572
|
+
if (!rects.length)
|
|
1573
|
+
return;
|
|
1574
|
+
const viewport = this.viewport().rect;
|
|
1575
|
+
const leading = rects[0];
|
|
1576
|
+
const envelope = {
|
|
1577
|
+
top: leading.top,
|
|
1578
|
+
bottom: leading.bottom,
|
|
1579
|
+
left: leading.left,
|
|
1580
|
+
right: leading.right,
|
|
1581
|
+
};
|
|
1582
|
+
for (const rect of rects) {
|
|
1583
|
+
envelope.top = Math.min(envelope.top, rect.top);
|
|
1584
|
+
envelope.bottom = Math.max(envelope.bottom, rect.bottom);
|
|
1585
|
+
envelope.left = Math.min(envelope.left, rect.left);
|
|
1586
|
+
envelope.right = Math.max(envelope.right, rect.right);
|
|
1587
|
+
}
|
|
1588
|
+
const fitsHeight = envelope.bottom - envelope.top <= viewport.height;
|
|
1589
|
+
const fitsWidth = envelope.right - envelope.left <= viewport.width;
|
|
1590
|
+
const rect = {
|
|
1591
|
+
top: fitsHeight ? envelope.top : leading.top,
|
|
1592
|
+
bottom: fitsHeight
|
|
1593
|
+
? envelope.bottom
|
|
1594
|
+
: Math.min(leading.bottom, leading.top + viewport.height),
|
|
1595
|
+
left: fitsWidth ? envelope.left : leading.left,
|
|
1596
|
+
right: fitsWidth
|
|
1597
|
+
? envelope.right
|
|
1598
|
+
: Math.min(leading.right, leading.left + viewport.width),
|
|
1599
|
+
};
|
|
1600
|
+
const vertical = viewport.height <= 0
|
|
1601
|
+
? 0
|
|
1602
|
+
: rect.top < viewport.top
|
|
1603
|
+
? rect.top - viewport.top
|
|
1604
|
+
: rect.bottom > viewport.bottom
|
|
1605
|
+
? rect.bottom - viewport.bottom
|
|
1606
|
+
: 0;
|
|
1607
|
+
const horizontal = viewport.width <= 0
|
|
1608
|
+
? 0
|
|
1609
|
+
: rect.left < viewport.left
|
|
1610
|
+
? rect.left - viewport.left
|
|
1611
|
+
: rect.right > viewport.right
|
|
1612
|
+
? rect.right - viewport.right
|
|
1613
|
+
: 0;
|
|
1614
|
+
let remainingTop = vertical, remainingLeft = horizontal;
|
|
1615
|
+
if (this.literalSurface) {
|
|
1616
|
+
const top = this.plain.scrollTop, left = this.plain.scrollLeft;
|
|
1617
|
+
this.plain.scrollTop += vertical;
|
|
1618
|
+
this.plain.scrollLeft += horizontal;
|
|
1619
|
+
remainingTop -= this.plain.scrollTop - top;
|
|
1620
|
+
remainingLeft -= this.plain.scrollLeft - left;
|
|
1621
|
+
}
|
|
1622
|
+
let container = this.viewportContainer ?? this.root;
|
|
1623
|
+
const document = this.root.ownerDocument;
|
|
1624
|
+
while (container &&
|
|
1625
|
+
container !== document.body &&
|
|
1626
|
+
container !== document.documentElement) {
|
|
1627
|
+
const top = container.scrollTop, left = container.scrollLeft;
|
|
1628
|
+
container.scrollTop += remainingTop;
|
|
1629
|
+
container.scrollLeft += remainingLeft;
|
|
1630
|
+
remainingTop -= container.scrollTop - top;
|
|
1631
|
+
remainingLeft -= container.scrollLeft - left;
|
|
1632
|
+
container = container.parentElement;
|
|
1633
|
+
}
|
|
1634
|
+
if (remainingTop || remainingLeft)
|
|
1635
|
+
document.defaultView.scrollBy(remainingLeft, remainingTop);
|
|
1636
|
+
}
|
|
1637
|
+
revealCurrentSelection() {
|
|
1638
|
+
if (this.loading || !this.ready)
|
|
1639
|
+
return;
|
|
1640
|
+
if (this.literalSurface) {
|
|
1641
|
+
this.revealLiteralSelection();
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const view = this.editor.ctx.get(editorViewCtx);
|
|
1645
|
+
const projection = this.readableScope().projection;
|
|
1646
|
+
const from = projectionOffset(projection, view.state.selection.from);
|
|
1647
|
+
const to = projectionOffset(projection, view.state.selection.to);
|
|
1648
|
+
if (this.composing || this.pasteController.pending) {
|
|
1649
|
+
this.revealRects(formattedRects(view, projection, from, to));
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
this.revealTextRange({
|
|
1653
|
+
snapshotId: this.readableScope().id,
|
|
1654
|
+
from,
|
|
1655
|
+
to,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
revealLiteralSelection() {
|
|
1659
|
+
this.revealRects(literalRects(this.plain, this.plain.selectionStart, this.plain.selectionEnd));
|
|
1660
|
+
}
|
|
1303
1661
|
load(markdown, generation) {
|
|
1304
1662
|
this.generation = generation;
|
|
1305
1663
|
this.originalSource = markdown;
|
|
@@ -1319,7 +1677,7 @@ export class InkKitEditor {
|
|
|
1319
1677
|
const { doc } = view.state;
|
|
1320
1678
|
const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
|
|
1321
1679
|
Selection.atEnd(doc);
|
|
1322
|
-
view.dispatch(setSearchState(view.state.tr.setSelection(end)
|
|
1680
|
+
view.dispatch(setSearchState(view.state.tr.setSelection(end), new SearchQuery({ search: '' })));
|
|
1323
1681
|
}
|
|
1324
1682
|
find(text, expectedGeneration) {
|
|
1325
1683
|
this.assertCurrent(expectedGeneration);
|
|
@@ -1349,10 +1707,11 @@ export class InkKitEditor {
|
|
|
1349
1707
|
else if (this.plainSearch)
|
|
1350
1708
|
this.plain.setSelectionRange(this.plain.selectionEnd, this.plain.selectionEnd);
|
|
1351
1709
|
this.plainSearch = text;
|
|
1352
|
-
|
|
1710
|
+
this.revealLiteralSelection();
|
|
1353
1711
|
return;
|
|
1354
1712
|
}
|
|
1355
|
-
findFormatted(this.editor.ctx.get(editorViewCtx), text)
|
|
1713
|
+
if (findFormatted(this.editor.ctx.get(editorViewCtx), text))
|
|
1714
|
+
this.revealCurrentSelection();
|
|
1356
1715
|
}
|
|
1357
1716
|
replace(search, replacement, expectedGeneration) {
|
|
1358
1717
|
this.assertMutation(expectedGeneration);
|
|
@@ -1417,7 +1776,7 @@ export class InkKitEditor {
|
|
|
1417
1776
|
const position = sourceHeadingPosition(this.editor.ctx, this.plain.value, heading, this.outlineContext());
|
|
1418
1777
|
this.plain.setSelectionRange(position, position);
|
|
1419
1778
|
this.plain.focus({ preventScroll: true });
|
|
1420
|
-
|
|
1779
|
+
this.revealLiteralSelection();
|
|
1421
1780
|
return true;
|
|
1422
1781
|
}
|
|
1423
1782
|
return navigateFormattedHeading(this.editor.ctx.get(editorViewCtx), heading, this.outlineContext());
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export type { EditorEvents, EditorOptions, TextInputPreferences, CommandState, E
|
|
|
3
3
|
export type { TableCommand, TableOptions } from './tables.js';
|
|
4
4
|
export type { Heading } from './outline.js';
|
|
5
5
|
export { InkKitError } from './types.js';
|
|
6
|
-
export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, PrintableDocument, PrintableWarning, } from './types.js';
|
|
6
|
+
export type { DocumentFormat, DocumentContext, DocumentInput, DocumentSnapshot, EditorErrorCode, ImageAdapter, CapturedImage, PortableImage, ClipboardInput, ClipboardOutput, ClipboardImage, ClipboardDiagram, PrintableDocument, PrintableWarning, TextRange, ReadableTextSnapshot, TextRect, ViewportInsets, ViewportOptions, ViewportSnapshot, } from './types.js';
|
|
7
7
|
export type { EditorLabels } from './labels.js';
|
package/dist/search.d.ts
CHANGED
|
@@ -10,5 +10,6 @@ export interface LiteralReplacement extends TextMatch {
|
|
|
10
10
|
}
|
|
11
11
|
export declare function findLiteral(text: string, search: string, from?: number): TextMatch | undefined;
|
|
12
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):
|
|
13
|
+
export declare function findFormatted(view: EditorView, text: string): boolean;
|
|
14
14
|
export declare function replaceFormatted(view: EditorView, search: string, replacement: string, preflight: (doc: ProseNode) => void, all?: boolean): number;
|
|
15
|
+
export declare function replaceFormattedRange(view: EditorView, from: number, to: number, replacement: string, preflight: (doc: ProseNode) => void): boolean;
|
package/dist/search.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
2
2
|
import { closeHistory } from '@milkdown/kit/prose/history';
|
|
3
|
-
import { Selection, TextSelection } from '@milkdown/kit/prose/state';
|
|
3
|
+
import { Selection, TextSelection, } from '@milkdown/kit/prose/state';
|
|
4
4
|
import { SearchQuery, setSearchState, getSearchState } from 'prosemirror-search';
|
|
5
5
|
import { InkKitError } from './types.js';
|
|
6
6
|
function escapedPattern(text) {
|
|
@@ -82,21 +82,44 @@ export function findFormatted(view, text) {
|
|
|
82
82
|
const query = formattedQuery(text);
|
|
83
83
|
const previous = getSearchState(view.state)?.query.search;
|
|
84
84
|
let tr = setSearchState(view.state.tr, query);
|
|
85
|
+
let found = false;
|
|
85
86
|
if (text) {
|
|
86
87
|
const start = previous === query.search
|
|
87
88
|
? view.state.selection.to
|
|
88
89
|
: view.state.selection.from;
|
|
89
90
|
const match = query.findNext(view.state, start) ?? query.findNext(view.state, 0);
|
|
90
|
-
if (match)
|
|
91
|
+
if (match) {
|
|
92
|
+
found = true;
|
|
91
93
|
tr = tr
|
|
92
94
|
.setSelection(TextSelection.create(tr.doc, match.from, match.to))
|
|
93
95
|
.scrollIntoView();
|
|
96
|
+
}
|
|
94
97
|
else
|
|
95
98
|
tr = tr.setSelection(Selection.near(view.state.selection.$to));
|
|
96
99
|
}
|
|
97
100
|
else if (previous)
|
|
98
101
|
tr = tr.setSelection(Selection.near(view.state.selection.$to));
|
|
99
102
|
view.dispatch(tr);
|
|
103
|
+
return found;
|
|
104
|
+
}
|
|
105
|
+
function replacementSlice(state, from, to, value) {
|
|
106
|
+
const start = state.doc.resolve(from), end = state.doc.resolve(to);
|
|
107
|
+
const marks = start.marksAcross(end) ?? start.marks();
|
|
108
|
+
const nodes = [];
|
|
109
|
+
if (start.parent.type.spec.code) {
|
|
110
|
+
if (value)
|
|
111
|
+
nodes.push(state.schema.text(value, marks));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const lines = value.split('\n');
|
|
115
|
+
lines.forEach((line, index) => {
|
|
116
|
+
if (index)
|
|
117
|
+
nodes.push(state.schema.nodes.hardbreak.create({ isHTML: index === lines.length - 1 && line === '' }, null, marks));
|
|
118
|
+
if (line)
|
|
119
|
+
nodes.push(state.schema.text(line, marks));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return new Slice(Fragment.fromArray(nodes), 0, 0);
|
|
100
123
|
}
|
|
101
124
|
export function replaceFormatted(view, search, replacement, preflight, all = false) {
|
|
102
125
|
if (!search)
|
|
@@ -132,26 +155,7 @@ export function replaceFormatted(view, search, replacement, preflight, all = fal
|
|
|
132
155
|
return 0;
|
|
133
156
|
let tr = setSearchState(state.tr, query);
|
|
134
157
|
for (const match of [...changed].reverse()) {
|
|
135
|
-
|
|
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));
|
|
158
|
+
tr.replace(match.from, match.to, replacementSlice(state, match.from, match.to, value));
|
|
155
159
|
}
|
|
156
160
|
if (!all) {
|
|
157
161
|
const match = changed[0];
|
|
@@ -169,3 +173,30 @@ export function replaceFormatted(view, search, replacement, preflight, all = fal
|
|
|
169
173
|
view.dispatch(closeHistory(view.state.tr).setMeta('addToHistory', false));
|
|
170
174
|
return changed.length;
|
|
171
175
|
}
|
|
176
|
+
export function replaceFormattedRange(view, from, to, replacement, preflight) {
|
|
177
|
+
const start = view.state.doc.resolve(from);
|
|
178
|
+
const end = view.state.doc.resolve(to);
|
|
179
|
+
let textOnly = start.parent === end.parent && start.parent.inlineContent;
|
|
180
|
+
view.state.doc.nodesBetween(from, to, (node) => {
|
|
181
|
+
if (!node.isText && !node.isTextblock)
|
|
182
|
+
textOnly = false;
|
|
183
|
+
});
|
|
184
|
+
if (!textOnly)
|
|
185
|
+
throw new InkKitError('invalid-range', 'Replacement requires literal text within one block');
|
|
186
|
+
const value = replacement.replace(/\r\n?/g, '\n');
|
|
187
|
+
if (view.state.doc.textBetween(from, to) === value)
|
|
188
|
+
return false;
|
|
189
|
+
const tr = closeHistory(view.state.tr.replace(from, to, replacementSlice(view.state, from, to, value)));
|
|
190
|
+
tr.setSelection(TextSelection.create(tr.doc, from, tr.mapping.map(to, 1)));
|
|
191
|
+
try {
|
|
192
|
+
preflight(tr.doc);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof InkKitError)
|
|
196
|
+
throw error;
|
|
197
|
+
throw new InkKitError('preservation', error instanceof Error ? error.message : 'Cannot preserve replacement');
|
|
198
|
+
}
|
|
199
|
+
view.dispatch(tr);
|
|
200
|
+
view.dispatch(closeHistory(view.state.tr).setMeta('addToHistory', false));
|
|
201
|
+
return true;
|
|
202
|
+
}
|
package/dist/source.d.ts
CHANGED
package/dist/source.js
CHANGED
|
@@ -55,63 +55,3 @@ export function sourceSelection(source, start, end) {
|
|
|
55
55
|
shareable += source.slice(cursor, to);
|
|
56
56
|
return { markdown: source.slice(from, to), shareable };
|
|
57
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
|
-
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
3
|
+
import { type TextRect } from './types.js';
|
|
4
|
+
export interface TextSpan {
|
|
5
|
+
from: number;
|
|
6
|
+
to: number;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
kind: 'text' | 'break' | 'embed' | 'separator';
|
|
10
|
+
}
|
|
11
|
+
export interface TextProjection {
|
|
12
|
+
text: string;
|
|
13
|
+
spans: TextSpan[];
|
|
14
|
+
}
|
|
15
|
+
export declare function readableProjection(doc: ProseNode): TextProjection;
|
|
16
|
+
export declare function projectionPosition(projection: TextProjection, offset: number, side: -1 | 1): number;
|
|
17
|
+
export declare function projectionOffset(projection: TextProjection, position: number): number;
|
|
18
|
+
export declare function validateOffsets(text: string, from: number, to: number): void;
|
|
19
|
+
export declare function textRect(rect: Pick<TextRect, 'left' | 'top' | 'right' | 'bottom'>): TextRect;
|
|
20
|
+
export declare function formattedRects(view: EditorView, projection: TextProjection, from: number, to: number): TextRect[];
|
|
21
|
+
export declare function literalRects(plain: HTMLTextAreaElement, from: number, to: number): TextRect[];
|
|
22
|
+
export declare function intersectsViewport(rect: Pick<TextRect, 'top' | 'right' | 'bottom' | 'left'>, viewport: TextRect): boolean;
|
|
23
|
+
export declare function literalVisibleRanges(plain: HTMLTextAreaElement, viewport: TextRect): {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
}[];
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { InkKitError } from './types.js';
|
|
2
|
+
export function readableProjection(doc) {
|
|
3
|
+
const result = { text: '', spans: [] };
|
|
4
|
+
let blocks = 0;
|
|
5
|
+
const append = (text, start, end, kind) => {
|
|
6
|
+
const from = result.text.length;
|
|
7
|
+
result.text += text;
|
|
8
|
+
result.spans.push({ from, to: result.text.length, start, end, kind });
|
|
9
|
+
};
|
|
10
|
+
const block = (start) => {
|
|
11
|
+
if (blocks++)
|
|
12
|
+
append('\n', result.spans.at(-1)?.end ?? start, start, 'separator');
|
|
13
|
+
};
|
|
14
|
+
doc.descendants((node, position) => {
|
|
15
|
+
const name = node.type.name;
|
|
16
|
+
if (name === 'comment_inline' ||
|
|
17
|
+
name === 'comment_block' ||
|
|
18
|
+
name === 'reference_definition')
|
|
19
|
+
return false;
|
|
20
|
+
if (name === 'inkkit_callout') {
|
|
21
|
+
block(position);
|
|
22
|
+
append(node.attrs.title ||
|
|
23
|
+
String(node.attrs.kind).replace(/^./, (value) => value.toUpperCase()), position, position + 1, 'embed');
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (node.isTextblock) {
|
|
27
|
+
block(position + 1);
|
|
28
|
+
if (!node.content.size)
|
|
29
|
+
append('', position + 1, position + 1, 'text');
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (node.isText) {
|
|
33
|
+
append(node.text ?? '', position, position + node.nodeSize, 'text');
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (name === 'hardbreak' || name === 'hard_break') {
|
|
37
|
+
append('\n', position, position + node.nodeSize, 'break');
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (name === 'image' || name === 'footnote_reference') {
|
|
41
|
+
append(name === 'image'
|
|
42
|
+
? String(node.attrs.alt ?? '').replace(/\|\d{1,5}$/, '') || 'Image'
|
|
43
|
+
: `[${node.attrs.label}]`, position, position + node.nodeSize, 'embed');
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export function projectionPosition(projection, offset, side) {
|
|
51
|
+
const spans = projection.spans;
|
|
52
|
+
const span = spans.find((span) => (offset > span.from && offset < span.to) ||
|
|
53
|
+
(side > 0 ? offset === span.from : offset === span.to)) ?? (side > 0 ? spans.at(-1) : spans[0]);
|
|
54
|
+
if (!span)
|
|
55
|
+
return 0;
|
|
56
|
+
if (span.kind === 'text')
|
|
57
|
+
return (span.start +
|
|
58
|
+
Math.max(0, Math.min(offset - span.from, span.to - span.from)));
|
|
59
|
+
return offset <= span.from ? span.start : span.end;
|
|
60
|
+
}
|
|
61
|
+
export function projectionOffset(projection, position) {
|
|
62
|
+
for (const span of projection.spans) {
|
|
63
|
+
if (position < span.start)
|
|
64
|
+
return span.from;
|
|
65
|
+
if (position <= span.end)
|
|
66
|
+
return span.kind === 'text'
|
|
67
|
+
? span.from + Math.max(0, position - span.start)
|
|
68
|
+
: position === span.start
|
|
69
|
+
? span.from
|
|
70
|
+
: span.to;
|
|
71
|
+
}
|
|
72
|
+
return projection.text.length;
|
|
73
|
+
}
|
|
74
|
+
export function validateOffsets(text, from, to) {
|
|
75
|
+
const split = (offset) => offset > 0 &&
|
|
76
|
+
offset < text.length &&
|
|
77
|
+
/[\uD800-\uDBFF]/.test(text[offset - 1]) &&
|
|
78
|
+
/[\uDC00-\uDFFF]/.test(text[offset]);
|
|
79
|
+
if (!Number.isInteger(from) ||
|
|
80
|
+
!Number.isInteger(to) ||
|
|
81
|
+
from < 0 ||
|
|
82
|
+
to < from ||
|
|
83
|
+
to > text.length ||
|
|
84
|
+
split(from) ||
|
|
85
|
+
split(to))
|
|
86
|
+
throw new InkKitError('invalid-range', 'Range must use valid UTF-16 boundaries in the text snapshot');
|
|
87
|
+
}
|
|
88
|
+
export function textRect(rect) {
|
|
89
|
+
return {
|
|
90
|
+
left: rect.left,
|
|
91
|
+
top: rect.top,
|
|
92
|
+
right: rect.right,
|
|
93
|
+
bottom: rect.bottom,
|
|
94
|
+
width: Math.max(0, rect.right - rect.left),
|
|
95
|
+
height: Math.max(0, rect.bottom - rect.top),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function formattedRects(view, projection, from, to) {
|
|
99
|
+
if (from === to) {
|
|
100
|
+
const position = projectionPosition(projection, from, 1);
|
|
101
|
+
const dom = view.domAtPos(position);
|
|
102
|
+
const element = dom.node.nodeType === 1 ? dom.node : dom.node.parentElement;
|
|
103
|
+
if (!element?.getClientRects().length)
|
|
104
|
+
return [];
|
|
105
|
+
return [textRect(view.coordsAtPos(position))];
|
|
106
|
+
}
|
|
107
|
+
const rects = [];
|
|
108
|
+
for (const span of projection.spans) {
|
|
109
|
+
if (span.to <= from || span.from >= to || span.kind === 'separator')
|
|
110
|
+
continue;
|
|
111
|
+
if (span.kind === 'embed' || span.kind === 'break') {
|
|
112
|
+
const node = view.nodeDOM(span.start);
|
|
113
|
+
if (node instanceof Element) {
|
|
114
|
+
const label = node.matches('.inkkit-callout')
|
|
115
|
+
? (node.querySelector(':scope > .inkkit-callout-header > .inkkit-callout-title') ?? node)
|
|
116
|
+
: node;
|
|
117
|
+
rects.push(...Array.from(label.getClientRects(), textRect));
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const start = view.domAtPos(span.start + Math.max(0, from - span.from), 1);
|
|
122
|
+
const end = view.domAtPos(span.start + Math.min(span.to - span.from, to - span.from), -1);
|
|
123
|
+
const range = view.dom.ownerDocument.createRange();
|
|
124
|
+
range.setStart(start.node, start.offset);
|
|
125
|
+
range.setEnd(end.node, end.offset);
|
|
126
|
+
rects.push(...Array.from(range.getClientRects(), textRect));
|
|
127
|
+
}
|
|
128
|
+
return rects.filter((rect) => rect.height > 0 && rect.width >= 0);
|
|
129
|
+
}
|
|
130
|
+
function withLiteralLayout(plain, measure) {
|
|
131
|
+
if (!plain.clientWidth || !plain.clientHeight)
|
|
132
|
+
return undefined;
|
|
133
|
+
const document = plain.ownerDocument;
|
|
134
|
+
const style = document.defaultView.getComputedStyle(plain);
|
|
135
|
+
const mirror = document.createElement('div');
|
|
136
|
+
for (const property of [
|
|
137
|
+
'font',
|
|
138
|
+
'font-family',
|
|
139
|
+
'font-size',
|
|
140
|
+
'font-weight',
|
|
141
|
+
'font-style',
|
|
142
|
+
'font-stretch',
|
|
143
|
+
'font-variant',
|
|
144
|
+
'line-height',
|
|
145
|
+
'letter-spacing',
|
|
146
|
+
'word-spacing',
|
|
147
|
+
'tab-size',
|
|
148
|
+
'padding',
|
|
149
|
+
'text-indent',
|
|
150
|
+
'text-transform',
|
|
151
|
+
'direction',
|
|
152
|
+
'word-break',
|
|
153
|
+
])
|
|
154
|
+
mirror.style.setProperty(property, style.getPropertyValue(property));
|
|
155
|
+
const origin = plain.getBoundingClientRect();
|
|
156
|
+
Object.assign(mirror.style, {
|
|
157
|
+
position: 'fixed',
|
|
158
|
+
visibility: 'hidden',
|
|
159
|
+
pointerEvents: 'none',
|
|
160
|
+
left: `${origin.left + plain.clientLeft - plain.scrollLeft}px`,
|
|
161
|
+
top: `${origin.top + plain.clientTop - plain.scrollTop}px`,
|
|
162
|
+
width: `${plain.clientWidth}px`,
|
|
163
|
+
boxSizing: 'border-box',
|
|
164
|
+
whiteSpace: plain.wrap === 'off' ? 'pre' : 'pre-wrap',
|
|
165
|
+
overflowWrap: plain.wrap === 'off' ? 'normal' : 'break-word',
|
|
166
|
+
});
|
|
167
|
+
mirror.setAttribute('aria-hidden', 'true');
|
|
168
|
+
const text = document.createTextNode(plain.value + '\u200b');
|
|
169
|
+
mirror.append(text);
|
|
170
|
+
document.body.append(mirror);
|
|
171
|
+
try {
|
|
172
|
+
return measure(text);
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
mirror.remove();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export function literalRects(plain, from, to) {
|
|
179
|
+
return (withLiteralLayout(plain, (text) => {
|
|
180
|
+
const range = plain.ownerDocument.createRange();
|
|
181
|
+
range.setStart(text, from);
|
|
182
|
+
range.setEnd(text, from === to ? Math.min(to + 1, text.length) : to);
|
|
183
|
+
return Array.from(range.getClientRects(), (rect) => textRect({
|
|
184
|
+
left: rect.left,
|
|
185
|
+
right: from === to ? rect.left : rect.right,
|
|
186
|
+
top: rect.top,
|
|
187
|
+
bottom: rect.bottom,
|
|
188
|
+
}));
|
|
189
|
+
}) ?? []);
|
|
190
|
+
}
|
|
191
|
+
export function intersectsViewport(rect, viewport) {
|
|
192
|
+
return (viewport.width > 0 &&
|
|
193
|
+
viewport.height > 0 &&
|
|
194
|
+
rect.bottom > viewport.top &&
|
|
195
|
+
rect.top < viewport.bottom &&
|
|
196
|
+
rect.right >= viewport.left &&
|
|
197
|
+
rect.left < viewport.right);
|
|
198
|
+
}
|
|
199
|
+
export function literalVisibleRanges(plain, viewport) {
|
|
200
|
+
return (withLiteralLayout(plain, (text) => {
|
|
201
|
+
const range = plain.ownerDocument.createRange();
|
|
202
|
+
const ranges = [];
|
|
203
|
+
const visible = (from, to) => {
|
|
204
|
+
range.setStart(text, from);
|
|
205
|
+
range.setEnd(text, to);
|
|
206
|
+
return Array.from(range.getClientRects()).some((rect) => intersectsViewport(rect, viewport));
|
|
207
|
+
};
|
|
208
|
+
for (let start = 0; start < plain.value.length;) {
|
|
209
|
+
const newline = plain.value.indexOf('\n', start);
|
|
210
|
+
const end = newline < 0 ? plain.value.length : newline + 1;
|
|
211
|
+
if (visible(start, end)) {
|
|
212
|
+
for (let from = start; from < end;) {
|
|
213
|
+
const to = from + (plain.value.codePointAt(from) > 0xffff ? 2 : 1);
|
|
214
|
+
if (visible(from, to)) {
|
|
215
|
+
const previous = ranges.at(-1);
|
|
216
|
+
if (previous?.to === from)
|
|
217
|
+
previous.to = to;
|
|
218
|
+
else
|
|
219
|
+
ranges.push({ from, to });
|
|
220
|
+
}
|
|
221
|
+
from = to;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
start = end;
|
|
225
|
+
}
|
|
226
|
+
return ranges;
|
|
227
|
+
}) ?? []);
|
|
228
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface DocumentSnapshot extends DocumentInput {
|
|
|
14
14
|
revision: number;
|
|
15
15
|
dirty: boolean;
|
|
16
16
|
}
|
|
17
|
-
export type EditorErrorCode = 'not-ready' | 'stale-document' | 'composition' | 'read-only' | 'operation-pending' | 'destroyed' | 'preservation' | 'image-unavailable' | 'diagram-unavailable';
|
|
17
|
+
export type EditorErrorCode = 'not-ready' | 'stale-document' | 'composition' | 'read-only' | 'operation-pending' | 'destroyed' | 'preservation' | 'image-unavailable' | 'diagram-unavailable' | 'invalid-range';
|
|
18
18
|
export declare class InkKitError extends Error {
|
|
19
19
|
readonly code: EditorErrorCode;
|
|
20
20
|
constructor(code: EditorErrorCode, message: string);
|
|
@@ -78,3 +78,42 @@ export interface PrintableDocument {
|
|
|
78
78
|
assets: readonly PortableImage[];
|
|
79
79
|
warnings: readonly PrintableWarning[];
|
|
80
80
|
}
|
|
81
|
+
export interface TextRange {
|
|
82
|
+
snapshotId: string;
|
|
83
|
+
from: number;
|
|
84
|
+
to: number;
|
|
85
|
+
}
|
|
86
|
+
export interface ReadableTextSnapshot {
|
|
87
|
+
snapshotId: string;
|
|
88
|
+
documentId: string;
|
|
89
|
+
generation: number;
|
|
90
|
+
revision: number;
|
|
91
|
+
format: DocumentFormat;
|
|
92
|
+
mode: 'source' | 'formatted';
|
|
93
|
+
text: string;
|
|
94
|
+
selection: TextRange;
|
|
95
|
+
}
|
|
96
|
+
export interface TextRect {
|
|
97
|
+
left: number;
|
|
98
|
+
top: number;
|
|
99
|
+
right: number;
|
|
100
|
+
bottom: number;
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
}
|
|
104
|
+
export interface ViewportInsets {
|
|
105
|
+
top: number;
|
|
106
|
+
right: number;
|
|
107
|
+
bottom: number;
|
|
108
|
+
left: number;
|
|
109
|
+
}
|
|
110
|
+
export interface ViewportOptions {
|
|
111
|
+
scrollContainer?: HTMLElement;
|
|
112
|
+
insets?: Partial<ViewportInsets>;
|
|
113
|
+
}
|
|
114
|
+
export interface ViewportSnapshot {
|
|
115
|
+
rect: TextRect;
|
|
116
|
+
insets: ViewportInsets;
|
|
117
|
+
scrollTop: number;
|
|
118
|
+
scrollLeft: number;
|
|
119
|
+
}
|