@fluidframework/react 2.91.0 → 2.92.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/alpha.d.ts +1 -1
  3. package/lib/beta.d.ts +1 -1
  4. package/lib/index.d.ts +1 -1
  5. package/lib/index.d.ts.map +1 -1
  6. package/lib/index.js +1 -1
  7. package/lib/index.js.map +1 -1
  8. package/lib/public.d.ts +1 -1
  9. package/lib/test/text/textEditor.test.js +88 -731
  10. package/lib/test/text/textEditor.test.js.map +1 -1
  11. package/lib/text/index.d.ts +3 -2
  12. package/lib/text/index.d.ts.map +1 -1
  13. package/lib/text/index.js +1 -2
  14. package/lib/text/index.js.map +1 -1
  15. package/lib/text/plain/index.d.ts +1 -1
  16. package/lib/text/plain/index.d.ts.map +1 -1
  17. package/lib/text/plain/index.js +1 -1
  18. package/lib/text/plain/index.js.map +1 -1
  19. package/lib/text/plain/plainTextView.d.ts +5 -2
  20. package/lib/text/plain/plainTextView.d.ts.map +1 -1
  21. package/lib/text/plain/plainTextView.js.map +1 -1
  22. package/lib/text/plain/plainUtils.d.ts +1 -0
  23. package/lib/text/plain/plainUtils.d.ts.map +1 -1
  24. package/lib/text/plain/plainUtils.js +1 -0
  25. package/lib/text/plain/plainUtils.js.map +1 -1
  26. package/package.json +14 -16
  27. package/react.test-files.tar +0 -0
  28. package/src/index.ts +1 -9
  29. package/src/text/index.ts +3 -10
  30. package/src/text/plain/index.ts +1 -1
  31. package/src/text/plain/plainTextView.tsx +2 -2
  32. package/src/text/plain/plainUtils.ts +1 -0
  33. package/tsconfig.json +0 -6
  34. package/lib/test/mochaHooks.js +0 -13
  35. package/lib/test/mochaHooks.js.map +0 -1
  36. package/lib/text/formatted/index.d.ts +0 -6
  37. package/lib/text/formatted/index.d.ts.map +0 -1
  38. package/lib/text/formatted/index.js +0 -6
  39. package/lib/text/formatted/index.js.map +0 -1
  40. package/lib/text/formatted/quillFormattedView.d.ts +0 -66
  41. package/lib/text/formatted/quillFormattedView.d.ts.map +0 -1
  42. package/lib/text/formatted/quillFormattedView.js +0 -520
  43. package/lib/text/formatted/quillFormattedView.js.map +0 -1
  44. package/lib/text/plain/quillView.d.ts +0 -22
  45. package/lib/text/plain/quillView.d.ts.map +0 -1
  46. package/lib/text/plain/quillView.js +0 -106
  47. package/lib/text/plain/quillView.js.map +0 -1
  48. package/src/text/formatted/index.ts +0 -11
  49. package/src/text/formatted/quillFormattedView.tsx +0 -627
  50. package/src/text/plain/quillView.tsx +0 -149
@@ -1,66 +0,0 @@
1
- /*!
2
- * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
- * Licensed under the MIT License.
4
- */
5
- import { FormattedTextAsTree } from "@fluidframework/tree/internal";
6
- export { FormattedTextAsTree } from "@fluidframework/tree/internal";
7
- import DeltaPackage from "quill-delta";
8
- import { type PropTreeNode } from "../../propNode.js";
9
- import type { UndoRedo } from "../../undoRedo.js";
10
- type Delta = DeltaPackage.default;
11
- type QuillDeltaOp = DeltaPackage.Op;
12
- declare const Delta: typeof DeltaPackage.default;
13
- /**
14
- * Props for the FormattedMainView component.
15
- * @input @internal
16
- */
17
- export interface FormattedMainViewProps {
18
- readonly root: PropTreeNode<FormattedTextAsTree.Tree>;
19
- /** Optional undo/redo stack for the editor. */
20
- readonly undoRedo?: UndoRedo;
21
- }
22
- /**
23
- * Ref handle exposing undo/redo methods for the formatted editor.
24
- * @input @internal
25
- */
26
- export type FormattedEditorHandle = Pick<UndoRedo, "undo" | "redo">;
27
- /**
28
- * A React component for formatted text editing.
29
- * @remarks
30
- * Uses {@link @fluidframework/tree#FormattedTextAsTree.Tree} for the data-model and Quill for the rich text editor UI.
31
- * @internal
32
- */
33
- export declare const FormattedMainView: import("react").ForwardRefExoticComponent<FormattedMainViewProps & import("react").RefAttributes<FormattedEditorHandle>>;
34
- /**
35
- * Parse CSS font-size from a pasted HTML element's inline style.
36
- * Returns a Quill size name if the pixel value matches a supported size, undefined otherwise.
37
- * 12px is the default size and returns undefined (no Quill attribute needed).
38
- */
39
- export declare function parseCssFontSize(node: HTMLElement): string | undefined;
40
- /**
41
- * Parse CSS font-family from a pasted HTML element's inline style.
42
- * Tries fonts in priority order (first to last per CSS spec) and returns
43
- * the first recognized Quill font value.
44
- */
45
- export declare function parseCssFontFamily(node: HTMLElement): string | undefined;
46
- /**
47
- * Clipboard matcher that preserves recognized font-size and font-family
48
- * from pasted HTML elements. Applies each format independently via
49
- * compose/retain so new attributes can be added without risk of an
50
- * early return skipping them.
51
- * @see https://quilljs.com/docs/modules/clipboard#addmatcher
52
- */
53
- export declare function clipboardFormatMatcher(node: Node, delta: Delta): Delta;
54
- /** Extract a LineTag from Quill attributes, or undefined if none present. Quill only supports one LineTag at a time. */
55
- export declare function parseLineTag(attributes?: Record<string, unknown>): FormattedTextAsTree.LineTag | undefined;
56
- /**
57
- * Build a Quill Delta representing the full tree content.
58
- * Iterates through formatted characters and groups consecutive characters
59
- * with identical formatting into single insert operations for efficiency.
60
- *
61
- * @remarks
62
- * This is used to sync Quill's display when the tree changes externally
63
- * (e.g., from a remote collaborator's edit).
64
- */
65
- export declare function buildDeltaFromTree(root: FormattedTextAsTree.Tree): QuillDeltaOp[];
66
- //# sourceMappingURL=quillFormattedView.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"quillFormattedView.d.ts","sourceRoot":"","sources":["../../../src/text/formatted/quillFormattedView.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAmB,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAEpE,OAAO,YAAY,MAAM,aAAa,CAAC;AAWvC,OAAO,EAAE,KAAK,YAAY,EAAsB,MAAM,mBAAmB,CAAC;AAC1E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAGlD,KAAK,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC;AAClC,KAAK,YAAY,GAAG,YAAY,CAAC,EAAE,CAAC;AACpC,QAAA,MAAM,KAAK,6BAAuB,CAAC;AAEnC;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC,mBAAmB,CAAC,IAAI,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;CAC7B;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,0HAI7B,CAAC;AAkCF;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAiBtE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,CAkBxE;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,GAAG,KAAK,CActE;AAoBD,wHAAwH;AACxH,wBAAgB,YAAY,CAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAClC,mBAAmB,CAAC,OAAO,GAAG,SAAS,CAgBzC;AA8ED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,mBAAmB,CAAC,IAAI,GAAG,YAAY,EAAE,CA4DjF"}
@@ -1,520 +0,0 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- /*!
3
- * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
4
- * Licensed under the MIT License.
5
- */
6
- import { assert } from "@fluidframework/core-utils/internal";
7
- import { Tree, TreeAlpha, FormattedTextAsTree } from "@fluidframework/tree/internal";
8
- export { FormattedTextAsTree } from "@fluidframework/tree/internal";
9
- import Quill from "quill";
10
- import DeltaPackage from "quill-delta";
11
- import { forwardRef, useEffect, useImperativeHandle, useReducer, useRef, useState, } from "react";
12
- import * as ReactDOM from "react-dom";
13
- import { unwrapPropTreeNode } from "../../propNode.js";
14
- const Delta = DeltaPackage.default;
15
- /**
16
- * A React component for formatted text editing.
17
- * @remarks
18
- * Uses {@link @fluidframework/tree#FormattedTextAsTree.Tree} for the data-model and Quill for the rich text editor UI.
19
- * @internal
20
- */
21
- export const FormattedMainView = forwardRef(({ root, undoRedo }, ref) => {
22
- return _jsx(FormattedTextEditorView, { root: root, undoRedo: undoRedo, ref: ref });
23
- });
24
- FormattedMainView.displayName = "FormattedMainView";
25
- /** Quill size names mapped to pixel values for tree storage. */
26
- const sizeMap = { small: 10, large: 18, huge: 24 };
27
- /** Reverse mapping: pixel values back to Quill size names for display. */
28
- const sizeReverse = { 10: "small", 18: "large", 24: "huge" };
29
- /** Set of recognized font families for Quill. */
30
- const fontSet = new Set(["monospace", "serif", "sans-serif", "Arial"]);
31
- /** Default formatting values when no explicit format is specified. */
32
- const defaultSize = 12;
33
- /** Default font when no explicit font is specified. */
34
- const defaultFont = "Arial";
35
- /** default heading for when an unsupported header is supplied */
36
- const defaultHeading = "h5";
37
- /** Quill header numbers → LineTag values. */
38
- const headerToLineTag = {
39
- 1: "h1",
40
- 2: "h2",
41
- 3: "h3",
42
- 4: "h4",
43
- 5: "h5",
44
- };
45
- /** LineTag values → Quill attributes. Used by buildDeltaFromTree (tree → Quill). */
46
- const lineTagToQuillAttributes = {
47
- h1: { header: 1 },
48
- h2: { header: 2 },
49
- h3: { header: 3 },
50
- h4: { header: 4 },
51
- h5: { header: 5 },
52
- li: { list: "bullet" },
53
- };
54
- /**
55
- * Parse CSS font-size from a pasted HTML element's inline style.
56
- * Returns a Quill size name if the pixel value matches a supported size, undefined otherwise.
57
- * 12px is the default size and returns undefined (no Quill attribute needed).
58
- */
59
- export function parseCssFontSize(node) {
60
- const style = node.style.fontSize;
61
- if (!style)
62
- return undefined;
63
- // check if pixel value is in <size>px format
64
- if (style.endsWith("px")) {
65
- // Parse pixel value (e.g., "18px" -> 18)
66
- const parsed = Number.parseFloat(style);
67
- if (Number.isNaN(parsed))
68
- return undefined;
69
- // Round to nearest integer and look up Quill size name
70
- const rounded = Math.round(parsed);
71
- if (rounded in sizeReverse) {
72
- return sizeReverse[rounded];
73
- }
74
- }
75
- return undefined;
76
- }
77
- /**
78
- * Parse CSS font-family from a pasted HTML element's inline style.
79
- * Tries fonts in priority order (first to last per CSS spec) and returns
80
- * the first recognized Quill font value.
81
- */
82
- export function parseCssFontFamily(node) {
83
- const style = node.style.fontFamily;
84
- if (style === "")
85
- return undefined;
86
- // Splitting on "," does not handle commas inside quoted font names, and escape
87
- // sequences within font names are not supported. This is fine since none of the
88
- // font names we match against contain commas or escapes.
89
- const fonts = style.split(",");
90
- for (const raw of fonts) {
91
- // Trim whitespace and leading and trailing quotes
92
- const font = raw.trim().replace(/^["']/, "").replace(/["']$/, "");
93
- // check if font is in our supported font set
94
- if (fontSet.has(font)) {
95
- return font;
96
- }
97
- }
98
- // No recognized font family found; fall back to default (Arial)
99
- return undefined;
100
- }
101
- /**
102
- * Clipboard matcher that preserves recognized font-size and font-family
103
- * from pasted HTML elements. Applies each format independently via
104
- * compose/retain so new attributes can be added without risk of an
105
- * early return skipping them.
106
- * @see https://quilljs.com/docs/modules/clipboard#addmatcher
107
- */
108
- export function clipboardFormatMatcher(node, delta) {
109
- if (!(node instanceof HTMLElement))
110
- return delta;
111
- const size = parseCssFontSize(node);
112
- const font = parseCssFontFamily(node);
113
- let result = delta;
114
- if (size !== undefined) {
115
- result = result.compose(new Delta().retain(result.length(), { size }));
116
- }
117
- if (font !== undefined) {
118
- result = result.compose(new Delta().retain(result.length(), { font }));
119
- }
120
- return result;
121
- }
122
- /**
123
- * Parse a size value from Quill into a numeric pixel value.
124
- * Handles Quill's named sizes (small, large, huge), numeric values, and pixel strings.
125
- */
126
- function parseSize(size) {
127
- if (typeof size === "number")
128
- return size;
129
- if (size === "small" || size === "large" || size === "huge") {
130
- return sizeMap[size];
131
- }
132
- if (typeof size === "string") {
133
- const parsed = Number.parseInt(size, 10);
134
- if (!Number.isNaN(parsed)) {
135
- return parsed;
136
- }
137
- }
138
- return defaultSize;
139
- }
140
- /** Extract a LineTag from Quill attributes, or undefined if none present. Quill only supports one LineTag at a time. */
141
- export function parseLineTag(attributes) {
142
- if (!attributes)
143
- return undefined;
144
- // Quill should never send both header and list attributes simultaneously.
145
- assert(!(typeof attributes.header === "number" && typeof attributes.list === "string"), 0xce2 /* expected at most one line tag (header or list), but received both */);
146
- if (typeof attributes.header === "number") {
147
- const tag = headerToLineTag[attributes.header] ?? defaultHeading;
148
- return FormattedTextAsTree.LineTag(tag);
149
- }
150
- if (attributes.list === "bullet") {
151
- return FormattedTextAsTree.LineTag("li");
152
- }
153
- return undefined;
154
- }
155
- /** Create a StringAtom containing a StringLineAtom with the given line tag. */
156
- function createLineAtom(lineTag) {
157
- return new FormattedTextAsTree.StringAtom({
158
- content: new FormattedTextAsTree.StringLineAtom({
159
- tag: lineTag,
160
- }),
161
- format: new FormattedTextAsTree.CharacterFormat(quillAttributesToFormat()),
162
- });
163
- }
164
- /**
165
- * Convert Quill attributes to a complete CharacterFormat object.
166
- * Used when inserting new characters - all format properties must have values.
167
- * Missing attributes default to false/default values.
168
- */
169
- function quillAttributesToFormat(attributes) {
170
- return {
171
- bold: attributes?.bold === true,
172
- italic: attributes?.italic === true,
173
- underline: attributes?.underline === true,
174
- size: parseSize(attributes?.size),
175
- font: typeof attributes?.font === "string" ? attributes.font : defaultFont,
176
- };
177
- }
178
- /**
179
- * Convert Quill attributes to a partial CharacterFormat object.
180
- * Used when applying formatting to existing text via retain operations.
181
- * Only includes properties that were explicitly set in the Quill attributes,
182
- * allowing selective format updates without overwriting unrelated properties.
183
- */
184
- function quillAttributesToPartial(attributes) {
185
- if (!attributes)
186
- return {};
187
- const format = {};
188
- // Only include attributes that are explicitly present in the Quill delta
189
- if ("bold" in attributes)
190
- format.bold = attributes.bold === true;
191
- if ("italic" in attributes)
192
- format.italic = attributes.italic === true;
193
- if ("underline" in attributes)
194
- format.underline = attributes.underline === true;
195
- if ("size" in attributes)
196
- format.size = parseSize(attributes.size);
197
- if ("font" in attributes)
198
- format.font = typeof attributes.font === "string" ? attributes.font : defaultFont;
199
- return format;
200
- }
201
- /**
202
- * Convert a CharacterFormat from the tree to Quill attributes.
203
- * Used when building Quill deltas from tree content to sync external changes.
204
- * Only includes non-default values to keep deltas minimal.
205
- */
206
- function formatToQuillAttributes(format) {
207
- const attributes = {};
208
- // Only include non-default formatting to keep Quill deltas minimal
209
- if (format.bold)
210
- attributes.bold = true;
211
- if (format.italic)
212
- attributes.italic = true;
213
- if (format.underline)
214
- attributes.underline = true;
215
- if (format.size !== defaultSize) {
216
- // Convert pixel value back to Quill size name if possible
217
- attributes.size =
218
- format.size in sizeReverse
219
- ? sizeReverse[format.size]
220
- : `${format.size}px`;
221
- }
222
- if (format.font !== defaultFont)
223
- attributes.font = format.font;
224
- return attributes;
225
- }
226
- /**
227
- * Build a Quill Delta representing the full tree content.
228
- * Iterates through formatted characters and groups consecutive characters
229
- * with identical formatting into single insert operations for efficiency.
230
- *
231
- * @remarks
232
- * This is used to sync Quill's display when the tree changes externally
233
- * (e.g., from a remote collaborator's edit).
234
- */
235
- export function buildDeltaFromTree(root) {
236
- const ops = [];
237
- // Accumulator for current run of identically-formatted text
238
- let text = "";
239
- let previousAttributes = {};
240
- // JSON key for current attributes, used for equality comparison
241
- // TODO:Performance: implement faster equality check.
242
- let key = "";
243
- // Helper to push accumulated text as an insert operation
244
- const pushRun = () => {
245
- if (!text)
246
- return;
247
- const op = { insert: text };
248
- if (Object.keys(previousAttributes).length > 0)
249
- op.attributes = previousAttributes;
250
- ops.push(op);
251
- };
252
- // Iterate through each formatted character in the tree
253
- // TODO:Performance: Optimize this loop by adding an API to get runs to FormattedTextAsTree.Tree, and implementing that using cursors.
254
- // Something like `getUniformRun(startIndex, maxLength): number` and `substring(startIndex, length): string`.
255
- for (const atom of root.charactersWithFormatting()) {
256
- const currentAttributes = formatToQuillAttributes(atom.format);
257
- if (atom.content instanceof FormattedTextAsTree.StringLineAtom) {
258
- // Merge line-specific attributes (header/list) into the format
259
- const lineTag = atom.content.tag.value;
260
- Object.assign(currentAttributes, lineTagToQuillAttributes[lineTag]);
261
- // Line atoms always break the current run and emit a newline
262
- pushRun();
263
- text = "";
264
- key = "";
265
- const op = { insert: "\n" };
266
- if (Object.keys(currentAttributes).length > 0)
267
- op.attributes = currentAttributes;
268
- ops.push(op);
269
- }
270
- else {
271
- const stringifiedAttributes = JSON.stringify(currentAttributes);
272
- if (stringifiedAttributes === key) {
273
- // Same formatting as previous character - extend run
274
- text += atom.content.content;
275
- }
276
- else {
277
- // Different formatting - push previous run and start a new one
278
- pushRun();
279
- text = atom.content.content;
280
- previousAttributes = currentAttributes;
281
- key = stringifiedAttributes;
282
- }
283
- }
284
- }
285
- // Push any remaining accumulated text
286
- pushRun();
287
- // Quill expects documents to end with a newline
288
- // eslint-disable-next-line unicorn/prefer-at -- .at() not available in target
289
- const last = ops[ops.length - 1];
290
- if (typeof last?.insert !== "string" || !last.insert.endsWith("\n")) {
291
- ops.push({ insert: "\n" });
292
- }
293
- return ops;
294
- }
295
- /**
296
- * The formatted text editor view component with Quill integration.
297
- * Uses FormattedTextAsTree for collaborative rich text storage with formatting.
298
- *
299
- * @remarks
300
- * This component uses event-based synchronization via Tree.on("treeChanged")
301
- * to efficiently handle external changes without expensive render-time operations.
302
- * Unlike the plain text version, this component uses Quill's delta operations
303
- * to make targeted edits (insert at index, delete range, format range) rather
304
- * than replacing all content on each change.
305
- */
306
- const FormattedTextEditorView = forwardRef(({ root: propRoot, undoRedo }, ref) => {
307
- // Unwrap the PropTreeNode to get the actual tree node
308
- const root = unwrapPropTreeNode(propRoot);
309
- // DOM element where Quill will mount its editor
310
- const editorRef = useRef(null);
311
- // Quill instance, persisted across renders to avoid re-initialization
312
- const quillRef = useRef(null);
313
- // Guards against update loops between Quill and the tree
314
- const isUpdating = useRef(false);
315
- // Container element for undo/redo button portal
316
- const [undoRedoContainer, setUndoRedoContainer] = useState(undefined);
317
- // Force re-render when undo/redo state changes
318
- const [, forceUpdate] = useReducer((x) => x + 1, 0);
319
- // Expose undo/redo methods via ref
320
- useImperativeHandle(ref, () => ({
321
- undo: () => undoRedo?.undo(),
322
- redo: () => undoRedo?.redo(),
323
- }));
324
- // Initialize Quill editor with formatting toolbar using Quill provided CSS
325
- useEffect(() => {
326
- if (!editorRef.current || quillRef.current)
327
- return;
328
- const quill = new Quill(editorRef.current, {
329
- theme: "snow",
330
- placeholder: "Start typing with formatting...",
331
- modules: {
332
- history: false, // Disable Quill's built-in undo/redo
333
- toolbar: [
334
- ["bold", "italic", "underline"],
335
- [{ size: ["small", false, "large", "huge"] }],
336
- [{ font: [] }],
337
- [{ header: [1, 2, 3, 4, 5, false] }],
338
- [{ list: "bullet" }],
339
- ["clean"],
340
- ],
341
- clipboard: [Node.ELEMENT_NODE, clipboardFormatMatcher],
342
- },
343
- });
344
- // Set initial content from tree
345
- quill.setContents(buildDeltaFromTree(root));
346
- // Listen to local Quill changes and apply them to the tree.
347
- // We process delta operations to make targeted edits, preserving collaboration integrity.
348
- // Note: Quill uses UTF-16 code units for positions, but the tree uses Unicode codepoints.
349
- // We must convert between them to handle emoji and other non-BMP characters correctly.
350
- //
351
- // The typing here is very fragile: if no parameter types are given,
352
- // the inference for this event is strongly typed, but the types are wrong (The wrong "Delta" type is provided).
353
- // This is likely related to the node16 module resolution issues with quill-delta.
354
- // If we break that inference by adding types, `any` is inferred for all of them, so incorrect types here would still compile.
355
- quill.on("text-change", (delta, _oldDelta, source) => {
356
- if (source !== "user" || isUpdating.current)
357
- return;
358
- isUpdating.current = true;
359
- // Wrap all tree mutations in a transaction so they undo/redo as one atomic unit.
360
- // If the node is not part of a branch (e.g. unhydrated), apply edits directly.
361
- const branch = TreeAlpha.branch(root);
362
- const applyDelta = () => {
363
- // Helper to count Unicode codepoints in a string
364
- const codepointCount = (s) => [...s].length;
365
- // Get current content for UTF-16 to codepoint position mapping
366
- // We update this as we process operations to keep positions accurate
367
- let content = root.fullString();
368
- let utf16Pos = 0; // Position in UTF-16 code units (Quill's view)
369
- let cpPos = 0; // Position in codepoints (tree's view)
370
- for (const op of delta.ops) {
371
- if (op.retain !== undefined) {
372
- // The docs for retain imply this is always a number, but the type definitions allow a record here.
373
- // It is unknown why the type definitions allow a record as they have no doc comments.
374
- // For now this assert seems to be passing, so we just assume its always a number.
375
- assert(typeof op.retain === "number", 0xcdf /* Expected retain count to be a number */);
376
- // Convert UTF-16 retain count to codepoint count
377
- const retainedStr = content.slice(utf16Pos, utf16Pos + op.retain);
378
- const cpCount = codepointCount(retainedStr);
379
- if (op.attributes) {
380
- const lineTag = parseLineTag(op.attributes);
381
- // Case 1: Applying line formatting (header/list) to an existing newline in the document.
382
- if (lineTag !== undefined && content[utf16Pos] === "\n") {
383
- // Swap existing newline atom to StringLineAtom
384
- root.removeRange(cpPos, cpPos + 1);
385
- root.insertWithFormattingAt(cpPos, [createLineAtom(lineTag)]);
386
- // Case 2: Applying line formatting past the end of content. Quill's implicit trailing newline.
387
- }
388
- else if (lineTag !== undefined && utf16Pos >= content.length) {
389
- // Quill's implicit trailing newline — insert a new line atom
390
- root.insertWithFormattingAt(cpPos, [createLineAtom(lineTag)]);
391
- content += "\n";
392
- // Case 3: clearing line formatting. Deletes StringLineAtom and inserts a plain
393
- // StringTextAtom("\n") in its place.
394
- }
395
- else if (lineTag === undefined &&
396
- content[utf16Pos] === "\n" &&
397
- root.charactersWithFormatting()[cpPos]?.content instanceof
398
- FormattedTextAsTree.StringLineAtom) {
399
- // Quill is clearing line formatting (e.g. { retain: 1, attributes: { header: null } }).
400
- // StringLineAtom and StringTextAtom are distinct schema types in the tree,
401
- // so we can't convert between them via formatRange — we must delete the
402
- // StringLineAtom and insert a plain StringTextAtom("\n") in its place.
403
- root.removeRange(cpPos, cpPos + 1);
404
- root.insertAt(cpPos, "\n");
405
- // Case 4: Normal character formatting (bold, italic, size, etc...)
406
- }
407
- else {
408
- root.formatRange(cpPos, cpPos + cpCount, quillAttributesToPartial(op.attributes));
409
- }
410
- }
411
- utf16Pos += op.retain;
412
- cpPos += cpCount;
413
- }
414
- else if (op.delete !== undefined) {
415
- // Convert UTF-16 delete count to codepoint count
416
- const deletedStr = content.slice(utf16Pos, utf16Pos + op.delete);
417
- const cpCount = codepointCount(deletedStr);
418
- root.removeRange(cpPos, cpPos + cpCount);
419
- // Update content to reflect deletion for future position calculations
420
- content = content.slice(0, utf16Pos) + content.slice(utf16Pos + op.delete);
421
- // Don't advance positions - next op starts at same position
422
- }
423
- else if (typeof op.insert === "string") {
424
- const lineTag = parseLineTag(op.attributes);
425
- if (lineTag !== undefined && op.insert === "\n") {
426
- root.insertWithFormattingAt(cpPos, [createLineAtom(lineTag)]);
427
- }
428
- else {
429
- // Insert: add new text with formatting at current position
430
- root.defaultFormat = new FormattedTextAsTree.CharacterFormat(quillAttributesToFormat(op.attributes));
431
- root.insertAt(cpPos, op.insert);
432
- }
433
- // Update content to reflect insertion
434
- content = content.slice(0, utf16Pos) + op.insert + content.slice(utf16Pos);
435
- // Advance by inserted content length
436
- utf16Pos += op.insert.length;
437
- cpPos += codepointCount(op.insert);
438
- }
439
- }
440
- };
441
- if (branch === undefined) {
442
- applyDelta();
443
- }
444
- else {
445
- branch.runTransaction(applyDelta);
446
- }
447
- isUpdating.current = false;
448
- });
449
- quillRef.current = quill;
450
- // Create container for React-controlled undo/redo buttons and prepend to toolbar
451
- const toolbar = editorRef.current.previousElementSibling;
452
- const container = document.createElement("span");
453
- container.className = "ql-formats";
454
- toolbar.prepend(container);
455
- setUndoRedoContainer(container);
456
- // In React strict mode, effects run twice. The `!quillRef.current` check above
457
- // makes the second call a no-op, preventing double-initialization of Quill.
458
- // eslint-disable-next-line react-hooks/exhaustive-deps
459
- }, []);
460
- // Sync Quill when tree changes externally (e.g., from remote collaborators).
461
- // Uses event subscription instead of render-time observation for efficiency.
462
- useEffect(() => {
463
- return Tree.on(root, "treeChanged", () => {
464
- // Skip if we caused the tree change ourselves via the text-change handler
465
- if (!quillRef.current || isUpdating.current)
466
- return;
467
- // TODO:Performance: Once SharedTree has better ArrayNode change events,
468
- // use those events to construct a delta, instead of rebuilding a new delta then diffing every edit.
469
- // After doing the optimization, keep this diffing logic as a way to test for de-sync between the tree and Quill:
470
- // Use it in tests and possibly occasionally in debug builds.
471
- const treeDelta = buildDeltaFromTree(root);
472
- // eslint doesn't seem to be resolving the types correctly here.
473
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
474
- const quillDelta = quillRef.current.getContents();
475
- // Compute diff between current Quill state and tree state
476
- const diff = new Delta(quillDelta).diff(new Delta(treeDelta));
477
- // Only update if there are actual differences
478
- if (diff.ops.length > 0) {
479
- isUpdating.current = true;
480
- // Apply only the diff for surgical updates (better cursor preservation)
481
- quillRef.current.updateContents(diff.ops);
482
- isUpdating.current = false;
483
- }
484
- });
485
- }, [root]);
486
- // Subscribe to undo/redo state changes to update button disabled state
487
- useEffect(() => {
488
- if (!undoRedo)
489
- return;
490
- return undoRedo.onStateChange(() => {
491
- forceUpdate();
492
- });
493
- }, [undoRedo]);
494
- // Render undo/redo buttons via portal into Quill toolbar
495
- const undoRedoButtons = undoRedoContainer
496
- ? ReactDOM.createPortal(_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "ql-undo", disabled: undoRedo?.canUndo() !== true, onClick: () => undoRedo?.undo() }), _jsx("button", { type: "button", className: "ql-redo", disabled: undoRedo?.canRedo() !== true, onClick: () => undoRedo?.redo() })] }), undoRedoContainer)
497
- : undefined;
498
- return (_jsxs("div", { style: { height: "100%", display: "flex", flexDirection: "column" }, children: [_jsx("style", { children: `
499
- .ql-container { height: 100%; font-size: 14px; }
500
- .ql-editor { height: 100%; outline: none; }
501
- .ql-editor.ql-blank::before { color: #999; font-style: italic; }
502
- .ql-toolbar { border-radius: 4px 4px 0 0; background: #f8f9fa; }
503
- .ql-container.ql-snow { border-radius: 0 0 4px 4px; }
504
- .ql-undo, .ql-redo { width: 28px !important; }
505
- .ql-undo::after { content: "↶"; font-size: 18px; }
506
- .ql-redo::after { content: "↷"; font-size: 18px; }
507
- .ql-undo:disabled, .ql-redo:disabled { opacity: 0.3; cursor: not-allowed; }
508
- /* custom css altering Quill's default bullet point alignment */
509
- /* vertically center bullets in list items, since Quill's bullet has no inherent height */
510
- li[data-list="bullet"] { display: flex; align-items: center; }
511
- li[data-list="bullet"] .ql-ui { align-self: center; }
512
- ` }), _jsx("h2", { style: { margin: "10px 0" }, children: "Collaborative Formatted Text Editor" }), _jsx("div", { ref: editorRef, style: {
513
- flex: 1,
514
- minHeight: "300px",
515
- border: "1px solid #ccc",
516
- borderRadius: "4px",
517
- } }), undoRedoButtons] }));
518
- });
519
- FormattedTextEditorView.displayName = "FormattedTextEditorView";
520
- //# sourceMappingURL=quillFormattedView.js.map