@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/api-report/react.alpha.api.md +8 -8
  3. package/lib/reactSharedTreeView.d.ts +6 -6
  4. package/lib/reactSharedTreeView.d.ts.map +1 -1
  5. package/lib/reactSharedTreeView.js +16 -18
  6. package/lib/reactSharedTreeView.js.map +1 -1
  7. package/lib/test/reactSharedTreeView.spec.js +3 -3
  8. package/lib/test/reactSharedTreeView.spec.js.map +1 -1
  9. package/lib/test/text/textEditor.test.js +100 -44
  10. package/lib/test/text/textEditor.test.js.map +1 -1
  11. package/lib/test/useObservation.spec.js +8 -8
  12. package/lib/test/useObservation.spec.js.map +1 -1
  13. package/lib/test/useTree.spec.js +15 -15
  14. package/lib/test/useTree.spec.js.map +1 -1
  15. package/lib/text/formatted/quillFormattedView.d.ts +14 -2
  16. package/lib/text/formatted/quillFormattedView.d.ts.map +1 -1
  17. package/lib/text/formatted/quillFormattedView.js +165 -71
  18. package/lib/text/formatted/quillFormattedView.js.map +1 -1
  19. package/lib/text/plain/plainTextView.d.ts +2 -2
  20. package/lib/text/plain/plainTextView.d.ts.map +1 -1
  21. package/lib/text/plain/plainTextView.js +16 -21
  22. package/lib/text/plain/plainTextView.js.map +1 -1
  23. package/lib/text/plain/quillView.d.ts +2 -2
  24. package/lib/text/plain/quillView.d.ts.map +1 -1
  25. package/lib/text/plain/quillView.js +15 -21
  26. package/lib/text/plain/quillView.js.map +1 -1
  27. package/lib/useObservation.js +6 -6
  28. package/lib/useObservation.js.map +1 -1
  29. package/lib/useTree.d.ts +7 -7
  30. package/lib/useTree.d.ts.map +1 -1
  31. package/lib/useTree.js +6 -6
  32. package/lib/useTree.js.map +1 -1
  33. package/package.json +14 -13
  34. package/react.test-files.tar +0 -0
  35. package/src/reactSharedTreeView.tsx +11 -13
  36. package/src/text/formatted/quillFormattedView.tsx +176 -58
  37. package/src/text/plain/plainTextView.tsx +6 -6
  38. package/src/text/plain/quillView.tsx +6 -6
  39. package/src/useObservation.ts +6 -6
  40. 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 * as React from "react";
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 = React.forwardRef<
45
- FormattedEditorHandle,
46
- FormattedMainViewProps
47
- >(({ root, undoRedo }, ref) => {
48
- return <FormattedTextEditorView root={root} undoRedo={undoRedo} ref={ref} />;
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 quillAttrsToFormat(attrs?: Record<string, unknown>): {
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: attrs?.bold === true,
166
- italic: attrs?.italic === true,
167
- underline: attrs?.underline === true,
168
- size: parseSize(attrs?.size),
169
- font: typeof attrs?.font === "string" ? attrs.font : defaultFont,
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 quillAttrsToPartial(
180
- attrs?: Record<string, unknown>,
237
+ function quillAttributesToPartial(
238
+ attributes?: Record<string, unknown>,
181
239
  ): Partial<FormattedTextAsTree.CharacterFormat> {
182
- if (!attrs) return {};
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 attrs) format.bold = attrs.bold === true;
186
- if ("italic" in attrs) format.italic = attrs.italic === true;
187
- if ("underline" in attrs) format.underline = attrs.underline === true;
188
- if ("size" in attrs) format.size = parseSize(attrs.size);
189
- if ("font" in attrs) format.font = typeof attrs.font === "string" ? attrs.font : defaultFont;
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 formatToQuillAttrs(
257
+ function formatToQuillAttributes(
199
258
  format: FormattedTextAsTree.CharacterFormat,
200
259
  ): Record<string, unknown> {
201
- const attrs: Record<string, unknown> = {};
260
+ const attributes: Record<string, unknown> = {};
202
261
  // Only include non-default formatting to keep Quill deltas minimal
203
- if (format.bold) attrs.bold = true;
204
- if (format.italic) attrs.italic = true;
205
- if (format.underline) attrs.underline = true;
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
- attrs.size =
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) attrs.font = format.font;
214
- return attrs;
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 attrs: Record<string, unknown> = {};
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(attrs).length > 0) op.attributes = attrs;
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 a = formatToQuillAttrs(atom.format);
248
- const k = JSON.stringify(a);
249
- if (k === key) {
250
- // Same formatting as previous character - extend current run
251
- text += atom.content.content;
252
- } else {
253
- // Different formatting - push current run and start new one
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 = atom.content.content;
256
- attrs = a;
257
- key = k;
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 = React.forwardRef<
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 = React.useRef<HTMLDivElement>(null);
368
+ const editorRef = useRef<HTMLDivElement>(null);
294
369
  // Quill instance, persisted across renders to avoid re-initialization
295
- const quillRef = React.useRef<Quill | null>(null);
370
+ const quillRef = useRef<Quill | null>(null);
296
371
  // Guards against update loops between Quill and the tree
297
- const isUpdating = React.useRef(false);
372
+ const isUpdating = useRef(false);
298
373
  // Container element for undo/redo button portal
299
- const [undoRedoContainer, setUndoRedoContainer] = React.useState<HTMLElement | undefined>(
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] = React.useReducer((x: number) => x + 1, 0);
378
+ const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
304
379
 
305
380
  // Expose undo/redo methods via ref
306
- React.useImperativeHandle(ref, () => ({
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
- React.useEffect(() => {
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
- root.formatRange(cpPos, cpPos + cpCount, quillAttrsToPartial(op.attributes));
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
- // Insert: add new text with formatting at current position
387
- root.defaultFormat = new FormattedTextAsTree.CharacterFormat(
388
- quillAttrsToFormat(op.attributes),
389
- );
390
- root.insertAt(cpPos, op.insert);
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
- React.useEffect(() => {
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
- React.useEffect(() => {
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 * as React from "react";
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: React.FC<MainViewProps> = ({ root }) => {
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 = React.useRef<HTMLTextAreaElement>(null);
35
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
36
36
  // Guards against update loops between textarea and the tree
37
- const isUpdatingRef = React.useRef<boolean>(false);
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 = React.useCallback(
45
- (event: React.ChangeEvent<HTMLTextAreaElement>) => {
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 * as React from "react";
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: React.FC<MainViewProps> = ({ root }) => {
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 = React.useRef<HTMLDivElement>(null);
43
+ const editorRef = useRef<HTMLDivElement>(null);
44
44
  // Quill instance, persisted across renders to avoid re-initialization
45
- const quillRef = React.useRef<Quill | null>(null);
45
+ const quillRef = useRef<Quill | null>(null);
46
46
  // Guards against update loops between Quill and the tree
47
- const isUpdatingRef = React.useRef<boolean>(false);
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
- React.useEffect(() => {
54
+ useEffect(() => {
55
55
  if (editorRef.current && !quillRef.current) {
56
56
  const quill = new Quill(editorRef.current, {
57
57
  placeholder: "Start typing...",
@@ -3,7 +3,7 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
- import * as React from "react";
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] = React.useState<SubscriptionsWrapper>(
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
- // React.useEffect(
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] = React.useState(0);
182
+ const [_subscriptions, setSubscriptions] = useState(0);
183
183
 
184
184
  const { result, subscribe } = trackDuring();
185
185
 
186
- React.useEffect(() => {
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] = React.useState<{
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 * as React from "react";
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] = React.useState(0);
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
- React.useEffect(() => {
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
- * `React.FC` does not seem to be covariant over its input type, so to make use of this more ergonomic,
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: React.FC<TIn>,
67
+ component: FC<TIn>,
61
68
  options?: ObservationOptions,
62
- ): React.FC<TIn> & React.FC<WrapNodes<TIn>> & React.FC<TIn | WrapNodes<TIn>> {
63
- return (props: TIn | WrapNodes<TIn>): React.ReactNode =>
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 React.memo.
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: React.FC<TIn>,
81
+ component: FC<TIn>,
75
82
  options?: ObservationOptions & {
76
- readonly propsAreEqual?: Parameters<typeof React.memo>[1];
83
+ readonly propsAreEqual?: Parameters<typeof memo>[1];
77
84
  },
78
- ): React.MemoExoticComponent<ReturnType<typeof withTreeObservations<TIn>>> {
79
- return React.memo(withTreeObservations(component, options), options?.propsAreEqual);
85
+ ): MemoExoticComponent<ReturnType<typeof withTreeObservations<TIn>>> {
86
+ return memo(withTreeObservations(component, options), options?.propsAreEqual);
80
87
  }
81
88
 
82
89
  /**