@fluidframework/react 2.90.0 → 2.91.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 +4 -0
- package/api-report/react.alpha.api.md +8 -8
- package/lib/reactSharedTreeView.d.ts +6 -6
- package/lib/reactSharedTreeView.d.ts.map +1 -1
- package/lib/reactSharedTreeView.js +16 -18
- package/lib/reactSharedTreeView.js.map +1 -1
- package/lib/test/reactSharedTreeView.spec.js +3 -3
- package/lib/test/reactSharedTreeView.spec.js.map +1 -1
- package/lib/test/text/textEditor.test.js +100 -44
- package/lib/test/text/textEditor.test.js.map +1 -1
- package/lib/test/useObservation.spec.js +8 -8
- package/lib/test/useObservation.spec.js.map +1 -1
- package/lib/test/useTree.spec.js +15 -15
- package/lib/test/useTree.spec.js.map +1 -1
- package/lib/text/formatted/quillFormattedView.d.ts +14 -2
- package/lib/text/formatted/quillFormattedView.d.ts.map +1 -1
- package/lib/text/formatted/quillFormattedView.js +165 -71
- package/lib/text/formatted/quillFormattedView.js.map +1 -1
- package/lib/text/plain/plainTextView.d.ts +2 -2
- package/lib/text/plain/plainTextView.d.ts.map +1 -1
- package/lib/text/plain/plainTextView.js +16 -21
- package/lib/text/plain/plainTextView.js.map +1 -1
- package/lib/text/plain/quillView.d.ts +2 -2
- package/lib/text/plain/quillView.d.ts.map +1 -1
- package/lib/text/plain/quillView.js +15 -21
- package/lib/text/plain/quillView.js.map +1 -1
- package/lib/useObservation.js +6 -6
- package/lib/useObservation.js.map +1 -1
- package/lib/useTree.d.ts +7 -7
- package/lib/useTree.d.ts.map +1 -1
- package/lib/useTree.js +6 -6
- package/lib/useTree.js.map +1 -1
- package/package.json +14 -13
- package/react.test-files.tar +0 -0
- package/src/reactSharedTreeView.tsx +11 -13
- package/src/text/formatted/quillFormattedView.tsx +176 -58
- package/src/text/plain/plainTextView.tsx +6 -6
- package/src/text/plain/quillView.tsx +6 -6
- package/src/useObservation.ts +6 -6
- package/src/useTree.ts +19 -12
|
@@ -8,7 +8,14 @@ import { Tree, TreeAlpha, FormattedTextAsTree } from "@fluidframework/tree/inter
|
|
|
8
8
|
export { FormattedTextAsTree } from "@fluidframework/tree/internal";
|
|
9
9
|
import Quill, { type EmitterSource } from "quill";
|
|
10
10
|
import DeltaPackage from "quill-delta";
|
|
11
|
-
import
|
|
11
|
+
import {
|
|
12
|
+
forwardRef,
|
|
13
|
+
useEffect,
|
|
14
|
+
useImperativeHandle,
|
|
15
|
+
useReducer,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
12
19
|
import * as ReactDOM from "react-dom";
|
|
13
20
|
|
|
14
21
|
import { type PropTreeNode, unwrapPropTreeNode } from "../../propNode.js";
|
|
@@ -41,12 +48,11 @@ export type FormattedEditorHandle = Pick<UndoRedo, "undo" | "redo">;
|
|
|
41
48
|
* Uses {@link @fluidframework/tree#FormattedTextAsTree.Tree} for the data-model and Quill for the rich text editor UI.
|
|
42
49
|
* @internal
|
|
43
50
|
*/
|
|
44
|
-
export const FormattedMainView =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
51
|
+
export const FormattedMainView = forwardRef<FormattedEditorHandle, FormattedMainViewProps>(
|
|
52
|
+
({ root, undoRedo }, ref) => {
|
|
53
|
+
return <FormattedTextEditorView root={root} undoRedo={undoRedo} ref={ref} />;
|
|
54
|
+
},
|
|
55
|
+
);
|
|
50
56
|
FormattedMainView.displayName = "FormattedMainView";
|
|
51
57
|
|
|
52
58
|
/** Quill size names mapped to pixel values for tree storage. */
|
|
@@ -59,6 +65,27 @@ const fontSet = new Set<string>(["monospace", "serif", "sans-serif", "Arial"]);
|
|
|
59
65
|
const defaultSize = 12;
|
|
60
66
|
/** Default font when no explicit font is specified. */
|
|
61
67
|
const defaultFont = "Arial";
|
|
68
|
+
/** default heading for when an unsupported header is supplied */
|
|
69
|
+
const defaultHeading = "h5";
|
|
70
|
+
/** The string literal values accepted by LineTag. */
|
|
71
|
+
type LineTagValue = Parameters<typeof FormattedTextAsTree.LineTag>[0];
|
|
72
|
+
/** Quill header numbers → LineTag values. */
|
|
73
|
+
const headerToLineTag = {
|
|
74
|
+
1: "h1",
|
|
75
|
+
2: "h2",
|
|
76
|
+
3: "h3",
|
|
77
|
+
4: "h4",
|
|
78
|
+
5: "h5",
|
|
79
|
+
} as const satisfies Readonly<Record<number, LineTagValue>>;
|
|
80
|
+
/** LineTag values → Quill attributes. Used by buildDeltaFromTree (tree → Quill). */
|
|
81
|
+
const lineTagToQuillAttributes = {
|
|
82
|
+
h1: { header: 1 },
|
|
83
|
+
h2: { header: 2 },
|
|
84
|
+
h3: { header: 3 },
|
|
85
|
+
h4: { header: 4 },
|
|
86
|
+
h5: { header: 5 },
|
|
87
|
+
li: { list: "bullet" },
|
|
88
|
+
} as const satisfies Readonly<Record<LineTagValue, Record<string, unknown>>>;
|
|
62
89
|
/**
|
|
63
90
|
* Parse CSS font-size from a pasted HTML element's inline style.
|
|
64
91
|
* Returns a Quill size name if the pixel value matches a supported size, undefined otherwise.
|
|
@@ -149,12 +176,43 @@ function parseSize(size: unknown): number {
|
|
|
149
176
|
return defaultSize;
|
|
150
177
|
}
|
|
151
178
|
|
|
179
|
+
/** Extract a LineTag from Quill attributes, or undefined if none present. Quill only supports one LineTag at a time. */
|
|
180
|
+
export function parseLineTag(
|
|
181
|
+
attributes?: Record<string, unknown>,
|
|
182
|
+
): FormattedTextAsTree.LineTag | undefined {
|
|
183
|
+
if (!attributes) return undefined;
|
|
184
|
+
// Quill should never send both header and list attributes simultaneously.
|
|
185
|
+
assert(
|
|
186
|
+
!(typeof attributes.header === "number" && typeof attributes.list === "string"),
|
|
187
|
+
0xce2 /* expected at most one line tag (header or list), but received both */,
|
|
188
|
+
);
|
|
189
|
+
if (typeof attributes.header === "number") {
|
|
190
|
+
const tag: LineTagValue =
|
|
191
|
+
headerToLineTag[attributes.header as keyof typeof headerToLineTag] ?? defaultHeading;
|
|
192
|
+
return FormattedTextAsTree.LineTag(tag);
|
|
193
|
+
}
|
|
194
|
+
if (attributes.list === "bullet") {
|
|
195
|
+
return FormattedTextAsTree.LineTag("li");
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Create a StringAtom containing a StringLineAtom with the given line tag. */
|
|
201
|
+
function createLineAtom(lineTag: FormattedTextAsTree.LineTag): FormattedTextAsTree.StringAtom {
|
|
202
|
+
return new FormattedTextAsTree.StringAtom({
|
|
203
|
+
content: new FormattedTextAsTree.StringLineAtom({
|
|
204
|
+
tag: lineTag,
|
|
205
|
+
}),
|
|
206
|
+
format: new FormattedTextAsTree.CharacterFormat(quillAttributesToFormat()),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
152
210
|
/**
|
|
153
211
|
* Convert Quill attributes to a complete CharacterFormat object.
|
|
154
212
|
* Used when inserting new characters - all format properties must have values.
|
|
155
213
|
* Missing attributes default to false/default values.
|
|
156
214
|
*/
|
|
157
|
-
function
|
|
215
|
+
function quillAttributesToFormat(attributes?: Record<string, unknown>): {
|
|
158
216
|
bold: boolean;
|
|
159
217
|
italic: boolean;
|
|
160
218
|
underline: boolean;
|
|
@@ -162,11 +220,11 @@ function quillAttrsToFormat(attrs?: Record<string, unknown>): {
|
|
|
162
220
|
font: string;
|
|
163
221
|
} {
|
|
164
222
|
return {
|
|
165
|
-
bold:
|
|
166
|
-
italic:
|
|
167
|
-
underline:
|
|
168
|
-
size: parseSize(
|
|
169
|
-
font: typeof
|
|
223
|
+
bold: attributes?.bold === true,
|
|
224
|
+
italic: attributes?.italic === true,
|
|
225
|
+
underline: attributes?.underline === true,
|
|
226
|
+
size: parseSize(attributes?.size),
|
|
227
|
+
font: typeof attributes?.font === "string" ? attributes.font : defaultFont,
|
|
170
228
|
};
|
|
171
229
|
}
|
|
172
230
|
|
|
@@ -176,17 +234,18 @@ function quillAttrsToFormat(attrs?: Record<string, unknown>): {
|
|
|
176
234
|
* Only includes properties that were explicitly set in the Quill attributes,
|
|
177
235
|
* allowing selective format updates without overwriting unrelated properties.
|
|
178
236
|
*/
|
|
179
|
-
function
|
|
180
|
-
|
|
237
|
+
function quillAttributesToPartial(
|
|
238
|
+
attributes?: Record<string, unknown>,
|
|
181
239
|
): Partial<FormattedTextAsTree.CharacterFormat> {
|
|
182
|
-
if (!
|
|
240
|
+
if (!attributes) return {};
|
|
183
241
|
const format: Partial<FormattedTextAsTree.CharacterFormat> = {};
|
|
184
242
|
// Only include attributes that are explicitly present in the Quill delta
|
|
185
|
-
if ("bold" in
|
|
186
|
-
if ("italic" in
|
|
187
|
-
if ("underline" in
|
|
188
|
-
if ("size" in
|
|
189
|
-
if ("font" in
|
|
243
|
+
if ("bold" in attributes) format.bold = attributes.bold === true;
|
|
244
|
+
if ("italic" in attributes) format.italic = attributes.italic === true;
|
|
245
|
+
if ("underline" in attributes) format.underline = attributes.underline === true;
|
|
246
|
+
if ("size" in attributes) format.size = parseSize(attributes.size);
|
|
247
|
+
if ("font" in attributes)
|
|
248
|
+
format.font = typeof attributes.font === "string" ? attributes.font : defaultFont;
|
|
190
249
|
return format;
|
|
191
250
|
}
|
|
192
251
|
|
|
@@ -195,23 +254,23 @@ function quillAttrsToPartial(
|
|
|
195
254
|
* Used when building Quill deltas from tree content to sync external changes.
|
|
196
255
|
* Only includes non-default values to keep deltas minimal.
|
|
197
256
|
*/
|
|
198
|
-
function
|
|
257
|
+
function formatToQuillAttributes(
|
|
199
258
|
format: FormattedTextAsTree.CharacterFormat,
|
|
200
259
|
): Record<string, unknown> {
|
|
201
|
-
const
|
|
260
|
+
const attributes: Record<string, unknown> = {};
|
|
202
261
|
// Only include non-default formatting to keep Quill deltas minimal
|
|
203
|
-
if (format.bold)
|
|
204
|
-
if (format.italic)
|
|
205
|
-
if (format.underline)
|
|
262
|
+
if (format.bold) attributes.bold = true;
|
|
263
|
+
if (format.italic) attributes.italic = true;
|
|
264
|
+
if (format.underline) attributes.underline = true;
|
|
206
265
|
if (format.size !== defaultSize) {
|
|
207
266
|
// Convert pixel value back to Quill size name if possible
|
|
208
|
-
|
|
267
|
+
attributes.size =
|
|
209
268
|
format.size in sizeReverse
|
|
210
269
|
? sizeReverse[format.size as keyof typeof sizeReverse]
|
|
211
270
|
: `${format.size}px`;
|
|
212
271
|
}
|
|
213
|
-
if (format.font !== defaultFont)
|
|
214
|
-
return
|
|
272
|
+
if (format.font !== defaultFont) attributes.font = format.font;
|
|
273
|
+
return attributes;
|
|
215
274
|
}
|
|
216
275
|
|
|
217
276
|
/**
|
|
@@ -223,11 +282,11 @@ function formatToQuillAttrs(
|
|
|
223
282
|
* This is used to sync Quill's display when the tree changes externally
|
|
224
283
|
* (e.g., from a remote collaborator's edit).
|
|
225
284
|
*/
|
|
226
|
-
function buildDeltaFromTree(root: FormattedTextAsTree.Tree): QuillDeltaOp[] {
|
|
285
|
+
export function buildDeltaFromTree(root: FormattedTextAsTree.Tree): QuillDeltaOp[] {
|
|
227
286
|
const ops: QuillDeltaOp[] = [];
|
|
228
287
|
// Accumulator for current run of identically-formatted text
|
|
229
288
|
let text = "";
|
|
230
|
-
let
|
|
289
|
+
let previousAttributes: Record<string, unknown> = {};
|
|
231
290
|
// JSON key for current attributes, used for equality comparison
|
|
232
291
|
// TODO:Performance: implement faster equality check.
|
|
233
292
|
let key = "";
|
|
@@ -236,7 +295,7 @@ function buildDeltaFromTree(root: FormattedTextAsTree.Tree): QuillDeltaOp[] {
|
|
|
236
295
|
const pushRun = (): void => {
|
|
237
296
|
if (!text) return;
|
|
238
297
|
const op: QuillDeltaOp = { insert: text };
|
|
239
|
-
if (Object.keys(
|
|
298
|
+
if (Object.keys(previousAttributes).length > 0) op.attributes = previousAttributes;
|
|
240
299
|
ops.push(op);
|
|
241
300
|
};
|
|
242
301
|
|
|
@@ -244,19 +303,35 @@ function buildDeltaFromTree(root: FormattedTextAsTree.Tree): QuillDeltaOp[] {
|
|
|
244
303
|
// TODO:Performance: Optimize this loop by adding an API to get runs to FormattedTextAsTree.Tree, and implementing that using cursors.
|
|
245
304
|
// Something like `getUniformRun(startIndex, maxLength): number` and `substring(startIndex, length): string`.
|
|
246
305
|
for (const atom of root.charactersWithFormatting()) {
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
306
|
+
const currentAttributes = formatToQuillAttributes(atom.format);
|
|
307
|
+
|
|
308
|
+
if (atom.content instanceof FormattedTextAsTree.StringLineAtom) {
|
|
309
|
+
// Merge line-specific attributes (header/list) into the format
|
|
310
|
+
const lineTag = atom.content.tag.value;
|
|
311
|
+
Object.assign(currentAttributes, lineTagToQuillAttributes[lineTag]);
|
|
312
|
+
|
|
313
|
+
// Line atoms always break the current run and emit a newline
|
|
254
314
|
pushRun();
|
|
255
|
-
text =
|
|
256
|
-
|
|
257
|
-
|
|
315
|
+
text = "";
|
|
316
|
+
key = "";
|
|
317
|
+
const op: QuillDeltaOp = { insert: "\n" };
|
|
318
|
+
if (Object.keys(currentAttributes).length > 0) op.attributes = currentAttributes;
|
|
319
|
+
ops.push(op);
|
|
320
|
+
} else {
|
|
321
|
+
const stringifiedAttributes = JSON.stringify(currentAttributes);
|
|
322
|
+
if (stringifiedAttributes === key) {
|
|
323
|
+
// Same formatting as previous character - extend run
|
|
324
|
+
text += atom.content.content;
|
|
325
|
+
} else {
|
|
326
|
+
// Different formatting - push previous run and start a new one
|
|
327
|
+
pushRun();
|
|
328
|
+
text = atom.content.content;
|
|
329
|
+
previousAttributes = currentAttributes;
|
|
330
|
+
key = stringifiedAttributes;
|
|
331
|
+
}
|
|
258
332
|
}
|
|
259
333
|
}
|
|
334
|
+
|
|
260
335
|
// Push any remaining accumulated text
|
|
261
336
|
pushRun();
|
|
262
337
|
|
|
@@ -280,7 +355,7 @@ function buildDeltaFromTree(root: FormattedTextAsTree.Tree): QuillDeltaOp[] {
|
|
|
280
355
|
* to make targeted edits (insert at index, delete range, format range) rather
|
|
281
356
|
* than replacing all content on each change.
|
|
282
357
|
*/
|
|
283
|
-
const FormattedTextEditorView =
|
|
358
|
+
const FormattedTextEditorView = forwardRef<
|
|
284
359
|
FormattedEditorHandle,
|
|
285
360
|
{
|
|
286
361
|
root: PropTreeNode<FormattedTextAsTree.Tree>;
|
|
@@ -290,26 +365,26 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
290
365
|
// Unwrap the PropTreeNode to get the actual tree node
|
|
291
366
|
const root = unwrapPropTreeNode(propRoot);
|
|
292
367
|
// DOM element where Quill will mount its editor
|
|
293
|
-
const editorRef =
|
|
368
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
294
369
|
// Quill instance, persisted across renders to avoid re-initialization
|
|
295
|
-
const quillRef =
|
|
370
|
+
const quillRef = useRef<Quill | null>(null);
|
|
296
371
|
// Guards against update loops between Quill and the tree
|
|
297
|
-
const isUpdating =
|
|
372
|
+
const isUpdating = useRef(false);
|
|
298
373
|
// Container element for undo/redo button portal
|
|
299
|
-
const [undoRedoContainer, setUndoRedoContainer] =
|
|
374
|
+
const [undoRedoContainer, setUndoRedoContainer] = useState<HTMLElement | undefined>(
|
|
300
375
|
undefined,
|
|
301
376
|
);
|
|
302
377
|
// Force re-render when undo/redo state changes
|
|
303
|
-
const [, forceUpdate] =
|
|
378
|
+
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
|
304
379
|
|
|
305
380
|
// Expose undo/redo methods via ref
|
|
306
|
-
|
|
381
|
+
useImperativeHandle(ref, () => ({
|
|
307
382
|
undo: () => undoRedo?.undo(),
|
|
308
383
|
redo: () => undoRedo?.redo(),
|
|
309
384
|
}));
|
|
310
385
|
|
|
311
386
|
// Initialize Quill editor with formatting toolbar using Quill provided CSS
|
|
312
|
-
|
|
387
|
+
useEffect(() => {
|
|
313
388
|
if (!editorRef.current || quillRef.current) return;
|
|
314
389
|
const quill = new Quill(editorRef.current, {
|
|
315
390
|
theme: "snow",
|
|
@@ -320,6 +395,8 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
320
395
|
["bold", "italic", "underline"],
|
|
321
396
|
[{ size: ["small", false, "large", "huge"] }],
|
|
322
397
|
[{ font: [] }],
|
|
398
|
+
[{ header: [1, 2, 3, 4, 5, false] }],
|
|
399
|
+
[{ list: "bullet" }],
|
|
323
400
|
["clean"],
|
|
324
401
|
],
|
|
325
402
|
clipboard: [Node.ELEMENT_NODE, clipboardFormatMatcher],
|
|
@@ -369,7 +446,39 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
369
446
|
const cpCount = codepointCount(retainedStr);
|
|
370
447
|
|
|
371
448
|
if (op.attributes) {
|
|
372
|
-
|
|
449
|
+
const lineTag = parseLineTag(op.attributes);
|
|
450
|
+
// Case 1: Applying line formatting (header/list) to an existing newline in the document.
|
|
451
|
+
if (lineTag !== undefined && content[utf16Pos] === "\n") {
|
|
452
|
+
// Swap existing newline atom to StringLineAtom
|
|
453
|
+
root.removeRange(cpPos, cpPos + 1);
|
|
454
|
+
root.insertWithFormattingAt(cpPos, [createLineAtom(lineTag)]);
|
|
455
|
+
// Case 2: Applying line formatting past the end of content. Quill's implicit trailing newline.
|
|
456
|
+
} else if (lineTag !== undefined && utf16Pos >= content.length) {
|
|
457
|
+
// Quill's implicit trailing newline — insert a new line atom
|
|
458
|
+
root.insertWithFormattingAt(cpPos, [createLineAtom(lineTag)]);
|
|
459
|
+
content += "\n";
|
|
460
|
+
// Case 3: clearing line formatting. Deletes StringLineAtom and inserts a plain
|
|
461
|
+
// StringTextAtom("\n") in its place.
|
|
462
|
+
} else if (
|
|
463
|
+
lineTag === undefined &&
|
|
464
|
+
content[utf16Pos] === "\n" &&
|
|
465
|
+
root.charactersWithFormatting()[cpPos]?.content instanceof
|
|
466
|
+
FormattedTextAsTree.StringLineAtom
|
|
467
|
+
) {
|
|
468
|
+
// Quill is clearing line formatting (e.g. { retain: 1, attributes: { header: null } }).
|
|
469
|
+
// StringLineAtom and StringTextAtom are distinct schema types in the tree,
|
|
470
|
+
// so we can't convert between them via formatRange — we must delete the
|
|
471
|
+
// StringLineAtom and insert a plain StringTextAtom("\n") in its place.
|
|
472
|
+
root.removeRange(cpPos, cpPos + 1);
|
|
473
|
+
root.insertAt(cpPos, "\n");
|
|
474
|
+
// Case 4: Normal character formatting (bold, italic, size, etc...)
|
|
475
|
+
} else {
|
|
476
|
+
root.formatRange(
|
|
477
|
+
cpPos,
|
|
478
|
+
cpPos + cpCount,
|
|
479
|
+
quillAttributesToPartial(op.attributes),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
373
482
|
}
|
|
374
483
|
utf16Pos += op.retain;
|
|
375
484
|
cpPos += cpCount;
|
|
@@ -383,11 +492,16 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
383
492
|
content = content.slice(0, utf16Pos) + content.slice(utf16Pos + op.delete);
|
|
384
493
|
// Don't advance positions - next op starts at same position
|
|
385
494
|
} else if (typeof op.insert === "string") {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
495
|
+
const lineTag = parseLineTag(op.attributes);
|
|
496
|
+
if (lineTag !== undefined && op.insert === "\n") {
|
|
497
|
+
root.insertWithFormattingAt(cpPos, [createLineAtom(lineTag)]);
|
|
498
|
+
} else {
|
|
499
|
+
// Insert: add new text with formatting at current position
|
|
500
|
+
root.defaultFormat = new FormattedTextAsTree.CharacterFormat(
|
|
501
|
+
quillAttributesToFormat(op.attributes),
|
|
502
|
+
);
|
|
503
|
+
root.insertAt(cpPos, op.insert);
|
|
504
|
+
}
|
|
391
505
|
// Update content to reflect insertion
|
|
392
506
|
content = content.slice(0, utf16Pos) + op.insert + content.slice(utf16Pos);
|
|
393
507
|
// Advance by inserted content length
|
|
@@ -420,7 +534,7 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
420
534
|
|
|
421
535
|
// Sync Quill when tree changes externally (e.g., from remote collaborators).
|
|
422
536
|
// Uses event subscription instead of render-time observation for efficiency.
|
|
423
|
-
|
|
537
|
+
useEffect(() => {
|
|
424
538
|
return Tree.on(root, "treeChanged", () => {
|
|
425
539
|
// Skip if we caused the tree change ourselves via the text-change handler
|
|
426
540
|
if (!quillRef.current || isUpdating.current) return;
|
|
@@ -451,7 +565,7 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
451
565
|
}, [root]);
|
|
452
566
|
|
|
453
567
|
// Subscribe to undo/redo state changes to update button disabled state
|
|
454
|
-
|
|
568
|
+
useEffect(() => {
|
|
455
569
|
if (!undoRedo) return;
|
|
456
570
|
return undoRedo.onStateChange(() => {
|
|
457
571
|
forceUpdate();
|
|
@@ -491,6 +605,10 @@ const FormattedTextEditorView = React.forwardRef<
|
|
|
491
605
|
.ql-undo::after { content: "↶"; font-size: 18px; }
|
|
492
606
|
.ql-redo::after { content: "↷"; font-size: 18px; }
|
|
493
607
|
.ql-undo:disabled, .ql-redo:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
608
|
+
/* custom css altering Quill's default bullet point alignment */
|
|
609
|
+
/* vertically center bullets in list items, since Quill's bullet has no inherent height */
|
|
610
|
+
li[data-list="bullet"] { display: flex; align-items: center; }
|
|
611
|
+
li[data-list="bullet"] .ql-ui { align-self: center; }
|
|
494
612
|
`}</style>
|
|
495
613
|
<h2 style={{ margin: "10px 0" }}>Collaborative Formatted Text Editor</h2>
|
|
496
614
|
<div
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { TextAsTree } from "@fluidframework/tree/internal";
|
|
7
|
-
import
|
|
7
|
+
import { type ChangeEvent, type FC, useCallback, useRef } from "react";
|
|
8
8
|
|
|
9
9
|
import { withMemoizedTreeObservations } from "../../useTree.js";
|
|
10
10
|
|
|
@@ -17,7 +17,7 @@ import type { MainViewProps } from "./quillView.js";
|
|
|
17
17
|
* Uses {@link @fluidframework/tree#TextAsTree.Tree} for the data-model and an HTML textarea for the UI.
|
|
18
18
|
* @internal
|
|
19
19
|
*/
|
|
20
|
-
export const MainView:
|
|
20
|
+
export const MainView: FC<MainViewProps> = ({ root }) => {
|
|
21
21
|
return <PlainTextEditorView root={root} />;
|
|
22
22
|
};
|
|
23
23
|
|
|
@@ -32,17 +32,17 @@ export const MainView: React.FC<MainViewProps> = ({ root }) => {
|
|
|
32
32
|
const PlainTextEditorView = withMemoizedTreeObservations(
|
|
33
33
|
({ root }: { root: TextAsTree.Tree }) => {
|
|
34
34
|
// Reference to the textarea element
|
|
35
|
-
const textareaRef =
|
|
35
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
36
36
|
// Guards against update loops between textarea and the tree
|
|
37
|
-
const isUpdatingRef =
|
|
37
|
+
const isUpdatingRef = useRef<boolean>(false);
|
|
38
38
|
|
|
39
39
|
// Access tree content during render to establish observation.
|
|
40
40
|
// The HOC will automatically re-render when this content changes.
|
|
41
41
|
const currentText = root.fullString();
|
|
42
42
|
|
|
43
43
|
// Handle textarea changes - sync textarea → tree
|
|
44
|
-
const handleChange =
|
|
45
|
-
(event:
|
|
44
|
+
const handleChange = useCallback(
|
|
45
|
+
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
46
46
|
if (isUpdatingRef.current) {
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { TextAsTree } from "@fluidframework/tree/internal";
|
|
7
7
|
import Quill from "quill";
|
|
8
|
-
import
|
|
8
|
+
import { type FC, useEffect, useRef } from "react";
|
|
9
9
|
|
|
10
10
|
import type { PropTreeNode } from "../../propNode.js";
|
|
11
11
|
import { withMemoizedTreeObservations } from "../../useTree.js";
|
|
@@ -26,7 +26,7 @@ export interface MainViewProps {
|
|
|
26
26
|
* Uses {@link @fluidframework/tree#TextAsTree.Tree} for the data-model and Quill for the UI.
|
|
27
27
|
* @internal
|
|
28
28
|
*/
|
|
29
|
-
export const MainView:
|
|
29
|
+
export const MainView: FC<MainViewProps> = ({ root }) => {
|
|
30
30
|
return <TextEditorView root={root} />;
|
|
31
31
|
};
|
|
32
32
|
|
|
@@ -40,18 +40,18 @@ export const MainView: React.FC<MainViewProps> = ({ root }) => {
|
|
|
40
40
|
*/
|
|
41
41
|
const TextEditorView = withMemoizedTreeObservations(({ root }: { root: TextAsTree.Tree }) => {
|
|
42
42
|
// DOM element where Quill will mount its editor
|
|
43
|
-
const editorRef =
|
|
43
|
+
const editorRef = useRef<HTMLDivElement>(null);
|
|
44
44
|
// Quill instance, persisted across renders to avoid re-initialization
|
|
45
|
-
const quillRef =
|
|
45
|
+
const quillRef = useRef<Quill | null>(null);
|
|
46
46
|
// Guards against update loops between Quill and the tree
|
|
47
|
-
const isUpdatingRef =
|
|
47
|
+
const isUpdatingRef = useRef<boolean>(false);
|
|
48
48
|
|
|
49
49
|
// Access tree content during render to establish observation.
|
|
50
50
|
// The HOC will automatically re-render when this content changes.
|
|
51
51
|
const currentText = root.fullString();
|
|
52
52
|
|
|
53
53
|
// Initialize Quill editor
|
|
54
|
-
|
|
54
|
+
useEffect(() => {
|
|
55
55
|
if (editorRef.current && !quillRef.current) {
|
|
56
56
|
const quill = new Quill(editorRef.current, {
|
|
57
57
|
placeholder: "Start typing...",
|
package/src/useObservation.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Licensed under the MIT License.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Tracks and subscriptions from the latests render of a given instance of the {@link useObservation} hook.
|
|
@@ -68,7 +68,7 @@ export function useObservation<TResult>(
|
|
|
68
68
|
options?: ObservationOptions,
|
|
69
69
|
): TResult {
|
|
70
70
|
// Use a React state hook to invalidate this component something tracked by `trackDuring` changes.
|
|
71
|
-
const [subscriptions, setSubscriptions] =
|
|
71
|
+
const [subscriptions, setSubscriptions] = useState<SubscriptionsWrapper>(
|
|
72
72
|
new SubscriptionsWrapper(),
|
|
73
73
|
);
|
|
74
74
|
|
|
@@ -115,7 +115,7 @@ export function useObservation<TResult>(
|
|
|
115
115
|
// Suppressing that invalidation bug with an extra call to setSubscriptions could work, but would produce incorrect warnings about leaks,
|
|
116
116
|
// and might cause infinite rerender depending on how StrictMode works.
|
|
117
117
|
// Such an Effect would look like this:
|
|
118
|
-
//
|
|
118
|
+
// useEffect(
|
|
119
119
|
// () => () => {
|
|
120
120
|
// subscriptions.unsubscribe?.();
|
|
121
121
|
// subscriptions.unsubscribe = undefined;
|
|
@@ -179,11 +179,11 @@ function useObservationPure<TResult>(
|
|
|
179
179
|
options?: ObservationPureOptions,
|
|
180
180
|
): TResult {
|
|
181
181
|
// Dummy state used to trigger invalidations.
|
|
182
|
-
const [_subscriptions, setSubscriptions] =
|
|
182
|
+
const [_subscriptions, setSubscriptions] = useState(0);
|
|
183
183
|
|
|
184
184
|
const { result, subscribe } = trackDuring();
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
useEffect(() => {
|
|
187
187
|
// Subscribe to events from the latest render
|
|
188
188
|
|
|
189
189
|
const invalidate = (): void => {
|
|
@@ -363,7 +363,7 @@ export function useObservationStrict<TResult>(
|
|
|
363
363
|
): TResult {
|
|
364
364
|
// Used to unsubscribe from the previous render's subscriptions.
|
|
365
365
|
// See `useObservation` for a more documented explanation of this pattern.
|
|
366
|
-
const [subscriptions] =
|
|
366
|
+
const [subscriptions] = useState<{
|
|
367
367
|
previousTracker: SubscriptionTracker | undefined;
|
|
368
368
|
}>({ previousTracker: undefined });
|
|
369
369
|
|
package/src/useTree.ts
CHANGED
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
import type { TreeLeafValue, TreeNode } from "@fluidframework/tree";
|
|
7
7
|
import { Tree } from "@fluidframework/tree";
|
|
8
8
|
import { TreeAlpha } from "@fluidframework/tree/internal";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
type FC,
|
|
11
|
+
memo,
|
|
12
|
+
type MemoExoticComponent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
useEffect,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
10
17
|
|
|
11
18
|
import {
|
|
12
19
|
unwrapPropTreeNode,
|
|
@@ -29,10 +36,10 @@ import { useObservation, type ObservationOptions } from "./useObservation.js";
|
|
|
29
36
|
export function useTree(subtreeRoot: TreeNode): number {
|
|
30
37
|
// Use a React effect hook to invalidate this component when the subtreeRoot changes.
|
|
31
38
|
// We do this by incrementing a counter, which is passed as a dependency to the effect hook.
|
|
32
|
-
const [invalidations, setInvalidations] =
|
|
39
|
+
const [invalidations, setInvalidations] = useState(0);
|
|
33
40
|
|
|
34
41
|
// React effect hook that increments the 'invalidation' counter whenever subtreeRoot or any of its children change.
|
|
35
|
-
|
|
42
|
+
useEffect(() => {
|
|
36
43
|
// Returns the cleanup function to be invoked when the component unmounts.
|
|
37
44
|
return Tree.on(subtreeRoot, "treeChanged", () => {
|
|
38
45
|
setInvalidations((i) => i + 1);
|
|
@@ -52,31 +59,31 @@ export function useTree(subtreeRoot: TreeNode): number {
|
|
|
52
59
|
* It is recommended that sub-components which take in TreeNodes, if not defined using this higher order components, take the nodes in as {@link PropTreeNode}s.
|
|
53
60
|
* Components defined using this higher order component can take in either raw TreeNodes or {@link PropTreeNode}s: the latter will be automatically unwrapped.
|
|
54
61
|
* @privateRemarks
|
|
55
|
-
* `
|
|
62
|
+
* `FC` does not seem to be covariant over its input type, so to make use of this more ergonomic,
|
|
56
63
|
* the return type intersects the various ways this could be used (with or without PropTreeNode wrapping).
|
|
57
64
|
* @alpha
|
|
58
65
|
*/
|
|
59
66
|
export function withTreeObservations<TIn>(
|
|
60
|
-
component:
|
|
67
|
+
component: FC<TIn>,
|
|
61
68
|
options?: ObservationOptions,
|
|
62
|
-
):
|
|
63
|
-
return (props: TIn | WrapNodes<TIn>):
|
|
69
|
+
): FC<TIn> & FC<WrapNodes<TIn>> & FC<TIn | WrapNodes<TIn>> {
|
|
70
|
+
return (props: TIn | WrapNodes<TIn>): ReactNode =>
|
|
64
71
|
useTreeObservations(() => component(props as TIn), options);
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
/**
|
|
68
|
-
* {@link withTreeObservations} wrapped with
|
|
75
|
+
* {@link withTreeObservations} wrapped with memo.
|
|
69
76
|
* @remarks
|
|
70
77
|
* There is no special logic here, just a convenience wrapper.
|
|
71
78
|
* @alpha
|
|
72
79
|
*/
|
|
73
80
|
export function withMemoizedTreeObservations<TIn>(
|
|
74
|
-
component:
|
|
81
|
+
component: FC<TIn>,
|
|
75
82
|
options?: ObservationOptions & {
|
|
76
|
-
readonly propsAreEqual?: Parameters<typeof
|
|
83
|
+
readonly propsAreEqual?: Parameters<typeof memo>[1];
|
|
77
84
|
},
|
|
78
|
-
):
|
|
79
|
-
return
|
|
85
|
+
): MemoExoticComponent<ReturnType<typeof withTreeObservations<TIn>>> {
|
|
86
|
+
return memo(withTreeObservations(component, options), options?.propsAreEqual);
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/**
|