@aseity/idocs-preview 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @aseity/idocs-preview
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Match the caret to the text being edited and move it to the next line after Shift+Enter. Keep edits and undo history when changing paragraph formatting, handle shorthand table rows, and focus newly inserted paragraphs, headings, and callouts. Improve excerpt selection, the narrow toolbar, and page thumbnails.
8
+
3
9
  ## 0.4.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -69,8 +69,21 @@ const workspace = mountEditorWorkspace(container, session, { railOpen: true });
69
69
 
70
70
  `source` is an iDocs YAML string or a `Uint8Array`. Use `readResource` in
71
71
  `openEditor` to resolve referenced images. The workspace includes page thumbnails,
72
- an outline, formatting controls, and an insert dock. It supports text selection,
73
- Chinese IME input, undo and redo, paragraph styles, tables, and images.
72
+ an outline, formatting controls, and an icon-only insert dock. It supports text
73
+ selection, Chinese IME input, undo and redo, paragraph styles, tables, and images.
74
+ Table cells are editable in both array rows and rows written with `cells:`.
75
+ Edits preserve the source form of each row.
76
+ Inserting a paragraph, heading, or callout from the dock focuses the new text.
77
+ A new callout uses the document's `theme.boxStyles.callout` when present.
78
+ The default text is left-aligned, with no paragraph indents and compact paragraph spacing.
79
+ For callout colors, the editor uses the box style, then `theme.colors.calloutBg`
80
+ and `theme.colors.accent`. The host can supply fallback `calloutColors`
81
+ as hex values. The selected style or colors are stored in the document.
82
+ When the formatting controls overflow, use the toolbar arrows to scroll.
83
+ In the narrowest panels, the arrows scroll the entire toolbar.
84
+ The caret and IME composition use the font size and color at the insertion point.
85
+ Press Shift+Enter for a line break within the paragraph. The caret moves to the start of the new line.
86
+ The page rail updates its contents while open.
74
87
  Use `mountEditor(container, session)` for the page editor without workspace controls.
75
88
 
76
89
  Save the source snapshot through your host, then mark that revision as saved:
@@ -85,7 +98,14 @@ const docx = await session.exportDocx();
85
98
  `saveSource` is your application's persistence function. Subscribe to
86
99
  `session.subscribe(listener)` to observe revisions, unsaved changes, and layout
87
100
  state. Supply `onPickImage` to the workspace to enable image insertion.
88
- `onQuoteSelection` receives selected text for a host assistant integration.
101
+ Supply `mountInsertButtons` to render the insert dock buttons using your host's
102
+ tooltips. Render each button in its provided container, use its label and class
103
+ name, call `bind` with the mounted button, and call `activate` on click.
104
+ Return a cleanup function to unmount the buttons when the workspace is destroyed.
105
+ Supply `onQuoteSelection` to enable **询问 AI**. The callback receives
106
+ `{ text, path, page }` from the current layout, including unsaved edits. The
107
+ page index starts at zero. An excerpt includes TeX for formulas in the selection.
108
+ Clicking a read-only line or formula offers a quote when it has text or TeX.
89
109
 
90
110
  When closing the editor, call `workspace.destroy()` and `session.dispose()`.
91
111
  The workspace does not own the session. Existing preview APIs remain available.
@@ -215,6 +215,23 @@ export type LineOccurrence = {
215
215
  } | {
216
216
  readonly kind: 'readOnlyStory';
217
217
  };
218
+ export interface CaretStyle {
219
+ readonly family: string;
220
+ readonly declaredFamily: string;
221
+ readonly role: string;
222
+ readonly size: number;
223
+ readonly bold: boolean;
224
+ readonly italic: boolean;
225
+ readonly color: string;
226
+ readonly ascent: number;
227
+ readonly descent: number;
228
+ readonly rise: number;
229
+ }
230
+ export interface CaretStyleSpan {
231
+ readonly start: number;
232
+ readonly end: number;
233
+ readonly style: CaretStyle;
234
+ }
218
235
  /** One laid-out line's glyph table, for hit testing. */
219
236
  export interface TextLine {
220
237
  story: Story;
@@ -236,6 +253,12 @@ export interface TextLine {
236
253
  turn?: [number, number];
237
254
  sourceSegments?: readonly LineSourceSegment[];
238
255
  occurrence?: LineOccurrence;
256
+ caretStyle?: CaretStyle;
257
+ caretStyles?: readonly CaretStyleSpan[];
258
+ formulas?: readonly {
259
+ readonly offset: DisplayUtf16;
260
+ readonly tex: string;
261
+ }[];
239
262
  }
240
263
  export interface DisplayPage {
241
264
  number: number;
@@ -12,6 +12,10 @@ export interface Selection {
12
12
  readonly reversed: boolean;
13
13
  }
14
14
  export type Mark = 'bold' | 'italic' | 'underline' | 'strike';
15
+ export interface CalloutColors {
16
+ readonly fill: string;
17
+ readonly border: string;
18
+ }
15
19
  export type InsertBlock = {
16
20
  kind: 'paragraph';
17
21
  text?: string;
@@ -22,6 +26,7 @@ export type InsertBlock = {
22
26
  } | {
23
27
  kind: 'callout';
24
28
  text?: string;
29
+ colors?: CalloutColors;
25
30
  } | {
26
31
  kind: 'table';
27
32
  rows: number;
@@ -164,9 +169,12 @@ export type LineHit = {
164
169
  role?: GeneratedRole;
165
170
  before?: Anchor;
166
171
  after?: Anchor;
172
+ segment?: LineSourceSegment;
167
173
  } | {
168
174
  kind: 'unmapped';
169
175
  };
176
+ export declare function caretXForOffset(line: TextLine, target: number): number;
170
177
  export declare function locateLineSource(line: TextLine, xPt: number): LineHit;
178
+ export declare function lineCaretStop(line: TextLine, anchor: Anchor): CaretStop | null;
171
179
  export declare function lineCaretX(line: TextLine, anchor: Anchor): number | null;
172
180
  export declare function openEditorWith(worker: Endpoint, options: OpenEditorOptions): Promise<EditorSession>;
@@ -1,27 +1,28 @@
1
1
  import {} from './display.js';
2
2
  import { copyBytes, PreviewError, } from './protocol.js';
3
3
  import { decodeCompact, glyphOffset, glyphX } from './wire.js';
4
- function caretXForOffset(line, target) {
4
+ export function caretXForOffset(line, target) {
5
5
  const glyphs = line.glyphs;
6
- const count = glyphs.length;
7
- if (count === 0)
6
+ let low = 0;
7
+ let high = glyphs.length;
8
+ if (high === 0)
8
9
  return line.x;
9
- for (let i = 0; i < count; i += 1) {
10
- const gOff = glyphOffset(glyphs, i);
11
- const gX = glyphX(glyphs, i);
12
- if (gOff === target || (gOff > target && i === 0))
13
- return gX;
14
- if (gOff > target) {
15
- const prevOff = glyphOffset(glyphs, i - 1);
16
- const prevX = glyphX(glyphs, i - 1);
17
- const span = Math.max(0, gOff - prevOff);
18
- if (span === 0)
19
- return gX;
20
- const t = Math.max(0, target - prevOff) / span;
21
- return prevX + (gX - prevX) * t;
22
- }
10
+ while (low < high) {
11
+ const mid = Math.floor((low + high) / 2);
12
+ if (glyphOffset(glyphs, mid) < target)
13
+ low = mid + 1;
14
+ else
15
+ high = mid;
23
16
  }
24
- return line.x + line.width;
17
+ if (low === glyphs.length)
18
+ return line.x + line.width;
19
+ const offset = glyphOffset(glyphs, low);
20
+ const x = glyphX(glyphs, low);
21
+ if (low === 0 || offset === target)
22
+ return x;
23
+ const previousOffset = glyphOffset(glyphs, low - 1);
24
+ const previousX = glyphX(glyphs, low - 1);
25
+ return previousX + (x - previousX) * (target - previousOffset) / (offset - previousOffset);
25
26
  }
26
27
  function hitFromSegment(line, seg, xPt) {
27
28
  const source = seg.source;
@@ -29,6 +30,7 @@ function hitFromSegment(line, seg, xPt) {
29
30
  return {
30
31
  kind: 'atomic',
31
32
  reason: source.reason,
33
+ segment: seg,
32
34
  ...(source.role !== undefined ? { role: source.role } : {}),
33
35
  ...(source.before !== undefined ? { before: source.before } : {}),
34
36
  ...(source.after !== undefined ? { after: source.after } : {}),
@@ -140,7 +142,7 @@ export function locateLineSource(line, xPt) {
140
142
  }
141
143
  return hitFromSegment(line, segments[chosenIdx], xPt);
142
144
  }
143
- export function lineCaretX(line, anchor) {
145
+ export function lineCaretStop(line, anchor) {
144
146
  const segments = line.sourceSegments;
145
147
  if (!segments)
146
148
  return null;
@@ -152,7 +154,7 @@ export function lineCaretX(line, anchor) {
152
154
  stop.anchor.flow === anchor.flow &&
153
155
  stop.anchor.offset === anchor.offset &&
154
156
  stop.anchor.affinity === anchor.affinity) {
155
- return caretXForOffset(line, stop.displayOffset);
157
+ return stop;
156
158
  }
157
159
  }
158
160
  }
@@ -165,12 +167,16 @@ export function lineCaretX(line, anchor) {
165
167
  stop.anchor.offset === anchor.offset &&
166
168
  stop.anchor.affinity === 'downstream' &&
167
169
  !(anchor.affinity === 'upstream' && line.index > 0 && stop.displayOffset === 0)) {
168
- return caretXForOffset(line, stop.displayOffset);
170
+ return stop;
169
171
  }
170
172
  }
171
173
  }
172
174
  return null;
173
175
  }
176
+ export function lineCaretX(line, anchor) {
177
+ const stop = lineCaretStop(line, anchor);
178
+ return stop ? caretXForOffset(line, stop.displayOffset) : null;
179
+ }
174
180
  const textEncoder = new TextEncoder();
175
181
  function toSourceBytes(source) {
176
182
  return typeof source === 'string' ? textEncoder.encode(source) : copyBytes(source);
@@ -3,7 +3,7 @@ import { type DocumentKind, type PreviewDocument, type PreviewPage, type Read, t
3
3
  export type * from './display.js';
4
4
  export type * from './protocol.js';
5
5
  export { PreviewError, type Read, type SessionOptions, } from './protocol.js';
6
- export type { Commit, Edit, EditRange, EditorFrame, EditorSession, EditorState, ImageAlign, InsertBlock, LayoutState, LineHit, Mark, OpenEditorOptions, RenderStats, Selection, SourceSnapshot, TableAction, } from './editor.js';
6
+ export type { CalloutColors, Commit, Edit, EditRange, EditorFrame, EditorSession, EditorState, ImageAlign, InsertBlock, LayoutState, LineHit, Mark, OpenEditorOptions, RenderStats, Selection, SourceSnapshot, TableAction, } from './editor.js';
7
7
  export { lineCaretX, locateLineSource };
8
8
  type Bytes = Uint8Array | ArrayBuffer;
9
9
  export interface OpenOptions {
@@ -1,4 +1,4 @@
1
- import { type Anchor, type DisplayPage, type EditRange, type FlowId, type GeneratedRole, type ReadOnlyReason } from '../core/index.js';
1
+ import { type Anchor, type CaretStyle, type DisplayPage, type EditRange, type FlowId, type GeneratedRole, type ReadOnlyReason } from '../core/index.js';
2
2
  import { type HighlightBox, type PagePoint } from './selection.js';
3
3
  export interface CaretBox {
4
4
  readonly page: number;
@@ -7,6 +7,7 @@ export interface CaretBox {
7
7
  readonly bottom: number;
8
8
  readonly baseline: number;
9
9
  readonly height: number;
10
+ readonly style?: CaretStyle;
10
11
  readonly turn?: [number, number];
11
12
  }
12
13
  export interface ReadOnlyTarget {
@@ -15,6 +16,7 @@ export interface ReadOnlyTarget {
15
16
  readonly reason: ReadOnlyReason;
16
17
  readonly role?: GeneratedRole;
17
18
  readonly box: HighlightBox;
19
+ readonly excerpt?: string;
18
20
  readonly before?: Anchor;
19
21
  readonly after?: Anchor;
20
22
  }
@@ -56,7 +58,7 @@ export declare class EditDocumentIndex {
56
58
  } | undefined;
57
59
  caretForAnchor(anchor: Anchor): CaretBox | undefined;
58
60
  highlightsForRange(range: EditRange, page: number): HighlightBox[];
59
- selectedText(range: EditRange): string;
61
+ selectedText(range: EditRange, format?: 'clipboard' | 'quote'): string;
60
62
  neighborStop(anchor: Anchor, direction: 'backward' | 'forward'): Anchor | undefined;
61
63
  moveHorizontal(anchor: Anchor, direction: 'backward' | 'forward', granularity?: 'character' | 'word'): Anchor;
62
64
  moveVertical(anchor: Anchor, direction: 'up' | 'down', preferredX?: number): {
@@ -1,5 +1,5 @@
1
- import { lineCaretX, locateLineSource, } from '../core/index.js';
2
- import { glyphOffset, glyphX } from '../core/wire.js';
1
+ import { locateLineSource, } from '../core/index.js';
2
+ import { caretXForOffset as caretXForDisplayOffset, lineCaretStop } from '../core/editor.js';
3
3
  import { turnedToPage } from './selection.js';
4
4
  const wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
5
5
  function lineVerticalBounds(line) {
@@ -9,28 +9,6 @@ function lineVerticalBounds(line) {
9
9
  bottom: middle + line.height / 2,
10
10
  };
11
11
  }
12
- function caretXForDisplayOffset(line, target) {
13
- const glyphs = line.glyphs;
14
- const count = glyphs.length;
15
- if (count === 0)
16
- return line.x;
17
- for (let i = 0; i < count; i += 1) {
18
- const gOff = glyphOffset(glyphs, i);
19
- const gX = glyphX(glyphs, i);
20
- if (gOff === target || (gOff > target && i === 0))
21
- return gX;
22
- if (gOff > target) {
23
- const prevOff = glyphOffset(glyphs, i - 1);
24
- const prevX = glyphX(glyphs, i - 1);
25
- const span = Math.max(0, gOff - prevOff);
26
- if (span === 0)
27
- return gX;
28
- const t = Math.max(0, target - prevOff) / span;
29
- return prevX + (gX - prevX) * t;
30
- }
31
- }
32
- return line.x + line.width;
33
- }
34
12
  function localCoords(line, x, y) {
35
13
  if (!line.turn)
36
14
  return [x, y];
@@ -56,15 +34,22 @@ function distanceToLine(placed, x, y) {
56
34
  const dy = y < placed.pageBox.top ? placed.pageBox.top - y : y >= placed.pageBox.bottom ? y - placed.pageBox.bottom + 1e-6 : 0;
57
35
  return dy * 1e6 + dx;
58
36
  }
59
- function caretBoxFromX(placed, x) {
60
- const { line, page, top, bottom } = placed;
37
+ function caretBox(placed, x, offset, affinity) {
38
+ const { line, page } = placed;
39
+ const spans = line.caretStyles ?? [];
40
+ const preceding = spans.find((span) => span.start < offset && offset <= span.end);
41
+ const following = spans.find((span) => span.start <= offset && offset < span.end);
42
+ const style = (affinity === 'upstream' ? preceding ?? following : following ?? preceding)?.style ?? line.caretStyle;
43
+ const baseline = line.baseline - (style?.rise ?? 0);
44
+ const height = style ? style.ascent + style.descent : line.ascent + line.descent;
61
45
  return {
62
46
  page,
63
47
  x,
64
- top,
65
- bottom,
66
- baseline: line.baseline,
67
- height: line.height,
48
+ top: baseline - (style?.ascent ?? line.ascent),
49
+ bottom: baseline + (style?.descent ?? line.descent),
50
+ baseline,
51
+ height,
52
+ ...(style ? { style } : {}),
68
53
  ...(line.turn ? { turn: line.turn } : {}),
69
54
  };
70
55
  }
@@ -116,63 +101,16 @@ function extractLineStops(line) {
116
101
  stops.sort((a, b) => a.anchor.offset - b.anchor.offset || a.displayOffset - b.displayOffset);
117
102
  return { flow, stops };
118
103
  }
119
- function chooseSegmentAtX(line, xPt) {
120
- const segments = line.sourceSegments;
121
- if (!segments || segments.length === 0)
122
- return undefined;
123
- let positive = [];
124
- for (let i = 0; i < segments.length; i += 1) {
125
- const seg = segments[i];
126
- if (seg.start >= seg.end)
104
+ function lineExcerpt(line, start = 0, end = line.text.length) {
105
+ let text = '';
106
+ let offset = start;
107
+ for (const formula of line.formulas ?? []) {
108
+ if (formula.offset < start || formula.offset >= end)
127
109
  continue;
128
- const left = caretXForDisplayOffset(line, seg.start);
129
- const right = caretXForDisplayOffset(line, seg.end);
130
- if (right - left > 1e-9)
131
- positive.push({ idx: i, left, right });
132
- }
133
- if (positive.length === 0) {
134
- for (let i = 0; i < segments.length; i += 1) {
135
- const seg = segments[i];
136
- if (seg.start < seg.end) {
137
- positive.push({
138
- idx: i,
139
- left: caretXForDisplayOffset(line, seg.start),
140
- right: caretXForDisplayOffset(line, seg.end),
141
- });
142
- }
143
- }
144
- }
145
- if (positive.length === 0)
146
- return segments[segments.length - 1];
147
- if (xPt <= positive[0].left)
148
- return segments[positive[0].idx];
149
- if (xPt >= positive[positive.length - 1].right) {
150
- const lastSeg = segments[segments.length - 1];
151
- return lastSeg.start === lastSeg.end && lastSeg.source.kind === 'editable'
152
- ? lastSeg
153
- : segments[positive[positive.length - 1].idx];
154
- }
155
- let containing;
156
- for (const item of positive) {
157
- if (xPt >= item.left && xPt <= item.right) {
158
- if (segments[item.idx].source.kind === 'editable')
159
- return segments[item.idx];
160
- if (!containing)
161
- containing = segments[item.idx];
162
- }
163
- }
164
- if (containing)
165
- return containing;
166
- let best = segments[positive[0].idx];
167
- let bestDist = Infinity;
168
- for (const item of positive) {
169
- const dist = xPt < item.left ? item.left - xPt : Math.max(0, xPt - item.right);
170
- if (dist < bestDist) {
171
- bestDist = dist;
172
- best = segments[item.idx];
173
- }
110
+ text += line.text.slice(offset, formula.offset).replaceAll('\ufffc', '') + formula.tex;
111
+ offset = formula.offset + 1;
174
112
  }
175
- return best;
113
+ return text + line.text.slice(offset, end).replaceAll('\ufffc', '');
176
114
  }
177
115
  function segmentHighlightBox(placed, seg) {
178
116
  const { line, top, bottom } = placed;
@@ -338,12 +276,15 @@ export class EditDocumentIndex {
338
276
  orderedStops.sort((a, b) => a.offset - b.offset);
339
277
  const stopIndexByOffset = new Map();
340
278
  orderedStops.forEach((anchor, i) => stopIndexByOffset.set(anchor.offset, i));
279
+ let words;
341
280
  this.#flows.set(id, {
342
281
  path: bucket.path,
343
282
  lines: bucket.lines,
344
283
  stops: orderedStops,
345
284
  stopIndexByOffset,
346
- words: buildFlowWords(bucket.lines),
285
+ get words() {
286
+ return words ??= buildFlowWords(bucket.lines);
287
+ },
347
288
  });
348
289
  }
349
290
  }
@@ -376,25 +317,15 @@ export class EditDocumentIndex {
376
317
  }
377
318
  }
378
319
  const occ = best.line.occurrence;
379
- if (occ?.kind === 'repeatedHeader') {
380
- return {
381
- kind: 'readOnly',
382
- target: {
383
- page: point.page,
384
- path: occ.canonicalPath || best.line.path,
385
- reason: 'repeatedHeader',
386
- box: segmentHighlightBox(best, undefined),
387
- },
388
- };
389
- }
390
- if (occ?.kind === 'readOnlyStory') {
320
+ if (occ?.kind === 'repeatedHeader' || occ?.kind === 'readOnlyStory') {
391
321
  return {
392
322
  kind: 'readOnly',
393
323
  target: {
394
324
  page: point.page,
395
- path: best.line.path,
396
- reason: 'unsupportedStory',
325
+ path: occ.kind === 'repeatedHeader' ? occ.canonicalPath || best.line.path : best.line.path,
326
+ reason: occ.kind === 'repeatedHeader' ? 'repeatedHeader' : 'unsupportedStory',
397
327
  box: segmentHighlightBox(best, undefined),
328
+ excerpt: lineExcerpt(best.line),
398
329
  },
399
330
  };
400
331
  }
@@ -404,13 +335,13 @@ export class EditDocumentIndex {
404
335
  return {
405
336
  kind: 'editable',
406
337
  anchor: hit.anchor,
407
- caret: caretBoxFromX(best, hit.x),
338
+ caret: caretBox(best, hit.x, hit.displayOffset, hit.anchor.affinity),
408
339
  page: point.page,
409
340
  path: best.line.path,
410
341
  };
411
342
  }
412
343
  if (hit.kind === 'atomic') {
413
- const seg = chooseSegmentAtX(best.line, localX);
344
+ const seg = hit.segment;
414
345
  const segPath = seg?.source.kind === 'atomic' && seg.source.path ? seg.source.path : best.line.path;
415
346
  return {
416
347
  kind: 'readOnly',
@@ -420,6 +351,7 @@ export class EditDocumentIndex {
420
351
  reason: hit.reason,
421
352
  ...(hit.role !== undefined ? { role: hit.role } : {}),
422
353
  box: segmentHighlightBox(best, seg),
354
+ excerpt: lineExcerpt(best.line, seg?.start, seg?.end),
423
355
  ...(hit.before ? { before: hit.before } : {}),
424
356
  ...(hit.after ? { after: hit.after } : {}),
425
357
  },
@@ -546,9 +478,11 @@ export class EditDocumentIndex {
546
478
  if (!entry)
547
479
  return undefined;
548
480
  for (const placed of entry.lines) {
549
- const x = lineCaretX(placed.line, anchor);
550
- if (x !== null)
551
- return caretBoxFromX(placed, x);
481
+ const stop = lineCaretStop(placed.line, anchor);
482
+ if (stop) {
483
+ const x = caretXForDisplayOffset(placed.line, stop.displayOffset);
484
+ return caretBox(placed, x, stop.displayOffset, anchor.affinity);
485
+ }
552
486
  }
553
487
  for (const placed of entry.lines) {
554
488
  for (const seg of placed.line.sourceSegments ?? []) {
@@ -558,13 +492,13 @@ export class EditDocumentIndex {
558
492
  seg.source.before.session === anchor.session &&
559
493
  seg.source.before.flow === anchor.flow &&
560
494
  seg.source.before.offset === anchor.offset) {
561
- return caretBoxFromX(placed, caretXForDisplayOffset(placed.line, seg.start));
495
+ return caretBox(placed, caretXForDisplayOffset(placed.line, seg.start), seg.start, anchor.affinity);
562
496
  }
563
497
  if (seg.source.after &&
564
498
  seg.source.after.session === anchor.session &&
565
499
  seg.source.after.flow === anchor.flow &&
566
500
  seg.source.after.offset === anchor.offset) {
567
- return caretBoxFromX(placed, caretXForDisplayOffset(placed.line, seg.end));
501
+ return caretBox(placed, caretXForDisplayOffset(placed.line, seg.end), seg.end, anchor.affinity);
568
502
  }
569
503
  }
570
504
  }
@@ -601,15 +535,17 @@ export class EditDocumentIndex {
601
535
  }
602
536
  return boxes;
603
537
  }
604
- selectedText(range) {
538
+ selectedText(range, format = 'clipboard') {
605
539
  if (range.start.flow !== range.end.flow || range.start.offset >= range.end.offset)
606
540
  return '';
607
541
  const entry = this.#flows.get(range.start.flow);
608
542
  if (!entry)
609
543
  return '';
610
544
  let text = '';
545
+ let lastFormulaOffset;
611
546
  for (const placed of entry.lines) {
612
- for (const seg of placed.line.sourceSegments ?? []) {
547
+ const { line } = placed;
548
+ for (const seg of line.sourceSegments ?? []) {
613
549
  if (seg.source.kind === 'editable') {
614
550
  const stops = seg.source.stops;
615
551
  if (stops.length === 0)
@@ -625,17 +561,26 @@ export class EditDocumentIndex {
625
561
  const dStart = displayOffsetAtFlowOffset(stops, from, false);
626
562
  const dEnd = displayOffsetAtFlowOffset(stops, to, true);
627
563
  if (dStart !== undefined && dEnd !== undefined && dEnd > dStart) {
628
- text += placed.line.text.slice(dStart, dEnd);
564
+ text += line.text.slice(dStart, dEnd).replaceAll('\ufffc', '');
629
565
  }
630
566
  }
631
567
  else if (seg.source.before && seg.source.after) {
632
568
  if (seg.source.before.offset >= range.start.offset && seg.source.after.offset <= range.end.offset) {
633
- text += placed.line.text.slice(seg.start, seg.end);
569
+ const formula = format === 'quote' && line.formulas?.find((part) => part.offset === seg.start);
570
+ if (formula) {
571
+ if (lastFormulaOffset === seg.source.before.offset)
572
+ continue;
573
+ lastFormulaOffset = seg.source.before.offset;
574
+ text += formula.tex;
575
+ }
576
+ else {
577
+ text += line.text.slice(seg.start, seg.end).replaceAll('\ufffc', '');
578
+ }
634
579
  }
635
580
  }
636
581
  }
637
582
  }
638
- return text.replaceAll('\ufffc', '');
583
+ return text;
639
584
  }
640
585
  neighborStop(anchor, direction) {
641
586
  const entry = this.#flows.get(anchor.flow);
@@ -761,7 +706,7 @@ export class EditDocumentIndex {
761
706
  }
762
707
  }
763
708
  for (const placed of entry.lines) {
764
- if (lineCaretX(placed.line, anchor) !== null)
709
+ if (lineCaretStop(placed.line, anchor))
765
710
  return placed;
766
711
  }
767
712
  for (const placed of entry.lines) {
@@ -20,6 +20,7 @@ export interface EditorViewOptions {
20
20
  export interface EditorViewSelection {
21
21
  readonly selection: Selection;
22
22
  readonly text: string;
23
+ readonly excerpt?: string;
23
24
  readonly path: string;
24
25
  readonly page: number;
25
26
  readonly pageBounds: {
@@ -1,3 +1,4 @@
1
+ import { canvasFont, CANVAS_TEXT_LANGUAGE } from '../core/fonts.js';
1
2
  import { EditDocumentIndex, orderedAnchors } from './edit-selection.js';
2
3
  import { createFindBar } from './find-bar.js';
3
4
  import { Finder } from './find.js';
@@ -12,8 +13,7 @@ const MATCH_HIGHLIGHT = 'rgba(255, 200, 0, 0.4)';
12
13
  const CURRENT_MATCH_HIGHLIGHT = 'rgba(255, 130, 0, 0.55)';
13
14
  const READONLY_FILL = 'rgba(217, 119, 6, 0.16)';
14
15
  const READONLY_STROKE = '#d97706';
15
- const CARET_COLOR = '#1d4ed8';
16
- const IME_UNDERLINE_COLOR = '#2563eb';
16
+ const DEFAULT_TEXT_COLOR = '#000';
17
17
  const DRAG_EDGE = 16;
18
18
  function clampZoom(zoom) {
19
19
  return Number.isFinite(zoom) && zoom > 0 ? Math.min(10, Math.max(0.1, zoom)) : 1;
@@ -192,22 +192,23 @@ function paintCaretBar(ctx, caret) {
192
192
  ctx.save();
193
193
  if (caret.turn)
194
194
  ctx.transform(0, 1, -1, 0, caret.turn[0], caret.turn[1]);
195
- ctx.fillStyle = CARET_COLOR;
196
- ctx.fillRect(caret.x - 0.75, caret.top, 1.5, Math.max(2, caret.bottom - caret.top));
195
+ ctx.fillStyle = caret.style?.color ?? DEFAULT_TEXT_COLOR;
196
+ ctx.fillRect(caret.x - 0.5, caret.top, 1, caret.height);
197
197
  ctx.restore();
198
198
  }
199
199
  function paintImePreview(ctx, caret, text) {
200
200
  ctx.save();
201
201
  if (caret.turn)
202
202
  ctx.transform(0, 1, -1, 0, caret.turn[0], caret.turn[1]);
203
- const fontSize = Math.max(8, caret.height * 0.78);
204
- ctx.font = `${fontSize}px system-ui, "PingFang SC", "Microsoft YaHei", sans-serif`;
203
+ ctx.font = caret.style ? canvasFont(caret.style) : `${caret.height}px system-ui, sans-serif`;
204
+ Object.assign(ctx, { lang: CANVAS_TEXT_LANGUAGE });
205
+ ctx.fontKerning = 'none';
205
206
  ctx.textBaseline = 'alphabetic';
206
207
  ctx.textAlign = 'left';
207
208
  const textWidth = text ? ctx.measureText(text).width : 0;
208
209
  const padX = 2;
209
210
  const boxWidth = Math.max(4, textWidth + padX * 2);
210
- const boxHeight = Math.max(4, caret.bottom - caret.top);
211
+ const boxHeight = caret.height;
211
212
  roundedPath(ctx, caret.x, caret.top, boxWidth, boxHeight, 2);
212
213
  ctx.fillStyle = 'rgba(255, 255, 255, 0.96)';
213
214
  ctx.fill();
@@ -215,19 +216,19 @@ function paintImePreview(ctx, caret, text) {
215
216
  ctx.lineWidth = 0.75;
216
217
  ctx.stroke();
217
218
  if (text) {
218
- ctx.fillStyle = '#111827';
219
+ ctx.fillStyle = caret.style?.color ?? DEFAULT_TEXT_COLOR;
219
220
  ctx.fillText(text, caret.x + padX, caret.baseline);
220
221
  ctx.beginPath();
221
222
  ctx.moveTo(caret.x + padX, caret.baseline + 1.5);
222
223
  ctx.lineTo(caret.x + padX + textWidth, caret.baseline + 1.5);
223
- ctx.strokeStyle = IME_UNDERLINE_COLOR;
224
+ ctx.strokeStyle = caret.style?.color ?? DEFAULT_TEXT_COLOR;
224
225
  ctx.lineWidth = 1;
225
226
  ctx.setLineDash([2, 1.5]);
226
227
  ctx.stroke();
227
228
  ctx.setLineDash([]);
228
229
  }
229
- ctx.fillStyle = CARET_COLOR;
230
- ctx.fillRect(caret.x + padX + textWidth - 0.75, caret.top, 1.5, boxHeight);
230
+ ctx.fillStyle = caret.style?.color ?? DEFAULT_TEXT_COLOR;
231
+ ctx.fillRect(caret.x + padX + textWidth - 0.5, caret.top, 1, boxHeight);
231
232
  ctx.restore();
232
233
  }
233
234
  function detectMarkInRuns(doc, index, range, mark) {
@@ -350,6 +351,8 @@ export function mountEditor(container, session, options = {}) {
350
351
  if (!activeSelection || !editIndex)
351
352
  return null;
352
353
  const range = activeSelection.range;
354
+ const index = editIndex;
355
+ const canQuote = range.revision === docRevision;
353
356
  const path = editIndex.flowPath(range.start.flow) ?? '';
354
357
  let page = 0;
355
358
  let pageBounds = { left: 0, right: 0, top: 0, bottom: 0 };
@@ -397,6 +400,9 @@ export function mountEditor(container, session, options = {}) {
397
400
  return {
398
401
  selection: activeSelection,
399
402
  text: isCollapsedRange(range) ? '' : editIndex.selectedText(range),
403
+ get excerpt() {
404
+ return canQuote ? index.selectedText(range, 'quote') : '';
405
+ },
400
406
  path,
401
407
  page,
402
408
  pageBounds,
@@ -1155,6 +1161,8 @@ export function mountEditor(container, session, options = {}) {
1155
1161
  updateViewport(false);
1156
1162
  }
1157
1163
  const applyFrameDocument = (nextDoc, nextRev, nextSelection) => {
1164
+ if (doc === nextDoc)
1165
+ return;
1158
1166
  const prevDoc = doc;
1159
1167
  const prevCount = prevDoc?.pageCount ?? 0;
1160
1168
  doc = nextDoc;
@@ -1183,12 +1191,12 @@ export function mountEditor(container, session, options = {}) {
1183
1191
  }
1184
1192
  }
1185
1193
  let selectionChanged = false;
1194
+ if (readOnlyTarget) {
1195
+ readOnlyTarget = null;
1196
+ options.onReadOnlyTarget?.(null);
1197
+ }
1186
1198
  if (nextSelection && nextSelection.revision === nextRev) {
1187
1199
  activeSelection = nextSelection;
1188
- if (readOnlyTarget) {
1189
- readOnlyTarget = null;
1190
- options.onReadOnlyTarget?.(null);
1191
- }
1192
1200
  selectionChanged = true;
1193
1201
  }
1194
1202
  else if (activeSelection && activeSelection.revision !== nextRev) {