@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 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
- **Version status:** 0.0.7 is published. This branch documents the **unpublished 0.0.8 candidate**, adding read-only and text-input policies, command availability, scoped appearance, labels and shortcuts. Installing from npm currently gets the published release; the new controls require the candidate.
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). Planned search, viewport and media extensions are tracked there; they are not capabilities of this candidate.
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, revealSourceCaret, rawOffset, normalizedOffset, } from './source.js';
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
- revealSourceCaret(this.plain);
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
- scrollThreshold: { top: 8, right: 0, bottom: 24, left: 0 },
1020
- scrollMargin: { top: 8, right: 0, bottom: 24, left: 0 },
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.from;
1278
- const end = wasLiteral ? this.plain.selectionEnd : at;
1279
- const top = this.root.scrollTop;
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
- view.dispatch(view.state.tr.setSelection(Selection.near(view.state.doc.resolve(Math.min(at, view.state.doc.content.size)))));
1292
- this.root.scrollTop = top;
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).scrollIntoView(), new SearchQuery({ search: '' })));
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
- revealSourceCaret(this.plain);
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
- revealSourceCaret(this.plain);
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): void;
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
- 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));
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
@@ -14,4 +14,3 @@ export declare function sourceSelection(source: string, start: number, end: numb
14
14
  markdown: string;
15
15
  shareable: string;
16
16
  };
17
- export declare function revealSourceCaret(plain: HTMLTextAreaElement): void;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicayzer/inkkit",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "A preserving Markdown editor for offline web views and the web",
5
5
  "license": "MIT",
6
6
  "type": "module",