@aseity/idocs-preview 0.3.0 → 0.4.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.
@@ -0,0 +1,527 @@
1
+ import {} from './display.js';
2
+ import { copyBytes, PreviewError, } from './protocol.js';
3
+ import { decodeCompact, glyphOffset, glyphX } from './wire.js';
4
+ function caretXForOffset(line, target) {
5
+ const glyphs = line.glyphs;
6
+ const count = glyphs.length;
7
+ if (count === 0)
8
+ 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
+ }
23
+ }
24
+ return line.x + line.width;
25
+ }
26
+ function hitFromSegment(line, seg, xPt) {
27
+ const source = seg.source;
28
+ if (source.kind === 'atomic') {
29
+ return {
30
+ kind: 'atomic',
31
+ reason: source.reason,
32
+ ...(source.role !== undefined ? { role: source.role } : {}),
33
+ ...(source.before !== undefined ? { before: source.before } : {}),
34
+ ...(source.after !== undefined ? { after: source.after } : {}),
35
+ };
36
+ }
37
+ const stops = source.stops;
38
+ if (stops.length === 0)
39
+ return { kind: 'unmapped' };
40
+ let best = stops[0];
41
+ let bestX = caretXForOffset(line, best.displayOffset);
42
+ let bestDist = Math.abs(xPt - bestX);
43
+ for (let i = 1; i < stops.length; i += 1) {
44
+ const stop = stops[i];
45
+ const sx = caretXForOffset(line, stop.displayOffset);
46
+ const dist = Math.abs(xPt - sx);
47
+ const prefer = dist < bestDist - 1e-9 || (Math.abs(dist - bestDist) <= 1e-9 && xPt >= sx);
48
+ if (prefer) {
49
+ best = stop;
50
+ bestX = sx;
51
+ bestDist = dist;
52
+ }
53
+ }
54
+ return {
55
+ kind: 'editable',
56
+ anchor: best.anchor,
57
+ displayOffset: best.displayOffset,
58
+ x: bestX,
59
+ };
60
+ }
61
+ export function locateLineSource(line, xPt) {
62
+ const segments = line.sourceSegments;
63
+ if (!segments || segments.length === 0) {
64
+ if (line.occurrence?.kind === 'readOnlyStory') {
65
+ return { kind: 'atomic', reason: 'unsupportedStory' };
66
+ }
67
+ if (line.occurrence?.kind === 'repeatedHeader') {
68
+ return { kind: 'atomic', reason: 'repeatedHeader' };
69
+ }
70
+ return { kind: 'unmapped' };
71
+ }
72
+ let positive = [];
73
+ for (let i = 0; i < segments.length; i += 1) {
74
+ const seg = segments[i];
75
+ if (seg.start >= seg.end)
76
+ continue;
77
+ const left = caretXForOffset(line, seg.start);
78
+ const right = caretXForOffset(line, seg.end);
79
+ if (right - left > 1e-9)
80
+ positive.push({ idx: i, left, right });
81
+ }
82
+ if (positive.length === 0) {
83
+ positive = [];
84
+ for (let i = 0; i < segments.length; i += 1) {
85
+ const seg = segments[i];
86
+ if (seg.start < seg.end) {
87
+ positive.push({
88
+ idx: i,
89
+ left: caretXForOffset(line, seg.start),
90
+ right: caretXForOffset(line, seg.end),
91
+ });
92
+ }
93
+ }
94
+ }
95
+ let chosenIdx;
96
+ if (positive.length === 0) {
97
+ chosenIdx = segments.length - 1;
98
+ }
99
+ else if (xPt <= positive[0].left) {
100
+ chosenIdx = positive[0].idx;
101
+ }
102
+ else if (xPt >= positive[positive.length - 1].right) {
103
+ const lastSeg = segments[segments.length - 1];
104
+ chosenIdx =
105
+ lastSeg.start === lastSeg.end && lastSeg.source.kind === 'editable'
106
+ ? segments.length - 1
107
+ : positive[positive.length - 1].idx;
108
+ }
109
+ else {
110
+ let editableBoundary;
111
+ let containing;
112
+ for (const item of positive) {
113
+ if (xPt >= item.left && xPt <= item.right) {
114
+ if (segments[item.idx].source.kind === 'editable') {
115
+ editableBoundary = item.idx;
116
+ break;
117
+ }
118
+ if (containing === undefined)
119
+ containing = item.idx;
120
+ }
121
+ }
122
+ if (editableBoundary !== undefined) {
123
+ chosenIdx = editableBoundary;
124
+ }
125
+ else if (containing !== undefined) {
126
+ chosenIdx = containing;
127
+ }
128
+ else {
129
+ let bestIdx = positive[0].idx;
130
+ let bestDist = Infinity;
131
+ for (const item of positive) {
132
+ const dist = xPt < item.left ? item.left - xPt : Math.max(0, xPt - item.right);
133
+ if (dist < bestDist) {
134
+ bestDist = dist;
135
+ bestIdx = item.idx;
136
+ }
137
+ }
138
+ chosenIdx = bestIdx;
139
+ }
140
+ }
141
+ return hitFromSegment(line, segments[chosenIdx], xPt);
142
+ }
143
+ export function lineCaretX(line, anchor) {
144
+ const segments = line.sourceSegments;
145
+ if (!segments)
146
+ return null;
147
+ for (const seg of segments) {
148
+ if (seg.source.kind !== 'editable')
149
+ continue;
150
+ for (const stop of seg.source.stops) {
151
+ if (stop.anchor.session === anchor.session &&
152
+ stop.anchor.flow === anchor.flow &&
153
+ stop.anchor.offset === anchor.offset &&
154
+ stop.anchor.affinity === anchor.affinity) {
155
+ return caretXForOffset(line, stop.displayOffset);
156
+ }
157
+ }
158
+ }
159
+ for (const seg of segments) {
160
+ if (seg.source.kind !== 'editable')
161
+ continue;
162
+ for (const stop of seg.source.stops) {
163
+ if (stop.anchor.session === anchor.session &&
164
+ stop.anchor.flow === anchor.flow &&
165
+ stop.anchor.offset === anchor.offset &&
166
+ stop.anchor.affinity === 'downstream' &&
167
+ !(anchor.affinity === 'upstream' && line.index > 0 && stop.displayOffset === 0)) {
168
+ return caretXForOffset(line, stop.displayOffset);
169
+ }
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+ const textEncoder = new TextEncoder();
175
+ function toSourceBytes(source) {
176
+ return typeof source === 'string' ? textEncoder.encode(source) : copyBytes(source);
177
+ }
178
+ async function readAnswer(read, name) {
179
+ if (!read)
180
+ return null;
181
+ try {
182
+ const bytes = await read(name);
183
+ return bytes ? copyBytes(bytes) : null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ export async function openEditorWith(worker, options) {
190
+ const post = (message, transfer = []) => worker.postMessage(message, transfer);
191
+ const jobs = new Map();
192
+ const listeners = new Set();
193
+ const editorId = 1;
194
+ let nextId = 1;
195
+ let disposed = false;
196
+ let broken;
197
+ let chain = Promise.resolve();
198
+ let pendingMutations = 0;
199
+ let lastReadyFrame = null;
200
+ let currentState = {
201
+ revision: '0',
202
+ dirty: false,
203
+ selection: null,
204
+ canUndo: false,
205
+ canRedo: false,
206
+ layout: { kind: 'pending', previous: null },
207
+ };
208
+ let renderScheduled = false;
209
+ let renderInFlight = false;
210
+ let renderRequestedAgain = false;
211
+ let renderTimer;
212
+ const notify = () => {
213
+ if (disposed)
214
+ return;
215
+ const snap = currentState;
216
+ for (const listener of listeners)
217
+ listener(snap);
218
+ };
219
+ const send = (message, transfer = []) => {
220
+ if (disposed)
221
+ return Promise.reject(new Error('The editor session is disposed'));
222
+ if (broken)
223
+ return Promise.reject(broken);
224
+ if (!('id' in message)) {
225
+ post(message, transfer);
226
+ return Promise.reject(new Error('Invalid request id'));
227
+ }
228
+ const id = message.id;
229
+ return new Promise((resolve, reject) => {
230
+ jobs.set(id, { resolve, reject });
231
+ post(message, transfer);
232
+ });
233
+ };
234
+ const enqueue = (task, mutates = false) => {
235
+ if (mutates)
236
+ pendingMutations += 1;
237
+ const run = chain
238
+ .then(() => {
239
+ if (disposed)
240
+ throw new Error('The editor session is disposed');
241
+ if (broken)
242
+ throw broken;
243
+ return task();
244
+ })
245
+ .finally(() => {
246
+ if (mutates) {
247
+ pendingMutations -= 1;
248
+ if (pendingMutations === 0 && (renderRequestedAgain || (currentState.layout.kind === 'pending' && !renderScheduled && !renderInFlight))) {
249
+ scheduleRender();
250
+ }
251
+ }
252
+ });
253
+ chain = run.catch(() => { });
254
+ return run;
255
+ };
256
+ const expectJson = async (message, transfer = []) => {
257
+ const reply = await send(message, transfer);
258
+ if (reply.type !== 'editorJson') {
259
+ throw new PreviewError({ code: 'internal-error', message: 'Unexpected worker response', diagnostics: [] });
260
+ }
261
+ return JSON.parse(reply.json);
262
+ };
263
+ const requestRender = async () => {
264
+ const reply = await send({ type: 'editorRender', id: nextId++, editorId });
265
+ if (reply.type !== 'editorRendered') {
266
+ throw new PreviewError({ code: 'internal-error', message: 'Unexpected worker response', diagnostics: [] });
267
+ }
268
+ const document = decodeCompact(reply.json, reply.data, reply.images);
269
+ const meta = JSON.parse(reply.metaJson);
270
+ return {
271
+ frame: { revision: meta.revision, document, stats: meta.stats },
272
+ meta,
273
+ };
274
+ };
275
+ const runRenderLoop = async () => {
276
+ if (disposed || broken || renderInFlight)
277
+ return;
278
+ renderInFlight = true;
279
+ try {
280
+ while (!disposed && !broken) {
281
+ if (pendingMutations > 0) {
282
+ renderRequestedAgain = true;
283
+ break;
284
+ }
285
+ renderRequestedAgain = false;
286
+ const targetRevision = currentState.revision;
287
+ try {
288
+ const rendered = await requestRender();
289
+ if (disposed)
290
+ return;
291
+ lastReadyFrame = rendered.frame;
292
+ if (rendered.meta.revision === currentState.revision && !renderRequestedAgain && pendingMutations === 0) {
293
+ currentState = {
294
+ ...currentState,
295
+ dirty: rendered.meta.dirty,
296
+ canUndo: rendered.meta.canUndo,
297
+ canRedo: rendered.meta.canRedo,
298
+ layout: { kind: 'ready', frame: rendered.frame },
299
+ };
300
+ notify();
301
+ break;
302
+ }
303
+ renderRequestedAgain = true;
304
+ }
305
+ catch (error) {
306
+ if (disposed)
307
+ return;
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ const diagnostics = error instanceof PreviewError ? error.diagnostics : [];
310
+ currentState = {
311
+ ...currentState,
312
+ layout: {
313
+ kind: 'failed',
314
+ revision: targetRevision,
315
+ message,
316
+ diagnostics,
317
+ previous: lastReadyFrame,
318
+ },
319
+ };
320
+ notify();
321
+ if (!renderRequestedAgain)
322
+ break;
323
+ }
324
+ }
325
+ }
326
+ finally {
327
+ renderInFlight = false;
328
+ if (renderRequestedAgain && pendingMutations === 0) {
329
+ scheduleRender();
330
+ }
331
+ }
332
+ };
333
+ const scheduleRender = () => {
334
+ if (disposed || broken)
335
+ return;
336
+ if (renderInFlight) {
337
+ renderRequestedAgain = true;
338
+ return;
339
+ }
340
+ if (!renderScheduled) {
341
+ renderScheduled = true;
342
+ renderTimer = setTimeout(() => {
343
+ renderTimer = undefined;
344
+ renderScheduled = false;
345
+ if (disposed || broken)
346
+ return;
347
+ if (pendingMutations === 0) {
348
+ void runRenderLoop();
349
+ }
350
+ else {
351
+ renderRequestedAgain = true;
352
+ }
353
+ }, 0);
354
+ }
355
+ };
356
+ const dispose = () => {
357
+ if (disposed)
358
+ return;
359
+ disposed = true;
360
+ if (renderTimer !== undefined) {
361
+ clearTimeout(renderTimer);
362
+ renderTimer = undefined;
363
+ }
364
+ try {
365
+ post({ type: 'editorDispose', editorId });
366
+ }
367
+ catch {
368
+ }
369
+ worker.terminate?.();
370
+ for (const job of jobs.values())
371
+ job.reject(new Error('The editor session is disposed'));
372
+ jobs.clear();
373
+ listeners.clear();
374
+ };
375
+ worker.addEventListener('message', async (event) => {
376
+ const message = event.data;
377
+ const job = jobs.get(message.id);
378
+ if (!job)
379
+ return;
380
+ if (message.type === 'needs') {
381
+ const answers = await Promise.all(message.needs.map(async (need) => ({
382
+ need,
383
+ bytes: await readAnswer(options.readResource, need.name),
384
+ })));
385
+ if (disposed)
386
+ return;
387
+ post({ type: 'resources', id: message.id, answers }, answers.flatMap((a) => (a.bytes ? [a.bytes.buffer] : [])));
388
+ return;
389
+ }
390
+ jobs.delete(message.id);
391
+ if (message.type === 'failed') {
392
+ job.reject(new PreviewError(message.error));
393
+ return;
394
+ }
395
+ job.resolve(message);
396
+ });
397
+ const onWorkerFail = (event) => {
398
+ if (disposed || broken)
399
+ return;
400
+ const detail = event.message;
401
+ broken = new PreviewError({
402
+ code: 'worker-unavailable',
403
+ message: `The preview worker failed (${event.type}${detail ? `: ${detail}` : ''})`,
404
+ diagnostics: [],
405
+ });
406
+ worker.terminate?.();
407
+ for (const job of jobs.values())
408
+ job.reject(broken);
409
+ jobs.clear();
410
+ };
411
+ worker.addEventListener('error', onWorkerFail);
412
+ worker.addEventListener('messageerror', onWorkerFail);
413
+ worker.start?.();
414
+ post({ type: 'init', wasmUrl: options.runtime?.wasmUrl?.toString() });
415
+ try {
416
+ const bytes = toSourceBytes(options.source);
417
+ const openMeta = await expectJson({
418
+ type: 'editorOpen',
419
+ id: nextId++,
420
+ editorId,
421
+ name: options.name ?? 'document.idoc',
422
+ bytes,
423
+ }, [bytes.buffer]);
424
+ currentState = {
425
+ revision: openMeta.revision,
426
+ dirty: openMeta.dirty,
427
+ selection: null,
428
+ canUndo: openMeta.canUndo,
429
+ canRedo: openMeta.canRedo,
430
+ layout: { kind: 'pending', previous: null },
431
+ };
432
+ const rendered = await requestRender();
433
+ lastReadyFrame = rendered.frame;
434
+ currentState = {
435
+ revision: rendered.meta.revision,
436
+ dirty: rendered.meta.dirty,
437
+ selection: null,
438
+ canUndo: rendered.meta.canUndo,
439
+ canRedo: rendered.meta.canRedo,
440
+ layout: { kind: 'ready', frame: rendered.frame },
441
+ };
442
+ }
443
+ catch (error) {
444
+ dispose();
445
+ throw error;
446
+ }
447
+ const applyCommit = (res) => {
448
+ currentState = {
449
+ revision: res.commit.revision,
450
+ dirty: res.dirty,
451
+ selection: res.commit.selection,
452
+ canUndo: res.canUndo,
453
+ canRedo: res.canRedo,
454
+ layout: { kind: 'pending', previous: lastReadyFrame },
455
+ };
456
+ notify();
457
+ scheduleRender();
458
+ return res.commit;
459
+ };
460
+ return {
461
+ state: () => currentState,
462
+ subscribe(listener) {
463
+ if (disposed)
464
+ return () => { };
465
+ listeners.add(listener);
466
+ return () => {
467
+ listeners.delete(listener);
468
+ };
469
+ },
470
+ range({ revision, start, end }) {
471
+ return enqueue(() => expectJson({
472
+ type: 'editorRange',
473
+ id: nextId++,
474
+ editorId,
475
+ revision,
476
+ startJson: JSON.stringify(start),
477
+ endJson: JSON.stringify(end),
478
+ }));
479
+ },
480
+ apply({ base, edit }) {
481
+ return enqueue(async () => applyCommit(await expectJson({
482
+ type: 'editorApply',
483
+ id: nextId++,
484
+ editorId,
485
+ base,
486
+ editJson: JSON.stringify(edit),
487
+ })), true);
488
+ },
489
+ undo({ base }) {
490
+ return enqueue(async () => applyCommit(await expectJson({ type: 'editorUndo', id: nextId++, editorId, base })), true);
491
+ },
492
+ redo({ base }) {
493
+ return enqueue(async () => applyCommit(await expectJson({ type: 'editorRedo', id: nextId++, editorId, base })), true);
494
+ },
495
+ snapshot() {
496
+ return enqueue(() => expectJson({ type: 'editorSnapshot', id: nextId++, editorId }));
497
+ },
498
+ markSaved({ revision }) {
499
+ return enqueue(async () => {
500
+ const res = await expectJson({
501
+ type: 'editorMarkSaved',
502
+ id: nextId++,
503
+ editorId,
504
+ revision,
505
+ });
506
+ currentState = { ...currentState, dirty: res.dirty };
507
+ notify();
508
+ });
509
+ },
510
+ refresh({ base, source }) {
511
+ return enqueue(async () => {
512
+ const bytes = toSourceBytes(source);
513
+ return applyCommit(await expectJson({ type: 'editorRefresh', id: nextId++, editorId, base, bytes }, [bytes.buffer]));
514
+ }, true);
515
+ },
516
+ exportDocx() {
517
+ return enqueue(async () => {
518
+ const reply = await send({ type: 'editorExportDocx', id: nextId++, editorId });
519
+ if (reply.type !== 'exported') {
520
+ throw new PreviewError({ code: 'internal-error', message: 'Unexpected worker response', diagnostics: [] });
521
+ }
522
+ return reply.bytes;
523
+ });
524
+ },
525
+ dispose,
526
+ };
527
+ }