diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b555748f3..c6837d48851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,65 @@ +## v0.47.0 (2026-07-10) + +- lexicallexical-rich-text Bug Fix Fix formatText toggle direction and add SETTEXTFORMATCOMMAND (#8807) mayrang +- scripts Bug Fix Let npm prompt for OTP when publishing bootstrap stubs (#8820) Bob Ippolito +- lexical-playground Bug Fix Clear inline font-size when converting to heading (#8800) mayrang +- lexical-tablelexical-playground Feature setTableRowIsHeader and setTableColumnIsHeader utilities (#8815) mayrang +- lexical-website Documentation Update Rewrite testing guide (#8811) mayrang +- lexical-mdastlexical-rich-text Feature lexicalmdast, a micromarkmdast-based alternative to lexicalmarkdown (#8794) Bob Ippolito +- Make dependency-check resilient to transient registry errors (#8818) Gerard Rovira +- lexical Refactor Move event module globals into per-editor InputState (#8809) mayrang +- lexical Bug Fix getDocument() should fall back to the global document when there is no active editor (#8813) Sherry +- lexical-playground Bug Fix Keep cell background color modal open on first click (#8806) sahir +- lexical-playground Bug Fix Use consistent default maxWidth for markdown-imported images (#8810) mayrang +- lexical-devtoolslexical-playground Chore Update flow, hermes, and babel packages to latest (#8795) Bob Ippolito +- Add a 7-day pnpm minimumReleaseAge to match the Dependabot cooldown (#8808) Gerard Rovira +- lexical Chore Fix tmp package dependency vulnerability (#8802) vijay ojha +- lexical Chore Add missing Flow type declarations (#8799) mayrang +- lexical-markdown Feature Add generateNodesFromMarkdownString (#8789) mayrang +- lexicallexical-playground Chore Refactor IME composition test infrastructure and add browser-level coverage (#8793) mayrang +- lexical-playground Bug Fix Use viewBox dimensions for unsized Excalidraw output (#8798) mayrang +- lexical-table Bug Fix Export insertTableRowAtNode and insertTableColumnAtNode (#8791) +- lexical-playgroundlexical-website Feature Add Vercel Analytics and Speed Insights (#8796) Gerard Rovira +- lexicallexical-eslint-plugin Feature Add getDocument() API and Shadow DOM lint enforcement (#8788) mayrang +- scripts Bug Fix strip misplaced pure annotations from prod builds (#8786) Bob Ippolito +- lexical Bug Fix deleteCharacter overwrites X11 PRIMARY selection via Selection.modify (#8774) Bob Ippolito +- lexical-playground Bug Fix Support Unicode URLs in autolink matcher (#8787) mayrang +- lexical-table Feature Spread pasted TSV text across table cells (#8780) mayrang +- Breaking Changelexical Bug Fix Preserve DOM element when composing on segmented TextNode middle (#8784) mayrang +- Breaking Changelexical-reactlexical-devtools-core Chore Drop React 17 support, baseline is now React 18 (#8782) Bob Ippolito +- lexical-playgroundlexical Feature Ruby annotation node with floating editor (#8741) mayrang +- lexical-playground Feature Find and Replace (#8779) mayrang +- lexical-playground Bug Fix restore RTL checkbox position in checklist (#8783) Aldo Ryanda +- lexical-rich-text Bug Fix Stop ArrowUpDown from skipping block decorators (#8775) mayrang +- lexical-playground Bug Fix Convert remaining icon glyphs to mask-image for forced-colors support (#8781) mayrang +- fix(lexical) use extends for type parameter bound in registerEventListeners flow type (#8778) Sherry +- lexical Bug Fix Keep selection inside single-child inline elements (#8772) mayrang +- lexical Feature registerEventListener registerEventListeners DOM helpers (#8767) Bob Ippolito +- lexical-playground Bug Fix Restore floating toolbar comment icon after mask-image conversion (#8773) mayrang +- lexical Bug Fix Skip ZWSP insertion for format mismatch on Android Chrome (#8769) mayrang +- lexical-a11ylexical-reactlexical-playgroundlexical-website Feature lexicala11y framework-agnostic accessibility helpers WCAG AA reference adoption (#8591) mayrang +- Add missing copyright headers to 4 source files (#8768) xiezhenjia-meta +- lexical-website Bug Fix Remove horizontal scroll on homepage hero (#8760) Bakhtiyar +- lexical-selection Bug Fix re-resolve detached firstBlock in insert (#8764) Olivier Chevallier +- lexical Modernize InlineFormattableNode Flow stub syntax rejected by fb-www flow strict (#8763) Sherry +- lexical-utils Bug Fix positionNodeOnRange leaking orphan rect nodes when rects shrink (#8762) Durvesh Pilankar +- docs fix duplicated words in comments and docs (#8761) Durvesh Pilankar +- lexical-selection Bug Fix Properly handle block end focus in backward selections during setBlocksType (#8753) Olivier Chevallier +- lexicallexical-extension Bug Fix Align DecoratorTextNode format with TextNode in mixed selections (#8737) sahir +- lexical Bug Fix Clean up trailing shadow root after select-all delete (#8751) mayrang +- lexicallexical-rich-text Bug Fix Place block cursor between decorators and shadow roots (#8758) mayrang +- lexical-playground Bug Fix Deduplicate speech-to-text insertion on Android Chrome (#8759) mayrang +- lexical Bug Fix Prevent deleteLine from removing adjacent block decorators (#8744) mayrang +- build(deps) bump actionscheckout from 6 to 7 in the github-actions group (#8757) dependabotbot +- ci Add internal-registry dependency availability check (#8756) Gerard Rovira +- lexical Bug Fix Prevent text duplication on iOS Safari when formatting during composition (#8755) mayrang +- lexical-codelexical-playground Bug Fix Prevent formatting on TabNode inside code blocks (#8752) mayrang +- lexical Bug Fix Defer onUpdate callbacks during nested commits (#8672) Rohith Matam +- lexical-extensionlexical-playground Chore Some cleanups (#8732) Sergey Gorbachev +- lexical-markdown Bug Fix keep fence-like lines inside a code block as content (#8734) German Jablonski +- v0.46.0 (#8748) Bob Ippolito +- v0.46.0 Lexical GitHub Actions Bot + ## v0.46.0 (2026-06-25) - lexical Bug Fix Ignore beforeinput and input events in captured decorators (#8740) Bob Ippolito diff --git a/examples/agent-example/package.json b/examples/agent-example/package.json index 801fc3c88f3..89d1e0ba5b4 100644 --- a/examples/agent-example/package.json +++ b/examples/agent-example/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/agent-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -14,13 +14,13 @@ }, "dependencies": { "@huggingface/transformers": "^4.0.1", - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/selection": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/selection": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/extension-react-table/package.json b/examples/extension-react-table/package.json index 4c3671bf6ab..e8ec10d80cd 100644 --- a/examples/extension-react-table/package.json +++ b/examples/extension-react-table/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/extension-react-table-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,14 +10,14 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/table": "0.46.0", - "@lexical/tailwind": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/table": "0.47.0", + "@lexical/tailwind": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/examples/extension-sveltekit-ssr-hydration/package.json b/examples/extension-sveltekit-ssr-hydration/package.json index 6aadd0b0da4..b9e83534212 100644 --- a/examples/extension-sveltekit-ssr-hydration/package.json +++ b/examples/extension-sveltekit-ssr-hydration/package.json @@ -1,7 +1,7 @@ { "name": "extension-sveltekit-ssr-hydration", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite dev", @@ -17,17 +17,17 @@ "devDependencies": { "@eslint/compat": "^2.0.2", "@eslint/js": "^10.0.1", - "@lexical/clipboard": "0.46.0", - "@lexical/extension": "0.46.0", - "@lexical/headless": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/html": "0.46.0", - "@lexical/link": "0.46.0", - "@lexical/list": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/table": "0.46.0", - "@lexical/tailwind": "0.46.0", - "@lexical/utils": "0.46.0", + "@lexical/clipboard": "0.47.0", + "@lexical/extension": "0.47.0", + "@lexical/headless": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/html": "0.47.0", + "@lexical/link": "0.47.0", + "@lexical/list": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/table": "0.47.0", + "@lexical/tailwind": "0.47.0", + "@lexical/utils": "0.47.0", "@playwright/test": "^1.60.0", "@sveltejs/adapter-auto": "^6.1.1", "@sveltejs/kit": "^2.53.3", @@ -39,7 +39,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.15.0", "globals": "^16.5.0", - "lexical": "0.46.0", + "lexical": "0.47.0", "playwright": "^1.60.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.0", diff --git a/examples/extension-vanilla-react-plugin-host/package.json b/examples/extension-vanilla-react-plugin-host/package.json index 6e12969b84c..33d5984e65f 100644 --- a/examples/extension-vanilla-react-plugin-host/package.json +++ b/examples/extension-vanilla-react-plugin-host/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/extension-vanilla-react-plugin-host", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -13,14 +13,14 @@ "startCommand": "npm i && npm run monorepo:dev" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/list": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/tailwind": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0" + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/list": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/tailwind": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0" }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", diff --git a/examples/extension-vanilla-tailwind/package.json b/examples/extension-vanilla-tailwind/package.json index de02a30372a..53e61becd4f 100644 --- a/examples/extension-vanilla-tailwind/package.json +++ b/examples/extension-vanilla-tailwind/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/extension-vanilla-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -13,13 +13,13 @@ "startCommand": "npm i && npm run monorepo:dev" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/list": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/tailwind": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0" + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/list": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/tailwind": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0" }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", diff --git a/examples/markdown-editor/package.json b/examples/markdown-editor/package.json index 0adbf1eb6b1..e66f9fb6f30 100644 --- a/examples/markdown-editor/package.json +++ b/examples/markdown-editor/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/markdown-editor-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -12,15 +12,15 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/list": "0.46.0", - "@lexical/markdown": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/selection": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/list": "0.47.0", + "@lexical/markdown": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/selection": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/nextjs-code-shiki/package.json b/examples/nextjs-code-shiki/package.json index ef98ab8749e..7803cca68a0 100644 --- a/examples/nextjs-code-shiki/package.json +++ b/examples/nextjs-code-shiki/package.json @@ -1,6 +1,6 @@ { "name": "@lexical/nextjs-code-shiki-example", - "version": "0.46.0", + "version": "0.47.0", "private": true, "scripts": { "dev": "next dev", @@ -9,14 +9,14 @@ "test": "playwright test" }, "dependencies": { - "@lexical/code-core": "0.46.0", - "@lexical/code-shiki": "0.46.0", - "@lexical/extension": "0.46.0", - "@lexical/headless": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "lexical": "0.46.0", + "@lexical/code-core": "0.47.0", + "@lexical/code-shiki": "0.47.0", + "@lexical/extension": "0.47.0", + "@lexical/headless": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "lexical": "0.47.0", "next": "^15.5.14", "react": "^19", "react-dom": "^19" diff --git a/examples/node-replacement/package.json b/examples/node-replacement/package.json index 9c88a7470fb..a8620a9d6a3 100644 --- a/examples/node-replacement/package.json +++ b/examples/node-replacement/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/node-replacement-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/react": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/react": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/node-state-style/package.json b/examples/node-state-style/package.json index a7322976f84..1830c1b25df 100644 --- a/examples/node-state-style/package.json +++ b/examples/node-state-style/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/node-state-style-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -11,17 +11,17 @@ }, "dependencies": { "@ark-ui/react": ">=5.6.0 <5.37.1 || >5.37.1 <6.0.0", - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/html": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/selection": "0.46.0", - "@lexical/utils": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/html": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/selection": "0.47.0", + "@lexical/utils": "0.47.0", "@shikijs/langs": "^4.2.0", "@shikijs/themes": "^4.2.0", "@zag-js/combobox": "^1.9.2", - "lexical": "0.46.0", + "lexical": "0.47.0", "lucide-react": "^1.17.0", "prettier": "^3.5.3", "react": "^19.1.0", diff --git a/examples/react-plain-text/package.json b/examples/react-plain-text/package.json index 8bb5ecf9eab..6f4e4627947 100644 --- a/examples/react-plain-text/package.json +++ b/examples/react-plain-text/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/react-plain-text-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/react": "0.46.0", - "lexical": "0.46.0", + "@lexical/react": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/react-rich-collab/package.json b/examples/react-rich-collab/package.json index caa528b955b..4ffb7ed841f 100644 --- a/examples/react-rich-collab/package.json +++ b/examples/react-rich-collab/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/react-rich-collab-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -14,10 +14,10 @@ "server:webrtc": "cross-env HOST=localhost PORT=1235 npx y-webrtc" }, "dependencies": { - "@lexical/react": "0.46.0", - "@lexical/utils": "0.46.0", - "@lexical/yjs": "0.46.0", - "lexical": "0.46.0", + "@lexical/react": "0.47.0", + "@lexical/utils": "0.47.0", + "@lexical/yjs": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5", "y-webrtc": "^10.3.0", diff --git a/examples/react-rich/package.json b/examples/react-rich/package.json index 90161ac976f..42115c4c3b1 100644 --- a/examples/react-rich/package.json +++ b/examples/react-rich/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/react-rich-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/react": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/react": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/react-table/package.json b/examples/react-table/package.json index dba404d3c4b..bcc38a9a3cb 100644 --- a/examples/react-table/package.json +++ b/examples/react-table/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/react-table-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,10 +10,10 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/react": "0.46.0", - "@lexical/table": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/react": "0.47.0", + "@lexical/table": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/vanilla-js-iframe/package.json b/examples/vanilla-js-iframe/package.json index 64bda454e3b..b4432ac3c6e 100644 --- a/examples/vanilla-js-iframe/package.json +++ b/examples/vanilla-js-iframe/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/vanilla-js-iframe-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,11 +10,11 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/dragon": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0" + "@lexical/dragon": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/examples/vanilla-js-plugin/package.json b/examples/vanilla-js-plugin/package.json index c01e7fcd03c..d2f4ea771a4 100644 --- a/examples/vanilla-js-plugin/package.json +++ b/examples/vanilla-js-plugin/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/vanilla-js-plugin-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,12 +10,12 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/dragon": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/utils": "0.46.0", + "@lexical/dragon": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/utils": "0.47.0", "emoji-datasource-facebook": "15.1.2", - "lexical": "0.46.0" + "lexical": "0.47.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/examples/vanilla-js/package.json b/examples/vanilla-js/package.json index cb79304db1b..00d60833c88 100644 --- a/examples/vanilla-js/package.json +++ b/examples/vanilla-js/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/vanilla-js-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -10,11 +10,11 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/dragon": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0" + "@lexical/dragon": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0" }, "devDependencies": { "cross-env": "^7.0.3", diff --git a/examples/website-chat/package.json b/examples/website-chat/package.json index ce8e95bfbe1..cf8f51152f1 100644 --- a/examples/website-chat/package.json +++ b/examples/website-chat/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/website-chat-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -9,11 +9,11 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/website-notion/package.json b/examples/website-notion/package.json index 75d08b9cafb..3cd6d59027c 100644 --- a/examples/website-notion/package.json +++ b/examples/website-notion/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/website-notion-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -9,14 +9,14 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/list": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/selection": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/list": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/selection": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/website-rich-input/package.json b/examples/website-rich-input/package.json index 97ae8594977..ce8e7965db4 100644 --- a/examples/website-rich-input/package.json +++ b/examples/website-rich-input/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/website-rich-input-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -9,12 +9,12 @@ "preview": "vite preview" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/hashtag": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/hashtag": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/examples/website-toolbar/package.json b/examples/website-toolbar/package.json index 7598f18f2ff..d9dd081be64 100644 --- a/examples/website-toolbar/package.json +++ b/examples/website-toolbar/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/website-toolbar-example", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "vite", @@ -11,13 +11,13 @@ "test:watch": "vitest" }, "dependencies": { - "@lexical/extension": "0.46.0", - "@lexical/history": "0.46.0", - "@lexical/react": "0.46.0", - "@lexical/rich-text": "0.46.0", - "@lexical/selection": "0.46.0", - "@lexical/utils": "0.46.0", - "lexical": "0.46.0", + "@lexical/extension": "0.47.0", + "@lexical/history": "0.47.0", + "@lexical/react": "0.47.0", + "@lexical/rich-text": "0.47.0", + "@lexical/selection": "0.47.0", + "@lexical/utils": "0.47.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/package.json b/package.json index 550285c05b3..75d58f21d18 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@lexical/monorepo", "description": "Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.", - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "private": true, "packageManager": "pnpm@10.34.1", diff --git a/packages/lexical-a11y/package.json b/packages/lexical-a11y/package.json index 5862afb19df..2ff25950fd8 100644 --- a/packages/lexical-a11y/package.json +++ b/packages/lexical-a11y/package.json @@ -9,7 +9,7 @@ "aria" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalA11y.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-clipboard/package.json b/packages/lexical-clipboard/package.json index 87e4eff0c41..8d7ebdeba75 100644 --- a/packages/lexical-clipboard/package.json +++ b/packages/lexical-clipboard/package.json @@ -9,7 +9,7 @@ "paste" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalClipboard.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-code-core/package.json b/packages/lexical-code-core/package.json index 2de8a064b57..d2d88662e6c 100644 --- a/packages/lexical-code-core/package.json +++ b/packages/lexical-code-core/package.json @@ -8,7 +8,7 @@ "code" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalCodeCore.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-code-prism/package.json b/packages/lexical-code-prism/package.json index e5f0ac2304c..0d8068b1ab4 100644 --- a/packages/lexical-code-prism/package.json +++ b/packages/lexical-code-prism/package.json @@ -8,7 +8,7 @@ "code" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalCodePrism.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-code-shiki/package.json b/packages/lexical-code-shiki/package.json index e21bbfc8395..f58069765f9 100644 --- a/packages/lexical-code-shiki/package.json +++ b/packages/lexical-code-shiki/package.json @@ -8,7 +8,7 @@ "code" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalCodeShiki.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-code/package.json b/packages/lexical-code/package.json index 87bf8f415d6..178a86bcffc 100644 --- a/packages/lexical-code/package.json +++ b/packages/lexical-code/package.json @@ -8,7 +8,7 @@ "code" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalCode.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-devtools-core/package.json b/packages/lexical-devtools-core/package.json index ba8cb1d45db..6a4dd45f741 100644 --- a/packages/lexical-devtools-core/package.json +++ b/packages/lexical-devtools-core/package.json @@ -8,7 +8,7 @@ "utils" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalDevtoolsCore.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-devtools/package.json b/packages/lexical-devtools/package.json index e4233e096c2..668d17ee7c8 100644 --- a/packages/lexical-devtools/package.json +++ b/packages/lexical-devtools/package.json @@ -2,7 +2,7 @@ "name": "@lexical/devtools", "description": "Lexical DevTools browser extension", "private": true, - "version": "0.46.0", + "version": "0.47.0", "type": "module", "scripts": { "dev": "wxt", diff --git a/packages/lexical-dragon/package.json b/packages/lexical-dragon/package.json index 6bbc33af4f4..f1a2ff7cdbc 100644 --- a/packages/lexical-dragon/package.json +++ b/packages/lexical-dragon/package.json @@ -9,7 +9,7 @@ "accessibility" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalDragon.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-eslint-plugin-internal/package.json b/packages/lexical-eslint-plugin-internal/package.json index c013e0e42f8..1c3655c0cb3 100644 --- a/packages/lexical-eslint-plugin-internal/package.json +++ b/packages/lexical-eslint-plugin-internal/package.json @@ -1,6 +1,6 @@ { "name": "@lexical/eslint-plugin-internal", - "version": "0.46.0", + "version": "0.47.0", "description": "Internal ESLint plugin for the lexical monorepo", "type": "module", "main": "src/index.js", diff --git a/packages/lexical-eslint-plugin/package.json b/packages/lexical-eslint-plugin/package.json index 9f3b2b9c6ab..296fbf07e28 100644 --- a/packages/lexical-eslint-plugin/package.json +++ b/packages/lexical-eslint-plugin/package.json @@ -8,7 +8,7 @@ "lexical", "editor" ], - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/lexical-extension/package.json b/packages/lexical-extension/package.json index 59e7db6fe28..f88663f4f4e 100644 --- a/packages/lexical-extension/package.json +++ b/packages/lexical-extension/package.json @@ -6,7 +6,7 @@ "extension", "plugin" ], - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/lexical-extension/src/__tests__/unit/DecoratorTextExtension.test.ts b/packages/lexical-extension/src/__tests__/unit/DecoratorTextExtension.test.ts index 81b68ceacf5..e362b5f5dec 100644 --- a/packages/lexical-extension/src/__tests__/unit/DecoratorTextExtension.test.ts +++ b/packages/lexical-extension/src/__tests__/unit/DecoratorTextExtension.test.ts @@ -17,7 +17,9 @@ import { $createParagraphNode, $createTextNode, $getRoot, + $getSelection, $isParagraphNode, + $isRangeSelection, $selectAll, FORMAT_TEXT_COMMAND, TEXT_TYPE_TO_FORMAT, @@ -43,6 +45,12 @@ describe('DecoratorTextExtension FORMAT_TEXT_COMMAND', () => { paragraph.append(decorator, text); $selectAll(); + // onSelectionChange doesn't run in jsdom, so manually set + // the AND-intersection format that the browser would compute. + const sel = $getSelection(); + if ($isRangeSelection(sel)) { + sel.format = BOLD; + } }, dependencies: [DecoratorTextExtension, RichTextExtension], name: 'test', @@ -58,7 +66,7 @@ describe('DecoratorTextExtension FORMAT_TEXT_COMMAND', () => { const text = children[children.length - 1] as unknown as { getFormat(): number; }; - // TextNode (bold=1) drives firstNextFormat → bold=0. + // selection.format = BOLD (AND of TextNodes) → toggle OFF → bold=0. // DecoratorTextNode must align to bold=0 (not toggle independently to bold=1). expect(decorator.getFormat()).toBe(0); expect(text.getFormat()).toBe(0); @@ -113,6 +121,10 @@ describe('DecoratorTextExtension FORMAT_TEXT_COMMAND', () => { paragraph.append(decorator, text); $selectAll(); + const sel = $getSelection(); + if ($isRangeSelection(sel)) { + sel.format = BOLD; + } }, dependencies: [DecoratorTextExtension, RichTextExtension], name: 'test', diff --git a/packages/lexical-file/package.json b/packages/lexical-file/package.json index d256c04ce68..b85fe0faec6 100644 --- a/packages/lexical-file/package.json +++ b/packages/lexical-file/package.json @@ -10,7 +10,7 @@ "export" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalFile.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-hashtag/package.json b/packages/lexical-hashtag/package.json index 76d78555575..3aa8c42f23a 100644 --- a/packages/lexical-hashtag/package.json +++ b/packages/lexical-hashtag/package.json @@ -8,7 +8,7 @@ "hashtag" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalHashtag.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-headless/package.json b/packages/lexical-headless/package.json index ff51b7be01d..8aaea6aa134 100644 --- a/packages/lexical-headless/package.json +++ b/packages/lexical-headless/package.json @@ -8,7 +8,7 @@ "headless" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "repository": { "type": "git", "url": "git+https://github.com/facebook/lexical.git", diff --git a/packages/lexical-history/package.json b/packages/lexical-history/package.json index afef7aaef2f..9a0b17d13ab 100644 --- a/packages/lexical-history/package.json +++ b/packages/lexical-history/package.json @@ -8,7 +8,7 @@ "history" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalHistory.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-html/package.json b/packages/lexical-html/package.json index 827f18d8434..4040dcfb2d0 100644 --- a/packages/lexical-html/package.json +++ b/packages/lexical-html/package.json @@ -8,7 +8,7 @@ "html" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalHtml.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-internal/package.json b/packages/lexical-internal/package.json index a619b85c77a..6ea125b9fe9 100644 --- a/packages/lexical-internal/package.json +++ b/packages/lexical-internal/package.json @@ -8,7 +8,7 @@ "internal" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "repository": { "type": "git", "url": "git+https://github.com/facebook/lexical.git", diff --git a/packages/lexical-link/package.json b/packages/lexical-link/package.json index d9d92e23b31..d6d3fbef607 100644 --- a/packages/lexical-link/package.json +++ b/packages/lexical-link/package.json @@ -8,7 +8,7 @@ "link" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalLink.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-list/package.json b/packages/lexical-list/package.json index 08b27d276be..e1a54f02ed0 100644 --- a/packages/lexical-list/package.json +++ b/packages/lexical-list/package.json @@ -8,7 +8,7 @@ "list" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalList.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-mark/package.json b/packages/lexical-mark/package.json index c515872332c..eab4764792f 100644 --- a/packages/lexical-mark/package.json +++ b/packages/lexical-mark/package.json @@ -8,7 +8,7 @@ "mark" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalMark.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-markdown/package.json b/packages/lexical-markdown/package.json index 4ca77d7e970..445123946c2 100644 --- a/packages/lexical-markdown/package.json +++ b/packages/lexical-markdown/package.json @@ -8,7 +8,7 @@ "markdown" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalMarkdown.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-mdast/package.json b/packages/lexical-mdast/package.json index 354e370e68c..dc21dcc8e92 100644 --- a/packages/lexical-mdast/package.json +++ b/packages/lexical-mdast/package.json @@ -10,7 +10,7 @@ "micromark" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalMdast.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-offset/package.json b/packages/lexical-offset/package.json index 7c013c788d3..9d9a591056a 100644 --- a/packages/lexical-offset/package.json +++ b/packages/lexical-offset/package.json @@ -8,7 +8,7 @@ "offset" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalOffset.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-overflow/package.json b/packages/lexical-overflow/package.json index 1b387168a7a..9fe081bd69d 100644 --- a/packages/lexical-overflow/package.json +++ b/packages/lexical-overflow/package.json @@ -8,7 +8,7 @@ "overflow" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalOverflow.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-plain-text/package.json b/packages/lexical-plain-text/package.json index 176f808b314..5f955fe9296 100644 --- a/packages/lexical-plain-text/package.json +++ b/packages/lexical-plain-text/package.json @@ -7,7 +7,7 @@ "plain-text" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalPlainText.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs index 6a71380dc23..9d0b5fa5023 100644 --- a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs @@ -932,38 +932,50 @@ test.describe('TextFormatting', () => { await selectCharacters(page, 'right', 5); await toggleBold(page); + // selection.format = AND(bold "w", italic "or", bold "ld") = 0 → bold toggled ON await assertHTML( page, html`

- Hello w - Hello + + w + + or - - ld! + + + ld + + !

`, ); await assertSelection(page, { - anchorOffset: 6, - anchorPath: [0, 0, 0], + anchorOffset: 0, + anchorPath: [0, 1, 0], focusOffset: 2, - focusPath: [0, 2, 0], + focusPath: [0, 3, 0], }); await toggleItalic(page); + // selection.format = AND(bold, bold+italic, bold) = bold → italic toggled ON await assertHTML( page, html`

Hello - world - + !

`, @@ -977,19 +989,26 @@ test.describe('TextFormatting', () => { }); await toggleItalic(page); + // selection.format = bold+italic → italic toggled OFF → bold only await assertHTML( page, html`

- Hello world! + Hello + + world + + !

`, ); await assertSelection(page, { - anchorOffset: 6, - anchorPath: [0, 0, 0], - focusOffset: 11, - focusPath: [0, 0, 0], + anchorOffset: 0, + anchorPath: [0, 1, 0], + focusOffset: 5, + focusPath: [0, 1, 0], }); }); @@ -1110,6 +1129,7 @@ test.describe('TextFormatting', () => { await page.keyboard.up('Shift'); await toggleBold(page); + // selection.format = AND(bold "3", plain "456") = 0 → bold toggled ON await assertHTML( page, html` @@ -1117,14 +1137,13 @@ test.describe('TextFormatting', () => { - 12 + 123456 - 3456

`, ); - // Toggle once more + // Toggle once more — now all selected text is bold → bold toggled OFF await toggleBold(page); await assertHTML( @@ -1134,8 +1153,9 @@ test.describe('TextFormatting', () => { - 123456 + 12 + 3456

`, ); diff --git a/packages/lexical-playground/package.json b/packages/lexical-playground/package.json index 1b71c635733..d2a5dd95e3c 100644 --- a/packages/lexical-playground/package.json +++ b/packages/lexical-playground/package.json @@ -1,6 +1,6 @@ { "name": "lexical-playground", - "version": "0.46.0", + "version": "0.47.0", "private": true, "type": "module", "scripts": { diff --git a/packages/lexical-react/package.json b/packages/lexical-react/package.json index 9f38386f8f3..f3ea7c6ec5b 100644 --- a/packages/lexical-react/package.json +++ b/packages/lexical-react/package.json @@ -8,7 +8,7 @@ "rich-text" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "dependencies": { "@floating-ui/react": "^0.27.19", "@lexical/a11y": "workspace:*", diff --git a/packages/lexical-rich-text/package.json b/packages/lexical-rich-text/package.json index b91b7a95664..b6d7a83107e 100644 --- a/packages/lexical-rich-text/package.json +++ b/packages/lexical-rich-text/package.json @@ -7,7 +7,7 @@ "rich-text" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalRichText.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index bf02e5857e7..3571b90e739 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -70,6 +70,7 @@ import { $setSelection, $setSelectionFromCaretRange, $setState, + $setTextFormat, addClassNamesToElement, CAN_USE_BEFORE_INPUT, type CaretDirection, @@ -133,6 +134,7 @@ import { REMOVE_TEXT_COMMAND, SELECT_ALL_COMMAND, type SerializedElementNode, + SET_TEXT_FORMAT_COMMAND, setNodeIndentFromDOM, type Spread, type TextFormatType, @@ -975,6 +977,111 @@ function restoreDOMSelection( } } +function $tryInlineGridLineNavigation( + selection: RangeSelection, + isBackward: boolean, +): boolean { + if (!selection.isCollapsed()) { + return false; + } + const focusNode = selection.focus.getNode(); + const parentBlock = $findMatchingParent( + $isElementNode(focusNode) ? focusNode : focusNode.getParentOrThrow(), + (n): n is ElementNode => $isElementNode(n) && !n.isInline(), + ); + if (parentBlock === null) { + return false; + } + const editor = $getEditor(); + const rootElement = editor.getRootElement(); + if (rootElement === null) { + return false; + } + const win = rootElement.ownerDocument.defaultView; + if (win === null) { + return false; + } + let hasGrid = false; + for (const child of parentBlock.getChildren()) { + if ($isElementNode(child) && child.isInline()) { + const dom = editor.getElementByKey(child.getKey()); + if (dom !== null) { + const d = win.getComputedStyle(dom).display; + if (d === 'inline-grid' || d === 'inline-flex') { + hasGrid = true; + break; + } + } + } + } + if (!hasGrid) { + return false; + } + const direction: CaretDirection = isBackward ? 'previous' : 'next'; + const siblingBlock = $getSiblingCaret( + parentBlock, + direction, + ).getNodeAtCaret(); + if (siblingBlock === null || !$isElementNode(siblingBlock)) { + if (isBackward) { + const first = parentBlock.getFirstDescendant(); + if ($isTextNode(first)) { + first.select(0, 0); + } else { + parentBlock.select(0, 0); + } + } else { + const last = parentBlock.getLastDescendant(); + if ($isTextNode(last)) { + const len = last.getTextContentSize(); + last.select(len, len); + } else { + const count = parentBlock.getChildrenSize(); + parentBlock.select(count, count); + } + } + return true; + } + const siblingDOM = editor.getElementByKey(siblingBlock.getKey()); + if (siblingDOM === null) { + return false; + } + const domSelection = getDOMSelection(win); + if (domSelection === null || domSelection.rangeCount === 0) { + return false; + } + const curRange = domSelection.getRangeAt(0).cloneRange(); + curRange.collapse(true); + const curRect = curRange.getBoundingClientRect(); + const sibRect = siblingDOM.getBoundingClientRect(); + const targetY = sibRect.top + sibRect.height / 2; + if (curRect.height > 0) { + const hit = caretFromPoint(curRect.left, targetY, rootElement); + if (hit !== null && siblingDOM.contains(hit.node)) { + const hitRange = rootElement.ownerDocument.createRange(); + hitRange.setStart(hit.node, hit.offset); + hitRange.collapse(true); + selection.applyDOMRange(hitRange); + selection.dirty = true; + return true; + } + } + const targetDesc = isBackward + ? siblingBlock.getLastDescendant() + : siblingBlock.getFirstDescendant(); + if ($isTextNode(targetDesc)) { + const offset = isBackward ? targetDesc.getTextContentSize() : 0; + targetDesc.select(offset, offset); + } else { + const childCount = siblingBlock.getChildrenSize(); + siblingBlock.select( + isBackward ? childCount : 0, + isBackward ? childCount : 0, + ); + } + return true; +} + function $exitNodeSelectionToward( node: LexicalNode, direction: CaretDirection, @@ -1174,6 +1281,18 @@ export function registerRichText( }, COMMAND_PRIORITY_EDITOR, ), + editor.registerCommand( + SET_TEXT_FORMAT_COMMAND, + formats => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) { + return false; + } + $setTextFormat(selection, formats); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), editor.registerCommand( FORMAT_ELEMENT_COMMAND, format => { @@ -1281,6 +1400,13 @@ export function registerRichText( event.preventDefault(); return true; } + if ( + !event.shiftKey && + $tryInlineGridLineNavigation(selection, true) + ) { + event.preventDefault(); + return true; + } } return false; }, @@ -1318,6 +1444,13 @@ export function registerRichText( event.preventDefault(); return true; } + if ( + !event.shiftKey && + $tryInlineGridLineNavigation(selection, false) + ) { + event.preventDefault(); + return true; + } } return false; }, diff --git a/packages/lexical-selection/package.json b/packages/lexical-selection/package.json index da51fcc10eb..41195874886 100644 --- a/packages/lexical-selection/package.json +++ b/packages/lexical-selection/package.json @@ -9,7 +9,7 @@ "selection" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalSelection.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index e4a33b035ba..ba2bb7b5ae1 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -20,7 +20,9 @@ import { $isLeafNode, $isRangeSelection, $isRootOrShadowRoot, + $isTabNode, $isTextNode, + $isTextPointCaret, $setSelection, type BaseSelection, type CaretDirection, @@ -547,6 +549,20 @@ export function $shouldOverrideDefaultCharacterSelection( if ($isExtendableTextPointCaret(focusCaret)) { return false; } + // At an unmergeable TextNode boundary adjacent to another plain TextNode, + // override so Lexical's modify() can pre-normalize across inline-grid/flex + // spans (#7301). Restricted to unmergeable nodes to avoid disrupting + // format-affinity at normal bold/italic boundaries. + if ( + $isTextPointCaret(focusCaret) && + !$isTabNode(focusCaret.origin) && + focusCaret.origin.isUnmergeable() + ) { + const sibling = focusCaret.getNodeAtCaret(); + if ($isTextNode(sibling) && !$isTabNode(sibling)) { + return true; + } + } for (const nextCaret of $extendCaretToRange(focusCaret)) { if ($isChildCaret(nextCaret)) { return !nextCaret.origin.isInline(); diff --git a/packages/lexical-table/package.json b/packages/lexical-table/package.json index 19b11ea7388..1b859f4c7c0 100644 --- a/packages/lexical-table/package.json +++ b/packages/lexical-table/package.json @@ -8,7 +8,7 @@ "table" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalTable.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-tailwind/package.json b/packages/lexical-tailwind/package.json index d3a37c31486..1405080cf70 100644 --- a/packages/lexical-tailwind/package.json +++ b/packages/lexical-tailwind/package.json @@ -7,7 +7,7 @@ "theme", "css" ], - "version": "0.46.0", + "version": "0.47.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/lexical-text/package.json b/packages/lexical-text/package.json index a3d74478c8d..5e7d6a4626d 100644 --- a/packages/lexical-text/package.json +++ b/packages/lexical-text/package.json @@ -9,7 +9,7 @@ "text" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalText.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical-utils/package.json b/packages/lexical-utils/package.json index 54f183bfba9..66e6b65a5ec 100644 --- a/packages/lexical-utils/package.json +++ b/packages/lexical-utils/package.json @@ -8,7 +8,7 @@ "utils" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalUtils.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical-website/package.json b/packages/lexical-website/package.json index c0239179879..ad2c3be62bd 100644 --- a/packages/lexical-website/package.json +++ b/packages/lexical-website/package.json @@ -1,6 +1,6 @@ { "name": "@lexical/website", - "version": "0.46.0", + "version": "0.47.0", "private": true, "scripts": { "docusaurus": "cross-env IGNORE_PEER_DEPENDENCIES=react docusaurus", diff --git a/packages/lexical-yjs/package.json b/packages/lexical-yjs/package.json index 63e6df86357..862bc0158ea 100644 --- a/packages/lexical-yjs/package.json +++ b/packages/lexical-yjs/package.json @@ -11,7 +11,7 @@ "crdt" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/LexicalYjs.js", "types": "./dist/typescript-too-old.d.ts", "dependencies": { diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 580edeae91f..48afdc6bd99 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -24,6 +24,7 @@ declare export var REMOVE_TEXT_COMMAND: LexicalCommand; declare export var DELETE_WORD_COMMAND: LexicalCommand; declare export var DELETE_LINE_COMMAND: LexicalCommand; declare export var FORMAT_TEXT_COMMAND: LexicalCommand; +declare export var SET_TEXT_FORMAT_COMMAND: LexicalCommand>>; declare export var UNDO_COMMAND: LexicalCommand; declare export var REDO_COMMAND: LexicalCommand; declare export var KEY_DOWN_COMMAND: LexicalCommand; @@ -742,6 +743,10 @@ declare export function $formatText( declare export function $generateNodesFromRawText( text: string, ): (TextNode | LineBreakNode)[]; +declare export function $setTextFormat( + selection: RangeSelection | NodeSelection, + formats: Partial>, +): void; declare export function $getSelection(): null | BaseSelection; declare export function $getTextContent(): string; declare export function $getPreviousSelection(): null | BaseSelection; @@ -826,6 +831,7 @@ declare export class TextNode extends LexicalNode { } export interface InlineFormattableNode { readonly __isInlineFormattable: true; + getFormat(): number; getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number; hasFormat(type: TextFormatType): boolean; setFormat(format: number): unknown; diff --git a/packages/lexical/package.json b/packages/lexical/package.json index f8cf63d3dc0..a3ca47e380c 100644 --- a/packages/lexical/package.json +++ b/packages/lexical/package.json @@ -9,7 +9,7 @@ "rich-text" ], "license": "MIT", - "version": "0.46.0", + "version": "0.47.0", "main": "./dist/Lexical.js", "types": "./dist/typescript-too-old.d.ts", "repository": { diff --git a/packages/lexical/src/LexicalCommands.ts b/packages/lexical/src/LexicalCommands.ts index 14a464903f5..cc0b3e79046 100644 --- a/packages/lexical/src/LexicalCommands.ts +++ b/packages/lexical/src/LexicalCommands.ts @@ -86,6 +86,14 @@ export const DELETE_LINE_COMMAND: LexicalCommand = */ export const FORMAT_TEXT_COMMAND: LexicalCommand = /* @__PURE__ */ createCommand('FORMAT_TEXT_COMMAND'); +/** + * Dispatched to explicitly set or unset text formats on the selection. + * Unlike FORMAT_TEXT_COMMAND which toggles, this command sets each specified + * format to the exact boolean value provided. + */ +export const SET_TEXT_FORMAT_COMMAND: LexicalCommand< + Partial> +> = /* @__PURE__ */ createCommand('SET_TEXT_FORMAT_COMMAND'); /** * Dispatched on undo (Cmd+Z on macOS, Ctrl+Z elsewhere). */ diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 0440b1a72d5..67dcffbecb6 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -822,8 +822,8 @@ export class RangeSelection implements BaseSelection { } /** - * Returns whether the provided TextFormatType is present on the Selection. This will be true if any node in the Selection - * has the specified format. + * Returns whether the provided TextFormatType is present on the Selection. This will be true if all text nodes in the Selection + * have the specified format. * * @param type the TextFormatType to check for. * @returns true if the provided format is currently toggled on the Selection, false otherwise. @@ -1725,23 +1725,22 @@ export class RangeSelection implements BaseSelection { ) { removeDOMBlockCursorElement(blockCursorElement, editor, rootElement); } + const focusKeyedDOM = getElementByKeyOrThrow(editor, this.focus.key); + let nextFocusDOM: HTMLElement | Text | null = focusKeyedDOM; + if (this.focus.type === 'text') { + nextFocusDOM = $isTextNode(focusNode) + ? $getDOMTextNode(focusNode, focusKeyedDOM, editor) + : null; + } if (this.dirty) { const anchorKeyedDOM = getElementByKeyOrThrow(editor, this.anchor.key); - const focusKeyedDOM = getElementByKeyOrThrow(editor, this.focus.key); let nextAnchorDOM: HTMLElement | Text | null = anchorKeyedDOM; - let nextFocusDOM: HTMLElement | Text | null = focusKeyedDOM; if (this.anchor.type === 'text') { const node = this.anchor.getNode(); nextAnchorDOM = $isTextNode(node) ? $getDOMTextNode(node, anchorKeyedDOM, editor) : null; } - if (this.focus.type === 'text') { - const node = this.focus.getNode(); - nextFocusDOM = $isTextNode(node) - ? $getDOMTextNode(node, focusKeyedDOM, editor) - : null; - } if (nextAnchorDOM && nextFocusDOM) { setDOMSelectionBaseAndExtent( domSelection, @@ -1752,6 +1751,55 @@ export class RangeSelection implements BaseSelection { ); } } + // When focus sits at a TextNode boundary, pre-normalize the DOM + // selection into the adjacent sibling's Text node so that the + // native Selection.modify can cross inline-grid/flex spans (#7301). + if ( + granularity === 'character' && + $isTextNode(focusNode) && + focusNode.isUnmergeable() + ) { + const atBoundary = isBackward + ? this.focus.offset === 0 + : this.focus.offset === focusNode.getTextContentSize(); + if (atBoundary) { + const sibling = $getSiblingCaret( + focusNode, + isBackward ? 'previous' : 'next', + ).getNodeAtCaret(); + if ($isTextNode(sibling)) { + if (collapse) { + const sibKeyedDOM = editor.getElementByKey(sibling.getKey()); + const sibDOM = sibKeyedDOM + ? $getDOMTextNode(sibling, sibKeyedDOM, editor) + : null; + if (sibDOM) { + const sibOffset = isBackward ? sibDOM.length : 0; + setDOMSelectionBaseAndExtent( + domSelection, + sibDOM, + sibOffset, + sibDOM, + sibOffset, + ); + } + } else { + // For extend (used by deleteCharacter), native Selection.modify + // cannot cross inline-grid span boundaries even after + // pre-normalization. Set the Lexical selection directly and + // return early to skip the native moveNativeSelection call. + const sibLen = sibling.getTextContentSize(); + if (isBackward) { + this.focus.set(sibling.__key, sibLen - 1, 'text'); + } else { + this.focus.set(sibling.__key, 1, 'text'); + } + this.dirty = true; + return; + } + } + } + } // We use the DOM selection.modify API here to "tell" us what the selection // will be. We then use it to update the Lexical selection accordingly. This // is much more reliable than waiting for a beforeinput and using the ranges @@ -2187,72 +2235,50 @@ export function $isNodeSelection(x: unknown): x is NodeSelection { } /** - * Applies the provided format to TextNodes and inline formattable nodes - * (e.g. DecoratorTextNode) in the selection, splitting or merging TextNodes - * as necessary and aligning all formattable nodes to the same target format. - * - * For RangeSelection the target format is determined by the first TextNode in - * the selection (same semantics as the previous RangeSelection.formatText). - * For NodeSelection each node is toggled independently since there is no - * TextNode to use as an alignment reference. + * Applies a pure bitmask transform to every formattable node in the selection + * in a single traversal, splitting the first and last TextNodes as necessary + * so that only the selected text is affected. Each node receives exactly one + * `setFormat(applyFormat(getFormat()))` (ElementNodes use their textFormat). * * @param selection - the selection whose nodes should be formatted. - * @param formatType - the format type to apply. - * @param alignWithFormat - optional 32-bit bitmask to align with (RangeSelection only). + * @param applyFormat - maps a node's current 32-bit format to its new format. */ -export function $formatText( +function $updateTextFormat( selection: RangeSelection | NodeSelection, - formatType: TextFormatType, - alignWithFormat: number | null = null, + applyFormat: (format: number) => number, ): void { if ($isNodeSelection(selection)) { for (const node of selection.getNodes()) { if ($isInlineFormattable(node)) { - node.setFormat(node.getFormatFlags(formatType, null)); + node.setFormat(applyFormat(node.getFormat())); } } return; } if (selection.isCollapsed()) { - selection.toggleFormat(formatType); + selection.setFormat(applyFormat(selection.format)); // When changing format, we should stop composition $setCompositionKey(null); return; } - const selectedNodes = selection.getNodes(); const selectedTextNodes: TextNode[] = []; - for (const selectedNode of selectedNodes) { - if ($isTextNode(selectedNode)) { - selectedTextNodes.push(selectedNode); + for (const node of selection.getNodes()) { + if ($isTextNode(node)) { + selectedTextNodes.push(node); + } else if ($isElementNode(node)) { + node.setTextFormat(applyFormat(node.getTextFormat())); + } else if ($isInlineFormattable(node)) { + node.setFormat(applyFormat(node.getFormat())); } } - const applyFormatToElements = (alignWith: number | null) => { - for (const node of selectedNodes) { - if ($isElementNode(node)) { - const newFormat = node.getFormatFlags(formatType, alignWith); - node.setTextFormat(newFormat); - } - } - }; - - const applyFormatToInlineNodes = (alignWith: number | null) => { - for (const node of selectedNodes) { - if (!$isTextNode(node) && $isInlineFormattable(node)) { - node.setFormat(node.getFormatFlags(formatType, alignWith)); - } - } - }; - const selectedTextNodesLength = selectedTextNodes.length; if (selectedTextNodesLength === 0) { - selection.toggleFormat(formatType); + selection.setFormat(applyFormat(selection.format)); // When changing format, we should stop composition $setCompositionKey(null); - applyFormatToElements(alignWithFormat); - applyFormatToInlineNodes(alignWithFormat); return; } @@ -2276,13 +2302,6 @@ export function $formatText( startOffset = 0; } - const firstNextFormat = (firstNode ?? selectedTextNodes[0]).getFormatFlags( - formatType, - alignWithFormat, - ); - applyFormatToElements(firstNextFormat); - applyFormatToInlineNodes(firstNextFormat); - if (firstNode == null) { return; } @@ -2298,18 +2317,19 @@ export function $formatText( if (startOffset === endOffset) { return; } + const newFormat = applyFormat(firstNode.getFormat()); // The entire node is selected or it is token, so just format it if ( $isTokenOrSegmented(firstNode) || (startOffset === 0 && endOffset === firstNode.getTextContentSize()) ) { - firstNode.setFormat(firstNextFormat); + firstNode.setFormat(newFormat); } else { // Node is partially selected, so split it into two nodes // and style the selected one. const splitNodes = firstNode.splitText(startOffset, endOffset); const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1]; - replacement.setFormat(firstNextFormat); + replacement.setFormat(newFormat); // Update selection only if starts/ends on text node if (startPoint.type === 'text') { @@ -2320,7 +2340,7 @@ export function $formatText( } } - selection.format = firstNextFormat; + selection.format = newFormat; return; } @@ -2330,9 +2350,10 @@ export function $formatText( [, firstNode] = firstNode.splitText(startOffset); startOffset = 0; } + const firstNextFormat = applyFormat(firstNode.getFormat()); firstNode.setFormat(firstNextFormat); - const lastNextFormat = lastNode.getFormatFlags(formatType, firstNextFormat); + const lastNextFormat = applyFormat(lastNode.getFormat()); // If the offset is 0, it means no actual characters are selected, // so we skip formatting the last node altogether. if (endOffset > 0) { @@ -2348,8 +2369,7 @@ export function $formatText( // Process all text nodes in between for (let i = firstIndex + 1; i < lastIndex; i++) { const textNode = selectedTextNodes[i]; - const nextFormat = textNode.getFormatFlags(formatType, lastNextFormat); - textNode.setFormat(nextFormat); + textNode.setFormat(applyFormat(textNode.getFormat())); } // Update selection only if starts/ends on text node @@ -2363,6 +2383,74 @@ export function $formatText( selection.format = firstNextFormat | lastNextFormat; } +/** + * Explicitly sets or unsets text formats on the selection. Unlike $formatText + * which toggles based on the current selection state, this function sets each + * specified format to the exact boolean value provided. Mutually exclusive + * formats (subscript/superscript, lowercase/uppercase/capitalize) are + * reconciled by {@link toggleTextFormatType}, with later entries winning when + * the requested formats conflict. + * + * @param selection - the selection whose nodes should be formatted. + * @param formats - a partial record mapping TextFormatType to boolean. + */ +export function $setTextFormat( + selection: RangeSelection | NodeSelection, + formats: Partial>, +): void { + const entries: [TextFormatType, boolean][] = []; + for (const [type, value] of Object.entries(formats) as [ + TextFormatType, + boolean | undefined, + ][]) { + if (typeof value === 'boolean') { + entries.push([type, value]); + } + } + if (entries.length === 0) { + return; + } + $updateTextFormat(selection, format => { + for (const [type, value] of entries) { + format = toggleTextFormatType( + format, + type, + value ? TEXT_TYPE_TO_FORMAT[type] : 0, + ); + } + return format; + }); +} + +/** + * Applies the provided format to TextNodes and inline formattable nodes + * (e.g. DecoratorTextNode) in the selection, splitting or merging TextNodes + * as necessary and aligning all formattable nodes to the same target format. + * + * For RangeSelection the toggle direction is determined by the selection's + * computed format (intersection of all text nodes) when no explicit alignment + * is given. For NodeSelection each node is toggled independently when no + * explicit alignment is given, since there is no TextNode to use as an + * alignment reference. + * + * @param selection - the selection whose nodes should be formatted. + * @param formatType - the format type to apply. + * @param alignWithFormat - optional 32-bit bitmask to align with. + */ +export function $formatText( + selection: RangeSelection | NodeSelection, + formatType: TextFormatType, + alignWithFormat: number | null = null, +): void { + const effectiveAlign = + alignWithFormat === null && $isRangeSelection(selection) + ? toggleTextFormatType(selection.format, formatType, null) + : alignWithFormat; + $updateTextFormat(selection, format => + toggleTextFormatType(format, formatType, effectiveAlign), + ); +} + function getCharacterOffset(point: PointType): number { const offset = point.offset; if (point.type === 'text') { @@ -2585,6 +2673,30 @@ function $extendSelectionForDeletion( domSelection.getRangeAt(0); const landedContainer = landedRange.startContainer; const landedOffset = landedRange.startOffset; + // Native 'move' cannot cross inline-grid/flex span boundaries (#7301). + // When at the deletion-side edge of an unmergeable TextNode, extend into + // the adjacent sibling directly instead of relying on the native result. + if ( + wasCollapsed && + granularity === 'character' && + anchor.type === 'text' && + $isTextNode(anchorNode) && + anchorNode.isUnmergeable() + ) { + const boundaryOffset = isBackward ? 0 : anchorNode.getTextContentSize(); + if (anchorOffset === boundaryOffset) { + const sibling = $getSiblingCaret( + anchorNode, + isBackward ? 'previous' : 'next', + ).getNodeAtCaret(); + if ($isTextNode(sibling)) { + const sibOffset = isBackward ? sibling.getTextContentSize() - 1 : 1; + selection.focus.set(sibling.__key, sibOffset, 'text'); + selection.dirty = true; + return; + } + } + } // In-node character deletion (the common case): keep the focus in the // anchor's own text node. A native 'move' reports a caret that lands on a // text-node boundary as a position in the *adjacent* node, and that @@ -3068,7 +3180,7 @@ function resolveSelectionPointOnBoundary( prevSibling.isInline() ) { point.set(prevSibling.__key, prevSibling.getChildrenSize(), 'element'); - } else if ($isTextNode(prevSibling)) { + } else if ($isTextNode(prevSibling) && !node.isUnmergeable()) { point.set( prevSibling.__key, prevSibling.getTextContent().length, diff --git a/packages/lexical/src/__tests__/browser/Issue7301InlineGridDeletion.test.ts b/packages/lexical/src/__tests__/browser/Issue7301InlineGridDeletion.test.ts new file mode 100644 index 00000000000..5721b435166 --- /dev/null +++ b/packages/lexical/src/__tests__/browser/Issue7301InlineGridDeletion.test.ts @@ -0,0 +1,222 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {buildEditorFromExtensions, defineExtension} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + ElementNode, + KEY_ARROW_RIGHT_COMMAND, +} from 'lexical'; +import {assert, describe, expect, onTestFinished, test} from 'vitest'; + +/** + * Inline element that renders with `display: inline-grid`. + * Reproduces the layout context from #7301 where native + * Selection.modify fails to cross adjacent boundaries. + */ +class InlineGridNode extends ElementNode { + $config() { + return this.config('test_inline_grid', {extends: ElementNode}); + } + createDOM() { + const el = document.createElement('span'); + el.style.display = 'inline-grid'; + return el; + } + updateDOM() { + return false; + } + isInline() { + return true; + } +} + +function $createInlineGridNode() { + return new InlineGridNode(); +} + +const ext = defineExtension({ + dependencies: [RichTextExtension], + name: '[7301-browser]', + nodes: [InlineGridNode], +}); + +function mountEditor($initialEditorState?: () => void) { + const container = document.createElement('div'); + document.body.appendChild(container); + const contentEditable = document.createElement('div'); + contentEditable.contentEditable = 'true'; + container.appendChild(contentEditable); + + const editor = buildEditorFromExtensions({ + $initialEditorState, + ...ext, + }); + editor.setRootElement(contentEditable); + + onTestFinished(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); + + return {contentEditable, editor}; +} + +describe('Deletion across adjacent unmergeable text in inline-grid (#7301)', () => { + test('forward delete crosses span boundary inside inline-grid container', async () => { + const {editor} = mountEditor(() => { + const paragraph = $createParagraphNode(); + const grid = $createInlineGridNode(); + const a = $createTextNode('A').toggleUnmergeable(); + const b = $createTextNode('B').toggleUnmergeable(); + const c = $createTextNode('C').toggleUnmergeable(); + grid.append(a, b, c); + paragraph.append(grid); + $getRoot().append(paragraph); + // Place cursor at end of "A" (= boundary before "B") + a.select(1, 1); + }); + + // Verify initial state + editor.read(() => { + expect($getRoot().getTextContent()).toBe('ABC'); + }); + + // Forward delete — should delete "B" + await editor.update(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + sel.deleteCharacter(false); + }); + + editor.read(() => { + expect($getRoot().getTextContent()).toBe('AC'); + }); + + // Forward delete again — should delete "C" + await editor.update(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + sel.deleteCharacter(false); + }); + + editor.read(() => { + expect($getRoot().getTextContent()).toBe('A'); + }); + }); + + test('backward delete crosses span boundary inside inline-grid container', async () => { + const {editor} = mountEditor(() => { + const paragraph = $createParagraphNode(); + const grid = $createInlineGridNode(); + const a = $createTextNode('A').toggleUnmergeable(); + const b = $createTextNode('B').toggleUnmergeable(); + const c = $createTextNode('C').toggleUnmergeable(); + grid.append(a, b, c); + paragraph.append(grid); + $getRoot().append(paragraph); + // Place cursor at start of "C" (= boundary after "B") + c.select(0, 0); + }); + + editor.read(() => { + expect($getRoot().getTextContent()).toBe('ABC'); + }); + + // Backspace — should delete "B" + await editor.update(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + sel.deleteCharacter(true); + }); + + editor.read(() => { + expect($getRoot().getTextContent()).toBe('AC'); + }); + }); + + test('arrow key movement crosses span boundary via modify()', async () => { + let bKey: string; + const {editor} = mountEditor(() => { + const paragraph = $createParagraphNode(); + const grid = $createInlineGridNode(); + const a = $createTextNode('A').toggleUnmergeable(); + const b = $createTextNode('B').toggleUnmergeable(); + const c = $createTextNode('C').toggleUnmergeable(); + bKey = b.getKey(); + grid.append(a, b, c); + paragraph.append(grid); + $getRoot().append(paragraph); + a.select(1, 1); + }); + + // Move forward from end of "A" — should cross boundary past "B" + await editor.update(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + sel.modify('move', false, 'character'); + }); + + editor.read(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + expect(sel.anchor.key).toBe(bKey); + expect(sel.anchor.offset).toBe(1); + expect(sel.isCollapsed()).toBe(true); + }); + + // Move backward from end of "B" — should land at start of "B" + // (the boundary position), not normalize to end of "A" + await editor.update(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + sel.modify('move', true, 'character'); + }); + + editor.read(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.key).toBe(bKey); + expect(sel.anchor.offset).toBe(0); + }); + }); + + test('arrow key command pipeline overrides native handling at boundary', async () => { + let bKey: string; + const {editor} = mountEditor(() => { + const paragraph = $createParagraphNode(); + const grid = $createInlineGridNode(); + const a = $createTextNode('A').toggleUnmergeable(); + const b = $createTextNode('B').toggleUnmergeable(); + const c = $createTextNode('C').toggleUnmergeable(); + bKey = b.getKey(); + grid.append(a, b, c); + paragraph.append(grid); + $getRoot().append(paragraph); + a.select(1, 1); + }); + + const event = new KeyboardEvent('keydown', {key: 'ArrowRight'}); + const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event); + expect(handled).toBe(true); + + editor.read(() => { + const sel = $getSelection(); + assert($isRangeSelection(sel)); + expect(sel.anchor.key).toBe(bKey); + expect(sel.anchor.offset).toBe(1); + expect(sel.isCollapsed()).toBe(true); + }); + }); +}); diff --git a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts index 3d8c0cb7448..13abe629d6f 100644 --- a/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalSelection.test.ts @@ -19,6 +19,7 @@ import { $createParagraphNode, $createRangeSelection, $createTextNode, + $formatText, $getCaretInDirection, $getRoot, $getSelection, @@ -29,9 +30,11 @@ import { $selectAll, $setCompositionKey, $setSelection, + $setTextFormat, createEditor, ElementNode, getDOMSelection, + IS_BOLD, type LexicalEditor, type LexicalNode, type ParagraphNode, @@ -2260,3 +2263,169 @@ describe('Regression #7551 - Selection boundary normalization for single-child i }); }); }); + +describe('$formatText toggle direction (#6935)', () => { + initializeUnitTest(testEnv => { + test('uses selection.format (AND) instead of first node for toggle direction', () => { + const {editor} = testEnv; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const boldText = $createTextNode('bold'); + boldText.setFormat(IS_BOLD); + const plainText = $createTextNode('plain'); + paragraph.append(boldText, plainText); + root.append(paragraph); + + const selection = $selectAll(); + expect(selection.hasFormat('bold')).toBe(false); + + $formatText(selection, 'bold'); + const children = paragraph.getChildren(); + const first = $assertNodeType(children[0], $isTextNode); + const second = $assertNodeType(children[1], $isTextNode); + expect(first.hasFormat('bold')).toBe(true); + expect(second.hasFormat('bold')).toBe(true); + }, + {discrete: true}, + ); + }); + + test('toggling off works when all nodes share the format', () => { + const {editor} = testEnv; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const bold1 = $createTextNode('aaa'); + bold1.setFormat(IS_BOLD); + const bold2 = $createTextNode('bbb'); + bold2.setFormat(IS_BOLD); + paragraph.append(bold1, bold2); + root.append(paragraph); + + const selection = $selectAll(); + // onSelectionChange computes this as AND of all text nodes; + // unit tests bypass the event handler so we set it manually. + selection.format = IS_BOLD; + expect(selection.hasFormat('bold')).toBe(true); + + $formatText(selection, 'bold'); + const children = paragraph.getChildren(); + const first = $assertNodeType(children[0], $isTextNode); + const second = $assertNodeType(children[1], $isTextNode); + expect(first.hasFormat('bold')).toBe(false); + expect(second.hasFormat('bold')).toBe(false); + }, + {discrete: true}, + ); + }); + + test('explicit alignWithFormat bypasses selection.format reference', () => { + const {editor} = testEnv; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const boldText = $createTextNode('bold'); + boldText.setFormat(IS_BOLD); + const plainText = $createTextNode('plain'); + paragraph.append(boldText, plainText); + root.append(paragraph); + + const selection = $selectAll(); + $formatText(selection, 'bold', IS_BOLD); + const children = paragraph.getChildren(); + const first = $assertNodeType(children[0], $isTextNode); + const second = $assertNodeType(children[1], $isTextNode); + expect(first.hasFormat('bold')).toBe(true); + expect(second.hasFormat('bold')).toBe(true); + }, + {discrete: true}, + ); + }); + }); +}); + +describe('$setTextFormat (#5518)', () => { + initializeUnitTest(testEnv => { + test('sets bold to true on mixed formatting', () => { + const {editor} = testEnv; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const boldText = $createTextNode('bold'); + boldText.setFormat(IS_BOLD); + const plainText = $createTextNode('plain'); + paragraph.append(boldText, plainText); + root.append(paragraph); + + const selection = $selectAll(); + $setTextFormat(selection, {bold: true}); + const children = paragraph.getChildren(); + const first = $assertNodeType(children[0], $isTextNode); + const second = $assertNodeType(children[1], $isTextNode); + expect(first.hasFormat('bold')).toBe(true); + expect(second.hasFormat('bold')).toBe(true); + }, + {discrete: true}, + ); + }); + + test('sets bold to false on all-bold text', () => { + const {editor} = testEnv; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const bold1 = $createTextNode('aaa'); + bold1.setFormat(IS_BOLD); + const bold2 = $createTextNode('bbb'); + bold2.setFormat(IS_BOLD); + paragraph.append(bold1, bold2); + root.append(paragraph); + + const selection = $selectAll(); + // $setTextFormat uses explicit alignWithFormat, so selection.format + // doesn't matter here — but we set it for realism. + selection.format = IS_BOLD; + $setTextFormat(selection, {bold: false}); + const children = paragraph.getChildren(); + const first = $assertNodeType(children[0], $isTextNode); + const second = $assertNodeType(children[1], $isTextNode); + expect(first.hasFormat('bold')).toBe(false); + expect(second.hasFormat('bold')).toBe(false); + }, + {discrete: true}, + ); + }); + + test('sets multiple formats at once', () => { + const {editor} = testEnv; + editor.update( + () => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + const text = $createTextNode('hello'); + paragraph.append(text); + root.append(paragraph); + + const selection = $selectAll(); + $setTextFormat(selection, {bold: true, italic: true}); + const node = $assertNodeType(paragraph.getFirstChild(), $isTextNode); + expect(node.hasFormat('bold')).toBe(true); + expect(node.hasFormat('italic')).toBe(true); + }, + {discrete: true}, + ); + }); + }); +}); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 96fa789c89c..00bd55508b5 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -118,6 +118,7 @@ export { SELECT_ALL_COMMAND, SELECTION_CHANGE_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, + SET_TEXT_FORMAT_COMMAND, UNDO_COMMAND, } from './LexicalCommands'; export { @@ -263,6 +264,7 @@ export { $isBlockElementNode, $isNodeSelection, $isRangeSelection, + $setTextFormat, $updateDOMSelection, type RawTextVisitor, tokenizeRawText, diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index c0844fb2c35..6701990c605 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -308,6 +308,7 @@ export interface TextNode { export interface InlineFormattableNode { /** @internal */ readonly __isInlineFormattable: true; + getFormat(): number; getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number; hasFormat(type: TextFormatType): boolean; setFormat(format: number): unknown; diff --git a/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json b/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json index 029f0417e3e..fa86fb35b6c 100644 --- a/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json +++ b/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json @@ -2,7 +2,7 @@ "name": "lexical-esm-astro-react", "private": true, "type": "module", - "version": "0.46.0", + "version": "0.47.0", "scripts": { "dev": "astro dev", "start": "astro dev", @@ -13,12 +13,12 @@ "dependencies": { "@astrojs/check": "^0.9.9", "@astrojs/react": "^5.0.5", - "@lexical/code": "0.46.0", - "@lexical/react": "0.46.0", + "@lexical/code": "0.47.0", + "@lexical/react": "0.47.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.9", "astro": "^6.1.10", - "lexical": "0.46.0", + "lexical": "0.47.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/scripts/__tests__/integration/fixtures/lexical-link-source-mode/package.json b/scripts/__tests__/integration/fixtures/lexical-link-source-mode/package.json index d9f3daa0949..59e95115670 100644 --- a/scripts/__tests__/integration/fixtures/lexical-link-source-mode/package.json +++ b/scripts/__tests__/integration/fixtures/lexical-link-source-mode/package.json @@ -2,7 +2,7 @@ "name": "lexical-link-source-mode", "private": true, "type": "module", - "version": "0.46.0", + "version": "0.47.0", "description": "Integration fixture: consume lexical, @lexical/rich-text and @lexical/extension via pnpm's link: protocol and the `source` export condition (TypeScript source, no build required, and no consumer-side aliases). Importing several packages confirms source-mode resolution links every package (and their transitive @lexical/* deps) against each other.", "scripts": { "build": "vite build", diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 1f9ca5c3858..6b6c607751f 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -393,5 +393,8 @@ "391": "Expected parent to be a collab element node, or a collab decorator node hosting this shared type as a named slot", "392": "Expected parent of a text or linebreak node to be a collab element node", "393": "Expected a decorator node for a collab decorator node", - "394": "getOffset: expected parent to be a collab element node" + "394": "getOffset: expected parent to be a collab element node", + "395": "insertText: lastNode is not a TextNode", + "396": "Table row index %s out of range", + "397": "Table column index %s out of range" }