@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 +6 -0
- package/README.md +23 -3
- package/dist/core/display.d.ts +23 -0
- package/dist/core/editor.d.ts +8 -0
- package/dist/core/editor.js +27 -21
- package/dist/core/index.d.ts +1 -1
- package/dist/view/edit-selection.d.ts +4 -2
- package/dist/view/edit-selection.js +58 -113
- package/dist/view/editor.d.ts +1 -0
- package/dist/view/editor.js +23 -15
- package/dist/view/ime.js +5 -5
- package/dist/view/index.d.ts +1 -1
- package/dist/view/workspace-styles.js +110 -50
- package/dist/view/workspace.d.ts +10 -3
- package/dist/view/workspace.js +244 -151
- package/package.json +7 -2
- package/wasm/idocs_wasm_bg.wasm +0 -0
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
|
|
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
|
-
`
|
|
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.
|
package/dist/core/display.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/editor.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/editor.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
6
|
+
let low = 0;
|
|
7
|
+
let high = glyphs.length;
|
|
8
|
+
if (high === 0)
|
|
8
9
|
return line.x;
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
|
60
|
-
const { line, page
|
|
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
|
|
67
|
-
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
550
|
-
if (
|
|
551
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
|
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 (
|
|
709
|
+
if (lineCaretStop(placed.line, anchor))
|
|
765
710
|
return placed;
|
|
766
711
|
}
|
|
767
712
|
for (const placed of entry.lines) {
|
package/dist/view/editor.d.ts
CHANGED
package/dist/view/editor.js
CHANGED
|
@@ -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
|
|
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 =
|
|
196
|
-
ctx.fillRect(caret.x - 0.
|
|
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
|
-
|
|
204
|
-
ctx
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
230
|
-
ctx.fillRect(caret.x + padX + textWidth - 0.
|
|
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) {
|