From 830bab41932178df6506d6fcc58effc6d0c8cc18 Mon Sep 17 00:00:00 2001 From: brrichards Date: Mon, 9 Mar 2026 18:21:04 +0000 Subject: [PATCH 01/10] resolve dom on import issue by splitting out quill imports --- examples/data-objects/text-editor/src/app.tsx | 9 ++++++--- packages/framework/react/package.json | 4 ++++ packages/framework/react/src/index.ts | 8 -------- packages/framework/react/src/text/{index.ts => quill.ts} | 0 4 files changed, 10 insertions(+), 11 deletions(-) rename packages/framework/react/src/text/{index.ts => quill.ts} (100%) diff --git a/examples/data-objects/text-editor/src/app.tsx b/examples/data-objects/text-editor/src/app.tsx index a4bc6338aa14..d5f1433d43eb 100644 --- a/examples/data-objects/text-editor/src/app.tsx +++ b/examples/data-objects/text-editor/src/app.tsx @@ -7,13 +7,16 @@ import { AzureClient, type AzureLocalConnectionConfig } from "@fluidframework/az import { createDevtoolsLogger, initializeDevtools } from "@fluidframework/devtools/beta"; import { toPropTreeNode, - FormattedMainView, - PlainTextMainView, - PlainQuillView, UndoRedoStacks, type UndoRedo, // eslint-disable-next-line import-x/no-internal-modules } from "@fluidframework/react/internal"; +import { + FormattedMainView, + PlainTextMainView, + PlainQuillView, + // eslint-disable-next-line import-x/no-internal-modules +} from "@fluidframework/react/quill"; /** * InsecureTokenProvider is used here for local development and demo purposes only. * Do not use in production - implement proper authentication for production scenarios. diff --git a/packages/framework/react/package.json b/packages/framework/react/package.json index dc715c3d657e..91e1dc66be0a 100644 --- a/packages/framework/react/package.json +++ b/packages/framework/react/package.json @@ -36,6 +36,10 @@ "types": "./lib/index.d.ts", "default": "./lib/index.js" } + }, + "./quill": { + "types": "./lib/text/quill.d.ts", + "import": "./lib/text/quill.js" } }, "main": "lib/index.js", diff --git a/packages/framework/react/src/index.ts b/packages/framework/react/src/index.ts index baf38ffca56a..bee6570c1946 100644 --- a/packages/framework/react/src/index.ts +++ b/packages/framework/react/src/index.ts @@ -46,12 +46,4 @@ export { } from "./useTree.js"; export { objectIdNumber } from "./simpleIdentifier.js"; -export { - FormattedMainView, - PlainTextMainView, - PlainQuillView, - type FormattedMainViewProps, - type PlainMainViewProps, - type FormattedEditorHandle, -} from "./text/index.js"; export { UndoRedoStacks, type UndoRedo } from "./undoRedo.js"; diff --git a/packages/framework/react/src/text/index.ts b/packages/framework/react/src/text/quill.ts similarity index 100% rename from packages/framework/react/src/text/index.ts rename to packages/framework/react/src/text/quill.ts From 319dbc407ee1d186f8ddbde5d3518a26e78c43e3 Mon Sep 17 00:00:00 2001 From: brrichards Date: Mon, 9 Mar 2026 18:47:34 +0000 Subject: [PATCH 02/10] Address feedback --- .../api-extractor/api-extractor-lint-text_quill.esm.json | 5 +++++ packages/framework/react/package.json | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json diff --git a/packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json b/packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json new file mode 100644 index 000000000000..aee5a12041e9 --- /dev/null +++ b/packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "/../../../common/build/build-common/api-extractor-lint.entrypoint.json", + "mainEntryPointFilePath": "/lib/text/quill.d.ts" +} diff --git a/packages/framework/react/package.json b/packages/framework/react/package.json index 91e1dc66be0a..9a2529a55aea 100644 --- a/packages/framework/react/package.json +++ b/packages/framework/react/package.json @@ -38,8 +38,10 @@ } }, "./quill": { - "types": "./lib/text/quill.d.ts", - "import": "./lib/text/quill.js" + "import": { + "types": "./lib/text/quill.d.ts", + "default": "./lib/text/quill.js" + } } }, "main": "lib/index.js", @@ -60,6 +62,7 @@ "check:exports:esm:alpha": "api-extractor run --config api-extractor/api-extractor-lint-alpha.esm.json", "check:exports:esm:beta": "api-extractor run --config api-extractor/api-extractor-lint-beta.esm.json", "check:exports:esm:public": "api-extractor run --config api-extractor/api-extractor-lint-public.esm.json", + "check:exports:esm:text:quill": "api-extractor run --config api-extractor/api-extractor-lint-text_quill.esm.json", "check:format": "npm run check:biome", "ci:build:docs": "api-extractor run", "clean": "rimraf --glob dist lib {alpha,beta,internal,legacy}.d.ts \"**/*.tsbuildinfo\" \"**/*.build.log\" _api-extractor-temp nyc", From c5f7d20c239b3c5fc2197bda9496aa990e356874 Mon Sep 17 00:00:00 2001 From: brrichards Date: Mon, 9 Mar 2026 19:07:07 +0000 Subject: [PATCH 03/10] update exports --- packages/framework/react/src/text/quill.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/framework/react/src/text/quill.ts b/packages/framework/react/src/text/quill.ts index d0c6934a1926..e91b2cfa5ce1 100644 --- a/packages/framework/react/src/text/quill.ts +++ b/packages/framework/react/src/text/quill.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. */ +export { type PropTreeNode } from "../propNode.js"; +export { type UndoRedo } from "../undoRedo.js"; export { FormattedMainView, type FormattedMainViewProps, From 2541e23a850cd92465ef6b2bc16789235cc5d42d Mon Sep 17 00:00:00 2001 From: brrichards Date: Tue, 10 Mar 2026 22:04:27 +0000 Subject: [PATCH 04/10] remove inventory-app workaround --- .../data-objects/inventory-app/.mocharc.cjs | 4 ---- .../inventory-app/src/test/mochaHooks.ts | 17 ----------------- 2 files changed, 21 deletions(-) delete mode 100644 examples/data-objects/inventory-app/src/test/mochaHooks.ts diff --git a/examples/data-objects/inventory-app/.mocharc.cjs b/examples/data-objects/inventory-app/.mocharc.cjs index a19ed21e9cd7..abdc987d1f19 100644 --- a/examples/data-objects/inventory-app/.mocharc.cjs +++ b/examples/data-objects/inventory-app/.mocharc.cjs @@ -12,8 +12,4 @@ const config = getFluidTestMochaConfig(__dirname); // AB#7856 config.exit = true; -// Set up JSDOM before Quill is imported (Quill requires document at import time) -config["node-option"] ??= []; -config["node-option"].push("import=./lib/test/mochaHooks.js"); - module.exports = config; diff --git a/examples/data-objects/inventory-app/src/test/mochaHooks.ts b/examples/data-objects/inventory-app/src/test/mochaHooks.ts deleted file mode 100644 index 13d6d1f2346f..000000000000 --- a/examples/data-objects/inventory-app/src/test/mochaHooks.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import globalJsdom from "global-jsdom"; - -// Set up JSDOM before any modules are loaded. Importing @fluidframework/react transitively -// imports Quill, which requires document at import time. -// This file is loaded via Node's --import flag (before Mocha starts) rather than being -// discovered as a spec file, because Mocha loads spec files alphabetically and -// "inventoryApp.test.tsx" ("i") sorts before "mochaHooks.ts" ("m") โ€” meaning the test file -// would be imported first and Quill would load before JSDOM is set up. -// As a consequence, Mocha globals like before() are not available here, to follow the same -// pattern that packages/framework/react uses (and which apparently could break if it gained -// test files that sorted before `mochaHooks.ts` lexicographically). -globalJsdom(); From a3e46dafd69d6974e69858adbbd3655adf3a9eb6 Mon Sep 17 00:00:00 2001 From: brrichards Date: Fri, 13 Mar 2026 16:44:13 +0000 Subject: [PATCH 05/10] new quill-react package --- .changeset/five-loops-sip.md | 10 + PACKAGES.md | 2 +- feeds/internal-build.txt | 1 + feeds/internal-test.txt | 1 + feeds/public.txt | 1 + fluidBuild.config.cjs | 1 + packages/framework/quill-react/.mocharc.cjs | 15 + packages/framework/quill-react/.npmignore | 5 + packages/framework/quill-react/LICENSE | 21 + packages/framework/quill-react/README.md | 9 + .../framework/quill-react/eslint.config.mts | 11 + packages/framework/quill-react/package.json | 108 ++ .../src}/formatted/index.ts | 0 .../src}/formatted/quillFormattedView.tsx | 12 +- .../quill.ts => quill-react/src/index.ts} | 9 +- .../framework/quill-react/src/plain/index.ts | 6 + .../src}/plain/quillView.tsx | 12 +- .../src/test/mochaHooks.ts | 0 .../quill-react/src/test/textEditor.test.tsx | 951 ++++++++++++++++ .../quill-react/src/test/tsconfig.json | 15 + packages/framework/quill-react/tsconfig.json | 19 + packages/framework/quill-react/tsdoc.json | 4 + .../api-extractor-lint-text_quill.esm.json | 5 - packages/framework/react/package.json | 9 - packages/framework/react/src/index.ts | 2 +- .../react/src/test/text/textEditor.test.tsx | 1004 ++--------------- packages/framework/react/src/text/index.ts | 8 + .../framework/react/src/text/plain/index.ts | 2 +- .../react/src/text/plain/plainTextView.tsx | 4 +- .../react/src/text/plain/plainUtils.ts | 1 + packages/framework/react/tsconfig.json | 6 - pnpm-lock.yaml | 106 +- 32 files changed, 1399 insertions(+), 961 deletions(-) create mode 100644 .changeset/five-loops-sip.md create mode 100644 packages/framework/quill-react/.mocharc.cjs create mode 100644 packages/framework/quill-react/.npmignore create mode 100644 packages/framework/quill-react/LICENSE create mode 100644 packages/framework/quill-react/README.md create mode 100644 packages/framework/quill-react/eslint.config.mts create mode 100644 packages/framework/quill-react/package.json rename packages/framework/{react/src/text => quill-react/src}/formatted/index.ts (100%) rename packages/framework/{react/src/text => quill-react/src}/formatted/quillFormattedView.tsx (99%) rename packages/framework/{react/src/text/quill.ts => quill-react/src/index.ts} (50%) create mode 100644 packages/framework/quill-react/src/plain/index.ts rename packages/framework/{react/src/text => quill-react/src}/plain/quillView.tsx (95%) rename packages/framework/{react => quill-react}/src/test/mochaHooks.ts (100%) create mode 100644 packages/framework/quill-react/src/test/textEditor.test.tsx create mode 100644 packages/framework/quill-react/src/test/tsconfig.json create mode 100644 packages/framework/quill-react/tsconfig.json create mode 100644 packages/framework/quill-react/tsdoc.json delete mode 100644 packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json create mode 100644 packages/framework/react/src/text/index.ts diff --git a/.changeset/five-loops-sip.md b/.changeset/five-loops-sip.md new file mode 100644 index 000000000000..8041089fde9a --- /dev/null +++ b/.changeset/five-loops-sip.md @@ -0,0 +1,10 @@ +--- +"@fluidframework/react": minor +"@fluidframework/quill-react": minor +"__section": other +--- +New package created containing all examples for integrating Quill with Fluid Framework into React applications. + +Applications utilizing quill require DOM access at import time. This package contains all integrations of Fluid Framework with Quill/React. This package should only be imported in browser environments or test environments with JSDOM set up before import. + +To import this package use `` diff --git a/PACKAGES.md b/PACKAGES.md index 83ab4edc9f9d..e045e790fac7 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -105,7 +105,7 @@ The dependencies between layers are enforced by the layer-check command._ | Packages | Layer Dependencies | | --- | --- | -| - [@fluid-experimental/data-objects](/experimental/framework/data-objects)
- [@fluid-experimental/property-changeset](/experimental/PropertyDDS/packages/property-changeset)
- [@fluid-experimental/property-common](/experimental/PropertyDDS/packages/property-common)
- [@fluid-internal/platform-dependent](/experimental/PropertyDDS/packages/property-common/platform-dependent) (private)
- [@fluid-experimental/property-dds](/experimental/PropertyDDS/packages/property-dds)
- [@fluid-experimental/property-properties](/experimental/PropertyDDS/packages/property-properties)
- [@fluid-experimental/last-edited](/experimental/framework/last-edited)
- [@fluidframework/agent-scheduler](/packages/framework/agent-scheduler)
- [@fluidframework/aqueduct](/packages/framework/aqueduct)
- [@fluid-experimental/attributor](/packages/framework/attributor)
- [@fluidframework/app-insights-logger](/packages/framework/client-logger/app-insights-logger)
- [@fluidframework/fluid-telemetry](/packages/framework/client-logger/fluid-telemetry)
- [@fluid-experimental/data-object-base](/packages/framework/data-object-base)
- [@fluid-experimental/dds-interceptions](/packages/framework/dds-interceptions)
- [@fluidframework/fluid-static](/packages/framework/fluid-static)
- [@fluid-experimental/oldest-client-observer](/packages/framework/oldest-client-observer)
- [@fluidframework/presence](/packages/framework/presence)
- [@fluidframework/react](/packages/framework/react)
- [@fluidframework/request-handler](/packages/framework/request-handler)
- [@fluidframework/synthesize](/packages/framework/synthesize)
- [@fluidframework/tree-agent](/packages/framework/tree-agent)
- [@fluidframework/tree-agent-langchain](/packages/framework/tree-agent-langchain)
- [@fluidframework/tree-agent-ses](/packages/framework/tree-agent-ses)
- [@fluidframework/undo-redo](/packages/framework/undo-redo) | - [Core-Interfaces](#Core-Interfaces)
- [Driver-Definitions](#Driver-Definitions)
- [Container-Definitions](#Container-Definitions)
- [Core-Utils](#Core-Utils)
- [Client-Utils](#Client-Utils)
- [Telemetry-Utils](#Telemetry-Utils)
- [Loader](#Loader)
- [Runtime](#Runtime)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
  | +| - [@fluid-experimental/data-objects](/experimental/framework/data-objects)
- [@fluid-experimental/property-changeset](/experimental/PropertyDDS/packages/property-changeset)
- [@fluid-experimental/property-common](/experimental/PropertyDDS/packages/property-common)
- [@fluid-internal/platform-dependent](/experimental/PropertyDDS/packages/property-common/platform-dependent) (private)
- [@fluid-experimental/property-dds](/experimental/PropertyDDS/packages/property-dds)
- [@fluid-experimental/property-properties](/experimental/PropertyDDS/packages/property-properties)
- [@fluid-experimental/last-edited](/experimental/framework/last-edited)
- [@fluidframework/agent-scheduler](/packages/framework/agent-scheduler)
- [@fluidframework/aqueduct](/packages/framework/aqueduct)
- [@fluid-experimental/attributor](/packages/framework/attributor)
- [@fluidframework/app-insights-logger](/packages/framework/client-logger/app-insights-logger)
- [@fluidframework/fluid-telemetry](/packages/framework/client-logger/fluid-telemetry)
- [@fluid-experimental/data-object-base](/packages/framework/data-object-base)
- [@fluid-experimental/dds-interceptions](/packages/framework/dds-interceptions)
- [@fluidframework/fluid-static](/packages/framework/fluid-static)
- [@fluid-experimental/oldest-client-observer](/packages/framework/oldest-client-observer)
- [@fluidframework/presence](/packages/framework/presence)
- [@fluidframework/quill-react](/packages/framework/quill-react)
- [@fluidframework/react](/packages/framework/react)
- [@fluidframework/request-handler](/packages/framework/request-handler)
- [@fluidframework/synthesize](/packages/framework/synthesize)
- [@fluidframework/tree-agent](/packages/framework/tree-agent)
- [@fluidframework/tree-agent-langchain](/packages/framework/tree-agent-langchain)
- [@fluidframework/tree-agent-ses](/packages/framework/tree-agent-ses)
- [@fluidframework/undo-redo](/packages/framework/undo-redo) | - [Core-Interfaces](#Core-Interfaces)
- [Driver-Definitions](#Driver-Definitions)
- [Container-Definitions](#Container-Definitions)
- [Core-Utils](#Core-Utils)
- [Client-Utils](#Client-Utils)
- [Telemetry-Utils](#Telemetry-Utils)
- [Loader](#Loader)
- [Runtime](#Runtime)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
  | ### Build diff --git a/feeds/internal-build.txt b/feeds/internal-build.txt index b09eea3f3941..e933a90a8ba8 100644 --- a/feeds/internal-build.txt +++ b/feeds/internal-build.txt @@ -31,6 +31,7 @@ @fluidframework/synthesize @fluidframework/request-handler @fluidframework/react +@fluidframework/quill-react @fluidframework/presence @fluid-experimental/oldest-client-observer @fluidframework/fluid-static diff --git a/feeds/internal-test.txt b/feeds/internal-test.txt index d87aa427a99b..cb0553a66f02 100644 --- a/feeds/internal-test.txt +++ b/feeds/internal-test.txt @@ -37,6 +37,7 @@ @fluidframework/synthesize @fluidframework/request-handler @fluidframework/react +@fluidframework/quill-react @fluidframework/presence @fluid-experimental/oldest-client-observer @fluidframework/fluid-static diff --git a/feeds/public.txt b/feeds/public.txt index cb76a515b28a..f193df2a682f 100644 --- a/feeds/public.txt +++ b/feeds/public.txt @@ -30,6 +30,7 @@ @fluidframework/synthesize @fluidframework/request-handler @fluidframework/react +@fluidframework/quill-react @fluidframework/presence @fluid-experimental/oldest-client-observer @fluidframework/fluid-static diff --git a/fluidBuild.config.cjs b/fluidBuild.config.cjs index 891c20358718..d15591dc38be 100644 --- a/fluidBuild.config.cjs +++ b/fluidBuild.config.cjs @@ -437,6 +437,7 @@ module.exports = { "^build-tools/", "^common/build/", "^experimental/PropertyDDS/", + "^packages/framework/quill-react/", "^tools/api-markdown-documenter/", ], "npm-package-exports-field": [ diff --git a/packages/framework/quill-react/.mocharc.cjs b/packages/framework/quill-react/.mocharc.cjs new file mode 100644 index 000000000000..abdc987d1f19 --- /dev/null +++ b/packages/framework/quill-react/.mocharc.cjs @@ -0,0 +1,15 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +"use strict"; + +const getFluidTestMochaConfig = require("@fluid-internal/mocha-test-setup/mocharc-common"); + +const config = getFluidTestMochaConfig(__dirname); +// TODO: figure out why this package needs the --exit flag, tests might not be cleaning up correctly after themselves. +// AB#7856 +config.exit = true; + +module.exports = config; diff --git a/packages/framework/quill-react/.npmignore b/packages/framework/quill-react/.npmignore new file mode 100644 index 000000000000..82dfd0b1dbf0 --- /dev/null +++ b/packages/framework/quill-react/.npmignore @@ -0,0 +1,5 @@ +nyc +*.log +**/*.tsbuildinfo +src/test +dist/test diff --git a/packages/framework/quill-react/LICENSE b/packages/framework/quill-react/LICENSE new file mode 100644 index 000000000000..60af0a6a40e9 --- /dev/null +++ b/packages/framework/quill-react/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation and contributors. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/framework/quill-react/README.md b/packages/framework/quill-react/README.md new file mode 100644 index 000000000000..63b780133a27 --- /dev/null +++ b/packages/framework/quill-react/README.md @@ -0,0 +1,9 @@ +# @fluidframework/quill-react + +Examples for integrating content powered by the Fluid Framework into [React](https://react.dev/) applications that utilize the [Quill](https://quilljs.com/) rich text editor. + +This package provides Quill-based views for both plain and formatted text editing backed by SharedTree. + +## Known Issues and Limitations + +Quill requires DOM access at import time, so this package should only be imported in browser environments or test environments with JSDOM set up before import. diff --git a/packages/framework/quill-react/eslint.config.mts b/packages/framework/quill-react/eslint.config.mts new file mode 100644 index 000000000000..863f297b7ac3 --- /dev/null +++ b/packages/framework/quill-react/eslint.config.mts @@ -0,0 +1,11 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { Linter } from "eslint"; +import { strict } from "../../../common/build/eslint-config-fluid/flat.mts"; + +const config: Linter.Config[] = [...strict]; + +export default config; diff --git a/packages/framework/quill-react/package.json b/packages/framework/quill-react/package.json new file mode 100644 index 000000000000..ad4de9056447 --- /dev/null +++ b/packages/framework/quill-react/package.json @@ -0,0 +1,108 @@ +{ + "name": "@fluidframework/quill-react", + "version": "2.91.0", + "description": "Utilities for integrating content powered by the Fluid Framework into React applications that utilize the Quill rich text editor", + "homepage": "https://fluidframework.com", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/FluidFramework.git", + "directory": "packages/framework/quill-react" + }, + "license": "MIT", + "author": "Microsoft and contributors", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + } + } + }, + "main": "lib/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "build": "fluid-build . --task build", + "build:compile": "fluid-build . --task compile", + "build:esnext": "tsc --project ./tsconfig.json && copyfiles -f ../../../common/build/build-common/src/esm/package.json ./lib", + "build:test": "npm run build:test:esm", + "build:test:esm": "tsc --project ./src/test/tsconfig.json", + "check:biome": "biome check .", + "check:format": "npm run check:biome", + "clean": "rimraf --glob dist lib \"**/*.tsbuildinfo\" \"**/*.build.log\" nyc", + "eslint": "eslint --quiet --format stylish src", + "eslint:fix": "eslint --quiet --format stylish src --fix --fix-type problem,suggestion,layout", + "format": "npm run format:biome", + "format:biome": "biome check . --write", + "lint": "fluid-build . --task lint", + "lint:fix": "fluid-build . --task eslint:fix --task format", + "pack:tests": "tar -cf ./react.test-files.tar ./src/test ./lib/test", + "test": "npm run test:mocha", + "test:coverage": "c8 npm test", + "test:mocha": "npm run test:mocha:esm", + "test:mocha:esm": "mocha", + "test:mocha:verbose": "cross-env FLUID_TEST_VERBOSE=1 npm run test:mocha" + }, + "c8": { + "all": true, + "cache-dir": "nyc/.cache", + "exclude": [ + "src/test/**/*.*ts", + "dist/test/**/*.*js", + "lib/test/**/*.*js" + ], + "exclude-after-remap": false, + "include": [ + "src/**/*.*ts", + "dist/**/*.*js", + "lib/**/*.*js" + ], + "report-dir": "nyc/report", + "reporter": [ + "cobertura", + "html", + "text" + ], + "temp-directory": "nyc/.nyc_output" + }, + "dependencies": { + "@fluidframework/core-utils": "workspace:~", + "@fluidframework/tree": "workspace:~", + "quill": "^2.0.3", + "quill-delta": "^5.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@biomejs/biome": "~2.4.5", + "@fluid-internal/mocha-test-setup": "workspace:~", + "@fluid-tools/build-cli": "^0.63.0", + "@fluidframework/build-common": "^2.0.3", + "@fluidframework/build-tools": "^0.63.0", + "@fluidframework/eslint-config-fluid": "workspace:~", + "@fluidframework/react": "workspace:~", + "@fluidframework/tinylicious-client": "workspace:~", + "@testing-library/react": "^16.3.0", + "@types/mocha": "^10.0.10", + "@types/node": "~20.19.30", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "c8": "^10.1.3", + "copyfiles": "^2.4.1", + "cross-env": "^10.1.0", + "eslint": "~9.39.1", + "eslint-config-prettier": "~10.1.8", + "global-jsdom": "^26.0.0", + "jiti": "^2.6.1", + "jsdom": "^26.1.0", + "mocha": "^11.7.5", + "mocha-multi-reporters": "^1.5.1", + "rimraf": "^6.1.3", + "typescript": "~5.4.5" + }, + "typeValidation": { + "disabled": true + } +} diff --git a/packages/framework/react/src/text/formatted/index.ts b/packages/framework/quill-react/src/formatted/index.ts similarity index 100% rename from packages/framework/react/src/text/formatted/index.ts rename to packages/framework/quill-react/src/formatted/index.ts diff --git a/packages/framework/react/src/text/formatted/quillFormattedView.tsx b/packages/framework/quill-react/src/formatted/quillFormattedView.tsx similarity index 99% rename from packages/framework/react/src/text/formatted/quillFormattedView.tsx rename to packages/framework/quill-react/src/formatted/quillFormattedView.tsx index efd7c817ecb8..78cd10558580 100644 --- a/packages/framework/react/src/text/formatted/quillFormattedView.tsx +++ b/packages/framework/quill-react/src/formatted/quillFormattedView.tsx @@ -4,6 +4,11 @@ */ import { assert } from "@fluidframework/core-utils/internal"; +import { + type PropTreeNode, + unwrapPropTreeNode, + type UndoRedo, +} from "@fluidframework/react/internal"; import { Tree, TreeAlpha, FormattedTextAsTree } from "@fluidframework/tree/internal"; export { FormattedTextAsTree } from "@fluidframework/tree/internal"; import Quill, { type EmitterSource } from "quill"; @@ -18,9 +23,6 @@ import { } from "react"; import * as ReactDOM from "react-dom"; -import { type PropTreeNode, unwrapPropTreeNode } from "../../propNode.js"; -import type { UndoRedo } from "../../undoRedo.js"; - // Workaround for quill-delta's export style not working well with node16 module resolution. type Delta = DeltaPackage.default; type QuillDeltaOp = DeltaPackage.Op; @@ -28,7 +30,7 @@ const Delta = DeltaPackage.default; /** * Props for the FormattedMainView component. - * @input @internal + * @internal */ export interface FormattedMainViewProps { readonly root: PropTreeNode; @@ -38,7 +40,7 @@ export interface FormattedMainViewProps { /** * Ref handle exposing undo/redo methods for the formatted editor. - * @input @internal + * @internal */ export type FormattedEditorHandle = Pick; diff --git a/packages/framework/react/src/text/quill.ts b/packages/framework/quill-react/src/index.ts similarity index 50% rename from packages/framework/react/src/text/quill.ts rename to packages/framework/quill-react/src/index.ts index e91b2cfa5ce1..311aa2c42504 100644 --- a/packages/framework/react/src/text/quill.ts +++ b/packages/framework/quill-react/src/index.ts @@ -3,15 +3,10 @@ * Licensed under the MIT License. */ -export { type PropTreeNode } from "../propNode.js"; -export { type UndoRedo } from "../undoRedo.js"; +export { QuillMainView, type MainViewProps } from "./plain/index.js"; export { + FormattedTextAsTree, FormattedMainView, type FormattedMainViewProps, type FormattedEditorHandle, } from "./formatted/index.js"; -export { - PlainTextMainView, - QuillMainView as PlainQuillView, - type MainViewProps as PlainMainViewProps, -} from "./plain/index.js"; diff --git a/packages/framework/quill-react/src/plain/index.ts b/packages/framework/quill-react/src/plain/index.ts new file mode 100644 index 000000000000..989d8270b816 --- /dev/null +++ b/packages/framework/quill-react/src/plain/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { MainView as QuillMainView, type MainViewProps } from "./quillView.js"; diff --git a/packages/framework/react/src/text/plain/quillView.tsx b/packages/framework/quill-react/src/plain/quillView.tsx similarity index 95% rename from packages/framework/react/src/text/plain/quillView.tsx rename to packages/framework/quill-react/src/plain/quillView.tsx index 09e0af4e252c..98d0c8e1a355 100644 --- a/packages/framework/react/src/text/plain/quillView.tsx +++ b/packages/framework/quill-react/src/plain/quillView.tsx @@ -3,18 +3,18 @@ * Licensed under the MIT License. */ +import { + withMemoizedTreeObservations, + syncTextToTree, + type PropTreeNode, +} from "@fluidframework/react/internal"; import type { TextAsTree } from "@fluidframework/tree/internal"; import Quill from "quill"; import { type FC, useEffect, useRef } from "react"; -import type { PropTreeNode } from "../../propNode.js"; -import { withMemoizedTreeObservations } from "../../useTree.js"; - -import { syncTextToTree } from "./plainUtils.js"; - /** * Props for the MainView component. - * @input @internal + * @internal */ export interface MainViewProps { root: PropTreeNode; diff --git a/packages/framework/react/src/test/mochaHooks.ts b/packages/framework/quill-react/src/test/mochaHooks.ts similarity index 100% rename from packages/framework/react/src/test/mochaHooks.ts rename to packages/framework/quill-react/src/test/mochaHooks.ts diff --git a/packages/framework/quill-react/src/test/textEditor.test.tsx b/packages/framework/quill-react/src/test/textEditor.test.tsx new file mode 100644 index 000000000000..5d02e2f150b0 --- /dev/null +++ b/packages/framework/quill-react/src/test/textEditor.test.tsx @@ -0,0 +1,951 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import { toPropTreeNode, UndoRedoStacks } from "@fluidframework/react/internal"; +import { TreeViewConfiguration, type TreeView } from "@fluidframework/tree"; +import { TreeAlpha } from "@fluidframework/tree/alpha"; +import { independentView, TextAsTree } from "@fluidframework/tree/internal"; +import { render } from "@testing-library/react"; +import globalJsdom from "global-jsdom"; +import DeltaPackage from "quill-delta"; +import { createRef } from "react"; + +import { + clipboardFormatMatcher, + FormattedTextAsTree, + FormattedMainView, + type FormattedEditorHandle, + parseCssFontFamily, + parseCssFontSize, + // Allow import of files being tested + // eslint-disable-next-line import-x/no-internal-modules +} from "../formatted/quillFormattedView.js"; +import { + QuillMainView, + // Allow import of files being tested +} from "../plain/index.js"; + +// Workaround for quill-delta's export style not working well with node16 module resolution. +type Delta = DeltaPackage.default; +const Delta = DeltaPackage.default; + +// Configuration for creating formatted text views +const formattedTreeConfig = new TreeViewConfiguration({ schema: FormattedTextAsTree.Tree }); + +/** + * Creates a TreeView for formatted text, initialized with the provided initial value. + */ +function createFormattedTreeView(initialValue = ""): { + tree: FormattedTextAsTree.Tree; +} { + const treeView = independentView(formattedTreeConfig); + treeView.initialize(FormattedTextAsTree.Tree.fromString(initialValue)); + return { tree: treeView.root }; +} + +/** + * Creates a TreeView for formatted text with events access (needed for undo/redo tests). + */ +function createFormattedTreeViewWithEvents( + initialValue = "", +): TreeView { + const treeView = independentView(formattedTreeConfig); + treeView.initialize(FormattedTextAsTree.Tree.fromString(initialValue)); + return treeView; +} + +// TODO add collaboration tests when rich formatting is supported using TestContainerRuntimeFactory from +// @fluidframework/test-utils to test rich formatting data sync between multiple collaborators +describe("textEditor", () => { + // Note: JSDOM is initialized once in mochaHooks.ts before Quill is imported, + // since Quill requires document at import time. See src/test/mochaHooks.ts. + // These tests reset up a clean DOM. + + let cleanup: () => void; + + // TODO: why does making this beforeEach/afterEach instead of before/after cause cleanup to crash? + // It seems like each test should be able to have its own clean DOM. + before(() => { + cleanup = globalJsdom(); + }); + + after(() => { + cleanup(); + }); + + describe(`Quill view`, () => { + describe("dom tests", () => { + // Run without strict mode to make sure it works in a normal production setup. + // Run with strict mode to potentially detect additional issues. + for (const reactStrictMode of [false, true]) { + describe(`StrictMode: ${reactStrictMode}`, () => { + const ViewComponent = QuillMainView; + + it("renders MainView with editor container", () => { + const text = TextAsTree.Tree.fromString(""); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.match(rendered.baseElement.textContent ?? "", /Collaborative Text Editor/); + }); + + it("renders MainView with initial text content", () => { + const text = TextAsTree.Tree.fromString("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.match(rendered.baseElement.textContent ?? "", /Hello World/); + }); + + it("invalidates view when tree is mutated", () => { + const text = TextAsTree.Tree.fromString("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Mutate the tree by inserting text + text.insertAt(5, " World"); + + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Hello World/); + }); + + it("invalidates view when text is removed", () => { + const text = TextAsTree.Tree.fromString("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Mutate the tree by removing " World" (indices 5 to 11) + text.removeRange(5, 11); + + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Hello/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /World/); + }); + + it("invalidates view when text is cleared and replaced", () => { + const text = TextAsTree.Tree.fromString("Original"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Clear all text + const length = [...text.characters()].length; + text.removeRange(0, length); + + // Insert new text + text.insertAt(0, "Replaced"); + + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Replaced/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /Original/); + }); + + // Tests for surrogate pair characters (emojis use 2 UTF-16 code units) + // These verify correct handling where editor indexing may differ from iteration. + + it("renders MainView with surrogate pair characters", () => { + // ๐Ÿ˜€ is a surrogate pair: "๐Ÿ˜€".length === 2, but [..."๐Ÿ˜€"].length === 1 + const text = TextAsTree.Tree.fromString("Hello ๐Ÿ˜€ World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.match(rendered.baseElement.textContent ?? "", /Hello ๐Ÿ˜€ World/); + }); + + it("inserts text after surrogate pair characters", () => { + const text = TextAsTree.Tree.fromString("A๐Ÿ˜€B"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Insert after the emoji (index 2 in character count: A, ๐Ÿ˜€, B) + text.insertAt(2, "X"); + + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /A๐Ÿ˜€XB/); + }); + + it("removes surrogate pair characters", () => { + const text = TextAsTree.Tree.fromString("A๐Ÿ˜€B"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Remove the emoji (index 1, length 1 in character count) + text.removeRange(1, 2); + + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /AB/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /๐Ÿ˜€/); + }); + + it("handles multiple surrogate pair characters", () => { + const text = TextAsTree.Tree.fromString("๐Ÿ‘‹๐ŸŒ๐ŸŽ‰"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Insert between emojis + text.insertAt(2, "!"); + + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /๐Ÿ‘‹๐ŸŒ!๐ŸŽ‰/); + }); + }); + } + }); + }); + + // Formatted text view tests - Initial view rendering (matching plain text test structure) + describe("Formatted Quill view", () => { + describe("dom tests", () => { + for (const reactStrictMode of [false, true]) { + describe(`StrictMode: ${reactStrictMode}`, () => { + it("renders FormattedMainView with editor container", () => { + const { tree } = createFormattedTreeView(); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.match( + rendered.baseElement.textContent ?? "", + /Collaborative Formatted Text Editor/, + ); + }); + + it("renders FormattedMainView with initial text content", () => { + const { tree } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.match(rendered.baseElement.textContent ?? "", /Hello World/); + }); + + it("invalidates view when tree is mutated", () => { + const { tree: text } = createFormattedTreeView("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Mutate the tree by inserting text + text.insertAt(5, " World"); + + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Hello World/); + }); + + it("invalidates view when text is removed", () => { + const { tree: text } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Mutate the tree by removing " World" (indices 5 to 11) + text.removeRange(5, 11); + + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Hello/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /World/); + }); + + it("invalidates view when text is cleared and replaced", () => { + const { tree: text } = createFormattedTreeView("Original"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Clear all text + const length = [...text.characters()].length; + text.removeRange(0, length); + + // Insert new text + text.insertAt(0, "Replaced"); + + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Replaced/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /Original/); + }); + + // Tests for surrogate pair characters (emojis use 2 UTF-16 code units) + // These verify correct handling where editor indexing may differ from iteration. + + it("renders FormattedMainView with surrogate pair characters", () => { + // ๐Ÿ˜€ is a surrogate pair: "๐Ÿ˜€".length === 2, but [..."๐Ÿ˜€"].length === 1 + const { tree: text } = createFormattedTreeView("Hello ๐Ÿ˜€ World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.match(rendered.baseElement.textContent ?? "", /Hello ๐Ÿ˜€ World/); + }); + + it("inserts text after surrogate pair characters", () => { + const { tree: text } = createFormattedTreeView("A๐Ÿ˜€B"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Insert after the emoji (index 2 in character count: A, ๐Ÿ˜€, B) + text.insertAt(2, "X"); + + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /A๐Ÿ˜€XB/); + }); + + it("removes surrogate pair characters", () => { + const { tree: text } = createFormattedTreeView("A๐Ÿ˜€B"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Remove the emoji (index 1, length 1 in character count) + text.removeRange(1, 2); + + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /AB/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /๐Ÿ˜€/); + }); + + it("handles multiple surrogate pair characters", () => { + const { tree: text } = createFormattedTreeView("๐Ÿ‘‹๐ŸŒ๐ŸŽ‰"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + // Insert between emojis + text.insertAt(2, "!"); + + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /๐Ÿ‘‹๐ŸŒ!๐ŸŽ‰/); + }); + }); + } + }); + + // Helper to create default format + function createPlainFormat(): FormattedTextAsTree.CharacterFormat { + return new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: false, + size: 12, + font: "Arial", + }); + } + + // Essential tests for character attributes + // Each attribute needs: insert, delete, and formatRange tests + describe("character attribute tests", () => { + for (const reactStrictMode of [false, true]) { + describe(`StrictMode: ${reactStrictMode}`, () => { + it("delete on empty string does not throw", () => { + const { tree: text } = createFormattedTreeView(); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.doesNotThrow(() => { + text.removeRange(0, 0); + rendered.rerender(content); + }); + }); + + describe("bold", () => { + it("inserts bold text and renders with tag", () => { + const { tree: text } = createFormattedTreeView("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok(!rendered.container.querySelector("strong"), "Initially: no "); + + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: true, + italic: false, + underline: false, + size: 12, + font: "Arial", + }); + text.insertAt(2, "BOLD"); + + rendered.rerender(content); + const el = rendered.container.querySelector("strong"); + assert.ok(el, "Expected tag"); + assert.match(el.textContent ?? "", /BOLD/); + }); + + it("deletes bold text and removes tag", () => { + const { tree: text } = createFormattedTreeView(); + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: true, + italic: false, + underline: false, + size: 12, + font: "Arial", + }); + text.insertAt(0, "BOLD"); + text.defaultFormat = createPlainFormat(); + text.insertAt(4, "plain"); + + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok(rendered.container.querySelector("strong"), "Initially: has "); + + text.removeRange(0, 4); + rendered.rerender(content); + + assert.ok( + !rendered.container.querySelector("strong"), + "After delete: no ", + ); + }); + + it("applies bold via formatRange", () => { + const { tree: text } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + text.formatRange(6, 11, { bold: true }); + rendered.rerender(content); + + const el = rendered.container.querySelector("strong"); + assert.ok(el, "Expected after formatRange"); + assert.match(el.textContent ?? "", /World/); + }); + }); + + describe("italic", () => { + it("inserts italic text and renders with tag", () => { + const { tree: text } = createFormattedTreeView("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok(!rendered.container.querySelector("em"), "Initially: no "); + + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: true, + underline: false, + size: 12, + font: "Arial", + }); + text.insertAt(2, "ITAL"); + + rendered.rerender(content); + const el = rendered.container.querySelector("em"); + assert.ok(el, "Expected tag"); + assert.match(el.textContent ?? "", /ITAL/); + }); + + it("deletes italic text and removes tag", () => { + const { tree: text } = createFormattedTreeView(); + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: true, + underline: false, + size: 12, + font: "Arial", + }); + text.insertAt(0, "ITAL"); + text.defaultFormat = createPlainFormat(); + text.insertAt(4, "plain"); + + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok(rendered.container.querySelector("em"), "Initially: has "); + + text.removeRange(0, 4); + rendered.rerender(content); + + assert.ok(!rendered.container.querySelector("em"), "After delete: no "); + }); + + it("applies italic via formatRange", () => { + const { tree: text } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + text.formatRange(6, 11, { italic: true }); + rendered.rerender(content); + + const el = rendered.container.querySelector("em"); + assert.ok(el, "Expected after formatRange"); + assert.match(el.textContent ?? "", /World/); + }); + }); + + describe("underline", () => { + it("inserts underlined text and renders with tag", () => { + const { tree: text } = createFormattedTreeView("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok(!rendered.container.querySelector("u"), "Initially: no "); + + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: true, + size: 12, + font: "Arial", + }); + text.insertAt(2, "UNDER"); + + rendered.rerender(content); + const el = rendered.container.querySelector("u"); + assert.ok(el, "Expected tag"); + assert.match(el.textContent ?? "", /UNDER/); + }); + + it("deletes underlined text and removes tag", () => { + const { tree: text } = createFormattedTreeView(); + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: true, + size: 12, + font: "Arial", + }); + text.insertAt(0, "UNDER"); + text.defaultFormat = createPlainFormat(); + text.insertAt(5, "plain"); + + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok(rendered.container.querySelector("u"), "Initially: has "); + + text.removeRange(0, 5); + rendered.rerender(content); + + assert.ok(!rendered.container.querySelector("u"), "After delete: no "); + }); + + it("applies underline via formatRange", () => { + const { tree: text } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + text.formatRange(6, 11, { underline: true }); + rendered.rerender(content); + + const el = rendered.container.querySelector("u"); + assert.ok(el, "Expected after formatRange"); + assert.match(el.textContent ?? "", /World/); + }); + }); + + describe("size", () => { + it("inserts huge size text and renders with .ql-size-huge", () => { + const { tree: text } = createFormattedTreeView("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok( + !rendered.container.querySelector(".ql-size-huge"), + "Initially: no .ql-size-huge", + ); + + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: false, + size: 24, + font: "Arial", + }); + text.insertAt(2, "HUGE"); + + rendered.rerender(content); + const el = rendered.container.querySelector(".ql-size-huge"); + assert.ok(el, "Expected .ql-size-huge"); + assert.match(el.textContent ?? "", /HUGE/); + }); + + it("deletes huge size text and removes .ql-size-huge", () => { + const { tree: text } = createFormattedTreeView(); + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: false, + size: 24, + font: "Arial", + }); + text.insertAt(0, "HUGE"); + text.defaultFormat = createPlainFormat(); + text.insertAt(4, "plain"); + + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok( + rendered.container.querySelector(".ql-size-huge"), + "Initially: has .ql-size-huge", + ); + + text.removeRange(0, 4); + rendered.rerender(content); + + assert.ok( + !rendered.container.querySelector(".ql-size-huge"), + "After delete: no .ql-size-huge", + ); + }); + + it("applies size via formatRange", () => { + const { tree: text } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + text.formatRange(6, 11, { size: 24 }); + rendered.rerender(content); + + const el = rendered.container.querySelector(".ql-size-huge"); + assert.ok(el, "Expected .ql-size-huge after formatRange"); + assert.match(el.textContent ?? "", /World/); + }); + }); + + describe("font", () => { + it("inserts monospace font text and renders with .ql-font-monospace", () => { + const { tree: text } = createFormattedTreeView("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok( + !rendered.container.querySelector(".ql-font-monospace"), + "Initially: no .ql-font-monospace", + ); + + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: false, + size: 12, + font: "monospace", + }); + text.insertAt(2, "MONO"); + + rendered.rerender(content); + const el = rendered.container.querySelector(".ql-font-monospace"); + assert.ok(el, "Expected .ql-font-monospace"); + assert.match(el.textContent ?? "", /MONO/); + }); + + it("deletes monospace font text and removes .ql-font-monospace", () => { + const { tree: text } = createFormattedTreeView(); + text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ + bold: false, + italic: false, + underline: false, + size: 12, + font: "monospace", + }); + text.insertAt(0, "MONO"); + text.defaultFormat = createPlainFormat(); + text.insertAt(4, "plain"); + + const content = ; + const rendered = render(content, { reactStrictMode }); + + assert.ok( + rendered.container.querySelector(".ql-font-monospace"), + "Initially: has .ql-font-monospace", + ); + + text.removeRange(0, 4); + rendered.rerender(content); + + assert.ok( + !rendered.container.querySelector(".ql-font-monospace"), + "After delete: no .ql-font-monospace", + ); + }); + + it("applies font via formatRange", () => { + const { tree: text } = createFormattedTreeView("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); + + text.formatRange(6, 11, { font: "monospace" }); + rendered.rerender(content); + + const el = rendered.container.querySelector(".ql-font-monospace"); + assert.ok(el, "Expected .ql-font-monospace after formatRange"); + assert.match(el.textContent ?? "", /World/); + }); + }); + }); + } + }); + + // Undo/Redo tests for non-transactional edits + describe("undo/redo", () => { + for (const reactStrictMode of [false, true]) { + describe(`StrictMode: ${reactStrictMode}`, () => { + it("insert character, undo removes it, redo restores it", () => { + const treeView = createFormattedTreeViewWithEvents(); + const text = treeView.root; + const undoRedo = new UndoRedoStacks(treeView.events); + const editorRef = createRef(); + const content = ( + + ); + const rendered = render(content, { reactStrictMode }); + + // Insert a character + text.insertAt(0, "A"); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /A/); + + // Undo - character should be removed + editorRef.current?.undo(); + rendered.rerender(content); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /A/); + + // Redo - character should be restored + editorRef.current?.redo(); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /A/); + }); + + it("insert character, make bold, undo removes bold but keeps character", () => { + const treeView = createFormattedTreeViewWithEvents(); + const text = treeView.root; + const undoRedo = new UndoRedoStacks(treeView.events); + const editorRef = createRef(); + const content = ( + + ); + const rendered = render(content, { reactStrictMode }); + + // Insert a character + text.insertAt(0, "B"); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /B/); + assert.ok(!rendered.container.querySelector("strong"), "Initially: no "); + + // Make it bold + text.formatRange(0, 1, { bold: true }); + rendered.rerender(content); + assert.ok( + rendered.container.querySelector("strong"), + "After format: has ", + ); + + // Undo - bold should be removed, character should remain + editorRef.current?.undo(); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /B/); + assert.ok( + !rendered.container.querySelector("strong"), + "After undo: no , character remains", + ); + }); + + it("multiple operations in transaction undo together as one unit", () => { + const treeView = createFormattedTreeViewWithEvents(); + const text = treeView.root; + const undoRedo = new UndoRedoStacks(treeView.events); + const editorRef = createRef(); + const content = ( + + ); + const rendered = render(content, { reactStrictMode }); + + // Two operations in one transaction + TreeAlpha.branch(text)?.runTransaction(() => { + text.insertAt(0, "A"); + text.insertAt(1, "B"); + }); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /AB/); + + // Single undo should remove both characters + editorRef.current?.undo(); + rendered.rerender(content); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /A/); + assert.doesNotMatch(rendered.baseElement.textContent, /B/); + + // Single redo should restore both characters + editorRef.current?.redo(); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /AB/); + }); + }); + } + }); + describe("copy-paste helpers", () => { + /** Helper to create an HTMLElement with inline styles. */ + function styledElement(styles: Partial): HTMLElement { + const el = document.createElement("span"); + Object.assign(el.style, styles); + return el; + } + + describe("parseCssFontSize", () => { + it("returns undefined when no fontSize is set", () => { + assert.equal(parseCssFontSize(styledElement({})), undefined); + }); + + it("returns Quill size name for supported pixel values", () => { + assert.equal(parseCssFontSize(styledElement({ fontSize: "10px" })), "small"); + assert.equal(parseCssFontSize(styledElement({ fontSize: "18px" })), "large"); + assert.equal(parseCssFontSize(styledElement({ fontSize: "24px" })), "huge"); + }); + + it("returns undefined for default or unrecognized sizes", () => { + assert.equal(parseCssFontSize(styledElement({ fontSize: "12px" })), undefined); + assert.equal(parseCssFontSize(styledElement({ fontSize: "42px" })), undefined); + }); + }); + + describe("parseCssFontFamily", () => { + it("returns undefined when no fontFamily is set", () => { + assert.equal(parseCssFontFamily(styledElement({})), undefined); + }); + + it("returns first recognized font in a comma-separated stack", () => { + assert.equal( + parseCssFontFamily(styledElement({ fontFamily: "monospace" })), + "monospace", + ); + assert.equal( + parseCssFontFamily(styledElement({ fontFamily: '"Courier New", monospace' })), + "monospace", + ); + assert.equal( + parseCssFontFamily( + styledElement({ fontFamily: '"Times New Roman", "Arial", serif' }), + ), + "Arial", + ); + }); + it("strips single quotes around recognized font names", () => { + assert.equal(parseCssFontFamily(styledElement({ fontFamily: "'Arial'" })), "Arial"); + }); + it("returns undefined for unrecognized fonts", () => { + assert.equal( + parseCssFontFamily(styledElement({ fontFamily: '"Courier New", fantasy' })), + undefined, + ); + }); + }); + + describe("clipboardFormatMatcher", () => { + it("returns delta unchanged for non-HTMLElement nodes", () => { + const delta = new Delta().insert("hello"); + const text = document.createTextNode("hello"); + const result = clipboardFormatMatcher(text, delta); + assert.deepEqual(result.ops, delta.ops); + }); + + it("applies size and font attributes from inline styles", () => { + const delta = new Delta().insert("hello"); + const el = styledElement({ fontSize: "18px", fontFamily: "serif" }); + const result = clipboardFormatMatcher(el, delta); + assert.equal(result.ops[0]?.attributes?.size, "large"); + assert.equal(result.ops[0]?.attributes?.font, "serif"); + }); + + it("returns delta unchanged when no recognized styles", () => { + const delta = new Delta().insert("hello"); + const el = styledElement({}); + const result = clipboardFormatMatcher(el, delta); + assert.deepEqual(result.ops, delta.ops); + }); + }); + }); + + // Unicode 16+ (joined emojis) section - test attribute cycling + describe("Unicode 16+ joined emoji attribute cycling", () => { + // ZWJ (Zero Width Joiner) emoji sequence: ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ = family emoji + const joinedEmoji = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"; + + for (const reactStrictMode of [false, true]) { + describe(`StrictMode: ${reactStrictMode}`, () => { + it("applies bold to joined emoji and removes it preserving text", () => { + const { tree: text } = createFormattedTreeView(`Test ${joinedEmoji} Text`); + const content = ; + const rendered = render(content, { reactStrictMode }); + + const emojiStart = 5; // "Test " is 5 chars + const emojiLength = [...joinedEmoji].length; + text.formatRange(emojiStart, emojiStart + emojiLength, { bold: true }); + rendered.rerender(content); + + assert.ok( + rendered.container.querySelector("strong"), + "After bold: expected ", + ); + assert.ok( + (rendered.baseElement.textContent ?? "").includes(joinedEmoji), + "After bold: emoji should be preserved", + ); + + text.formatRange(emojiStart, emojiStart + emojiLength, { bold: false }); + rendered.rerender(content); + + assert.ok( + !rendered.container.querySelector("strong"), + "After remove bold: no expected", + ); + assert.ok( + (rendered.baseElement.textContent ?? "").includes(joinedEmoji), + "After remove bold: emoji should still be preserved", + ); + }); + + it("applies size to joined emoji and removes it preserving text", () => { + const { tree: text } = createFormattedTreeView(`Test ${joinedEmoji} Text`); + const content = ; + const rendered = render(content, { reactStrictMode }); + + const emojiStart = 5; + const emojiLength = [...joinedEmoji].length; + text.formatRange(emojiStart, emojiStart + emojiLength, { size: 24 }); + rendered.rerender(content); + + assert.ok( + rendered.container.querySelector(".ql-size-huge"), + "After size: expected .ql-size-huge", + ); + assert.ok( + (rendered.baseElement.textContent ?? "").includes(joinedEmoji), + "Emoji preserved", + ); + + text.formatRange(emojiStart, emojiStart + emojiLength, { size: 12 }); + rendered.rerender(content); + + assert.ok( + !rendered.container.querySelector(".ql-size-huge"), + "After remove: no .ql-size-huge", + ); + assert.ok( + (rendered.baseElement.textContent ?? "").includes(joinedEmoji), + "Emoji still preserved", + ); + }); + }); + } + }); + }); +}); diff --git a/packages/framework/quill-react/src/test/tsconfig.json b/packages/framework/quill-react/src/test/tsconfig.json new file mode 100644 index 000000000000..a9a31d769553 --- /dev/null +++ b/packages/framework/quill-react/src/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../../common/build/build-common/tsconfig.test.node16.json", + "compilerOptions": { + "rootDir": "./", + "outDir": "../../lib/test", + "types": ["mocha", "node"], + "noUnusedLocals": false, + }, + "include": ["./**/*", "mochaHooks.ts"], + "references": [ + { + "path": "../..", + }, + ], +} diff --git a/packages/framework/quill-react/tsconfig.json b/packages/framework/quill-react/tsconfig.json new file mode 100644 index 000000000000..a2e02545e082 --- /dev/null +++ b/packages/framework/quill-react/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../common/build/build-common/tsconfig.node16.json", + "include": ["src/**/*"], + "exclude": ["src/test/**/*"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "exactOptionalPropertyTypes": false, + "noUnusedLocals": false, + // ES2021 needed for FinalizationRegistry + "lib": ["ES2021", "DOM", "DOM.Iterable"], + // Suppress type errors in Quill's use of quill-delta. + // Without this, the quill code gives a lot of errors like: + // node_modules/.pnpm/quill@2.0.3/node_modules/quill/blots/block.d.ts:6:17 - error TS2709: Cannot use namespace 'Delta' as a type. + // These issues (and others, see imports of quill-delta) are likely related to quill-delta's export style not working well with node16 module resolution. + // Quill internally uses `"moduleResolution": "bundler"` which seems to work properly with quill-delta's exports, but would be inconsistent with the rest of this repo. + "skipLibCheck": true, + }, +} diff --git a/packages/framework/quill-react/tsdoc.json b/packages/framework/quill-react/tsdoc.json new file mode 100644 index 000000000000..ecb918da5cb8 --- /dev/null +++ b/packages/framework/quill-react/tsdoc.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", + "extends": ["../../../common/build/build-common/tsdoc-base.json"] +} diff --git a/packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json b/packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json deleted file mode 100644 index aee5a12041e9..000000000000 --- a/packages/framework/react/api-extractor/api-extractor-lint-text_quill.esm.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", - "extends": "/../../../common/build/build-common/api-extractor-lint.entrypoint.json", - "mainEntryPointFilePath": "/lib/text/quill.d.ts" -} diff --git a/packages/framework/react/package.json b/packages/framework/react/package.json index 9a2529a55aea..fe5c0323c580 100644 --- a/packages/framework/react/package.json +++ b/packages/framework/react/package.json @@ -36,12 +36,6 @@ "types": "./lib/index.d.ts", "default": "./lib/index.js" } - }, - "./quill": { - "import": { - "types": "./lib/text/quill.d.ts", - "default": "./lib/text/quill.js" - } } }, "main": "lib/index.js", @@ -62,7 +56,6 @@ "check:exports:esm:alpha": "api-extractor run --config api-extractor/api-extractor-lint-alpha.esm.json", "check:exports:esm:beta": "api-extractor run --config api-extractor/api-extractor-lint-beta.esm.json", "check:exports:esm:public": "api-extractor run --config api-extractor/api-extractor-lint-public.esm.json", - "check:exports:esm:text:quill": "api-extractor run --config api-extractor/api-extractor-lint-text_quill.esm.json", "check:format": "npm run check:biome", "ci:build:docs": "api-extractor run", "clean": "rimraf --glob dist lib {alpha,beta,internal,legacy}.d.ts \"**/*.tsbuildinfo\" \"**/*.build.log\" _api-extractor-temp nyc", @@ -110,8 +103,6 @@ "@fluidframework/runtime-definitions": "workspace:~", "@fluidframework/shared-object-base": "workspace:~", "@fluidframework/tree": "workspace:~", - "quill": "^2.0.3", - "quill-delta": "^5.1.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/packages/framework/react/src/index.ts b/packages/framework/react/src/index.ts index bee6570c1946..7f0aaf39945d 100644 --- a/packages/framework/react/src/index.ts +++ b/packages/framework/react/src/index.ts @@ -45,5 +45,5 @@ export { withMemoizedTreeObservations, } from "./useTree.js"; export { objectIdNumber } from "./simpleIdentifier.js"; - +export { syncTextToTree } from "./text/index.js"; export { UndoRedoStacks, type UndoRedo } from "./undoRedo.js"; diff --git a/packages/framework/react/src/test/text/textEditor.test.tsx b/packages/framework/react/src/test/text/textEditor.test.tsx index ff1b7ff1358a..743109fb20ff 100644 --- a/packages/framework/react/src/test/text/textEditor.test.tsx +++ b/packages/framework/react/src/test/text/textEditor.test.tsx @@ -5,962 +5,142 @@ import { strict as assert } from "node:assert"; -import { TreeViewConfiguration, type TreeView } from "@fluidframework/tree"; -import { TreeAlpha } from "@fluidframework/tree/alpha"; -import { independentView, TextAsTree } from "@fluidframework/tree/internal"; +import { TextAsTree } from "@fluidframework/tree/internal"; import { render } from "@testing-library/react"; import globalJsdom from "global-jsdom"; -import DeltaPackage from "quill-delta"; -import { createRef, type FC } from "react"; import { toPropTreeNode } from "../../propNode.js"; -import { - clipboardFormatMatcher, - FormattedTextAsTree, - FormattedMainView, - type FormattedEditorHandle, - parseCssFontFamily, - parseCssFontSize, - // Allow import of files being tested - // eslint-disable-next-line import-x/no-internal-modules -} from "../../text/formatted/quillFormattedView.js"; -import { - PlainTextMainView, - QuillMainView, - type MainViewProps, - // Allow import of files being tested - // eslint-disable-next-line import-x/no-internal-modules -} from "../../text/plain/index.js"; -import { UndoRedoStacks } from "../../undoRedo.js"; - -// Workaround for quill-delta's export style not working well with node16 module resolution. -type Delta = DeltaPackage.default; -const Delta = DeltaPackage.default; - -// Configuration for creating formatted text views -const formattedTreeConfig = new TreeViewConfiguration({ schema: FormattedTextAsTree.Tree }); - -/** - * Creates a TreeView for formatted text, initialized with the provided initial value. - */ -function createFormattedTreeView(initialValue = ""): { - tree: FormattedTextAsTree.Tree; -} { - const treeView = independentView(formattedTreeConfig); - treeView.initialize(FormattedTextAsTree.Tree.fromString(initialValue)); - return { tree: treeView.root }; -} - -/** - * Creates a TreeView for formatted text with events access (needed for undo/redo tests). - */ -function createFormattedTreeViewWithEvents( - initialValue = "", -): TreeView { - const treeView = independentView(formattedTreeConfig); - treeView.initialize(FormattedTextAsTree.Tree.fromString(initialValue)); - return treeView; -} - -const views: { name: string; component: FC }[] = [ - { name: "Quill", component: QuillMainView }, - { name: "Plain TextArea", component: PlainTextMainView }, -]; - -// TODO add collaboration tests when rich formatting is supported using TestContainerRuntimeFactory from -// @fluidframework/test-utils to test rich formatting data sync between multiple collaborators -describe("textEditor", () => { - // Note: JSDOM is initialized once in mochaHooks.ts before Quill is imported, - // since Quill requires document at import time. See src/test/mochaHooks.ts. - // These tests reset up a clean DOM. +import { PlainTextMainView } from "../../text/index.js"; +describe("Plain TextArea view", () => { let cleanup: () => void; - - // TODO: why does making this beforeEach/afterEach instead of before/after cause cleanup to crash? - // It seems like each test should be able to have its own clean DOM. before(() => { cleanup = globalJsdom(); }); - after(() => { cleanup(); }); - // Loop through all registered views - for (const view of views) { - describe(`${view.name} view`, () => { - describe("dom tests", () => { - // Run without strict mode to make sure it works in a normal production setup. - // Run with strict mode to potentially detect additional issues. - for (const reactStrictMode of [false, true]) { - describe(`StrictMode: ${reactStrictMode}`, () => { - const ViewComponent = view.component; - - it("renders MainView with editor container", () => { - const text = TextAsTree.Tree.fromString(""); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.match( - rendered.baseElement.textContent ?? "", - /Collaborative Text Editor/, - ); - }); - - it("renders MainView with initial text content", () => { - const text = TextAsTree.Tree.fromString("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.match(rendered.baseElement.textContent ?? "", /Hello World/); - }); - - it("invalidates view when tree is mutated", () => { - const text = TextAsTree.Tree.fromString("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Mutate the tree by inserting text - text.insertAt(5, " World"); - - // Rerender and verify the view updates - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /Hello World/); - }); - - it("invalidates view when text is removed", () => { - const text = TextAsTree.Tree.fromString("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Mutate the tree by removing " World" (indices 5 to 11) - text.removeRange(5, 11); - - // Rerender and verify the view updates - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /Hello/); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /World/); - }); - - it("invalidates view when text is cleared and replaced", () => { - const text = TextAsTree.Tree.fromString("Original"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Clear all text - const length = [...text.characters()].length; - text.removeRange(0, length); - - // Insert new text - text.insertAt(0, "Replaced"); - - // Rerender and verify the view updates - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /Replaced/); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /Original/); - }); - - // Tests for surrogate pair characters (emojis use 2 UTF-16 code units) - // These verify correct handling where editor indexing may differ from iteration. - - it("renders MainView with surrogate pair characters", () => { - // ๐Ÿ˜€ is a surrogate pair: "๐Ÿ˜€".length === 2, but [..."๐Ÿ˜€"].length === 1 - const text = TextAsTree.Tree.fromString("Hello ๐Ÿ˜€ World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.match(rendered.baseElement.textContent ?? "", /Hello ๐Ÿ˜€ World/); - }); - - it("inserts text after surrogate pair characters", () => { - const text = TextAsTree.Tree.fromString("A๐Ÿ˜€B"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Insert after the emoji (index 2 in character count: A, ๐Ÿ˜€, B) - text.insertAt(2, "X"); - - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /A๐Ÿ˜€XB/); - }); - - it("removes surrogate pair characters", () => { - const text = TextAsTree.Tree.fromString("A๐Ÿ˜€B"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Remove the emoji (index 1, length 1 in character count) - text.removeRange(1, 2); - - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /AB/); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /๐Ÿ˜€/); - }); - - it("handles multiple surrogate pair characters", () => { - const text = TextAsTree.Tree.fromString("๐Ÿ‘‹๐ŸŒ๐ŸŽ‰"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Insert between emojis - text.insertAt(2, "!"); - - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /๐Ÿ‘‹๐ŸŒ!๐ŸŽ‰/); - }); - }); - } - }); - }); - } - - // Formatted text view tests - Initial view rendering (matching plain text test structure) - describe("Formatted Quill view", () => { - describe("dom tests", () => { - for (const reactStrictMode of [false, true]) { - describe(`StrictMode: ${reactStrictMode}`, () => { - it("renders FormattedMainView with editor container", () => { - const { tree } = createFormattedTreeView(); - const content = ; - const rendered = render(content, { reactStrictMode }); + describe("dom tests", () => { + // Run without strict mode to make sure it works in a normal production setup. + // Run with strict mode to potentially detect additional issues. + for (const reactStrictMode of [false, true]) { + describe(`StrictMode: ${reactStrictMode}`, () => { + const ViewComponent = PlainTextMainView; - assert.match( - rendered.baseElement.textContent ?? "", - /Collaborative Formatted Text Editor/, - ); - }); + it("renders MainView with editor container", () => { + const text = TextAsTree.Tree.fromString(""); + const content = ; + const rendered = render(content, { reactStrictMode }); - it("renders FormattedMainView with initial text content", () => { - const { tree } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.match(rendered.baseElement.textContent ?? "", /Hello World/); - }); - - it("invalidates view when tree is mutated", () => { - const { tree: text } = createFormattedTreeView("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Mutate the tree by inserting text - text.insertAt(5, " World"); - - // Rerender and verify the view updates - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /Hello World/); - }); - - it("invalidates view when text is removed", () => { - const { tree: text } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Mutate the tree by removing " World" (indices 5 to 11) - text.removeRange(5, 11); - - // Rerender and verify the view updates - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /Hello/); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /World/); - }); - - it("invalidates view when text is cleared and replaced", () => { - const { tree: text } = createFormattedTreeView("Original"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Clear all text - const length = [...text.characters()].length; - text.removeRange(0, length); - - // Insert new text - text.insertAt(0, "Replaced"); - - // Rerender and verify the view updates - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /Replaced/); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /Original/); - }); - - // Tests for surrogate pair characters (emojis use 2 UTF-16 code units) - // These verify correct handling where editor indexing may differ from iteration. - - it("renders FormattedMainView with surrogate pair characters", () => { - // ๐Ÿ˜€ is a surrogate pair: "๐Ÿ˜€".length === 2, but [..."๐Ÿ˜€"].length === 1 - const { tree: text } = createFormattedTreeView("Hello ๐Ÿ˜€ World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.match(rendered.baseElement.textContent ?? "", /Hello ๐Ÿ˜€ World/); - }); - - it("inserts text after surrogate pair characters", () => { - const { tree: text } = createFormattedTreeView("A๐Ÿ˜€B"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Insert after the emoji (index 2 in character count: A, ๐Ÿ˜€, B) - text.insertAt(2, "X"); - - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /A๐Ÿ˜€XB/); - }); - - it("removes surrogate pair characters", () => { - const { tree: text } = createFormattedTreeView("A๐Ÿ˜€B"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Remove the emoji (index 1, length 1 in character count) - text.removeRange(1, 2); - - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /AB/); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /๐Ÿ˜€/); - }); - - it("handles multiple surrogate pair characters", () => { - const { tree: text } = createFormattedTreeView("๐Ÿ‘‹๐ŸŒ๐ŸŽ‰"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - // Insert between emojis - text.insertAt(2, "!"); - - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /๐Ÿ‘‹๐ŸŒ!๐ŸŽ‰/); - }); + assert.match(rendered.baseElement.textContent ?? "", /Collaborative Text Editor/); }); - } - }); - - // Helper to create default format - function createPlainFormat(): FormattedTextAsTree.CharacterFormat { - return new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: false, - size: 12, - font: "Arial", - }); - } - - // Essential tests for character attributes - // Each attribute needs: insert, delete, and formatRange tests - describe("character attribute tests", () => { - for (const reactStrictMode of [false, true]) { - describe(`StrictMode: ${reactStrictMode}`, () => { - it("delete on empty string does not throw", () => { - const { tree: text } = createFormattedTreeView(); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.doesNotThrow(() => { - text.removeRange(0, 0); - rendered.rerender(content); - }); - }); - - describe("bold", () => { - it("inserts bold text and renders with tag", () => { - const { tree: text } = createFormattedTreeView("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok(!rendered.container.querySelector("strong"), "Initially: no "); - - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: true, - italic: false, - underline: false, - size: 12, - font: "Arial", - }); - text.insertAt(2, "BOLD"); - - rendered.rerender(content); - const el = rendered.container.querySelector("strong"); - assert.ok(el, "Expected tag"); - assert.match(el.textContent ?? "", /BOLD/); - }); - - it("deletes bold text and removes tag", () => { - const { tree: text } = createFormattedTreeView(); - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: true, - italic: false, - underline: false, - size: 12, - font: "Arial", - }); - text.insertAt(0, "BOLD"); - text.defaultFormat = createPlainFormat(); - text.insertAt(4, "plain"); - - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok(rendered.container.querySelector("strong"), "Initially: has "); - - text.removeRange(0, 4); - rendered.rerender(content); - - assert.ok( - !rendered.container.querySelector("strong"), - "After delete: no ", - ); - }); - it("applies bold via formatRange", () => { - const { tree: text } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); + it("renders MainView with initial text content", () => { + const text = TextAsTree.Tree.fromString("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); - text.formatRange(6, 11, { bold: true }); - rendered.rerender(content); - - const el = rendered.container.querySelector("strong"); - assert.ok(el, "Expected after formatRange"); - assert.match(el.textContent ?? "", /World/); - }); - }); - - describe("italic", () => { - it("inserts italic text and renders with tag", () => { - const { tree: text } = createFormattedTreeView("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok(!rendered.container.querySelector("em"), "Initially: no "); - - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: true, - underline: false, - size: 12, - font: "Arial", - }); - text.insertAt(2, "ITAL"); - - rendered.rerender(content); - const el = rendered.container.querySelector("em"); - assert.ok(el, "Expected tag"); - assert.match(el.textContent ?? "", /ITAL/); - }); - - it("deletes italic text and removes tag", () => { - const { tree: text } = createFormattedTreeView(); - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: true, - underline: false, - size: 12, - font: "Arial", - }); - text.insertAt(0, "ITAL"); - text.defaultFormat = createPlainFormat(); - text.insertAt(4, "plain"); - - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok(rendered.container.querySelector("em"), "Initially: has "); - - text.removeRange(0, 4); - rendered.rerender(content); - - assert.ok(!rendered.container.querySelector("em"), "After delete: no "); - }); - - it("applies italic via formatRange", () => { - const { tree: text } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - text.formatRange(6, 11, { italic: true }); - rendered.rerender(content); - - const el = rendered.container.querySelector("em"); - assert.ok(el, "Expected after formatRange"); - assert.match(el.textContent ?? "", /World/); - }); - }); - - describe("underline", () => { - it("inserts underlined text and renders with tag", () => { - const { tree: text } = createFormattedTreeView("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok(!rendered.container.querySelector("u"), "Initially: no "); - - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: true, - size: 12, - font: "Arial", - }); - text.insertAt(2, "UNDER"); - - rendered.rerender(content); - const el = rendered.container.querySelector("u"); - assert.ok(el, "Expected tag"); - assert.match(el.textContent ?? "", /UNDER/); - }); - - it("deletes underlined text and removes tag", () => { - const { tree: text } = createFormattedTreeView(); - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: true, - size: 12, - font: "Arial", - }); - text.insertAt(0, "UNDER"); - text.defaultFormat = createPlainFormat(); - text.insertAt(5, "plain"); - - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok(rendered.container.querySelector("u"), "Initially: has "); - - text.removeRange(0, 5); - rendered.rerender(content); - - assert.ok(!rendered.container.querySelector("u"), "After delete: no "); - }); - - it("applies underline via formatRange", () => { - const { tree: text } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - text.formatRange(6, 11, { underline: true }); - rendered.rerender(content); - - const el = rendered.container.querySelector("u"); - assert.ok(el, "Expected after formatRange"); - assert.match(el.textContent ?? "", /World/); - }); - }); - - describe("size", () => { - it("inserts huge size text and renders with .ql-size-huge", () => { - const { tree: text } = createFormattedTreeView("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok( - !rendered.container.querySelector(".ql-size-huge"), - "Initially: no .ql-size-huge", - ); - - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: false, - size: 24, - font: "Arial", - }); - text.insertAt(2, "HUGE"); - - rendered.rerender(content); - const el = rendered.container.querySelector(".ql-size-huge"); - assert.ok(el, "Expected .ql-size-huge"); - assert.match(el.textContent ?? "", /HUGE/); - }); - - it("deletes huge size text and removes .ql-size-huge", () => { - const { tree: text } = createFormattedTreeView(); - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: false, - size: 24, - font: "Arial", - }); - text.insertAt(0, "HUGE"); - text.defaultFormat = createPlainFormat(); - text.insertAt(4, "plain"); - - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok( - rendered.container.querySelector(".ql-size-huge"), - "Initially: has .ql-size-huge", - ); - - text.removeRange(0, 4); - rendered.rerender(content); - - assert.ok( - !rendered.container.querySelector(".ql-size-huge"), - "After delete: no .ql-size-huge", - ); - }); - - it("applies size via formatRange", () => { - const { tree: text } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - text.formatRange(6, 11, { size: 24 }); - rendered.rerender(content); - - const el = rendered.container.querySelector(".ql-size-huge"); - assert.ok(el, "Expected .ql-size-huge after formatRange"); - assert.match(el.textContent ?? "", /World/); - }); - }); - - describe("font", () => { - it("inserts monospace font text and renders with .ql-font-monospace", () => { - const { tree: text } = createFormattedTreeView("Hello"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok( - !rendered.container.querySelector(".ql-font-monospace"), - "Initially: no .ql-font-monospace", - ); - - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: false, - size: 12, - font: "monospace", - }); - text.insertAt(2, "MONO"); - - rendered.rerender(content); - const el = rendered.container.querySelector(".ql-font-monospace"); - assert.ok(el, "Expected .ql-font-monospace"); - assert.match(el.textContent ?? "", /MONO/); - }); - - it("deletes monospace font text and removes .ql-font-monospace", () => { - const { tree: text } = createFormattedTreeView(); - text.defaultFormat = new FormattedTextAsTree.CharacterFormat({ - bold: false, - italic: false, - underline: false, - size: 12, - font: "monospace", - }); - text.insertAt(0, "MONO"); - text.defaultFormat = createPlainFormat(); - text.insertAt(4, "plain"); - - const content = ; - const rendered = render(content, { reactStrictMode }); - - assert.ok( - rendered.container.querySelector(".ql-font-monospace"), - "Initially: has .ql-font-monospace", - ); - - text.removeRange(0, 4); - rendered.rerender(content); - - assert.ok( - !rendered.container.querySelector(".ql-font-monospace"), - "After delete: no .ql-font-monospace", - ); - }); - - it("applies font via formatRange", () => { - const { tree: text } = createFormattedTreeView("Hello World"); - const content = ; - const rendered = render(content, { reactStrictMode }); - - text.formatRange(6, 11, { font: "monospace" }); - rendered.rerender(content); - - const el = rendered.container.querySelector(".ql-font-monospace"); - assert.ok(el, "Expected .ql-font-monospace after formatRange"); - assert.match(el.textContent ?? "", /World/); - }); - }); + assert.match(rendered.baseElement.textContent ?? "", /Hello World/); }); - } - }); - - // Undo/Redo tests for non-transactional edits - describe("undo/redo", () => { - for (const reactStrictMode of [false, true]) { - describe(`StrictMode: ${reactStrictMode}`, () => { - it("insert character, undo removes it, redo restores it", () => { - const treeView = createFormattedTreeViewWithEvents(); - const text = treeView.root; - const undoRedo = new UndoRedoStacks(treeView.events); - const editorRef = createRef(); - const content = ( - - ); - const rendered = render(content, { reactStrictMode }); - - // Insert a character - text.insertAt(0, "A"); - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /A/); - - // Undo - character should be removed - editorRef.current?.undo(); - rendered.rerender(content); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /A/); - - // Redo - character should be restored - editorRef.current?.redo(); - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /A/); - }); - - it("insert character, make bold, undo removes bold but keeps character", () => { - const treeView = createFormattedTreeViewWithEvents(); - const text = treeView.root; - const undoRedo = new UndoRedoStacks(treeView.events); - const editorRef = createRef(); - const content = ( - - ); - const rendered = render(content, { reactStrictMode }); - - // Insert a character - text.insertAt(0, "B"); - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /B/); - assert.ok(!rendered.container.querySelector("strong"), "Initially: no "); - - // Make it bold - text.formatRange(0, 1, { bold: true }); - rendered.rerender(content); - assert.ok( - rendered.container.querySelector("strong"), - "After format: has ", - ); - // Undo - bold should be removed, character should remain - editorRef.current?.undo(); - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /B/); - assert.ok( - !rendered.container.querySelector("strong"), - "After undo: no , character remains", - ); - }); + it("invalidates view when tree is mutated", () => { + const text = TextAsTree.Tree.fromString("Hello"); + const content = ; + const rendered = render(content, { reactStrictMode }); - it("multiple operations in transaction undo together as one unit", () => { - const treeView = createFormattedTreeViewWithEvents(); - const text = treeView.root; - const undoRedo = new UndoRedoStacks(treeView.events); - const editorRef = createRef(); - const content = ( - - ); - const rendered = render(content, { reactStrictMode }); + // Mutate the tree by inserting text + text.insertAt(5, " World"); - // Two operations in one transaction - TreeAlpha.branch(text)?.runTransaction(() => { - text.insertAt(0, "A"); - text.insertAt(1, "B"); - }); - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /AB/); - - // Single undo should remove both characters - editorRef.current?.undo(); - rendered.rerender(content); - assert(rendered.baseElement.textContent !== null); - assert.doesNotMatch(rendered.baseElement.textContent, /A/); - assert.doesNotMatch(rendered.baseElement.textContent, /B/); - - // Single redo should restore both characters - editorRef.current?.redo(); - rendered.rerender(content); - assert.match(rendered.baseElement.textContent ?? "", /AB/); - }); + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Hello World/); }); - } - }); - describe("copy-paste helpers", () => { - /** Helper to create an HTMLElement with inline styles. */ - function styledElement(styles: Partial): HTMLElement { - const el = document.createElement("span"); - Object.assign(el.style, styles); - return el; - } - describe("parseCssFontSize", () => { - it("returns undefined when no fontSize is set", () => { - assert.equal(parseCssFontSize(styledElement({})), undefined); - }); + it("invalidates view when text is removed", () => { + const text = TextAsTree.Tree.fromString("Hello World"); + const content = ; + const rendered = render(content, { reactStrictMode }); - it("returns Quill size name for supported pixel values", () => { - assert.equal(parseCssFontSize(styledElement({ fontSize: "10px" })), "small"); - assert.equal(parseCssFontSize(styledElement({ fontSize: "18px" })), "large"); - assert.equal(parseCssFontSize(styledElement({ fontSize: "24px" })), "huge"); - }); + // Mutate the tree by removing " World" (indices 5 to 11) + text.removeRange(5, 11); - it("returns undefined for default or unrecognized sizes", () => { - assert.equal(parseCssFontSize(styledElement({ fontSize: "12px" })), undefined); - assert.equal(parseCssFontSize(styledElement({ fontSize: "42px" })), undefined); + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Hello/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /World/); }); - }); - describe("parseCssFontFamily", () => { - it("returns undefined when no fontFamily is set", () => { - assert.equal(parseCssFontFamily(styledElement({})), undefined); - }); + it("invalidates view when text is cleared and replaced", () => { + const text = TextAsTree.Tree.fromString("Original"); + const content = ; + const rendered = render(content, { reactStrictMode }); - it("returns first recognized font in a comma-separated stack", () => { - assert.equal( - parseCssFontFamily(styledElement({ fontFamily: "monospace" })), - "monospace", - ); - assert.equal( - parseCssFontFamily(styledElement({ fontFamily: '"Courier New", monospace' })), - "monospace", - ); - assert.equal( - parseCssFontFamily( - styledElement({ fontFamily: '"Times New Roman", "Arial", serif' }), - ), - "Arial", - ); - }); - it("strips single quotes around recognized font names", () => { - assert.equal(parseCssFontFamily(styledElement({ fontFamily: "'Arial'" })), "Arial"); - }); - it("returns undefined for unrecognized fonts", () => { - assert.equal( - parseCssFontFamily(styledElement({ fontFamily: '"Courier New", fantasy' })), - undefined, - ); - }); - }); + // Clear all text + const length = [...text.characters()].length; + text.removeRange(0, length); - describe("clipboardFormatMatcher", () => { - it("returns delta unchanged for non-HTMLElement nodes", () => { - const delta = new Delta().insert("hello"); - const text = document.createTextNode("hello"); - const result = clipboardFormatMatcher(text, delta); - assert.deepEqual(result.ops, delta.ops); - }); + // Insert new text + text.insertAt(0, "Replaced"); - it("applies size and font attributes from inline styles", () => { - const delta = new Delta().insert("hello"); - const el = styledElement({ fontSize: "18px", fontFamily: "serif" }); - const result = clipboardFormatMatcher(el, delta); - assert.equal(result.ops[0]?.attributes?.size, "large"); - assert.equal(result.ops[0]?.attributes?.font, "serif"); + // Rerender and verify the view updates + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /Replaced/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /Original/); }); - it("returns delta unchanged when no recognized styles", () => { - const delta = new Delta().insert("hello"); - const el = styledElement({}); - const result = clipboardFormatMatcher(el, delta); - assert.deepEqual(result.ops, delta.ops); - }); - }); - }); + // Tests for surrogate pair characters (emojis use 2 UTF-16 code units) + // These verify correct handling where editor indexing may differ from iteration. - // Unicode 16+ (joined emojis) section - test attribute cycling - describe("Unicode 16+ joined emoji attribute cycling", () => { - // ZWJ (Zero Width Joiner) emoji sequence: ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ = family emoji - const joinedEmoji = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"; + it("renders MainView with surrogate pair characters", () => { + // ๐Ÿ˜€ is a surrogate pair: "๐Ÿ˜€".length === 2, but [..."๐Ÿ˜€"].length === 1 + const text = TextAsTree.Tree.fromString("Hello ๐Ÿ˜€ World"); + const content = ; + const rendered = render(content, { reactStrictMode }); - for (const reactStrictMode of [false, true]) { - describe(`StrictMode: ${reactStrictMode}`, () => { - it("applies bold to joined emoji and removes it preserving text", () => { - const { tree: text } = createFormattedTreeView(`Test ${joinedEmoji} Text`); - const content = ; - const rendered = render(content, { reactStrictMode }); + assert.match(rendered.baseElement.textContent ?? "", /Hello ๐Ÿ˜€ World/); + }); - const emojiStart = 5; // "Test " is 5 chars - const emojiLength = [...joinedEmoji].length; - text.formatRange(emojiStart, emojiStart + emojiLength, { bold: true }); - rendered.rerender(content); + it("inserts text after surrogate pair characters", () => { + const text = TextAsTree.Tree.fromString("A๐Ÿ˜€B"); + const content = ; + const rendered = render(content, { reactStrictMode }); - assert.ok( - rendered.container.querySelector("strong"), - "After bold: expected ", - ); - assert.ok( - (rendered.baseElement.textContent ?? "").includes(joinedEmoji), - "After bold: emoji should be preserved", - ); + // Insert after the emoji (index 2 in character count: A, ๐Ÿ˜€, B) + text.insertAt(2, "X"); - text.formatRange(emojiStart, emojiStart + emojiLength, { bold: false }); - rendered.rerender(content); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /A๐Ÿ˜€XB/); + }); - assert.ok( - !rendered.container.querySelector("strong"), - "After remove bold: no expected", - ); - assert.ok( - (rendered.baseElement.textContent ?? "").includes(joinedEmoji), - "After remove bold: emoji should still be preserved", - ); - }); + it("removes surrogate pair characters", () => { + const text = TextAsTree.Tree.fromString("A๐Ÿ˜€B"); + const content = ; + const rendered = render(content, { reactStrictMode }); - it("applies size to joined emoji and removes it preserving text", () => { - const { tree: text } = createFormattedTreeView(`Test ${joinedEmoji} Text`); - const content = ; - const rendered = render(content, { reactStrictMode }); + // Remove the emoji (index 1, length 1 in character count) + text.removeRange(1, 2); - const emojiStart = 5; - const emojiLength = [...joinedEmoji].length; - text.formatRange(emojiStart, emojiStart + emojiLength, { size: 24 }); - rendered.rerender(content); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /AB/); + assert(rendered.baseElement.textContent !== null); + assert.doesNotMatch(rendered.baseElement.textContent, /๐Ÿ˜€/); + }); - assert.ok( - rendered.container.querySelector(".ql-size-huge"), - "After size: expected .ql-size-huge", - ); - assert.ok( - (rendered.baseElement.textContent ?? "").includes(joinedEmoji), - "Emoji preserved", - ); + it("handles multiple surrogate pair characters", () => { + const text = TextAsTree.Tree.fromString("๐Ÿ‘‹๐ŸŒ๐ŸŽ‰"); + const content = ; + const rendered = render(content, { reactStrictMode }); - text.formatRange(emojiStart, emojiStart + emojiLength, { size: 12 }); - rendered.rerender(content); + // Insert between emojis + text.insertAt(2, "!"); - assert.ok( - !rendered.container.querySelector(".ql-size-huge"), - "After remove: no .ql-size-huge", - ); - assert.ok( - (rendered.baseElement.textContent ?? "").includes(joinedEmoji), - "Emoji still preserved", - ); - }); + rendered.rerender(content); + assert.match(rendered.baseElement.textContent ?? "", /๐Ÿ‘‹๐ŸŒ!๐ŸŽ‰/); }); - } - }); + }); + } }); }); diff --git a/packages/framework/react/src/text/index.ts b/packages/framework/react/src/text/index.ts new file mode 100644 index 000000000000..176b4a39e896 --- /dev/null +++ b/packages/framework/react/src/text/index.ts @@ -0,0 +1,8 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +export { type PropTreeNode } from "../propNode.js"; +export { type UndoRedo } from "../undoRedo.js"; +export { PlainTextMainView, syncTextToTree } from "./plain/index.js"; diff --git a/packages/framework/react/src/text/plain/index.ts b/packages/framework/react/src/text/plain/index.ts index 078919ce7227..5d94ffe92547 100644 --- a/packages/framework/react/src/text/plain/index.ts +++ b/packages/framework/react/src/text/plain/index.ts @@ -4,4 +4,4 @@ */ export { MainView as PlainTextMainView } from "./plainTextView.js"; -export { MainView as QuillMainView, type MainViewProps } from "./quillView.js"; +export { syncTextToTree } from "./plainUtils.js"; diff --git a/packages/framework/react/src/text/plain/plainTextView.tsx b/packages/framework/react/src/text/plain/plainTextView.tsx index e142a9b393e0..24e1fc27d699 100644 --- a/packages/framework/react/src/text/plain/plainTextView.tsx +++ b/packages/framework/react/src/text/plain/plainTextView.tsx @@ -6,10 +6,10 @@ import type { TextAsTree } from "@fluidframework/tree/internal"; import { type ChangeEvent, type FC, useCallback, useRef } from "react"; +import type { PropTreeNode } from "../../propNode.js"; import { withMemoizedTreeObservations } from "../../useTree.js"; import { syncTextToTree } from "./plainUtils.js"; -import type { MainViewProps } from "./quillView.js"; /** * A React component for plain text editing. @@ -17,7 +17,7 @@ import type { MainViewProps } from "./quillView.js"; * Uses {@link @fluidframework/tree#TextAsTree.Tree} for the data-model and an HTML textarea for the UI. * @internal */ -export const MainView: FC = ({ root }) => { +export const MainView: FC<{ root: PropTreeNode }> = ({ root }) => { return ; }; diff --git a/packages/framework/react/src/text/plain/plainUtils.ts b/packages/framework/react/src/text/plain/plainUtils.ts index 0bf133569367..39da9fcf0b36 100644 --- a/packages/framework/react/src/text/plain/plainUtils.ts +++ b/packages/framework/react/src/text/plain/plainUtils.ts @@ -7,6 +7,7 @@ import type { TextAsTree } from "@fluidframework/tree/internal"; /** * Sync `newText` into the provided `root` tree. + * @internal */ export function syncTextToTree(root: TextAsTree.Tree, newText: string): void { const sync = computeSync(root.charactersCopy(), [...newText]); diff --git a/packages/framework/react/tsconfig.json b/packages/framework/react/tsconfig.json index a2e02545e082..7581825726d5 100644 --- a/packages/framework/react/tsconfig.json +++ b/packages/framework/react/tsconfig.json @@ -9,11 +9,5 @@ "noUnusedLocals": false, // ES2021 needed for FinalizationRegistry "lib": ["ES2021", "DOM", "DOM.Iterable"], - // Suppress type errors in Quill's use of quill-delta. - // Without this, the quill code gives a lot of errors like: - // node_modules/.pnpm/quill@2.0.3/node_modules/quill/blots/block.d.ts:6:17 - error TS2709: Cannot use namespace 'Delta' as a type. - // These issues (and others, see imports of quill-delta) are likely related to quill-delta's export style not working well with node16 module resolution. - // Quill internally uses `"moduleResolution": "bundler"` which seems to work properly with quill-delta's exports, but would be inconsistent with the rest of this repo. - "skipLibCheck": true, }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d716449e207..2bc3664a142f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11850,6 +11850,106 @@ importers: specifier: ~5.4.5 version: 5.4.5 + packages/framework/quill-react: + dependencies: + '@fluidframework/core-utils': + specifier: workspace:~ + version: link:../../common/core-utils + '@fluidframework/tree': + specifier: workspace:~ + version: link:../../dds/tree + quill: + specifier: ^2.0.3 + version: 2.0.3 + quill-delta: + specifier: ^5.1.0 + version: 5.1.0 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.2 + version: 0.18.2 + '@biomejs/biome': + specifier: ~2.4.5 + version: 2.4.5 + '@fluid-internal/mocha-test-setup': + specifier: workspace:~ + version: link:../../test/mocha-test-setup + '@fluid-tools/build-cli': + specifier: ^0.63.0 + version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) + '@fluidframework/build-common': + specifier: ^2.0.3 + version: 2.0.3 + '@fluidframework/build-tools': + specifier: ^0.63.0 + version: 0.63.0(@types/node@20.19.30) + '@fluidframework/eslint-config-fluid': + specifier: workspace:~ + version: link:../../../common/build/eslint-config-fluid + '@fluidframework/react': + specifier: workspace:~ + version: link:../react + '@fluidframework/tinylicious-client': + specifier: workspace:~ + version: link:../../service-clients/tinylicious-client + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.3(@types/react@18.3.15))(@types/react@18.3.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/mocha': + specifier: ^10.0.10 + version: 10.0.10 + '@types/node': + specifier: ~20.19.30 + version: 20.19.30 + '@types/react': + specifier: ^18.3.11 + version: 18.3.15 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.3(@types/react@18.3.15) + c8: + specifier: ^10.1.3 + version: 10.1.3 + copyfiles: + specifier: ^2.4.1 + version: 2.4.1 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + eslint: + specifier: ~9.39.1 + version: 9.39.1(jiti@2.6.1) + eslint-config-prettier: + specifier: ~10.1.8 + version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) + global-jsdom: + specifier: ^26.0.0 + version: 26.0.0(jsdom@26.1.0) + jiti: + specifier: ^2.6.1 + version: 2.6.1 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + mocha: + specifier: ^11.7.5 + version: 11.7.5 + mocha-multi-reporters: + specifier: ^1.5.1 + version: 1.5.1(mocha@11.7.5) + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + packages/framework/react: dependencies: '@fluidframework/aqueduct': @@ -11876,12 +11976,6 @@ importers: '@fluidframework/tree': specifier: workspace:~ version: link:../../dds/tree - quill: - specifier: ^2.0.3 - version: 2.0.3 - quill-delta: - specifier: ^5.1.0 - version: 5.1.0 react: specifier: ^18.3.1 version: 18.3.1 From 0303e8e9a8728d3dc710d161fc875e275d0787df Mon Sep 17 00:00:00 2001 From: brrichards Date: Fri, 13 Mar 2026 16:55:12 +0000 Subject: [PATCH 06/10] update app.tsx import paths --- examples/data-objects/text-editor/package.json | 1 + examples/data-objects/text-editor/src/app.tsx | 11 +++++------ packages/framework/react/src/index.ts | 2 +- pnpm-lock.yaml | 3 +++ 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/data-objects/text-editor/package.json b/examples/data-objects/text-editor/package.json index 2d70b51793d9..89cc28b845ab 100644 --- a/examples/data-objects/text-editor/package.json +++ b/examples/data-objects/text-editor/package.json @@ -38,6 +38,7 @@ "dependencies": { "@fluid-example/example-utils": "workspace:~", "@fluidframework/azure-client": "workspace:~", + "@fluidframework/quill-react": "workspace:~", "@fluidframework/react": "workspace:~", "@fluidframework/test-runtime-utils": "workspace:~", "@fluidframework/tree": "workspace:~", diff --git a/examples/data-objects/text-editor/src/app.tsx b/examples/data-objects/text-editor/src/app.tsx index d5f1433d43eb..f069208e4cf9 100644 --- a/examples/data-objects/text-editor/src/app.tsx +++ b/examples/data-objects/text-editor/src/app.tsx @@ -5,18 +5,17 @@ import { AzureClient, type AzureLocalConnectionConfig } from "@fluidframework/azure-client"; import { createDevtoolsLogger, initializeDevtools } from "@fluidframework/devtools/beta"; +import { + FormattedMainView, + QuillMainView as PlainQuillView, +} from "@fluidframework/quill-react"; import { toPropTreeNode, UndoRedoStacks, type UndoRedo, - // eslint-disable-next-line import-x/no-internal-modules -} from "@fluidframework/react/internal"; -import { - FormattedMainView, PlainTextMainView, - PlainQuillView, // eslint-disable-next-line import-x/no-internal-modules -} from "@fluidframework/react/quill"; +} from "@fluidframework/react/internal"; /** * InsecureTokenProvider is used here for local development and demo purposes only. * Do not use in production - implement proper authentication for production scenarios. diff --git a/packages/framework/react/src/index.ts b/packages/framework/react/src/index.ts index 7f0aaf39945d..00a33bf3c99a 100644 --- a/packages/framework/react/src/index.ts +++ b/packages/framework/react/src/index.ts @@ -45,5 +45,5 @@ export { withMemoizedTreeObservations, } from "./useTree.js"; export { objectIdNumber } from "./simpleIdentifier.js"; -export { syncTextToTree } from "./text/index.js"; +export { syncTextToTree, PlainTextMainView } from "./text/index.js"; export { UndoRedoStacks, type UndoRedo } from "./undoRedo.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bc3664a142f..fd0e2bb70026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3928,6 +3928,9 @@ importers: '@fluidframework/azure-client': specifier: workspace:~ version: link:../../../packages/service-clients/azure-client + '@fluidframework/quill-react': + specifier: workspace:~ + version: link:../../../packages/framework/quill-react '@fluidframework/react': specifier: workspace:~ version: link:../../../packages/framework/react From baeb7ee10cac82d0323c3fa9a47ecbef8bd748de Mon Sep 17 00:00:00 2001 From: brrichards Date: Fri, 13 Mar 2026 17:03:22 +0000 Subject: [PATCH 07/10] resolve merge conflicts --- .../quill-react/src/test/textEditor.test.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/framework/quill-react/src/test/textEditor.test.tsx b/packages/framework/quill-react/src/test/textEditor.test.tsx index 5d02e2f150b0..cabadf186ed1 100644 --- a/packages/framework/quill-react/src/test/textEditor.test.tsx +++ b/packages/framework/quill-react/src/test/textEditor.test.tsx @@ -21,6 +21,8 @@ import { type FormattedEditorHandle, parseCssFontFamily, parseCssFontSize, + parseLineTag, + buildDeltaFromTree, // Allow import of files being tested // eslint-disable-next-line import-x/no-internal-modules } from "../formatted/quillFormattedView.js"; @@ -947,5 +949,67 @@ describe("textEditor", () => { }); } }); + describe("parseLineTag", () => { + it("return lineTag for valid header", () => { + const result = parseLineTag({ header: 1 }); + assert.ok(result, "Expected a line tag for header 1"); + assert.equal(result.value, "h1", "Expected tag to be h1"); + }); + it("returns lineTag for valid bullet list", () => { + const result = parseLineTag({ list: "bullet" }); + assert.ok(result, "Expected a line tag for bullet list"); + assert.equal(result.value, "li", "Expected tag to be li)"); + }); + // Tests for unsupported parameters - should return default header ("h5"), not throw an error. + // Also used for when formatting is stripped from a newline and it becomes a default newline + it("returns undefined for unsupported parameter", () => { + const result = parseLineTag({ header: 7 }); + assert.ok(result, "Expected a line tag for header 5"); + assert.equal(result.value, "h5", "Expected tag to be h5 for unsupported header level"); + }); + }); + // tests quillFormattedview conversion that feeds into quill delta generation, + // specifically for line atoms which have special handling for headers and lists. + // Quill always has a trailing newline, so these tests set up the string with a newline, + // then replace it with a line atom with the appropriate tag. + describe("buildDeltaFromTree with line Atoms", () => { + it("emits header attribute for h1 line atom", () => { + const { tree } = createFormattedTreeView("Hello\n"); + tree.removeRange(5, 6); + tree.insertWithFormattingAt(5, [ + new FormattedTextAsTree.StringAtom({ + content: new FormattedTextAsTree.StringLineAtom({ + tag: FormattedTextAsTree.LineTag("h1"), + }), + format: createPlainFormat(), + }), + ]); + + const ops = buildDeltaFromTree(tree); + const lineOp = ops.find((op) => op.insert === "\n" && op.attributes?.header === 1); + assert.ok(lineOp, "Expected { insert : `\\n`, attributes: { header: 1 } } in delta"); + }); + it("emits header attribute for li line atom", () => { + const { tree } = createFormattedTreeView("item\n"); + tree.removeRange(4, 5); + tree.insertWithFormattingAt(4, [ + new FormattedTextAsTree.StringAtom({ + content: new FormattedTextAsTree.StringLineAtom({ + tag: FormattedTextAsTree.LineTag("li"), + }), + format: createPlainFormat(), + }), + ]); + + const ops = buildDeltaFromTree(tree); + const lineOp = ops.find( + (op) => op.insert === "\n" && op.attributes?.list === "bullet", + ); + assert.ok( + lineOp, + "Expected { insert : `\\n`, attributes: { list: 'bullet' } } in delta", + ); + }); + }); }); }); From 87170e7009e3255f1b94e77e4b61954893bc2504 Mon Sep 17 00:00:00 2001 From: brrichards Date: Fri, 13 Mar 2026 17:22:18 +0000 Subject: [PATCH 08/10] syncpack fixes --- .changeset/five-loops-sip.md | 2 +- packages/framework/quill-react/package.json | 4 ++-- .../quill-react/src/formatted/quillFormattedView.tsx | 4 ++-- packages/framework/quill-react/src/plain/quillView.tsx | 2 +- packages/framework/react/src/text/plain/plainUtils.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/five-loops-sip.md b/.changeset/five-loops-sip.md index 8041089fde9a..e99906ceeca3 100644 --- a/.changeset/five-loops-sip.md +++ b/.changeset/five-loops-sip.md @@ -7,4 +7,4 @@ New package created containing all examples for integrating Quill with Fluid Fra Applications utilizing quill require DOM access at import time. This package contains all integrations of Fluid Framework with Quill/React. This package should only be imported in browser environments or test environments with JSDOM set up before import. -To import this package use `` +To import this package use `@fluidframework/quill-react` diff --git a/packages/framework/quill-react/package.json b/packages/framework/quill-react/package.json index ad4de9056447..ba6f9b3f8c5f 100644 --- a/packages/framework/quill-react/package.json +++ b/packages/framework/quill-react/package.json @@ -78,9 +78,9 @@ "@arethetypeswrong/cli": "^0.18.2", "@biomejs/biome": "~2.4.5", "@fluid-internal/mocha-test-setup": "workspace:~", - "@fluid-tools/build-cli": "^0.63.0", + "@fluid-tools/build-cli": "catalog:buildTools", "@fluidframework/build-common": "^2.0.3", - "@fluidframework/build-tools": "^0.63.0", + "@fluidframework/build-tools": "catalog:buildTools", "@fluidframework/eslint-config-fluid": "workspace:~", "@fluidframework/react": "workspace:~", "@fluidframework/tinylicious-client": "workspace:~", diff --git a/packages/framework/quill-react/src/formatted/quillFormattedView.tsx b/packages/framework/quill-react/src/formatted/quillFormattedView.tsx index bd6046aa3050..2a99c83a055c 100644 --- a/packages/framework/quill-react/src/formatted/quillFormattedView.tsx +++ b/packages/framework/quill-react/src/formatted/quillFormattedView.tsx @@ -30,7 +30,7 @@ const Delta = DeltaPackage.default; /** * Props for the FormattedMainView component. - * @internal + * @input @internal */ export interface FormattedMainViewProps { readonly root: PropTreeNode; @@ -40,7 +40,7 @@ export interface FormattedMainViewProps { /** * Ref handle exposing undo/redo methods for the formatted editor. - * @internal + * @input @internal */ export type FormattedEditorHandle = Pick; diff --git a/packages/framework/quill-react/src/plain/quillView.tsx b/packages/framework/quill-react/src/plain/quillView.tsx index 98d0c8e1a355..2add4d0aa936 100644 --- a/packages/framework/quill-react/src/plain/quillView.tsx +++ b/packages/framework/quill-react/src/plain/quillView.tsx @@ -24,7 +24,7 @@ export interface MainViewProps { * A React component for plain text editing. * @remarks * Uses {@link @fluidframework/tree#TextAsTree.Tree} for the data-model and Quill for the UI. - * @internal + * @input internal */ export const MainView: FC = ({ root }) => { return ; diff --git a/packages/framework/react/src/text/plain/plainUtils.ts b/packages/framework/react/src/text/plain/plainUtils.ts index 39da9fcf0b36..d7d21e4e3bab 100644 --- a/packages/framework/react/src/text/plain/plainUtils.ts +++ b/packages/framework/react/src/text/plain/plainUtils.ts @@ -7,7 +7,7 @@ import type { TextAsTree } from "@fluidframework/tree/internal"; /** * Sync `newText` into the provided `root` tree. - * @internal + * @input @internal */ export function syncTextToTree(root: TextAsTree.Tree, newText: string): void { const sync = computeSync(root.charactersCopy(), [...newText]); From bb01c2bdc1534c740a9bc3218da8d25350e54c33 Mon Sep 17 00:00:00 2001 From: brrichards Date: Fri, 13 Mar 2026 17:26:03 +0000 Subject: [PATCH 09/10] small fixes --- packages/framework/quill-react/src/plain/quillView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/framework/quill-react/src/plain/quillView.tsx b/packages/framework/quill-react/src/plain/quillView.tsx index 2add4d0aa936..cb2f86f307fd 100644 --- a/packages/framework/quill-react/src/plain/quillView.tsx +++ b/packages/framework/quill-react/src/plain/quillView.tsx @@ -14,7 +14,7 @@ import { type FC, useEffect, useRef } from "react"; /** * Props for the MainView component. - * @internal + * @input @internal */ export interface MainViewProps { root: PropTreeNode; @@ -24,7 +24,7 @@ export interface MainViewProps { * A React component for plain text editing. * @remarks * Uses {@link @fluidframework/tree#TextAsTree.Tree} for the data-model and Quill for the UI. - * @input internal + * @internal */ export const MainView: FC = ({ root }) => { return ; From ab1c7f9e5e5f3a5af7346edd82a86068ff66e991 Mon Sep 17 00:00:00 2001 From: brrichards Date: Fri, 13 Mar 2026 18:49:58 +0000 Subject: [PATCH 10/10] update dependencies --- pnpm-lock.yaml | 227 +------------------------------------------------ 1 file changed, 2 insertions(+), 225 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5520f3f81eb0..8fb14d14d14e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2155,115 +2155,6 @@ importers: specifier: ^6.0.1 version: 6.0.1 - examples/benchmarks/odspsnapshotfetch-perftestapp: - dependencies: - '@fluidframework/core-utils': - specifier: workspace:~ - version: link:../../../packages/common/core-utils - '@fluidframework/driver-definitions': - specifier: workspace:~ - version: link:../../../packages/common/driver-definitions - '@fluidframework/driver-utils': - specifier: workspace:~ - version: link:../../../packages/loader/driver-utils - '@fluidframework/odsp-doclib-utils': - specifier: workspace:~ - version: link:../../../packages/utils/odsp-doclib-utils - '@fluidframework/odsp-driver': - specifier: workspace:~ - version: link:../../../packages/drivers/odsp-driver - '@fluidframework/odsp-urlresolver': - specifier: workspace:~ - version: link:../../../packages/drivers/odsp-urlResolver - '@fluidframework/telemetry-utils': - specifier: workspace:~ - version: link:../../../packages/utils/telemetry-utils - '@fluidframework/tool-utils': - specifier: workspace:~ - version: link:../../../packages/utils/tool-utils - express: - specifier: ^4.21.2 - version: 4.21.2 - webpack-dev-server: - specifier: ~4.15.2 - version: 4.15.2(webpack-cli@5.1.4)(webpack@5.103.0) - devDependencies: - '@biomejs/biome': - specifier: ~2.4.5 - version: 2.4.5 - '@fluid-tools/build-cli': - specifier: catalog:buildTools - version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) - '@fluidframework/build-common': - specifier: ^2.0.3 - version: 2.0.3 - '@fluidframework/build-tools': - specifier: catalog:buildTools - version: 0.63.0(@types/node@20.19.30) - '@fluidframework/eslint-config-fluid': - specifier: workspace:~ - version: link:../../../common/build/eslint-config-fluid - '@types/express': - specifier: ^4.17.21 - version: 4.17.21 - '@types/fs-extra': - specifier: ^9.0.11 - version: 9.0.13 - '@types/node': - specifier: ~20.19.30 - version: 20.19.30 - '@types/webpack-hot-middleware': - specifier: ^2.25.9 - version: 2.25.9(webpack-cli@5.1.4) - buffer: - specifier: ^6.0.3 - version: 6.0.3 - c8: - specifier: ^10.1.3 - version: 10.1.3 - css-loader: - specifier: ^7.1.2 - version: 7.1.2(webpack@5.103.0) - eslint: - specifier: ~9.39.1 - version: 9.39.1(jiti@2.6.1) - fs-extra: - specifier: ^9.1.0 - version: 9.1.0 - jiti: - specifier: ^2.6.1 - version: 2.6.1 - rimraf: - specifier: ^6.1.3 - version: 6.1.3 - source-map-loader: - specifier: ^5.0.0 - version: 5.0.0(webpack@5.103.0) - style-loader: - specifier: ^4.0.0 - version: 4.0.0(webpack@5.103.0) - ts-loader: - specifier: ^9.5.1 - version: 9.5.1(typescript@5.4.5)(webpack@5.103.0) - typescript: - specifier: ~5.4.5 - version: 5.4.5 - webpack: - specifier: ^5.94.0 - version: 5.103.0(webpack-cli@5.1.4) - webpack-cli: - specifier: ^5.1.4 - version: 5.1.4(webpack-dev-server@4.15.2)(webpack@5.103.0) - webpack-dev-middleware: - specifier: ^7.1.1 - version: 7.4.2(webpack@5.103.0) - webpack-hot-middleware: - specifier: ^2.25.3 - version: 2.26.1 - webpack-merge: - specifier: ^6.0.1 - version: 6.0.1 - examples/benchmarks/tablebench: dependencies: '@fluid-internal/client-utils': @@ -11908,13 +11799,13 @@ importers: specifier: workspace:~ version: link:../../test/mocha-test-setup '@fluid-tools/build-cli': - specifier: ^0.63.0 + specifier: catalog:buildTools version: 0.63.0(@types/node@20.19.30)(encoding@0.1.13)(webpack-cli@5.1.4) '@fluidframework/build-common': specifier: ^2.0.3 version: 2.0.3 '@fluidframework/build-tools': - specifier: ^0.63.0 + specifier: catalog:buildTools version: 0.63.0(@types/node@20.19.30) '@fluidframework/eslint-config-fluid': specifier: workspace:~ @@ -19360,24 +19251,6 @@ packages: '@json2csv/plainjs@7.0.6': resolution: {integrity: sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==} - '@jsonjoy.com/base64@1.1.2': - resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/json-pack@1.1.1': - resolution: {integrity: sha512-osjeBqMJ2lb/j/M8NCPjs1ylqWIcTRTycIhVB5pt6LgzgeRSb0YRZ7j9RfA8wIUrsr/medIuhVyonXRZWLyfdw==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - - '@jsonjoy.com/util@1.5.0': - resolution: {integrity: sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} @@ -20482,9 +20355,6 @@ packages: '@types/valid-url@1.0.7': resolution: {integrity: sha512-tgsWVG80dM5PVEBSbXUttPJTBCOo0IKbBh4R4z/SHsC5C81A3aaUH4fsbj+JYk7fopApU/Mao1c0EWTE592TSg==} - '@types/webpack-hot-middleware@2.25.9': - resolution: {integrity: sha512-fad4T9VfocBjS2fZxlqkGoXoVUAjVp0EEnKBRqPwnhEEDN/FqJoFkSP5t9O1gPH75qsyG2kkT/GSUqSNTn1ZPg==} - '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -23603,10 +23473,6 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} - hyperdyperid@1.2.0: - resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} - engines: {node: '>=10.18'} - hyperlinker@1.0.0: resolution: {integrity: sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==} engines: {node: '>=4'} @@ -24889,10 +24755,6 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - memfs@4.15.0: - resolution: {integrity: sha512-q9MmZXd2rRWHS6GU3WEm3HyiXZyyoA1DqdOhEq0lxPBmKb5S7IAOwX0RgUCwJfqjelDCySa5h8ujOy24LqsWcw==} - engines: {node: '>= 4.0.0'} - memoize@10.2.0: resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} engines: {node: '>=18'} @@ -27426,12 +27288,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - thingies@1.21.0: - resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==} - engines: {node: '>=10.18'} - peerDependencies: - tslib: ^2 - through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -27529,12 +27385,6 @@ packages: traverse@0.6.6: resolution: {integrity: sha512-kdf4JKs8lbARxWdp7RKdNzoJBhGUcIalSYibuGyHJbmk40pOysQ0+QPvlkCOICOivDWU2IJo2rkrxyTK2AH4fw==} - tree-dump@1.0.2: - resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==} - engines: {node: '>=10.0'} - peerDependencies: - tslib: '2' - tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -28145,15 +27995,6 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 - webpack-dev-middleware@7.4.2: - resolution: {integrity: sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.0.0 - peerDependenciesMeta: - webpack: - optional: true - webpack-dev-server@4.15.2: resolution: {integrity: sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==} engines: {node: '>= 12.13.0'} @@ -28167,9 +28008,6 @@ packages: webpack-cli: optional: true - webpack-hot-middleware@2.26.1: - resolution: {integrity: sha512-khZGfAeJx6I8K9zKohEWWYN6KDlVw2DHownoe+6Vtwj1LP9WFgegXnVMSkZ/dBEBtXFwrkkydsaPFlB7f8wU2A==} - webpack-merge@5.10.0: resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} engines: {node: '>=10.0.0'} @@ -32640,22 +32478,6 @@ snapshots: '@json2csv/formatters': 7.0.6 '@streamparser/json': 0.0.20 - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - - '@jsonjoy.com/json-pack@1.1.1(tslib@2.8.1)': - dependencies: - '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) - '@jsonjoy.com/util': 1.5.0(tslib@2.8.1) - hyperdyperid: 1.2.0 - thingies: 1.21.0(tslib@2.8.1) - tslib: 2.8.1 - - '@jsonjoy.com/util@1.5.0(tslib@2.8.1)': - dependencies: - tslib: 2.8.1 - '@juggle/resize-observer@3.4.0': {} '@kwsites/file-exists@1.1.1': @@ -34072,17 +33894,6 @@ snapshots: '@types/valid-url@1.0.7': {} - '@types/webpack-hot-middleware@2.25.9(webpack-cli@5.1.4)': - dependencies: - '@types/connect': 3.4.38 - tapable: 2.3.0 - webpack: 5.103.0(webpack-cli@5.1.4) - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack-cli - '@types/wrap-ansi@3.0.0': {} '@types/ws@6.0.4': @@ -37666,8 +37477,6 @@ snapshots: dependencies: ms: 2.1.3 - hyperdyperid@1.2.0: {} - hyperlinker@1.0.0: {} iconv-lite@0.4.24: @@ -39348,13 +39157,6 @@ snapshots: dependencies: fs-monkey: 1.1.0 - memfs@4.15.0: - dependencies: - '@jsonjoy.com/json-pack': 1.1.1(tslib@2.8.1) - '@jsonjoy.com/util': 1.5.0(tslib@2.8.1) - tree-dump: 1.0.2(tslib@2.8.1) - tslib: 2.8.1 - memoize@10.2.0: dependencies: mimic-function: 5.0.1 @@ -42566,10 +42368,6 @@ snapshots: dependencies: any-promise: 1.3.0 - thingies@1.21.0(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -42701,10 +42499,6 @@ snapshots: traverse@0.6.6: {} - tree-dump@1.0.2(tslib@2.8.1): - dependencies: - tslib: 2.8.1 - tree-kill@1.2.2: {} triple-beam@1.4.1: {} @@ -43432,17 +43226,6 @@ snapshots: schema-utils: 4.3.3 webpack: 5.103.0(webpack-cli@5.1.4) - webpack-dev-middleware@7.4.2(webpack@5.103.0): - dependencies: - colorette: 2.0.20 - memfs: 4.15.0 - mime-types: 2.1.35 - on-finished: 2.4.1 - range-parser: 1.2.1 - schema-utils: 4.3.3 - optionalDependencies: - webpack: 5.103.0(webpack-cli@5.1.4) - webpack-dev-server@4.15.2(debug@4.4.3)(webpack-cli@5.1.4)(webpack@5.103.0): dependencies: '@types/bonjour': 3.5.13 @@ -43525,12 +43308,6 @@ snapshots: - supports-color - utf-8-validate - webpack-hot-middleware@2.26.1: - dependencies: - ansi-html-community: 0.0.8 - html-entities: 2.6.0 - strip-ansi: 6.0.1 - webpack-merge@5.10.0: dependencies: clone-deep: 4.0.1