@codeauradev/ngx-wind-editor 1.0.0 → 1.0.2

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/src/session.ts DELETED
@@ -1,843 +0,0 @@
1
- import * as Y from 'yjs';
2
- import { attrsAt, commonEdges, deltaLength, deltaToText, graphemeAfter, graphemeBefore, mergeDelta, packAttrs, sliceDelta } from './delta';
3
- import { countWords, parseHtml, plainText, serializeBlocks } from './html';
4
- import type { BlockData, BlockType, Delta, MarkName, Point, Selection, TextAttrs } from './types';
5
- import { isBlockType } from './types';
6
-
7
- const BLOCKS = 'blocks';
8
-
9
- export interface LocatedBlock {
10
- map: Y.Map<unknown>;
11
- index: number;
12
- id: string;
13
- type: BlockType;
14
- level: number;
15
- text: Y.Text | null;
16
- }
17
-
18
- export type SlashChoice =
19
- | 'paragraph'
20
- | 'h1'
21
- | 'h2'
22
- | 'h3'
23
- | 'bullet'
24
- | 'ordered'
25
- | 'quote'
26
- | 'code'
27
- | 'rule'
28
- | 'image';
29
-
30
- let idSeq = 0;
31
-
32
- function nextId(): string {
33
- idSeq += 1;
34
- const rand = Math.random().toString(36).slice(2, 8);
35
- return `b${idSeq.toString(36)}${rand}`;
36
- }
37
-
38
- export function blocksOf(doc: Y.Doc): Y.Array<Y.Map<unknown>> {
39
- return doc.getArray<Y.Map<unknown>>(BLOCKS);
40
- }
41
-
42
- export function makeBlock(type: BlockType, fields?: { level?: number; src?: string; alt?: string; delta?: Delta }): Y.Map<unknown> {
43
- const map = new Y.Map<unknown>();
44
- map.set('id', nextId());
45
- map.set('type', type);
46
- if (type === 'heading') map.set('level', fields?.level ?? 1);
47
- if (type === 'image') {
48
- map.set('src', fields?.src ?? '');
49
- map.set('alt', fields?.alt ?? '');
50
- }
51
- if (type !== 'rule' && type !== 'image') {
52
- const text = new Y.Text();
53
- if (fields?.delta?.length) writeDelta(text, fields.delta, 0);
54
- map.set('text', text);
55
- }
56
- return map;
57
- }
58
-
59
- function writeDelta(text: Y.Text, delta: Delta, at: number): void {
60
- let index = at;
61
- for (const op of delta) {
62
- if (!op.insert) continue;
63
- text.insert(index, op.insert, packAttrs(op.attributes));
64
- index += op.insert.length;
65
- }
66
- }
67
-
68
- export function readDelta(text: Y.Text): Delta {
69
- const ops: Delta = [];
70
- for (const op of text.toDelta()) {
71
- if (typeof op.insert !== 'string' || op.insert.length === 0) continue;
72
- ops.push({ insert: op.insert, attributes: packAttrs(op.attributes as TextAttrs | undefined) });
73
- }
74
- return mergeDelta(ops);
75
- }
76
-
77
- function readMap(map: Y.Map<unknown>, index: number): LocatedBlock {
78
- const rawType = map.get('type');
79
- const type: BlockType = isBlockType(rawType) ? rawType : 'paragraph';
80
- const text = map.get('text');
81
- return {
82
- map,
83
- index,
84
- id: String(map.get('id') ?? ''),
85
- type,
86
- level: Number(map.get('level') ?? 1) || 1,
87
- text: text instanceof Y.Text ? text : null,
88
- };
89
- }
90
-
91
- export function readBlocks(doc: Y.Doc): BlockData[] {
92
- const blocks = blocksOf(doc);
93
- const out: BlockData[] = [];
94
- for (let i = 0; i < blocks.length; i += 1) {
95
- const map = blocks.get(i);
96
- const loc = readMap(map, i);
97
- out.push({
98
- id: loc.id,
99
- type: loc.type,
100
- level: loc.type === 'heading' ? loc.level : 0,
101
- src: String(map.get('src') ?? ''),
102
- alt: String(map.get('alt') ?? ''),
103
- delta: loc.text ? readDelta(loc.text) : [],
104
- });
105
- }
106
- return out;
107
- }
108
-
109
- function locate(doc: Y.Doc, id: string): LocatedBlock | null {
110
- const blocks = blocksOf(doc);
111
- for (let i = 0; i < blocks.length; i += 1) {
112
- const loc = readMap(blocks.get(i), i);
113
- if (loc.id === id) return loc;
114
- }
115
- return null;
116
- }
117
-
118
- function locateAt(doc: Y.Doc, index: number): LocatedBlock | null {
119
- const blocks = blocksOf(doc);
120
- if (index < 0 || index >= blocks.length) return null;
121
- return readMap(blocks.get(index), index);
122
- }
123
-
124
- function cloneSelection(selection: Selection): Selection {
125
- return {
126
- anchor: { ...selection.anchor },
127
- head: { ...selection.head },
128
- };
129
- }
130
-
131
- function collapsed(blockId: string, offset: number): Selection {
132
- return { anchor: { blockId, offset }, head: { blockId, offset } };
133
- }
134
-
135
- interface Bounds {
136
- start: Point;
137
- end: Point;
138
- startIndex: number;
139
- endIndex: number;
140
- }
141
-
142
- function boundsOf(doc: Y.Doc, selection: Selection): Bounds | null {
143
- const anchor = locate(doc, selection.anchor.blockId);
144
- const head = locate(doc, selection.head.blockId);
145
- if (!anchor || !head) return null;
146
- const anchorFirst = anchor.index < head.index || (anchor.index === head.index && selection.anchor.offset <= selection.head.offset);
147
- const start = anchorFirst ? selection.anchor : selection.head;
148
- const end = anchorFirst ? selection.head : selection.anchor;
149
- return {
150
- start,
151
- end,
152
- startIndex: anchorFirst ? anchor.index : head.index,
153
- endIndex: anchorFirst ? head.index : anchor.index,
154
- };
155
- }
156
-
157
- function hasRange(doc: Y.Doc, selection: Selection): boolean {
158
- const bounds = boundsOf(doc, selection);
159
- if (!bounds) return false;
160
- return bounds.start.blockId !== bounds.end.blockId || bounds.start.offset !== bounds.end.offset;
161
- }
162
-
163
- export interface SessionSnapshot {
164
- blockType: BlockType;
165
- level: number;
166
- marks: TextAttrs;
167
- href: string | null;
168
- slash: string | null;
169
- canUndo: boolean;
170
- canRedo: boolean;
171
- }
172
-
173
- export class WindSession {
174
- readonly doc: Y.Doc;
175
- readonly origin: object = {};
176
- selection: Selection;
177
- private stored: TextAttrs | null = null;
178
- private pendingBefore: Selection | null = null;
179
- private readonly undoManager: Y.UndoManager;
180
- private readonly listeners = new Set<(structural: boolean) => void>();
181
-
182
- constructor(doc: Y.Doc) {
183
- this.doc = doc;
184
- const blocks = blocksOf(doc);
185
- if (blocks.length === 0) {
186
- doc.transact(() => {
187
- if (blocksOf(doc).length === 0) blocksOf(doc).insert(0, [makeBlock('paragraph')]);
188
- }, 'seed');
189
- }
190
- const first = locateAt(doc, 0);
191
- this.selection = collapsed(first?.id ?? '', 0);
192
- this.undoManager = new Y.UndoManager(blocksOf(doc), {
193
- trackedOrigins: new Set([this.origin]),
194
- captureTimeout: 500,
195
- });
196
- this.undoManager.on('stack-item-added', (event) => {
197
- const item = event.stackItem;
198
- item.meta.set('before', this.pendingBefore);
199
- item.meta.set('after', cloneSelection(this.selection));
200
- });
201
- this.undoManager.on('stack-item-popped', (event) => {
202
- const item = event.stackItem;
203
- const stored = event.type === 'undo' ? item.meta.get('before') : item.meta.get('after');
204
- if (isSelection(stored)) this.selection = cloneSelection(stored);
205
- this.stored = null;
206
- this.clampSelection();
207
- this.emit(true);
208
- });
209
- blocksOf(doc).observeDeep((_events, transaction) => {
210
- if (transaction.origin === this.origin) return;
211
- this.clampSelection();
212
- this.stored = null;
213
- this.emit(true);
214
- });
215
- }
216
-
217
- onChange(listener: (structural: boolean) => void): () => void {
218
- this.listeners.add(listener);
219
- return () => this.listeners.delete(listener);
220
- }
221
-
222
- get html(): string {
223
- return serializeBlocks(readBlocks(this.doc));
224
- }
225
-
226
- get text(): string {
227
- return plainText(readBlocks(this.doc));
228
- }
229
-
230
- get words(): number {
231
- return countWords(this.text);
232
- }
233
-
234
- get canUndo(): boolean {
235
- return this.undoManager.undoStack.length > 0;
236
- }
237
-
238
- get canRedo(): boolean {
239
- return this.undoManager.redoStack.length > 0;
240
- }
241
-
242
- undo(): void {
243
- this.undoManager.undo();
244
- }
245
-
246
- redo(): void {
247
- this.undoManager.redo();
248
- }
249
-
250
- select(selection: Selection): void {
251
- this.selection = cloneSelection(selection);
252
- this.stored = null;
253
- this.clampSelection();
254
- this.emit(false);
255
- }
256
-
257
- focusPoint(blockId: string, offset: number): void {
258
- this.select(collapsed(blockId, offset));
259
- }
260
-
261
- insertText(value: string): void {
262
- if (!value) return;
263
- this.transact(() => {
264
- if (hasRange(this.doc, this.selection)) this.deleteRange();
265
- const loc = this.current();
266
- if (!loc) return;
267
- if (!loc.text) {
268
- const created = makeBlock('paragraph');
269
- const text = created.get('text') as Y.Text;
270
- text.insert(0, value, packAttrs(this.typingAttrs(null, 0)));
271
- blocksOf(this.doc).insert(loc.index + 1, [created]);
272
- this.selection = collapsed(String(created.get('id')), value.length);
273
- return;
274
- }
275
- const attrs = this.typingAttrs(loc.text, this.selection.head.offset);
276
- loc.text.insert(this.selection.head.offset, value, packAttrs(attrs));
277
- this.selection = collapsed(loc.id, this.selection.head.offset + value.length);
278
- });
279
- }
280
-
281
- deleteBackward(): void {
282
- this.transact(() => {
283
- if (hasRange(this.doc, this.selection)) {
284
- this.deleteRange();
285
- return;
286
- }
287
- const loc = this.current();
288
- if (!loc) return;
289
- if (!loc.text) {
290
- blocksOf(this.doc).delete(loc.index, 1);
291
- this.ensureBlock();
292
- const next = locateAt(this.doc, Math.min(loc.index, blocksOf(this.doc).length - 1));
293
- if (next) this.selection = collapsed(next.id, 0);
294
- return;
295
- }
296
- const offset = this.selection.head.offset;
297
- if (offset > 0) {
298
- const text = loc.text.toString();
299
- const from = graphemeBefore(text, offset);
300
- loc.text.delete(from, offset - from);
301
- this.selection = collapsed(loc.id, from);
302
- return;
303
- }
304
- if ((loc.type === 'bullet' || loc.type === 'ordered') && loc.text.length === 0) {
305
- loc.map.set('type', 'paragraph');
306
- return;
307
- }
308
- if (loc.type !== 'paragraph' && loc.type !== 'bullet' && loc.type !== 'ordered' && loc.index === 0) {
309
- loc.map.set('type', 'paragraph');
310
- loc.map.delete('level');
311
- return;
312
- }
313
- const prev = locateAt(this.doc, loc.index - 1);
314
- if (!prev) return;
315
- if (!prev.text) {
316
- blocksOf(this.doc).delete(prev.index, 1);
317
- this.selection = collapsed(loc.id, 0);
318
- return;
319
- }
320
- if (!loc.text.length && (loc.type === 'bullet' || loc.type === 'ordered' || loc.type === 'heading' || loc.type === 'code' || loc.type === 'blockquote')) {
321
- loc.map.set('type', 'paragraph');
322
- return;
323
- }
324
- const join = prev.text.length;
325
- writeDelta(prev.text, readDelta(loc.text), join);
326
- blocksOf(this.doc).delete(loc.index, 1);
327
- this.selection = collapsed(prev.id, join);
328
- });
329
- }
330
-
331
- deleteForward(): void {
332
- this.transact(() => {
333
- if (hasRange(this.doc, this.selection)) {
334
- this.deleteRange();
335
- return;
336
- }
337
- const loc = this.current();
338
- if (!loc?.text) {
339
- if (loc) this.deleteBackward();
340
- return;
341
- }
342
- const offset = this.selection.head.offset;
343
- const text = loc.text.toString();
344
- if (offset < text.length) {
345
- const to = graphemeAfter(text, offset);
346
- loc.text.delete(offset, to - offset);
347
- this.selection = collapsed(loc.id, offset);
348
- return;
349
- }
350
- const next = locateAt(this.doc, loc.index + 1);
351
- if (!next) return;
352
- if (!next.text) {
353
- blocksOf(this.doc).delete(next.index, 1);
354
- return;
355
- }
356
- writeDelta(loc.text, readDelta(next.text), loc.text.length);
357
- blocksOf(this.doc).delete(next.index, 1);
358
- this.selection = collapsed(loc.id, offset);
359
- });
360
- }
361
-
362
- split(mode: 'enter' | 'hard' | 'exit'): void {
363
- this.transact(() => {
364
- if (hasRange(this.doc, this.selection)) this.deleteRange();
365
- const loc = this.current();
366
- if (!loc) return;
367
- if (!loc.text) {
368
- const created = makeBlock('paragraph');
369
- blocksOf(this.doc).insert(loc.index + 1, [created]);
370
- this.selection = collapsed(String(created.get('id')), 0);
371
- return;
372
- }
373
- if (mode === 'hard' || (mode === 'enter' && loc.type === 'code')) {
374
- const attrs = this.typingAttrs(loc.text, this.selection.head.offset);
375
- loc.text.insert(this.selection.head.offset, '\n', packAttrs(attrs));
376
- this.selection = collapsed(loc.id, this.selection.head.offset + 1);
377
- return;
378
- }
379
- if (mode === 'exit' || (loc.type === 'code' && mode === 'enter')) {
380
- const created = makeBlock('paragraph');
381
- blocksOf(this.doc).insert(loc.index + 1, [created]);
382
- this.selection = collapsed(String(created.get('id')), 0);
383
- return;
384
- }
385
- if ((loc.type === 'bullet' || loc.type === 'ordered') && loc.text.length === 0) {
386
- loc.map.set('type', 'paragraph');
387
- this.selection = collapsed(loc.id, 0);
388
- return;
389
- }
390
- const offset = this.selection.head.offset;
391
- const delta = readDelta(loc.text);
392
- const right = sliceDelta(delta, offset, deltaLength(delta));
393
- if (offset < loc.text.length) loc.text.delete(offset, loc.text.length - offset);
394
- const nextType: BlockType = loc.type === 'heading' ? 'paragraph' : loc.type;
395
- const created = makeBlock(nextType, { level: loc.level, delta: right });
396
- blocksOf(this.doc).insert(loc.index + 1, [created]);
397
- this.selection = collapsed(String(created.get('id')), 0);
398
- this.stored = null;
399
- });
400
- }
401
-
402
- toggleMark(mark: MarkName): void {
403
- this.transact(() => {
404
- if (!hasRange(this.doc, this.selection)) {
405
- const current = this.effectiveMarks();
406
- this.stored = { ...current, [mark]: !current[mark] };
407
- if (!this.stored[mark]) delete this.stored[mark];
408
- return;
409
- }
410
- const enable = !this.rangeHas(mark);
411
- this.formatRange({ [mark]: enable ? true : null });
412
- this.stored = null;
413
- });
414
- }
415
-
416
- setLink(href: string | null): void {
417
- const safe = href ? href : null;
418
- this.transact(() => {
419
- if (!hasRange(this.doc, this.selection)) {
420
- const current = this.effectiveMarks();
421
- this.stored = { ...current };
422
- if (safe) this.stored.href = safe;
423
- else delete this.stored.href;
424
- return;
425
- }
426
- this.formatRange({ href: safe });
427
- this.stored = null;
428
- });
429
- }
430
-
431
- clearMarks(): void {
432
- this.transact(() => {
433
- if (!hasRange(this.doc, this.selection)) {
434
- this.stored = {};
435
- return;
436
- }
437
- this.formatRange({ bold: null, italic: null, underline: null, strike: null, code: null, href: null });
438
- this.stored = null;
439
- });
440
- }
441
-
442
- setBlockType(type: BlockType, level = 1): void {
443
- this.transact(() => {
444
- for (const loc of this.selectedBlocks()) {
445
- if (!loc.text && type !== 'rule' && type !== 'image') {
446
- const text = new Y.Text();
447
- loc.map.set('text', text);
448
- }
449
- loc.map.set('type', type);
450
- if (type === 'heading') loc.map.set('level', level);
451
- else loc.map.delete('level');
452
- if (type === 'rule' || type === 'image') loc.map.delete('text');
453
- }
454
- });
455
- }
456
-
457
- insertDivider(): void {
458
- this.transact(() => {
459
- if (hasRange(this.doc, this.selection)) this.deleteRange();
460
- const loc = this.current();
461
- if (!loc) return;
462
- const empty = !!loc.text && loc.text.length === 0;
463
- const rule = makeBlock('rule');
464
- const para = makeBlock('paragraph');
465
- if (empty) {
466
- blocksOf(this.doc).delete(loc.index, 1);
467
- blocksOf(this.doc).insert(loc.index, [rule, para]);
468
- this.selection = collapsed(String(para.get('id')), 0);
469
- return;
470
- }
471
- blocksOf(this.doc).insert(loc.index + 1, [rule, para]);
472
- this.selection = collapsed(String(para.get('id')), 0);
473
- });
474
- }
475
-
476
- insertImage(src: string, alt = ''): void {
477
- this.transact(() => {
478
- this.consumeSlash();
479
- const loc = this.current();
480
- if (!loc) return;
481
- const image = makeBlock('image', { src, alt });
482
- const para = makeBlock('paragraph');
483
- const empty = !!loc.text && loc.text.length === 0;
484
- if (empty) {
485
- blocksOf(this.doc).delete(loc.index, 1);
486
- blocksOf(this.doc).insert(loc.index, [image, para]);
487
- } else {
488
- blocksOf(this.doc).insert(loc.index + 1, [image, para]);
489
- }
490
- this.selection = collapsed(String(para.get('id')), 0);
491
- });
492
- }
493
-
494
- applySlash(choice: SlashChoice): 'image' | 'applied' {
495
- if (choice === 'image') {
496
- this.transact(() => this.consumeSlash());
497
- return 'image';
498
- }
499
- this.transact(() => {
500
- this.consumeSlash();
501
- if (choice === 'rule') {
502
- const loc = this.current();
503
- if (loc?.text && loc.text.length === 0) {
504
- const rule = makeBlock('rule');
505
- const para = makeBlock('paragraph');
506
- blocksOf(this.doc).delete(loc.index, 1);
507
- blocksOf(this.doc).insert(loc.index, [rule, para]);
508
- this.selection = collapsed(String(para.get('id')), 0);
509
- }
510
- return;
511
- }
512
- const type: BlockType =
513
- choice === 'h1' || choice === 'h2' || choice === 'h3'
514
- ? 'heading'
515
- : choice === 'quote'
516
- ? 'blockquote'
517
- : choice === 'paragraph'
518
- ? 'paragraph'
519
- : choice;
520
- const level = choice === 'h1' ? 1 : choice === 'h2' ? 2 : choice === 'h3' ? 3 : 1;
521
- const loc = this.current();
522
- if (!loc) return;
523
- loc.map.set('type', type);
524
- if (type === 'heading') loc.map.set('level', level);
525
- else loc.map.delete('level');
526
- });
527
- return 'applied';
528
- }
529
-
530
- paste(html: string | null, plain: string | null): void {
531
- const parsed = html?.trim() ? parseHtml(html) : [];
532
- if (parsed.length === 1 && parsed[0] && parsed[0].type === 'paragraph') {
533
- this.transact(() => {
534
- if (hasRange(this.doc, this.selection)) this.deleteRange();
535
- const loc = this.ensureText();
536
- if (!loc?.text) return;
537
- writeDelta(loc.text, parsed[0].delta, this.selection.head.offset);
538
- this.selection = collapsed(loc.id, this.selection.head.offset + deltaLength(parsed[0].delta));
539
- });
540
- return;
541
- }
542
- if (parsed.length > 0) {
543
- this.insertParsed(parsed);
544
- return;
545
- }
546
- if (!plain) return;
547
- const lines = plain.replace(/\r\n/g, '\n').split('\n');
548
- this.transact(() => {
549
- if (hasRange(this.doc, this.selection)) this.deleteRange();
550
- const loc = this.ensureText();
551
- if (!loc?.text) return;
552
- loc.text.insert(this.selection.head.offset, lines[0] ?? '', packAttrs(this.typingAttrs(loc.text, this.selection.head.offset)));
553
- let offset = this.selection.head.offset + (lines[0]?.length ?? 0);
554
- this.selection = collapsed(loc.id, offset);
555
- if (lines.length === 1) return;
556
- const rest = lines.slice(1).map((line) => makeBlock('paragraph', { delta: line ? [{ insert: line }] : [] }));
557
- const right = sliceDelta(readDelta(loc.text), offset, loc.text.length);
558
- if (offset < loc.text.length) loc.text.delete(offset, loc.text.length - offset);
559
- const tail = makeBlock(loc.type === 'heading' ? 'paragraph' : loc.type, { level: loc.level, delta: right });
560
- blocksOf(this.doc).insert(loc.index + 1, [...rest, tail]);
561
- const last = rest[rest.length - 1];
562
- this.selection = collapsed(String(last?.get('id') ?? loc.id), deltaLength(last ? readDelta(last.get('text') as Y.Text) : []));
563
- });
564
- }
565
-
566
- replaceHtml(html: string): void {
567
- const parsed = parseHtml(html);
568
- const blocks = parsed.length > 0 ? parsed : [{ id: '', type: 'paragraph' as const, level: 0, src: '', alt: '', delta: [] }];
569
- this.doc.transact(() => {
570
- const list = blocksOf(this.doc);
571
- if (list.length > 0) list.delete(0, list.length);
572
- list.insert(0, blocks.map((block) => makeBlock(block.type, block)));
573
- }, this.origin);
574
- this.undoManager.clear();
575
- const first = locateAt(this.doc, 0);
576
- this.selection = collapsed(first?.id ?? '', 0);
577
- this.stored = null;
578
- this.emit(true);
579
- }
580
-
581
- reconcileText(blockId: string, next: string): void {
582
- const loc = locate(this.doc, blockId);
583
- if (!loc?.text) return;
584
- const prev = loc.text.toString();
585
- if (prev === next) return;
586
- const edges = commonEdges(prev, next);
587
- const attrs = attrsAt(readDelta(loc.text), edges.start);
588
- this.transact(() => {
589
- const again = locate(this.doc, blockId);
590
- if (!again?.text) return;
591
- if (edges.prevEnd > edges.start) again.text.delete(edges.start, edges.prevEnd - edges.start);
592
- const insert = next.slice(edges.start, edges.nextEnd);
593
- if (insert) again.text.insert(edges.start, insert, packAttrs(this.stored ?? attrs));
594
- this.selection = collapsed(blockId, edges.start + insert.length);
595
- });
596
- }
597
-
598
- snapshot(): SessionSnapshot {
599
- const loc = this.current();
600
- const marks = this.toolbarMarks();
601
- return {
602
- blockType: loc?.type ?? 'paragraph',
603
- level: loc?.type === 'heading' ? loc.level : 0,
604
- marks,
605
- href: marks.href ?? null,
606
- slash: this.slashQuery(),
607
- canUndo: this.canUndo,
608
- canRedo: this.canRedo,
609
- };
610
- }
611
-
612
- dismissSlash(): void {
613
- this.transact(() => this.consumeSlash());
614
- }
615
-
616
- private slashQuery(): string | null {
617
- if (hasRange(this.doc, this.selection)) return null;
618
- const loc = this.current();
619
- if (!loc?.text) return null;
620
- const text = loc.text.toString();
621
- if (!/^\/[a-zA-Z0-9]*$/.test(text)) return null;
622
- if (this.selection.head.offset !== text.length) return null;
623
- return text.slice(1);
624
- }
625
-
626
- private consumeSlash(): void {
627
- const loc = this.current();
628
- if (!loc?.text) return;
629
- const text = loc.text.toString();
630
- if (/^\/[a-zA-Z0-9]*$/.test(text)) {
631
- loc.text.delete(0, text.length);
632
- this.selection = collapsed(loc.id, 0);
633
- }
634
- }
635
-
636
- private typingAttrs(text: Y.Text | null, offset: number): TextAttrs {
637
- if (this.stored) return { ...this.stored };
638
- if (!text) return {};
639
- return attrsAt(readDelta(text), offset);
640
- }
641
-
642
- private effectiveMarks(): TextAttrs {
643
- const loc = this.current();
644
- return this.typingAttrs(loc?.text ?? null, this.selection.head.offset);
645
- }
646
-
647
- private toolbarMarks(): TextAttrs {
648
- if (!hasRange(this.doc, this.selection)) return this.effectiveMarks();
649
- const marks: TextAttrs = { bold: true, italic: true, underline: true, strike: true, code: true };
650
- let href: string | null | undefined;
651
- let any = false;
652
- for (const slice of this.iterRange()) {
653
- any = true;
654
- for (const op of slice.delta) {
655
- const attrs = op.attributes ?? {};
656
- if (!attrs.bold) marks.bold = false;
657
- if (!attrs.italic) marks.italic = false;
658
- if (!attrs.underline) marks.underline = false;
659
- if (!attrs.strike) marks.strike = false;
660
- if (!attrs.code) marks.code = false;
661
- const link = attrs.href ?? null;
662
- if (href === undefined) href = link;
663
- else if (href !== link) href = null;
664
- }
665
- }
666
- if (!any) return {};
667
- if (href) marks.href = href;
668
- return marks;
669
- }
670
-
671
- private rangeHas(mark: MarkName): boolean {
672
- let any = false;
673
- for (const slice of this.iterRange()) {
674
- for (const op of slice.delta) {
675
- any = true;
676
- if (!op.attributes?.[mark]) return false;
677
- }
678
- }
679
- return any;
680
- }
681
-
682
- private formatRange(attrs: Record<string, unknown>): void {
683
- for (const slice of this.iterRange()) {
684
- if (slice.delta.length === 0) continue;
685
- const loc = locate(this.doc, slice.id);
686
- if (!loc?.text) continue;
687
- const length = deltaLength(slice.delta);
688
- if (length > 0) loc.text.format(slice.from, length, attrs);
689
- }
690
- }
691
-
692
- private iterRange(): Array<{ id: string; from: number; delta: Delta }> {
693
- const bounds = boundsOf(this.doc, this.selection);
694
- if (!bounds) return [];
695
- const out: Array<{ id: string; from: number; delta: Delta }> = [];
696
- for (let index = bounds.startIndex; index <= bounds.endIndex; index += 1) {
697
- const loc = locateAt(this.doc, index);
698
- if (!loc?.text) continue;
699
- const delta = readDelta(loc.text);
700
- const from = index === bounds.startIndex ? bounds.start.offset : 0;
701
- const to = index === bounds.endIndex ? bounds.end.offset : loc.text.length;
702
- out.push({ id: loc.id, from, delta: sliceDelta(delta, from, to) });
703
- }
704
- return out;
705
- }
706
-
707
- private deleteRange(): void {
708
- const bounds = boundsOf(this.doc, this.selection);
709
- if (!bounds) return;
710
- if (bounds.startIndex === bounds.endIndex) {
711
- const loc = locateAt(this.doc, bounds.startIndex);
712
- if (!loc) return;
713
- if (!loc.text) {
714
- blocksOf(this.doc).delete(loc.index, 1);
715
- this.ensureBlock();
716
- const next = locateAt(this.doc, Math.min(loc.index, blocksOf(this.doc).length - 1));
717
- if (next) this.selection = collapsed(next.id, 0);
718
- return;
719
- }
720
- loc.text.delete(bounds.start.offset, bounds.end.offset - bounds.start.offset);
721
- this.selection = collapsed(loc.id, bounds.start.offset);
722
- return;
723
- }
724
- const start = locateAt(this.doc, bounds.startIndex);
725
- const end = locateAt(this.doc, bounds.endIndex);
726
- if (!start || !end) return;
727
- const endSurvives = !!end.text && bounds.end.offset < end.text.length;
728
- if (end.text && bounds.end.offset > 0) end.text.delete(0, Math.min(bounds.end.offset, end.text.length));
729
- const startSurvives = !!start.text;
730
- if (start.text && bounds.start.offset < start.text.length) {
731
- start.text.delete(bounds.start.offset, start.text.length - bounds.start.offset);
732
- }
733
- const from = startSurvives ? start.index + 1 : start.index;
734
- const to = endSurvives ? end.index - 1 : end.index;
735
- if (to >= from) blocksOf(this.doc).delete(from, to - from + 1);
736
- const startNow = locate(this.doc, start.id);
737
- const endNow = locate(this.doc, end.id);
738
- if (startNow?.text && endNow?.text && startNow.id !== endNow.id) {
739
- const join = startNow.text.length;
740
- writeDelta(startNow.text, readDelta(endNow.text), join);
741
- blocksOf(this.doc).delete(endNow.index, 1);
742
- this.selection = collapsed(startNow.id, join);
743
- return;
744
- }
745
- this.ensureBlock();
746
- if (startNow) this.selection = collapsed(startNow.id, Math.min(bounds.start.offset, startNow.text?.length ?? 0));
747
- else {
748
- const fallback = locateAt(this.doc, Math.min(from, blocksOf(this.doc).length - 1));
749
- if (fallback) this.selection = collapsed(fallback.id, 0);
750
- }
751
- }
752
-
753
- private insertParsed(parsed: BlockData[]): void {
754
- this.transact(() => {
755
- if (hasRange(this.doc, this.selection)) this.deleteRange();
756
- const loc = this.current() ?? locateAt(this.doc, 0);
757
- if (!loc) return;
758
- const maps = parsed.map((block) => makeBlock(block.type, block));
759
- if (loc.text && loc.text.length === 0) {
760
- blocksOf(this.doc).delete(loc.index, 1);
761
- blocksOf(this.doc).insert(loc.index, maps);
762
- } else {
763
- blocksOf(this.doc).insert(loc.index + 1, maps);
764
- }
765
- const last = maps[maps.length - 1];
766
- const text = last?.get('text');
767
- const offset = text instanceof Y.Text ? text.length : 0;
768
- this.selection = collapsed(String(last?.get('id') ?? loc.id), offset);
769
- });
770
- }
771
-
772
- private selectedBlocks(): LocatedBlock[] {
773
- const bounds = boundsOf(this.doc, this.selection);
774
- if (!bounds) return [];
775
- const out: LocatedBlock[] = [];
776
- for (let index = bounds.startIndex; index <= bounds.endIndex; index += 1) {
777
- const loc = locateAt(this.doc, index);
778
- if (loc) out.push(loc);
779
- }
780
- return out;
781
- }
782
-
783
- private ensureText(): LocatedBlock | null {
784
- const loc = this.current();
785
- if (!loc) return null;
786
- if (loc.text) return loc;
787
- const created = makeBlock('paragraph');
788
- blocksOf(this.doc).insert(loc.index + 1, [created]);
789
- const next = locate(this.doc, String(created.get('id')));
790
- if (next) this.selection = collapsed(next.id, 0);
791
- return next;
792
- }
793
-
794
- private ensureBlock(): void {
795
- if (blocksOf(this.doc).length === 0) blocksOf(this.doc).insert(0, [makeBlock('paragraph')]);
796
- }
797
-
798
- private current(): LocatedBlock | null {
799
- return locate(this.doc, this.selection.head.blockId) ?? locateAt(this.doc, 0);
800
- }
801
-
802
- private clampSelection(): void {
803
- const blocks = blocksOf(this.doc);
804
- if (blocks.length === 0) return;
805
- const clampPoint = (point: Point): Point => {
806
- const loc = locate(this.doc, point.blockId);
807
- if (!loc) {
808
- const first = locateAt(this.doc, 0);
809
- return { blockId: first?.id ?? '', offset: 0 };
810
- }
811
- const max = loc.text?.length ?? 0;
812
- return { blockId: loc.id, offset: Math.max(0, Math.min(point.offset, max)) };
813
- };
814
- this.selection = { anchor: clampPoint(this.selection.anchor), head: clampPoint(this.selection.head) };
815
- }
816
-
817
- private transact(fn: () => void): void {
818
- this.pendingBefore = cloneSelection(this.selection);
819
- this.doc.transact(fn, this.origin);
820
- this.clampSelection();
821
- this.emit(true);
822
- }
823
-
824
- private emit(structural: boolean): void {
825
- for (const listener of this.listeners) listener(structural);
826
- }
827
- }
828
-
829
- function isSelection(value: unknown): value is Selection {
830
- if (!value || typeof value !== 'object') return false;
831
- const selection = value as Selection;
832
- return !!selection.anchor && !!selection.head && typeof selection.anchor.blockId === 'string' && typeof selection.head.offset === 'number';
833
- }
834
-
835
- export function loadHtml(doc: Y.Doc, html: string, origin: unknown = 'seed'): void {
836
- const parsed = parseHtml(html);
837
- const blocks = parsed.length > 0 ? parsed : [{ id: '', type: 'paragraph' as const, level: 0, src: '', alt: '', delta: [] }];
838
- doc.transact(() => {
839
- const list = blocksOf(doc);
840
- if (list.length > 0) list.delete(0, list.length);
841
- list.insert(0, blocks.map((block) => makeBlock(block.type, block)));
842
- }, origin);
843
- }