diff --git a/packages/lexical-conformance/package.json b/packages/lexical-conformance/package.json index d2b40d7b3db..5667ddcf7bf 100644 --- a/packages/lexical-conformance/package.json +++ b/packages/lexical-conformance/package.json @@ -32,6 +32,7 @@ "@lexical/plain-text": "workspace:*", "@lexical/rich-text": "workspace:*", "@lexical/selection": "workspace:*", + "@lexical/text": "workspace:*", "entities": "2.2.0", "fast-check": "^4.10.2", "lexical": "workspace:*" diff --git a/packages/lexical-conformance/src/ops.ts b/packages/lexical-conformance/src/ops.ts index 7725480525c..170c8eea967 100644 --- a/packages/lexical-conformance/src/ops.ts +++ b/packages/lexical-conformance/src/ops.ts @@ -52,6 +52,14 @@ import { registerRichText, } from '@lexical/rich-text'; import {$setBlocksType} from '@lexical/selection'; +import { + $canShowPlaceholder, + $canShowPlaceholderCurry, + $findTextIntersectionFromCharacters, + $isRootTextContentEmpty, + $isRootTextContentEmptyCurry, + $rootTextContent, +} from '@lexical/text'; import { $caretRangeFromSelection, $createNodeSelection, @@ -186,6 +194,28 @@ export type Action = * is `$createParagraphNode`, `$createQuoteNode` or `$createHeadingNode(tag)`. */ | {do: 'setBlocksType'; block: 'paragraph' | 'quote' | HeadingTagType} + /** Logs `['rootTextContent', $rootTextContent()]` (@lexical/text). */ + | {do: 'logRootTextContent'} + /** + * Logs `['rootTextContentEmpty', isComposing, trim, direct, curried]`: + * `$isRootTextContentEmpty(isComposing, trim)` and what + * `$isRootTextContentEmptyCurry(isComposing, trim)` returns, called + * (@lexical/text). `trim` is left out (`undefined`) when absent, and + * logged as `null` then. + */ + | {do: 'logRootTextContentEmpty'; isComposing: boolean; trim?: boolean} + /** + * Logs `['canShowPlaceholder', isComposing, direct, curried]`: + * `$canShowPlaceholder(isComposing)` and what + * `$canShowPlaceholderCurry(isComposing)` returns, called (@lexical/text). + */ + | {do: 'logCanShowPlaceholder'; isComposing: boolean} + /** + * Logs `['textIntersection', characters, [key, offset] | null]`: + * `$findTextIntersectionFromCharacters($getRoot(), characters)` + * (@lexical/text). + */ + | {do: 'logTextIntersection'; characters: number} | {do: 'dispatch'; command: string; payload?: Json}; /** @@ -746,6 +776,42 @@ function $runAction(action: Action, ctx: Context): void { case 'logSelectionText': ctx.log.push(['selectionText', $anySel().getTextContent()]); return; + case 'logRootTextContent': + ctx.log.push(['rootTextContent', $rootTextContent()]); + return; + case 'logRootTextContentEmpty': { + const {isComposing, trim} = action; + ctx.log.push([ + 'rootTextContentEmpty', + isComposing, + trim ?? null, + $isRootTextContentEmpty(isComposing, trim), + $isRootTextContentEmptyCurry(isComposing, trim)(), + ]); + return; + } + case 'logCanShowPlaceholder': { + const {isComposing} = action; + ctx.log.push([ + 'canShowPlaceholder', + isComposing, + $canShowPlaceholder(isComposing), + $canShowPlaceholderCurry(isComposing)(), + ]); + return; + } + case 'logTextIntersection': { + const found = $findTextIntersectionFromCharacters( + $getRoot(), + action.characters, + ); + ctx.log.push([ + 'textIntersection', + action.characters, + found === null ? null : [found.node.getKey(), found.offset], + ]); + return; + } case 'dispatch': ctx.log.push([ 'dispatched', diff --git a/packages/lexical-conformance/src/scripts.test.ts b/packages/lexical-conformance/src/scripts.test.ts index 2d43d7c0aa1..12b80f5eb59 100644 --- a/packages/lexical-conformance/src/scripts.test.ts +++ b/packages/lexical-conformance/src/scripts.test.ts @@ -3401,3 +3401,123 @@ test('scripts/decorator', () => { ], ); }); + +test('scripts/text', () => { + // @lexical/text's root helpers, read inside an update: $rootTextContent, + // $isRootTextContentEmpty (with and without trimming, composing or not) + // and its curried form, $canShowPlaceholder and its curried form, and + // $findTextIntersectionFromCharacters at offsets across the text nodes. + // Keys: the root's descendants get 1, 2, 3… in document order. + const rootText: Action = {do: 'logRootTextContent'}; + const empty = (isComposing: boolean, trim?: boolean): Action => + trim === undefined + ? {do: 'logRootTextContentEmpty', isComposing} + : {do: 'logRootTextContentEmpty', isComposing, trim}; + const placeholder = (isComposing: boolean): Action => ({ + do: 'logCanShowPlaceholder', + isComposing, + }); + const intersection = (characters: number): Action => ({ + characters, + do: 'logTextIntersection', + }); + const probe = (...characters: number[]): Step => + update( + rootText, + empty(false), + empty(false, true), + empty(false, false), + empty(true), + empty(true, false), + placeholder(false), + placeholder(true), + ...characters.map(intersection), + ); + const clear = node('root', 'clear'); + const hr = (): Node => ({type: 'horizontalrule', version: 1}) as Node; + const dt = (): Node => ({type: 'decorator-text', version: 1}) as Node; + emit( + 'text', + "@lexical/text's root helpers: $rootTextContent, $isRootTextContentEmpty and $isRootTextContentEmptyCurry (trimmed with JS's String#trim or not, composing or not), $canShowPlaceholder and $canShowPlaceholderCurry, and $findTextIntersectionFromCharacters, on documents of text, whitespace, breaks, tabs, nested and empty blocks, headings and decorators.", + [ + { + name: 'an empty root, then an empty paragraph', + steps: [probe(0), doc(p()), probe(0)], + }, + { + name: 'text in a paragraph', + steps: [doc(p(t('ab'), t('cd', {format: 1}))), probe(0, 1, 2, 3, 4, 5)], + }, + { + name: 'whitespace that String#trim removes', + steps: [doc(p(t(' \u00a0\ufeff\u2028\u3000'))), probe(0, 4, 5)], + }, + { + name: 'whitespace that String#trim keeps', + steps: [doc(p(t('\u0085\u200b'))), probe(0, 1, 2)], + }, + { + name: 'text outside the BMP', + steps: [doc(p(t('\ud83d\ude00a'))), probe(0, 1, 2, 3)], + }, + { + name: 'two paragraphs', + steps: [doc(p(t('ab')), p(t('cd'))), probe(0, 1, 2, 3, 4)], + }, + { + name: 'two empty paragraphs', + steps: [doc(p(), p()), probe(0)], + }, + { + name: 'an indented paragraph', + steps: [doc({...p(), indent: 1} as Node), probe(0)], + }, + { + name: 'a line break', + steps: [doc(p(br())), probe(0)], + }, + { + name: 'a tab', + steps: [doc(p(tab())), probe(0, 1)], + }, + { + name: 'a heading and a quote', + richText: true, + steps: [ + doc(h('h1')), + probe(0), + update(node('1', 'append', [t('x')])), + probe(0, 1), + update(clear), + doc(q(t('ab')), p(t('c'))), + probe(0, 1, 2, 3), + ], + }, + { + list: true, + name: 'nested lists', + steps: [ + doc( + list('bullet', li(t('a')), li(list('bullet', li(t('b'))))), + p(t('c')), + ), + probe(0, 1, 2, 3), + ], + }, + { + extension: true, + name: 'decorators', + steps: [ + doc(p(dt())), + probe(0), + update(clear), + doc(hr()), + probe(0), + update(clear), + doc(p(dt(), t('ab'))), + probe(0, 1, 2), + ], + }, + ], + ); +}); diff --git a/packages/lexical-conformance/vectors/scripts/text.json b/packages/lexical-conformance/vectors/scripts/text.json new file mode 100644 index 00000000000..535ec867bf8 --- /dev/null +++ b/packages/lexical-conformance/vectors/scripts/text.json @@ -0,0 +1,15 @@ +{"description":"@lexical/text's root helpers: $rootTextContent, $isRootTextContentEmpty and $isRootTextContentEmptyCurry (trimmed with JS's String#trim or not, composing or not), $canShowPlaceholder and $canShowPlaceholderCurry, and $findTextIntersectionFromCharacters, on documents of text, whitespace, breaks, tabs, nested and empty blocks, headings and decorators.","formatVersion":1,"kind":"script","cases":[ +{"name":"an empty root, then an empty paragraph","steps":[{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]},{"update":[{"do":"append","key":"root","nodes":[{"children":[],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]}],"results":[{"log":[["rootTextContent",""],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,true,true],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,true,true],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"","state":"{\"root\":{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[]}}"},{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[]}]}}"},{"log":[["rootTextContent",""],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,true,true],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,true,true],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[]}]}}"}]}, +{"name":"text in a paragraph","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"ab","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"cd","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"},{"characters":3,"do":"logTextIntersection"},{"characters":4,"do":"logTextIntersection"},{"characters":5,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"abcd","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1},{\"detail\":0,\"format\":1,\"mode\":\"normal\",\"style\":\"\",\"text\":\"cd\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"ab\"},{\"type\":\"text\",\"format\":1,\"text\":\"cd\"}]}]}}"},{"log":[["rootTextContent","abcd"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",1,["2",1]],["textIntersection",2,["3",0]],["textIntersection",3,["3",1]],["textIntersection",4,null],["textIntersection",5,null]],"selection":null,"text":"abcd","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1},{\"detail\":0,\"format\":1,\"mode\":\"normal\",\"style\":\"\",\"text\":\"cd\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"ab\"},{\"type\":\"text\",\"format\":1,\"text\":\"cd\"}]}]}}"}]}, +{"name":"whitespace that String#trim removes","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"  
 ","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":4,"do":"logTextIntersection"},{"characters":5,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"  
 ","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"  
 \",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"  
 \"}]}]}}"},{"log":[["rootTextContent","  
 "],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",4,["2",4]],["textIntersection",5,null]],"selection":null,"text":"  
 ","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"  
 \",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"  
 \"}]}]}}"}]}, +{"name":"whitespace that String#trim keeps","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"…​","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"…​","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"…​\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"…​\"}]}]}}"},{"log":[["rootTextContent","…​"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",1,["2",1]],["textIntersection",2,null]],"selection":null,"text":"…​","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"…​\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"…​\"}]}]}}"}]}, +{"name":"text outside the BMP","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"😀a","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"},{"characters":3,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"😀a","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"😀a\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"😀a\"}]}]}}"},{"log":[["rootTextContent","😀a"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",1,["2",1]],["textIntersection",2,["2",2]],["textIntersection",3,null]],"selection":null,"text":"😀a","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"😀a\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"😀a\"}]}]}}"}]}, +{"name":"two paragraphs","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"ab","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"cd","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"},{"characters":3,"do":"logTextIntersection"},{"characters":4,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"ab\n\ncd","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1},{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"cd\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"ab\"}]},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"cd\"}]}]}}"},{"log":[["rootTextContent","ab\n\ncd"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",1,["2",1]],["textIntersection",2,["4",0]],["textIntersection",3,["4",1]],["textIntersection",4,null]],"selection":null,"text":"ab\n\ncd","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1},{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"cd\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"ab\"}]},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"cd\"}]}]}}"}]}, +{"name":"two empty paragraphs","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1},{"children":[],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"\n\n","state":"{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1},{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[]},{\"type\":\"paragraph\",\"children\":[]}]}}"},{"log":[["rootTextContent","\n\n"],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"\n\n","state":"{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1},{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[]},{\"type\":\"paragraph\",\"children\":[]}]}}"}]}, +{"name":"an indented paragraph","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[],"direction":null,"format":"","indent":1,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":1,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[],\"indent\":1}]}}"},{"log":[["rootTextContent",""],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,true,true],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":1,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[],\"indent\":1}]}}"}]}, +{"name":"a line break","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"type":"linebreak","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"\n","state":"{\"root\":{\"children\":[{\"children\":[{\"type\":\"linebreak\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"linebreak\"}]}]}}"},{"log":[["rootTextContent","\n"],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"\n","state":"{\"root\":{\"children\":[{\"children\":[{\"type\":\"linebreak\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"linebreak\"}]}]}}"}]}, +{"name":"a tab","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\t","type":"tab","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"\t","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":2,\"mode\":\"normal\",\"text\":\"\\t\",\"format\":0,\"style\":\"\",\"type\":\"tab\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"tab\"}]}]}}"},{"log":[["rootTextContent","\t"],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",1,null]],"selection":null,"text":"\t","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":2,\"mode\":\"normal\",\"text\":\"\\t\",\"format\":0,\"style\":\"\",\"type\":\"tab\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"tab\"}]}]}}"}]}, +{"name":"a heading and a quote","richText":true,"steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"heading","version":1,"tag":"h1"}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]},{"update":[{"args":[[{"detail":0,"format":0,"mode":"normal","style":"","text":"x","type":"text","version":1}]],"do":"node","key":"1","method":"append"}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"}]},{"update":[{"args":[],"do":"node","key":"root","method":"clear"}]},{"update":[{"do":"append","key":"root","nodes":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"ab","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"c","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"},{"characters":3,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[],\"tag\":\"h1\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"heading\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"heading\",\"children\":[]}]}}"},{"log":[["rootTextContent",""],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,true,true],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[],\"tag\":\"h1\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"heading\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"heading\",\"children\":[]}]}}"},{"log":[],"selection":null,"text":"x","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"x\",\"type\":\"text\",\"version\":1}],\"tag\":\"h1\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"heading\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"heading\",\"children\":[{\"type\":\"text\",\"text\":\"x\"}]}]}}"},{"log":[["rootTextContent","x"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["2",0]],["textIntersection",1,null]],"selection":null,"text":"x","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"x\",\"type\":\"text\",\"version\":1}],\"tag\":\"h1\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"heading\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"heading\",\"children\":[{\"type\":\"text\",\"text\":\"x\"}]}]}}"},{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[]}}"},{"log":[],"selection":null,"text":"ab\n\nc","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"quote\",\"version\":1},{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"c\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"quote\",\"children\":[{\"type\":\"text\",\"text\":\"ab\"}]},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"c\"}]}]}}"},{"log":[["rootTextContent","ab\n\nc"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["4",0]],["textIntersection",1,["4",1]],["textIntersection",2,["6",0]],["textIntersection",3,null]],"selection":null,"text":"ab\n\nc","state":"{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"quote\",\"version\":1},{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"c\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"quote\",\"children\":[{\"type\":\"text\",\"text\":\"ab\"}]},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"c\"}]}]}}"}]}, +{"list":true,"name":"nested lists","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"a","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","value":1,"version":1},{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"b","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","value":1,"version":1}],"direction":null,"format":"","indent":0,"listType":"bullet","start":1,"tag":"ul","type":"list","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","value":1,"version":1}],"direction":null,"format":"","indent":0,"listType":"bullet","start":1,"tag":"ul","type":"list","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"c","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"},{"characters":3,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"a\n\nb\n\nc","state":"{\"root\":{\"children\":[{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"a\",\"type\":\"text\",\"version\":1}],\"indent\":0,\"value\":1,\"direction\":null,\"format\":\"\",\"type\":\"listitem\",\"version\":1},{\"children\":[{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"b\",\"type\":\"text\",\"version\":1}],\"indent\":1,\"value\":1,\"direction\":null,\"format\":\"\",\"type\":\"listitem\",\"version\":1}],\"listType\":\"bullet\",\"start\":1,\"tag\":\"ul\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"list\",\"version\":1}],\"indent\":0,\"value\":2,\"direction\":null,\"format\":\"\",\"type\":\"listitem\",\"version\":1}],\"listType\":\"bullet\",\"start\":1,\"tag\":\"ul\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"list\",\"version\":1},{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"c\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"list\",\"children\":[{\"type\":\"listitem\",\"children\":[{\"type\":\"text\",\"text\":\"a\"}]},{\"type\":\"listitem\",\"children\":[{\"type\":\"list\",\"children\":[{\"type\":\"listitem\",\"children\":[{\"type\":\"text\",\"text\":\"b\"}],\"indent\":1}],\"listType\":\"bullet\"}],\"value\":2}],\"listType\":\"bullet\"},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"c\"}]}]}}"},{"log":[["rootTextContent","a\n\nb\n\nc"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["3",0]],["textIntersection",1,["7",0]],["textIntersection",2,["9",0]],["textIntersection",3,null]],"selection":null,"text":"a\n\nb\n\nc","state":"{\"root\":{\"children\":[{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"a\",\"type\":\"text\",\"version\":1}],\"indent\":0,\"value\":1,\"direction\":null,\"format\":\"\",\"type\":\"listitem\",\"version\":1},{\"children\":[{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"b\",\"type\":\"text\",\"version\":1}],\"indent\":1,\"value\":1,\"direction\":null,\"format\":\"\",\"type\":\"listitem\",\"version\":1}],\"listType\":\"bullet\",\"start\":1,\"tag\":\"ul\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"list\",\"version\":1}],\"indent\":0,\"value\":2,\"direction\":null,\"format\":\"\",\"type\":\"listitem\",\"version\":1}],\"listType\":\"bullet\",\"start\":1,\"tag\":\"ul\",\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"list\",\"version\":1},{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"c\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"list\",\"children\":[{\"type\":\"listitem\",\"children\":[{\"type\":\"text\",\"text\":\"a\"}]},{\"type\":\"listitem\",\"children\":[{\"type\":\"list\",\"children\":[{\"type\":\"listitem\",\"children\":[{\"type\":\"text\",\"text\":\"b\"}],\"indent\":1}],\"listType\":\"bullet\"}],\"value\":2}],\"listType\":\"bullet\"},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"text\":\"c\"}]}]}}"}]}, +{"extension":true,"name":"decorators","steps":[{"update":[{"do":"append","key":"root","nodes":[{"children":[{"type":"decorator-text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]},{"update":[{"args":[],"do":"node","key":"root","method":"clear"}]},{"update":[{"do":"append","key":"root","nodes":[{"type":"horizontalrule","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"}]},{"update":[{"args":[],"do":"node","key":"root","method":"clear"}]},{"update":[{"do":"append","key":"root","nodes":[{"children":[{"type":"decorator-text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"ab","type":"text","version":1}],"direction":null,"format":"","indent":0,"textFormat":0,"textStyle":"","type":"paragraph","version":1}]}]},{"update":[{"do":"logRootTextContent"},{"do":"logRootTextContentEmpty","isComposing":false},{"do":"logRootTextContentEmpty","isComposing":false,"trim":true},{"do":"logRootTextContentEmpty","isComposing":false,"trim":false},{"do":"logRootTextContentEmpty","isComposing":true},{"do":"logRootTextContentEmpty","isComposing":true,"trim":false},{"do":"logCanShowPlaceholder","isComposing":false},{"do":"logCanShowPlaceholder","isComposing":true},{"characters":0,"do":"logTextIntersection"},{"characters":1,"do":"logTextIntersection"},{"characters":2,"do":"logTextIntersection"}]}],"results":[{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[{\"type\":\"decorator-text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"decorator-text\"}]}]}}"},{"log":[["rootTextContent",""],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,true,true],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"","state":"{\"root\":{\"children\":[{\"children\":[{\"type\":\"decorator-text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"decorator-text\"}]}]}}"},{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[]}}"},{"log":[],"selection":null,"text":"\n","state":"{\"root\":{\"children\":[{\"type\":\"horizontalrule\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"horizontalrule\"}]}}"},{"log":[["rootTextContent","\n"],["rootTextContentEmpty",false,null,true,true],["rootTextContentEmpty",false,true,true,true],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,null]],"selection":null,"text":"\n","state":"{\"root\":{\"children\":[{\"type\":\"horizontalrule\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"horizontalrule\"}]}}"},{"log":[],"selection":null,"text":"","state":"{\"root\":{\"children\":[],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[]}}"},{"log":[],"selection":null,"text":"ab","state":"{\"root\":{\"children\":[{\"children\":[{\"type\":\"decorator-text\",\"version\":1},{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"decorator-text\"},{\"type\":\"text\",\"text\":\"ab\"}]}]}}"},{"log":[["rootTextContent","ab"],["rootTextContentEmpty",false,null,false,false],["rootTextContentEmpty",false,true,false,false],["rootTextContentEmpty",false,false,false,false],["rootTextContentEmpty",true,null,false,false],["rootTextContentEmpty",true,false,false,false],["canShowPlaceholder",false,false,false],["canShowPlaceholder",true,false,false],["textIntersection",0,["6",0]],["textIntersection",1,["6",1]],["textIntersection",2,null]],"selection":null,"text":"ab","state":"{\"root\":{\"children\":[{\"children\":[{\"type\":\"decorator-text\",\"version\":1},{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"ab\",\"type\":\"text\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"textFormat\":0,\"textStyle\":\"\",\"type\":\"paragraph\",\"version\":1}],\"direction\":null,\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}","compact":"{\"root\":{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"decorator-text\"},{\"type\":\"text\",\"text\":\"ab\"}]}]}}"}]} +]} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a11e92cf0..426409a191d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -749,6 +749,9 @@ importers: '@lexical/selection': specifier: workspace:* version: link:../lexical-selection + '@lexical/text': + specifier: workspace:* + version: link:../lexical-text entities: specifier: 2.2.0 version: 2.2.0 diff --git a/rust/DESIGN.md b/rust/DESIGN.md index 80e91c5482a..4e777cf2860 100644 --- a/rust/DESIGN.md +++ b/rust/DESIGN.md @@ -74,7 +74,7 @@ depends on as the upstream package depends on them: | `file` | `@lexical/file` | | | `clipboard` | `@lexical/clipboard` | `selection`, `utils` (`html` for HTML copy and paste) | | `mark` | `@lexical/mark` | `utils` | -| `devtools` | `@lexical/devtools-core`'s tree view | | +| `devtools` | `@lexical/devtools-core`'s `generateContent` | (upstream lists `html`, `link`, `mark` and `table`; its link branch is built with `link`, and its HTML form, mark and table branches aren't ported) | Each module is `lexical_editor::` (`lexical_editor::rich_text`). Names may change as packages land; this table is updated with them. @@ -200,11 +200,10 @@ thread. | M3 | The commands `property/sequences` dispatches | `property/sequences` | done (all 81 cases match JS): the suite builds `sequenceEditor`'s editor without `buildEditorFromExtensions`, from what each of its extensions registers, in the builder's order, with `InitialStateExtension`'s paragraph; the commands M2 ported needed two fixes, `NormalizeInlineElementsExtension`'s transform (`register_normalize_inline_elements`, removing empty links) and `$extendSelectionForDeletion` and `modify` stopping where the window gives no DOM selection (`SelectionMovement::has_dom_selection`) | | M4 | `html`: a DOM abstraction over an HTML parser, CSS declarations, export and import | `html/*`, `css/*`, `property/documents` (HTML) | in progress ([HTML thread](https://ampcode.com/threads/T-506c8fa1-4309-40f1-9810-732b311eaeab)) | | M5 | `markdown`, and Markdown shortcuts in both playgrounds | `markdown/import-export`, `property/documents` (Markdown) | in progress ([Markdown thread](https://ampcode.com/threads/T-01a1daf1-150e-709b-aaed-996443a64459)) | -| M6 | The rest of Swift's feature set: the extension API's bundled extensions (`ClearEditor`, `InitialState`, `EditorState`, `TabIndentation`, `NodeStructure`, `SelectBlock` and the others Swift has), clipboard (JSON copy and paste first, HTML once M4 lands), the rest of `selection` and `utils` (style helpers, `$wrapNodes`, `$splitNode`, `$dfs`...), `mark`, `file`, the devtools tree view, `text`'s root helpers, `a11y`'s announcements, code highlighting (Swift uses tree-sitter where JS uses Prism), the character limit, the menu, auto-embed, decorator block and table of contents logic, the remaining built-in commands, slots, node schemas and editable mode | `file/*`, `devtools/tree`, and new JS vectors for the rest | in progress (this thread's owner: the [Rust port thread](https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d), which also owns M1's extension API and the merges) | +| M6 | The rest of Swift's feature set: the extension API's bundled extensions (`ClearEditor`, `InitialState`, `EditorState`, `TabIndentation`, `NodeStructure`, `SelectBlock` and the others Swift has), clipboard (JSON copy and paste first, HTML once M4 lands), the rest of `selection` and `utils` (style helpers, `$wrapNodes`, `$splitNode`, `$dfs`...), `mark`, `file`, the devtools tree view, `text`'s root helpers, `a11y`'s announcements, code highlighting (Swift uses tree-sitter where JS uses Prism), the character limit, the menu, auto-embed, decorator block and table of contents logic, the remaining built-in commands, slots, node schemas and editable mode | `file/*`, `devtools/tree`, and new JS vectors for the rest | in progress (this thread's owner: the [Rust port thread](https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d), which also owns M1's extension API and the merges): `file`, the devtools tree view and `text`'s root helpers are done (`file/*`, `devtools/tree` and `scripts/text` pass) | | M7 | Releases: the mirror, changesets, a name tmm settles | | | -The `devtools/tree` vectors come with whichever milestone a tool needs -them. The `scenarios/*` vectors, recorded from a real browser, need a text +The `scenarios/*` vectors, recorded from a real browser, need a text layout behind `Selection.modify`; the playgrounds' binding replays them once rich-text is in. diff --git a/rust/PARITY.md b/rust/PARITY.md index 9bd0e81b3a5..bc2b7d176c1 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -46,6 +46,9 @@ updated with each milestone. | `@lexical/extension`'s decorators (the `extension` feature) | `packages/lexical-extension/src/HorizontalRuleExtension.ts` (`HorizontalRuleNode`: a block decorator whose text is `\n`; `$createHorizontalRuleNode`, `$isHorizontalRuleNode`), `DecoratorTextExtension.ts` (`DecoratorTextNode`: inline, `__isInlineFormattable`, `getFormat`, `hasFormat`, `setFormat`, `toggleFormat`, its `format` as a flat NodeState; `$isDecoratorTextNode`), with `LexicalNode`'s generated JSON | `extension` | `scripts/decorator` | | `NormalizeInlineElementsExtension`'s register (the `extension` feature) | `packages/lexical-extension/src/NormalizeInlineElementsExtension.ts` (`deleteEmptyInline`, and the `register` that adds it to the transforms of every registered `ElementNode` class with an `isInline` of its own, without marking their nodes dirty), as `register_normalize_inline_elements`; `RichTextExtension` depends on it | `extension` | `property/sequences` | | `registerLexicalTextEntity` (the `text` feature) | `packages/lexical-text/src/registerLexicalTextEntity.ts`: `EntityMatch`, `registerLexicalTextEntity` (the `TextNode` transform and `$reverseNodeTransform`, `$replaceWithSimpleText`, `$wasTurnedIntoText`, `getFirstMatchAfter`, `isNextTextEntity`, `isTextAfterOutsideSource`, `isEntityAfter`, `$checkNextSibling`, `$moveSelectionIntoEntity`, `$wouldMerge`, `MAX_MATCHES_PASSED`, `MAX_TURNED_INTO_TEXT`, the per-update `markedInUpdate` and `turnedIntoTextInUpdate`); from the core `INTERNAL_getActiveEditorState` | `text`, `updates` | `entities/hashtag-documents`, `entities/hashtag-typing`, `property/sequences` | +| `@lexical/text`'s root helpers (the `text` feature) | `packages/lexical-text/src/rootTextContent.ts` (`$rootTextContent`), `isRootTextContentEmpty.ts` (`$isRootTextContentEmpty`, with `String#trim`'s whitespace, and `$isRootTextContentEmptyCurry`), `canShowPlaceholder.ts` (`$canShowPlaceholder`, `$canShowPlaceholderCurry`), `findTextIntersectionFromCharacters.ts` (`$findTextIntersectionFromCharacters`, deprecated upstream, with UTF-16 counts), `index.ts`'s `TextNodeWithOffset` | `text` | `scripts/text` | +| `@lexical/file` (the `file` feature) | `packages/lexical-file/src/fileImportExport.ts`: `SerializedDocument`, `serializedDocumentFromEditorState` (an empty `source` is `"Lexical"`, a `lastSaved` of 0 or NaN is `Date.now()`, the version is the upstream release), `editorStateFromSerializedDocument` (from a string or a document, either JSON form), `importFile` and `exportFile` (with `toISOString` for the default file name), with the app reading and saving the file where JS's `readTextFileFromSystem` and `exportBlob` use the DOM | `file::file_import_export` | `file/export`, `file/import`; unit tests for `toISOString` against Node and an export-then-import round trip | +| `@lexical/devtools-core`'s tree view (the `devtools` feature) | `packages/lexical-devtools-core/src/generateContent.ts`: `generateContent` (the text form), `$printRangeSelection`, `printNodeSelection`, `$visitTree`, `normalize` (with `obfuscateText`), `printNode` (text, link with `link`, paragraph), `printTextFormatProperties`, `printAllTextNodeProperties`, `printAllLinkNodeProperties`, `printDetailProperties`, `printModeProperties`, `printFormatProperties`, `printTargetProperties`, `printRelProperties`, `printTitleProperties`, `printStateProperties`, `$printSelectedCharsLine`, `$getSelectionStartEnd`, `CustomPrintNodeFn`, `LexicalCommandLog`'s entries; from the core `LexicalNode.isSelected` (`$isKeyInSelectionNodes` and the inline decorator rule), `isBefore`, `LexicalEditor.version` (the port's own, `lexical-rust@0.53.0`, as Swift prints `lexical-swift@...`), `_config.namespace` (`createUID` when none is given), `isEditable` and `setEditable` | `devtools`, `node`, `editor`, `lexical_utils` | `devtools/tree` (keys mapped to the port's and the version line to the port's, as Swift's runner does) | | `$setBlocksType` (the `selection` feature) | `packages/lexical-selection/src/range-selection.ts` (`$setBlocksType`, with its default `$afterCreateElement`, `$copyBlockFormatIndent`, and `$isPointAtBlockEdge`), through `LexicalNode.replace` and `ListItemNode.replace` | `selection` | `scripts/set-blocks-type`, the block-decorator case of `scripts/decorator`, `property/sequences` | | UTF-16 offsets | `String#length`, `String#slice` | `utf16` | through the text methods; unit tests against Node | | Upstream version | `meta.json` | `UPSTREAM_VERSION` | `meta` | @@ -65,10 +68,10 @@ touches: DOM; an app's renderer can call `decorate` itself. The parts of `$extendSelectionForDeletion` and `applyDOMRange` that read a decorator's DOM (a line boundary landing in its private text, a DOM range inside - one) have no model counterpart. `isSelected()`'s rule for an inline - decorator at the end of its parent and `getTopLevelElement` are not - ported (no range-selection `isSelected` or `getTopLevelElement` is), nor - are slots on decorators (`__slotHost`, `__slots`). `@lexical/extension`'s + one) have no model counterpart. `isSelected()`, with its rule for an + inline decorator at the end of its parent, is ported (for the tree view, + the `devtools` feature), though no vector reaches that rule; + `getTopLevelElement` is not, nor are slots on decorators (`__slotHost`, `__slots`). `@lexical/extension`'s `HorizontalRuleExtension` (`INSERT_HORIZONTAL_RULE_COMMAND`, clicks, the selected class) and `DecoratorTextExtension` need the extension API, and `applyFormatFromStyle` reads CSS. @@ -186,11 +189,7 @@ touches: - `@lexical/hashtag`'s `createDOM`, which comes with `html`, and `HashtagExtension`, which needs the extension API (`hashtag_nodes()` is its `nodes` and `register_lexical_hashtag(editor, - HashtagConfig::default())` its `register`). Of `@lexical/text`, only - `registerLexicalTextEntity`: `$canShowPlaceholder`, - `$isRootTextContentEmpty`, `$rootTextContent` and - `$findTextIntersectionFromCharacters` aren't used by anything ported and - no vector exercises them. Within `registerLexicalTextEntity`, + HashtagConfig::default())` its `register`). Within `registerLexicalTextEntity`, `$carryNodeState`, `splitOffInUpdate`, `demotedInUpdate` and `$isInsertedText` are ported (`scripts/node-state`'s hashtag cases), but the NodeState half of `$wouldMerge` (`haveEquivalentNodeState` with @@ -205,6 +204,26 @@ touches: the last state asked about, as one state is pending at a time. JS's `targetNode` class is a node type here, as `registerNodeTransform` takes one. +- Of `@lexical/file`: the DOM halves of `importFile` and `exportFile` + (`readTextFileFromSystem`'s `` and `FileReader`, + `exportBlob`'s `Blob`, object URL and download link), which an app + supplies as callbacks. `exportFile`'s `toISOString` is written for the + years 1970 to 9999, which `Date.now()` stays within. +- Of `@lexical/devtools-core`: `generateContent`'s `exportDOM` form + (`$generateHtmlFromNodes`, `printPrettyHTML`, `prettifyHTML`), which + needs `@lexical/html` and fails with an error until it is ported; its + mark and table branches (`$isMarkNode`, `$isTableSelection`, + `printTableSelection`), as `@lexical/mark` and `@lexical/table` aren't + ported (a tree view holding their nodes would be `NotPorted` in the + runner; no `devtools/tree` case holds one); slots (`$getSlotNames`, + `$getSlot`, `$resolveSlotContext`, `$slotValueMetaSuffix`), which the + core doesn't have; the `compositionKey` line, as a headless editor never + composes; and the React parts, `TreeView.tsx` and + `useLexicalCommandsLog.ts` (a log of commands is the caller's, as + `LexicalCommandLogEntry`s with the payload already printed). The editable + listeners `setEditable` triggers in JS (`registerEditableListener`) + aren't ported. `obfuscateText`'s `*` and the `^` widths count UTF-16 + code units, as JS does; no vector obfuscates. - In `$setBlocksType`, the skipping of a block that is a named slot's value (`$getSlotHost(prevNode) !== null`), as slots aren't ported. Its branch for a declared block container (`$isBlockContainer`) is ported but unreachable diff --git a/rust/conformance/Cargo.toml b/rust/conformance/Cargo.toml index c2ed107710f..0a5fd8e1553 100644 --- a/rust/conformance/Cargo.toml +++ b/rust/conformance/Cargo.toml @@ -9,7 +9,7 @@ license.workspace = true repository.workspace = true [dependencies] -lexical = { workspace = true, features = ["autolink", "code", "extension", "hashtag", "history", "link", "list", "overflow", "plain-text", "rich-text"] } +lexical = { workspace = true, features = ["autolink", "code", "devtools", "extension", "file", "hashtag", "history", "link", "list", "overflow", "plain-text", "rich-text", "text"] } [lints] workspace = true diff --git a/rust/conformance/src/scripts.rs b/rust/conformance/src/scripts.rs index 59836b31eaf..79f8f6c1325 100644 --- a/rust/conformance/src/scripts.rs +++ b/rust/conformance/src/scripts.rs @@ -411,7 +411,11 @@ fn unported_action(action: &Value) -> Option<&'static str> { | "logState" | "logStateChange" | "setBlocksType" - | "logSelectionText", + | "logSelectionText" + | "logRootTextContent" + | "logRootTextContentEmpty" + | "logCanShowPlaceholder" + | "logTextIntersection", ) => None, Some("selection" | "selectAll" | "selectNodes" | "insertNodes") => None, _ => Some("unknown"), @@ -770,6 +774,58 @@ fn run_action(action: &Value, ctx: &Ctx, editor: &Editor) -> Result<(), LexicalE ]); Ok(()) } + Some("logRootTextContent") => { + ctx.log(vec![ + "rootTextContent".into(), + lexical::text::root_text_content().into(), + ]); + Ok(()) + } + Some("logRootTextContentEmpty") => { + let is_composing = action.get("isComposing").and_then(Value::as_bool) == Some(true); + // `trim` absent is JS's `undefined`, so the default (`true`). + let trim = action.get("trim").and_then(Value::as_bool); + let curried = + lexical::text::is_root_text_content_empty_curry(is_composing, trim.unwrap_or(true)); + ctx.log(vec![ + "rootTextContentEmpty".into(), + is_composing.into(), + trim.map_or(Value::Null, Value::from), + lexical::text::is_root_text_content_empty(is_composing, trim.unwrap_or(true)) + .into(), + curried().into(), + ]); + Ok(()) + } + Some("logCanShowPlaceholder") => { + let is_composing = action.get("isComposing").and_then(Value::as_bool) == Some(true); + let curried = lexical::text::can_show_placeholder_curry(is_composing); + ctx.log(vec![ + "canShowPlaceholder".into(), + is_composing.into(), + lexical::text::can_show_placeholder(is_composing).into(), + curried().into(), + ]); + Ok(()) + } + Some("logTextIntersection") => { + let characters = number(action.get("characters").unwrap_or(&Value::Null))?; + let found = lexical::text::find_text_intersection_from_characters( + &get_root(), + characters as usize, + ); + ctx.log(vec![ + "textIntersection".into(), + characters.into(), + found.map_or(Value::Null, |found| { + Value::Array(vec![ + found.node.get_key().into(), + (found.offset as f64).into(), + ]) + }), + ]); + Ok(()) + } Some("insertNodes") => insert_nodes(parse(action.get("nodes").unwrap_or(&Value::Null))?), Some("selection") => run_selection_method( action.get("method").and_then(Value::as_str).unwrap_or(""), @@ -1086,7 +1142,10 @@ impl Session { if with_extension { nodes.extend(lexical::extension::extension_nodes()); } - let editor = create_editor(CreateEditorArgs { nodes }); + let editor = create_editor(CreateEditorArgs { + nodes, + ..CreateEditorArgs::default() + }); let ctx = Rc::new(Ctx::default()); if rich_text { let _unregister = lexical::rich_text::register_rich_text(&editor); diff --git a/rust/conformance/src/suites/devtools.rs b/rust/conformance/src/suites/devtools.rs new file mode 100644 index 00000000000..3ff4341e901 --- /dev/null +++ b/rust/conformance/src/suites/devtools.rs @@ -0,0 +1,237 @@ +use super::package_of; +use crate::case::{Case, Outcome, same}; +use lexical::devtools::generate_content; +use lexical::json::{self, Value}; +use lexical::{ + CreateEditorArgs, Editor, LexicalError, NodeRef, PointType, UpdateOptions, Which, + create_editor, create_node_selection, get_root, set_selection, +}; +use std::collections::HashMap; + +/// The first node type in a serialized document that needs a package not +/// ported yet. +fn unported_package(node: &Value) -> Option<&'static str> { + node.get("type") + .and_then(Value::as_str) + .and_then(package_of) + .or_else(|| { + node.get("children") + .and_then(Value::as_array)? + .iter() + .find_map(unported_package) + }) +} + +fn err(message: impl Into) -> LexicalError { + LexicalError::new(message.into()) +} + +/// The node at a path of child indexes from the root. +fn node_at(path: &Value) -> Result { + let mut node = get_root(); + for index in path.as_array().ok_or_else(|| err("a path is an array"))? { + let index = index.as_f64().ok_or_else(|| err("a path index"))? as usize; + node = node + .get_children() + .get(index) + .cloned() + .ok_or_else(|| err(format!("no child {index}")))?; + } + Ok(node) +} + +fn point_type(point: &Value) -> Result { + match point.get("type").and_then(Value::as_str) { + Some("text") => Ok(PointType::Text), + Some("element") => Ok(PointType::Element), + other => Err(err(format!("point type {other:?}"))), + } +} + +/// `treeView.test.ts`'s `$applySelection` for the selections it recorded +/// (each applies): none, a node selection of paths, or a range selection +/// made with `anchor.select(0, 0)` and its points set. +fn apply_selection(selection: &Value) -> Result<(), LexicalError> { + if selection.is_null() { + set_selection(None); + return Ok(()); + } + if selection.get("type").and_then(Value::as_str) == Some("node") { + let nodes = create_node_selection(); + for path in selection + .get("paths") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + nodes.add(node_at(path)?.get_key()); + } + set_selection(Some(nodes)); + return Ok(()); + } + let anchor = selection.get("anchor").ok_or_else(|| err("no anchor"))?; + let focus = selection.get("focus").ok_or_else(|| err("no focus"))?; + let anchor_node = node_at(anchor.get("path").unwrap_or(&Value::Null))?; + let focus_node = node_at(focus.get("path").unwrap_or(&Value::Null))?; + let range = anchor_node.select(Some(0), Some(0))?; + for (which, point, node) in [ + (Which::Anchor, anchor, &anchor_node), + (Which::Focus, focus, &focus_node), + ] { + let offset = point + .get("offset") + .and_then(Value::as_f64) + .ok_or_else(|| err("no offset"))?; + range.point_set(which, node.get_key(), offset as usize, point_type(point)?)?; + } + if let Some(format) = selection.get("format").and_then(Value::as_f64) { + range.set_format(format); + } + if let Some(style) = selection.get("style").and_then(Value::as_str) { + range.set_style(style); + } + Ok(()) +} + +/// The run of ASCII digits at the start of `s`. +fn digits(s: &str) -> &str { + &s[..s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())] +} + +/// The key a node line prints, `^[> ] [ |├└]* \((\d+)\) `, if it is one. +fn line_key(line: &str) -> Option<&str> { + let rest = line.strip_prefix(['>', ' '])?.strip_prefix(' ')?; + let rest = rest.trim_start_matches([' ', '|', '├', '└']); + let rest = rest.strip_prefix('(')?; + let key = digits(rest); + (!key.is_empty() && rest[key.len()..].starts_with(") ")).then_some(key) +} + +/// Replaces each `` with the digits mapped. +fn map_keys(text: &str, prefix: &str, suffix: &str, key: &HashMap<&str, String>) -> String { + let mut out = String::new(); + let mut rest = text; + while let Some(i) = rest.find(prefix) { + out.push_str(&rest[..i + prefix.len()]); + rest = &rest[i + prefix.len()..]; + let found = digits(rest); + if !found.is_empty() && rest[found.len()..].starts_with(suffix) { + out.push_str(key.get(found).map_or("?", String::as_str)); + rest = &rest[found.len()..]; + } + } + out.push_str(rest); + out +} + +/// The JS tree with its keys replaced by the port's and its version by the +/// port's, as Swift's `TreeViewTests` does. +fn expected(tree: &str, version: &str, key: &HashMap<&str, String>) -> String { + // A `^` line is padded by its node's printed key, whose width differs + // when the keys do. + let mut lines: Vec = Vec::new(); + let mut width_change: isize = 0; + for line in tree.split('\n') { + if let Some(js_key) = line_key(line) { + let rust_len = key.get(js_key).map_or(js_key.len(), String::len); + width_change = js_key.len() as isize - rust_len as isize; + lines.push(line.to_owned()); + } else if let (true, Some(caret)) = (line.starts_with('>'), line.find(" ^")) { + // The run of spaces before the first `^`. + let start = line[..=caret].trim_end_matches(' ').len(); + let kept = start + width_change.max(0) as usize; + lines.push(format!( + "{}{}{}", + &line[..start], + " ".repeat((-width_change).max(0) as usize), + &line[kept..] + )); + } else { + lines.push(line.to_owned()); + } + } + let tree = lines + .join("\n") + .replace(&format!("(v{version})"), &format!("(v{})", Editor::VERSION)); + let tree = map_keys(&tree, "(", ")", key); + let tree = map_keys(&tree, "key: ", "", key); + // A node selection's `└ [k1, k2]`. + match tree.find("└ [") { + Some(i) => { + let open = i + "└ [".len(); + let close = open + tree[open..].find(']').unwrap_or(0); + let mapped: Vec<&str> = tree[open..close] + .split(", ") + .filter(|k| !k.is_empty()) + .map(|k| key.get(k).map_or("?", String::as_str)) + .collect(); + format!("{}{}{}", &tree[..open], mapped.join(", "), &tree[close..]) + } + None => tree, + } +} + +/// `devtools/tree`: `generateContent(editor, [], false)` of the parsed +/// state, with the selection set after parsing and the editable flag, +/// matches JS's tree with JS's node keys mapped to the port's (both listed +/// in tree order) and the port's version. +pub fn check_tree(case: &Value, _file: &Value) -> Result { + let editor_state = case.str("editorState")?; + let parsed = json::parse(editor_state).map_err(|e| format!("editorState: {e}"))?; + if let Some(package) = parsed.get("root").and_then(unported_package) { + return Ok(Outcome::NotPorted(package)); + } + let editable = case + .field("editable")? + .as_bool() + .ok_or("vector field `editable` is not a boolean")?; + let editor = create_editor(CreateEditorArgs { + nodes: lexical::rich_text::rich_text_nodes(), + namespace: "playground".to_owned(), + }); + let state = editor + .parse_editor_state(editor_state) + .map_err(|e| format!("parsing: {e}"))?; + editor + .set_editor_state(&state, None) + .map_err(|e| format!("setEditorState: {e}"))?; + let selection = case.field("selection")?.clone(); + editor + .update( + move || apply_selection(&selection), + UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }, + ) + .map_err(|e| format!("setting the selection: {e}"))?; + editor.set_editable(editable); + let keys = editor + .read(|| { + fn visit(node: &NodeRef, keys: &mut Vec) { + for child in node.get_children() { + keys.push(child.get_key().to_owned()); + visit(&child, keys); + } + } + let mut keys = Vec::new(); + visit(&get_root(), &mut keys); + keys + }) + .map_err(|e| format!("reading: {e}"))?; + let js_keys = case + .arr("keys")? + .iter() + .map(|k| k.as_str().ok_or("a key is not a string")) + .collect::, _>>()?; + same("nodes", keys.len(), js_keys.len())?; + let key: HashMap<&str, String> = js_keys.into_iter().zip(keys).collect(); + let tree = generate_content(&editor, &[], false, None, false) + .map_err(|e| format!("generateContent: {e}"))?; + same( + "tree", + tree.as_str(), + expected(case.str("tree")?, case.str("version")?, &key).as_str(), + )?; + Ok(Outcome::Pass) +} diff --git a/rust/conformance/src/suites/entities.rs b/rust/conformance/src/suites/entities.rs index b6a0eac2bab..4a0f1048634 100644 --- a/rust/conformance/src/suites/entities.rs +++ b/rust/conformance/src/suites/entities.rs @@ -61,6 +61,7 @@ pub fn check_hashtag_match(case: &Value, _file: &Value) -> Result Editor { let editor = create_editor(CreateEditorArgs { nodes: hashtag_nodes(), + ..CreateEditorArgs::default() }); let _unregister = register_lexical_hashtag(&editor, HashtagConfig::default()); editor diff --git a/rust/conformance/src/suites/file.rs b/rust/conformance/src/suites/file.rs new file mode 100644 index 00000000000..e35f2459b85 --- /dev/null +++ b/rust/conformance/src/suites/file.rs @@ -0,0 +1,63 @@ +use crate::case::{Case, Outcome, same}; +use lexical::file::{ + SerializedDocumentConfig, editor_state_from_serialized_document, + serialized_document_from_editor_state, +}; +use lexical::json::Value; +use lexical::{CreateEditorArgs, Editor, create_editor}; + +/// `file.test.ts`'s `newEditor()`: a headless editor with the list nodes. +fn new_editor() -> Editor { + create_editor(CreateEditorArgs { + nodes: lexical::list::list_nodes(), + ..CreateEditorArgs::default() + }) +} + +/// `file/export`: `serializedDocumentFromEditorState` of the parsed editor +/// state and the case's config writes JS's document byte for byte, and +/// `editorStateFromSerializedDocument` reads the editor state back. +pub fn check_export(case: &Value, _file: &Value) -> Result { + let editor_state = case.str("editorState")?; + let config = case.obj("config")?; + let document = case.str("document")?; + let state = new_editor() + .parse_editor_state(editor_state) + .map_err(|e| format!("parsing the editor state: {e}"))?; + let config = SerializedDocumentConfig { + source: config + .get("source") + .and_then(Value::as_str) + .map(str::to_owned), + last_saved: config.get("lastSaved").and_then(Value::as_f64), + }; + same( + "document", + serialized_document_from_editor_state(&state, &config) + .to_json() + .to_string() + .as_str(), + document, + )?; + let read = editor_state_from_serialized_document(&new_editor(), document) + .map_err(|e| format!("reading the document: {e}"))?; + same( + "editor state read back", + read.to_json(false).to_string().as_str(), + editor_state, + )?; + Ok(Outcome::Pass) +} + +/// `file/import`: `editorStateFromSerializedDocument` of a document's text +/// gives JS's editor state. +pub fn check_import(case: &Value, _file: &Value) -> Result { + let state = editor_state_from_serialized_document(&new_editor(), case.str("document")?) + .map_err(|e| format!("reading the document: {e}"))?; + same( + "editor state", + state.to_json(false).to_string().as_str(), + case.str("editorState")?, + )?; + Ok(Outcome::Pass) +} diff --git a/rust/conformance/src/suites/meta.rs b/rust/conformance/src/suites/meta.rs index 21e003fcb5e..8469a496521 100644 --- a/rust/conformance/src/suites/meta.rs +++ b/rust/conformance/src/suites/meta.rs @@ -9,5 +9,10 @@ pub fn check(case: &Value, _file: &Value) -> Result { lexical::UPSTREAM_VERSION, case.str("lexicalVersion")?, )?; + same( + "Editor::VERSION", + lexical::Editor::VERSION, + format!("lexical-rust@{}", lexical::UPSTREAM_VERSION).as_str(), + )?; Ok(Outcome::Pass) } diff --git a/rust/conformance/src/suites/mod.rs b/rust/conformance/src/suites/mod.rs index b9226f98353..66a96de582f 100644 --- a/rust/conformance/src/suites/mod.rs +++ b/rust/conformance/src/suites/mod.rs @@ -1,7 +1,9 @@ //! One suite per vector file the port replays. A file with no suite is //! reported as not implemented. +mod devtools; mod entities; +mod file; mod meta; mod property; mod serialization; @@ -15,6 +17,21 @@ pub fn all() -> Vec { variant: None, check: meta::check, }, + Suite { + file: "devtools/tree", + variant: None, + check: devtools::check_tree, + }, + Suite { + file: "file/export", + variant: None, + check: file::check_export, + }, + Suite { + file: "file/import", + variant: None, + check: file::check_import, + }, Suite { file: "entities/autolink-match", variant: None, @@ -150,6 +167,11 @@ pub fn all() -> Vec { variant: None, check: crate::scripts::check, }, + Suite { + file: "scripts/text", + variant: None, + check: crate::scripts::check, + }, Suite { file: "fuzz/core", variant: None, diff --git a/rust/conformance/src/suites/property.rs b/rust/conformance/src/suites/property.rs index 159d07cca99..35f46714b4e 100644 --- a/rust/conformance/src/suites/property.rs +++ b/rust/conformance/src/suites/property.rs @@ -48,6 +48,7 @@ pub fn check_document(case: &Value, _file: &Value) -> Result { lexical::hashtag::hashtag_nodes(), ] .concat(), + ..CreateEditorArgs::default() }); let parsed = editor.parse_editor_state(input); if case.get("error").is_some() { @@ -177,6 +178,7 @@ fn sequence_editor() -> (Editor, Unregister) { lexical::overflow::overflow_nodes(), ] .concat(), + ..CreateEditorArgs::default() }); // Registered for the editor's life; dropping an unregister function // doesn't call it. diff --git a/rust/conformance/src/suites/serialization.rs b/rust/conformance/src/suites/serialization.rs index 5f9c8c50f5c..ebf25d5351f 100644 --- a/rust/conformance/src/suites/serialization.rs +++ b/rust/conformance/src/suites/serialization.rs @@ -17,6 +17,7 @@ pub fn check(case: &Value, _file: &Value) -> Result { lexical::hashtag::hashtag_nodes(), ] .concat(), + ..CreateEditorArgs::default() }); for (form, input) in [("legacy", legacy), ("compact", compact)] { let state = editor diff --git a/rust/lexical-editor/Cargo.toml b/rust/lexical-editor/Cargo.toml index d529fcd47d8..150e197a4ed 100644 --- a/rust/lexical-editor/Cargo.toml +++ b/rust/lexical-editor/Cargo.toml @@ -47,13 +47,23 @@ code = [] # @lexical/overflow: OverflowNode. overflow = [] # @lexical/text: registerLexicalTextEntity, the transforms that make text -# entities (hashtags) of the text a matcher finds. +# entities (hashtags) of the text a matcher finds, and the root helpers +# ($rootTextContent, $isRootTextContentEmpty, $canShowPlaceholder, +# $findTextIntersectionFromCharacters). text = [] # @lexical/hashtag: HashtagNode, its matcher (getHashtagMatch, without the # `regex` crate) and registerLexicalHashtag. Upstream it also depends on # @lexical/extension and @lexical/utils, for HashtagExtension and # mergeRegister, which aren't ported. hashtag = ["text"] +# @lexical/file: the .lexical document format, importFile and exportFile, with +# the app picking and saving the file. +file = [] +# @lexical/devtools-core: generateContent, the tree view's text. Upstream it +# depends on @lexical/html, @lexical/link, @lexical/mark and @lexical/table; +# the link branch is built with `link` (without it no link node can exist), +# and the HTML form, mark and table nodes aren't ported. +devtools = [] # @lexical/extension's decorators: HorizontalRuleNode (a block decorator) and # DecoratorTextNode (an inline one), and NormalizeInlineElementsExtension's # transform. The rest of the package, the extension API, isn't ported. diff --git a/rust/lexical-editor/src/code/flat_structure_utils.rs b/rust/lexical-editor/src/code/flat_structure_utils.rs index aa69aabc4e7..51e934dcd3c 100644 --- a/rust/lexical-editor/src/code/flat_structure_utils.rs +++ b/rust/lexical-editor/src/code/flat_structure_utils.rs @@ -302,6 +302,7 @@ mod tests { reset_random_key(); let editor = create_editor(CreateEditorArgs { nodes: code_nodes(), + ..CreateEditorArgs::default() }); editor .update( diff --git a/rust/lexical-editor/src/devtools/generate_content.rs b/rust/lexical-editor/src/devtools/generate_content.rs new file mode 100644 index 00000000000..e7b574d2195 --- /dev/null +++ b/rust/lexical-editor/src/devtools/generate_content.rs @@ -0,0 +1,520 @@ +//! `packages/lexical-devtools-core/src/generateContent.ts`. +//! +//! Not ported: the `exportDOM` form, which needs `@lexical/html`; mark and +//! table nodes and table selections (`@lexical/mark`, `@lexical/table`); and +//! slots (`$getSlotNames`, `$resolveSlotContext`, `$slotValueMetaSuffix`), +//! which the core doesn't have, so a tree prints no slot entries. A headless +//! editor never composes, so no `compositionKey` line is printed. + +use crate::constants::{IS_DIRECTIONLESS, TEXT_TYPE_TO_FORMAT, lookup}; +use crate::node_state::{is_default, unparse}; +use crate::nodes::paragraph::ParagraphNode; +use crate::nodes::text::to_int32; +use crate::selection_deletion::is_js_whitespace; +use crate::updates::read_editor_state; +use crate::{ + Editor, LexicalError, NodeRef, PointType, SelectionRef, get_root, get_selection, utf16, +}; + +/// `CustomPrintNodeFn`: what to print for a node (given whether to +/// obfuscate its text) instead of the default; `None` or an empty string +/// for the default. +pub type CustomPrintNodeFn<'a> = &'a dyn Fn(&NodeRef, bool) -> Option; + +/// An entry of `LexicalCommandLog` (`useLexicalCommandsLog.ts`): a +/// dispatched command's index in the log, its `type` and its payload as +/// the tree view prints it (JS prints `String(payload)`, or an event's +/// class name). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LexicalCommandLogEntry { + pub index: usize, + pub command_type: String, + pub payload: String, +} + +/// `NON_SINGLE_WIDTH_CHARS_REPLACEMENT`. +const NON_SINGLE_WIDTH_CHARS_REPLACEMENT: [(char, &str); 2] = [('\t', "\\t"), ('\n', "\\n")]; + +/// `SYMBOLS`. +const ANCESTOR_HAS_NEXT_SIBLING: &str = "|"; +const ANCESTOR_IS_LAST_CHILD: &str = " "; +const HAS_NEXT_SIBLING: &str = "├"; +const IS_LAST_CHILD: &str = "└"; +const SELECTED_CHAR: &str = "^"; +const SELECTED_LINE: &str = ">"; + +/// The formats `FORMAT_PREDICATES` and `FORMAT_PREDICATES_PARAGRAPH` ask +/// about, in their order, with the names they print. +const FORMATS: [(&str, &str); 8] = [ + ("bold", "Bold"), + ("code", "Code"), + ("italic", "Italic"), + ("strikethrough", "Strikethrough"), + ("subscript", "Subscript"), + ("superscript", "Superscript"), + ("underline", "Underline"), + ("highlight", "Highlight"), +]; + +/// `generateContent(editor, commandsLog, exportDOM, customPrintNode, +/// obfuscateText)`: the tree view's text: each node of the editor's state +/// on a line (`>` when selected, with a line of `^` under a text node's +/// selected characters), the selection, the commands logged and the +/// editor's version, namespace and editable flag. With `export_dom`, JS +/// prints the state's HTML instead, which needs `@lexical/html`, not ported: +/// that fails. +pub fn generate_content( + editor: &Editor, + commands_log: &[LexicalCommandLogEntry], + export_dom: bool, + custom_print_node: Option>, + obfuscate_text: bool, +) -> Result { + let editor_state = editor.get_editor_state(); + + if export_dom { + return Err(LexicalError::new( + "generateContent: exportDOM needs @lexical/html, which isn't ported", + )); + } + + let mut res = String::from(" root\n"); + + let selection_string = read_editor_state(Some(editor), &editor_state, || { + let selection = get_selection(); + visit_tree(&get_root(), &[], &mut |node, indent| { + let node_key_display = format!("({})", node.get_key()); + let type_display = node.get_type(); + let is_selected = node.is_selected(None)?; + res.push_str(&format!( + "{} {} {node_key_display} {type_display} {}\n", + if is_selected { SELECTED_LINE } else { " " }, + indent.join(" "), + print_node(node, custom_print_node, obfuscate_text), + )); + res.push_str(&print_selected_chars_line( + indent, + is_selected, + node, + &node_key_display, + selection.as_ref(), + type_display, + )?); + Ok(()) + })?; + Ok::<_, LexicalError>(match &selection { + None => ": null".to_owned(), + Some(selection) if selection.is_range() => print_range_selection(selection), + Some(selection) => print_node_selection(selection), + }) + })?; + + res.push_str("\n selection"); + res.push_str(&selection_string); + + res.push_str("\n\n commands:"); + + if commands_log.is_empty() { + res.push_str("\n └ None dispatched."); + } else { + for entry in commands_log { + res.push_str(&format!( + "\n └ {}. {{ type: {}, payload: {} }}", + entry.index, entry.command_type, entry.payload + )); + } + } + res.push_str(&format!("\n\n editor (v{}):", Editor::VERSION)); + res.push_str(&format!("\n └ namespace {}", editor.get_namespace())); + res.push_str(&format!("\n └ editable {}", editor.is_editable())); + + Ok(res) +} + +/// `$printRangeSelection(selection)`. +fn print_range_selection(selection: &SelectionRef) -> String { + let range = selection.range().expect("a range selection"); + let format_text = print_format_properties(|format| selection.has_format(format)); + let mut res = format!( + ": range {} {}", + if format_text.is_empty() { + String::new() + } else { + format!("{{ {format_text} }}") + }, + if range.style.is_empty() { + String::new() + } else { + format!("{{ style: {} }} ", range.style) + }, + ); + for (line, point) in [("├ anchor", &range.anchor), ("└ focus", &range.focus)] { + res.push_str(&format!( + "\n {line} {{ key: {}, offset: {}, type: {} }}", + point.key, + point.offset, + point.point_type.as_str() + )); + } + res +} + +/// `printNodeSelection(selection)`. +fn print_node_selection(selection: &SelectionRef) -> String { + format!(": node\n └ [{}]", selection.node_keys().join(", ")) +} + +/// What `$visitTree` calls for each node, with its indent. +type Visitor<'a> = dyn FnMut(&NodeRef, &[&'static str]) -> Result<(), LexicalError> + 'a; + +/// `$visitTree(currentNode, visitor, indent)`: each child of an element, +/// with its indent, then its own children. +fn visit_tree( + current_node: &NodeRef, + indent: &[&'static str], + visitor: &mut Visitor<'_>, +) -> Result<(), LexicalError> { + let child_nodes = if current_node.is_element() { + current_node.get_children() + } else { + Vec::new() + }; + let total_count = child_nodes.len(); + for (i, child_node) in child_nodes.iter().enumerate() { + let is_last = i + 1 == total_count; + let mut child_indent = indent.to_vec(); + child_indent.push(if is_last { + IS_LAST_CHILD + } else { + HAS_NEXT_SIBLING + }); + visitor(child_node, &child_indent)?; + if child_node.is_element() || child_node.is_decorator() { + let mut descendant_indent = indent.to_vec(); + descendant_indent.push(if is_last { + ANCESTOR_IS_LAST_CHILD + } else { + ANCESTOR_HAS_NEXT_SIBLING + }); + visit_tree(child_node, &descendant_indent, visitor)?; + } + } + Ok(()) +} + +/// `normalize(text, obfuscateText)`: tabs and newlines as escapes, and with +/// `obfuscate_text` every other code unit that isn't whitespace as `*`. +fn normalize(text: &str, obfuscate_text: bool) -> String { + let mut text_to_print = text.to_owned(); + for (key, value) in NON_SINGLE_WIDTH_CHARS_REPLACEMENT { + text_to_print = text_to_print.replace(key, value); + } + if obfuscate_text { + // `/[^\s]/g` without `u` replaces each UTF-16 code unit. + return text_to_print + .chars() + .flat_map(|c| { + let n = if is_js_whitespace(c) { + 0 + } else { + c.len_utf16() + }; + std::iter::repeat_n('*', n).chain((n == 0).then_some(c)) + }) + .collect(); + } + text_to_print +} + +/// `[title, properties].filter(Boolean).join(' ').trim()` with the +/// properties in braces. +fn title_and_properties(title: String, properties: &str) -> String { + let joined = if properties.is_empty() { + title + } else { + format!("{title} {{ {properties} }}") + }; + joined.trim_matches(is_js_whitespace).to_owned() +} + +/// `printNode(node, customPrintNode, obfuscateText)`. +fn print_node( + node: &NodeRef, + custom_print_node: Option>, + obfuscate_text: bool, +) -> String { + if let Some(custom_print) = custom_print_node + .and_then(|custom| custom(node, obfuscate_text)) + .filter(|custom| !custom.is_empty()) + { + return custom_print; + } + + if node.is_text() { + let text = node.get_text_content(); + let title = if text.is_empty() { + "(empty)".to_owned() + } else { + format!("\"{}\"", normalize(&text, obfuscate_text)) + }; + return title_and_properties(title, &print_all_text_node_properties(node)); + } + #[cfg(feature = "link")] + if crate::link::is_link_node(node) { + let link = node.get_url().unwrap_or_default(); + let title = if link.is_empty() { + "(empty)".to_owned() + } else { + format!("\"{}\"", normalize(&link, obfuscate_text)) + }; + return title_and_properties(title, &print_all_link_node_properties(node)); + } + let latest = node.get_latest(); + if latest.is::() { + let element = latest.element().expect("a paragraph is an element"); + let format_text = print_format_properties(|format| { + let flag = lookup(TEXT_TYPE_TO_FORMAT, format).unwrap_or(0); + to_int32(element.get_text_format()) & flag != 0 + }); + let mut paragraph_data = if format_text.is_empty() { + String::new() + } else { + format!("{{ {format_text} }}") + }; + if !element.get_style().is_empty() { + paragraph_data.push_str(&format!("({})", element.get_style())); + } + return paragraph_data; + } + String::new() +} + +/// `printAllTextNodeProperties(node)`. +fn print_all_text_node_properties(node: &NodeRef) -> String { + [ + print_format_properties(|format| node.has_format(format)), + print_detail_properties(node), + print_mode_properties(node), + print_state_properties(node), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(", ") +} + +/// `printAllLinkNodeProperties(node)`, with `printTargetProperties`, +/// `printRelProperties` and `printTitleProperties` (a `null` property is +/// left out; an empty one is printed). +#[cfg(feature = "link")] +fn print_all_link_node_properties(node: &NodeRef) -> String { + [ + node.get_target().map(|s| format!("target: {s}")), + node.get_rel().map(|s| format!("rel: {s}")), + node.get_title().map(|s| format!("title: {s}")), + Some(print_state_properties(node)), + ] + .into_iter() + .flatten() + .filter(|s| !s.is_empty()) + .collect::>() + .join(", ") +} + +/// `predicates.map(...).filter(Boolean).join(', ').toLocaleLowerCase()` +/// with `label: ` before it when not empty. +fn print_properties(label: &str, names: &[&str]) -> String { + if names.is_empty() { + return String::new(); + } + format!("{label}: {}", names.join(", ").to_lowercase()) +} + +/// `printDetailProperties(node)`. +fn print_detail_properties(node: &NodeRef) -> String { + let detail = to_int32(node.text_data().get_detail()); + let mut names = Vec::new(); + if detail & IS_DIRECTIONLESS != 0 { + names.push("Directionless"); + } + if node.is_unmergeable() { + names.push("Unmergeable"); + } + print_properties("detail", &names) +} + +/// `printModeProperties(node)`. +fn print_mode_properties(node: &NodeRef) -> String { + let mut names = Vec::new(); + if node.is_token() { + names.push("Token"); + } + if node.is_segmented() { + names.push("Segmented"); + } + print_properties("mode", &names) +} + +/// `printFormatProperties(nodeOrSelection)` and +/// `printTextFormatProperties(paragraph)`, by what `hasFormat` or +/// `hasTextFormat` answers. +fn print_format_properties(has_format: impl Fn(&str) -> bool) -> String { + let names: Vec<&str> = FORMATS + .iter() + .filter(|(format, _)| has_format(format)) + .map(|(_, name)| *name) + .collect(); + print_properties("format", &names) +} + +/// `printStateProperties(node)`: the node's known NodeState values that +/// aren't their default, as `JSON.stringify` writes their `unparse`. +fn print_state_properties(node: &NodeRef) -> String { + let Some(state) = node.get_latest().base().state.clone() else { + return String::new(); + }; + let states: Vec = state + .borrow() + .known_entries() + .iter() + .filter(|(config, value)| !is_default(config, value)) + .map(|(config, value)| { + let unparsed = unparse(config, value); + let text_value = if unparsed.is_undefined() { + "undefined".to_owned() + } else { + unparsed.to_string() + }; + format!("[{}: {text_value}]", config.key()) + }) + .collect(); + let joined = states.join(","); + if joined.is_empty() { + return joined; + } + format!("state: {joined}") +} + +/// `$printSelectedCharsLine(...)`: under a selected text node, a line with +/// `^` under its selected characters, or nothing. +fn print_selected_chars_line( + indent: &[&str], + is_selected: bool, + node: &NodeRef, + node_key_display: &str, + selection: Option<&SelectionRef>, + type_display: &str, +) -> Result { + // No selection or node is not selected. + let Some(range) = selection.and_then(SelectionRef::range) else { + return Ok(String::new()); + }; + if !node.is_text() || !is_selected || node.is_element() { + return Ok(String::new()); + } + + // No selected characters. + let anchor = &range.anchor; + let focus = &range.focus; + + if node.get_text_content().is_empty() + || (anchor.key == focus.key && anchor.offset == focus.offset) + { + return Ok(String::new()); + } + + let (start, end) = get_selection_start_end(node, anchor, focus)?; + + if start == end { + return Ok(String::new()); + } + + let selection_last_indent = if indent.last() == Some(&HAS_NEXT_SIBLING) { + ANCESTOR_HAS_NEXT_SIBLING + } else { + ANCESTOR_IS_LAST_CHILD + }; + + let mut indention_chars = indent[..indent.len().saturating_sub(1)].to_vec(); + indention_chars.push(selection_last_indent); + // `Array(n)` throws for a negative length. + let (Ok(unselected), Ok(selected)) = (usize::try_from(start + 1), usize::try_from(end - start)) + else { + return Err(LexicalError::new("Invalid array length")); + }; + // 1 for the space after + 1 for the double quote. + let padding_length = utf16::len(type_display) + 2; + let node_print_spaces = utf16::len(node_key_display) + padding_length; + + Ok(format!( + "{SELECTED_LINE} {} {}{}\n", + indention_chars.join(" "), + " ".repeat(node_print_spaces + unselected), + SELECTED_CHAR.repeat(selected), + )) +} + +/// `String#slice(start, end)` on UTF-16 code units: clamped, and a negative +/// index counts from the end. +fn js_slice(units: &[u16], start: i64, end: i64) -> &[u16] { + let n = units.len() as i64; + let clamp = |i: i64| (if i < 0 { (n + i).max(0) } else { i.min(n) }) as usize; + let (a, b) = (clamp(start), clamp(end)); + if a < b { &units[a..b] } else { &[] } +} + +/// `$getSelectionStartEnd(node, selection)`: the selected UTF-16 range of +/// `node`'s text, in printed characters (a tab or newline prints as two). +fn get_selection_start_end( + node: &NodeRef, + anchor: &crate::Point, + focus: &crate::Point, +) -> Result<(i64, i64), LexicalError> { + let text_content = node.get_text_content(); + let units: Vec = text_content.encode_utf16().collect(); + let text_length = units.len() as i64; + + let mut start = -1; + let mut end = -1; + + // Only one node is being selected. + if anchor.point_type == PointType::Text && focus.point_type == PointType::Text { + let anchor_node = anchor.get_node()?; + let focus_node = focus.get_node()?; + let (anchor_offset, focus_offset) = (anchor.offset as i64, focus.offset as i64); + + if anchor_node == focus_node && node == &anchor_node && anchor_offset != focus_offset { + (start, end) = if anchor_offset < focus_offset { + (anchor_offset, focus_offset) + } else { + (focus_offset, anchor_offset) + }; + } else if node == &anchor_node { + (start, end) = if anchor_node.is_before(&focus_node) { + (anchor_offset, text_length) + } else { + (0, anchor_offset) + }; + } else if node == &focus_node { + (start, end) = if focus_node.is_before(&anchor_node) { + (focus_offset, text_length) + } else { + (0, focus_offset) + }; + } else { + // Node is within selection but not the anchor nor focus. + (start, end) = (0, text_length); + } + } + + // Account for non-single width characters. + let is_wide = |unit: &&u16| { + NON_SINGLE_WIDTH_CHARS_REPLACEMENT + .iter() + .any(|(c, _)| u32::from(**unit) == u32::from(*c)) + }; + let before = js_slice(&units, 0, start).iter().filter(is_wide).count() as i64; + let inside = js_slice(&units, start, end).iter().filter(is_wide).count() as i64; + + Ok((start + before, end + before + inside)) +} diff --git a/rust/lexical-editor/src/devtools/mod.rs b/rust/lexical-editor/src/devtools/mod.rs new file mode 100644 index 00000000000..932ff39f4dc --- /dev/null +++ b/rust/lexical-editor/src/devtools/mod.rs @@ -0,0 +1,10 @@ +//! `@lexical/devtools-core` (the `devtools` feature), from +//! `packages/lexical-devtools-core/src`: [`generate_content`], the text of +//! the playground's tree view of an editor's state, its selection and the +//! editor's settings. Its React parts (`TreeView.tsx`, +//! `useLexicalCommandsLog.ts`) aren't ported; a log of commands is the +//! caller's to keep ([`LexicalCommandLogEntry`]). + +mod generate_content; + +pub use generate_content::{CustomPrintNodeFn, LexicalCommandLogEntry, generate_content}; diff --git a/rust/lexical-editor/src/editor.rs b/rust/lexical-editor/src/editor.rs index 8b610281fd4..922b37f9601 100644 --- a/rust/lexical-editor/src/editor.rs +++ b/rust/lexical-editor/src/editor.rs @@ -4,7 +4,7 @@ use crate::editor_state::{clone_editor_state, create_empty_editor_state}; use crate::error::invariant; use crate::json::{self, Object, Value}; -use crate::lexical_utils::set_node_key; +use crate::lexical_utils::{create_uid, set_node_key}; use crate::node::{LexicalNode, NodeKey, NodeRef}; use crate::node_state::{RequiredNodeStateConfig, SharedNodeState, create_shared_node_state}; use crate::nodes::{ @@ -115,10 +115,12 @@ pub const COMPOSITION_START_TAG: &str = "composition-start"; /// method composition. pub const COMPOSITION_END_TAG: &str = "composition-end"; -/// `CreateEditorArgs`: the node classes to register besides the core ones. +/// `CreateEditorArgs`: the node classes to register besides the core ones, +/// and the editor's `namespace` (a random one, `createUID()`, when empty). #[derive(Clone, Default)] pub struct CreateEditorArgs { pub nodes: Vec, + pub namespace: String, } /// `EditorUpdateOptions`. `onUpdate` and `event` come with their users. @@ -281,11 +283,21 @@ pub(crate) struct EditorInner { next_id: Cell, /// Stands in for the DOM's `Selection.modify` (no JS field). selection_movement: RefCell>>, + /// `_config.namespace`. + namespace: String, + /// `_editable`. + editable: Cell, } /// `createEditor(config)`: registers the core nodes (root, text, line /// break, tab, paragraph) and `args.nodes`. pub fn create_editor(args: CreateEditorArgs) -> Editor { + // `config.namespace || createUID()` (an editor has no parent here). + let namespace = if args.namespace.is_empty() { + create_uid() + } else { + args.namespace + }; let core = [ NodeClass::with_import_json("root", RootNode::import_json), NodeClass::of::(), @@ -322,10 +334,33 @@ pub fn create_editor(args: CreateEditorArgs) -> Editor { commands: RefCell::new(HashMap::new()), next_id: Cell::new(1), selection_movement: RefCell::new(None), + namespace, + editable: Cell::new(true), })) } impl Editor { + /// `LexicalEditor.version`: the port's own, as Swift's is + /// `lexical-swift@`, for the upstream release in + /// [`UPSTREAM_VERSION`](crate::UPSTREAM_VERSION). + pub const VERSION: &'static str = "lexical-rust@0.53.0"; + + /// `_config.namespace`: the namespace the editor was created with. + pub fn get_namespace(&self) -> &str { + &self.0.namespace + } + + /// `isEditable()`. + pub fn is_editable(&self) -> bool { + self.0.editable.get() + } + + /// `setEditable(editable)`. Editable listeners + /// (`registerEditableListener`) aren't ported, so nothing is notified. + pub fn set_editable(&self, editable: bool) { + self.0.editable.set(editable); + } + pub(crate) fn registered_nodes(&self) -> &HashMap<&'static str, NodeClass> { &self.0.nodes } diff --git a/rust/lexical-editor/src/file/file_import_export.rs b/rust/lexical-editor/src/file/file_import_export.rs new file mode 100644 index 00000000000..c2ba3269331 --- /dev/null +++ b/rust/lexical-editor/src/file/file_import_export.rs @@ -0,0 +1,259 @@ +//! `packages/lexical-file/src/fileImportExport.ts`. Upstream reads a file +//! through a DOM `` (`readTextFileFromSystem`) and saves +//! one through a download link (`exportBlob`); here the app passes both in, +//! as Swift's port does, since a native app has its own file panels. + +use crate::json::{self, Object, Value}; +use crate::{CLEAR_HISTORY_COMMAND, Editor, EditorState, LexicalError, UPSTREAM_VERSION}; + +/// `SerializedDocument`: a `.lexical` file's contents. +#[derive(Clone, Debug, PartialEq)] +pub struct SerializedDocument { + /// The serialized editor state produced by `editorState.toJSON()`. + pub editor_state: Value, + /// The time this document was created in epoch milliseconds + /// (`Date.now()`). + pub last_saved: f64, + /// The source of the document, `"Lexical"` by default. + pub source: String, + /// The version of Lexical that produced this document. + pub version: String, +} + +impl SerializedDocument { + /// The document as JS's object literal has it, in its key order. + pub fn to_json(&self) -> Value { + let mut json = Object::new(); + json.insert("editorState", self.editor_state.clone()); + json.insert("lastSaved", self.last_saved.into()); + json.insert("source", self.source.as_str().into()); + json.insert("version", self.version.as_str().into()); + Value::Object(json) + } +} + +/// The config of [`serialized_document_from_editor_state`]: `None`, an +/// empty `source` and a `last_saved` of 0 (or NaN) are left to the +/// defaults, as JS's `||` leaves them. +#[derive(Clone, Debug, Default)] +pub struct SerializedDocumentConfig { + pub source: Option, + pub last_saved: Option, +} + +/// `Date.now()`: the current time in whole epoch milliseconds. +fn date_now() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0.0, |d| d.as_millis() as f64) +} + +/// `serializedDocumentFromEditorState(editorState, config)`: the document of +/// `editor_state`, saved at `config.last_saved` (now by default), from +/// `config.source` (`"Lexical"` by default), with `@lexical/file`'s version, +/// the upstream release the port conforms to. +pub fn serialized_document_from_editor_state( + editor_state: &EditorState, + config: &SerializedDocumentConfig, +) -> SerializedDocument { + SerializedDocument { + editor_state: editor_state.to_json(false), + last_saved: config + .last_saved + .filter(|t| *t != 0.0 && !t.is_nan()) + .unwrap_or_else(date_now), + source: config + .source + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Lexical".to_owned()), + version: UPSTREAM_VERSION.to_owned(), + } +} + +/// What [`editor_state_from_serialized_document`] reads: the contents of a +/// `.lexical` file, as a JSON string or already parsed. +#[derive(Clone, Copy, Debug)] +pub enum MaybeStringifiedDocument<'a> { + Document(&'a SerializedDocument), + String(&'a str), +} + +impl<'a> From<&'a SerializedDocument> for MaybeStringifiedDocument<'a> { + fn from(document: &'a SerializedDocument) -> Self { + Self::Document(document) + } +} + +impl<'a> From<&'a str> for MaybeStringifiedDocument<'a> { + fn from(text: &'a str) -> Self { + Self::String(text) + } +} + +/// `editorStateFromSerializedDocument(editor, maybeStringifiedDocument)`: +/// parses the document's `editorState` with `editor`'s nodes, in either +/// JSON form. A document that doesn't parse, or has no `editorState`, +/// fails as `parseEditorState` does. +pub fn editor_state_from_serialized_document<'a>( + editor: &Editor, + maybe_stringified_document: impl Into>, +) -> Result { + match maybe_stringified_document.into() { + MaybeStringifiedDocument::Document(document) => { + editor.parse_editor_state_json(&document.editor_state) + } + MaybeStringifiedDocument::String(text) => { + let json = json::parse(text).map_err(|e| LexicalError::new(e.to_string()))?; + editor.parse_editor_state_json(json.get("editorState").unwrap_or(&Value::Undefined)) + } + } +} + +/// What reads a file the user picks (upstream's `readTextFileFromSystem`): +/// called with the file names to accept (`".lexical"`) and the callback to +/// call with the file's text, read as UTF-8. The app decides how to report +/// an error the callback returns, such as a file that isn't a document. +pub type ReadTextFileCallback = Box Result<(), LexicalError>>; + +/// `importFile(editor)`: lets the user pick a `.lexical` file through +/// `read_text_file_from_system`, then sets the editor's state to it and +/// clears its history (`CLEAR_HISTORY_COMMAND`). +pub fn import_file( + editor: &Editor, + read_text_file_from_system: impl FnOnce(&str, ReadTextFileCallback), +) { + let editor = editor.clone(); + read_text_file_from_system( + ".lexical", + Box::new(move |text| { + editor + .set_editor_state(&editor_state_from_serialized_document(&editor, text)?, None)?; + editor.dispatch_command(&CLEAR_HISTORY_COMMAND, ())?; + Ok(()) + }), + ) +} + +/// The config of [`export_file`]: `None` or empty values are left to the +/// defaults. +#[derive(Clone, Debug, Default)] +pub struct ExportFileConfig { + /// The file's name without `.lexical`; the time saved + /// (`toISOString()`) by default. + pub file_name: Option, + /// The document's source, `"Lexical"` by default. + pub source: Option, +} + +/// `exportFile(editor, config)`: the editor's state as a `.lexical` +/// document saved now, passed to `export_blob` (upstream's `exportBlob`, +/// which downloads it) as `JSON.stringify` writes it, with the file name +/// `.lexical`. +pub fn export_file( + editor: &Editor, + config: &ExportFileConfig, + export_blob: impl FnOnce(&str, &str), +) { + let now = date_now(); + let serialized_document = serialized_document_from_editor_state( + &editor.get_editor_state(), + &SerializedDocumentConfig { + source: config.source.clone(), + last_saved: Some(now), + }, + ); + let file_name = config + .file_name + .clone() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| to_iso_string(now)); + export_blob( + &serialized_document.to_json().to_string(), + &format!("{file_name}.lexical"), + ); +} + +/// `new Date(time).toISOString()` for a time from 1970 to 9999: +/// `YYYY-MM-DDTHH:mm:ss.sssZ` in UTC. +fn to_iso_string(time: f64) -> String { + let ms = time as i64; + let days = ms.div_euclid(86_400_000); + let in_day = ms.rem_euclid(86_400_000); + // `civil_from_days` (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z", + in_day / 3_600_000, + in_day / 60_000 % 60, + in_day / 1000 % 60, + in_day % 1000, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CreateEditorArgs, create_editor, get_root}; + use std::cell::RefCell; + use std::rc::Rc; + + #[test] + fn to_iso_string_matches_js() { + // `new Date(t).toISOString()` in Node. + for (time, iso) in [ + (1762401787158.0, "2025-11-06T04:03:07.158Z"), + (0.0, "1970-01-01T00:00:00.000Z"), + (951782400000.0, "2000-02-29T00:00:00.000Z"), + (4102444799999.0, "2099-12-31T23:59:59.999Z"), + (1.5, "1970-01-01T00:00:00.001Z"), + ] { + assert_eq!(to_iso_string(time), iso); + } + } + + #[test] + fn export_then_import_a_file() { + let editor = create_editor(CreateEditorArgs::default()); + let state = editor + .parse_editor_state(r#"{"root":{"type":"root","children":[{"type":"paragraph","children":[{"type":"text","text":"Hi"}]}]}}"#) + .unwrap(); + editor.set_editor_state(&state, None).unwrap(); + let saved = Rc::new(RefCell::new(None)); + let into = saved.clone(); + export_file( + &editor, + &ExportFileConfig { + file_name: Some("notes".to_owned()), + source: None, + }, + move |text, name| *into.borrow_mut() = Some((text.to_owned(), name.to_owned())), + ); + let (text, name) = saved.borrow_mut().take().unwrap(); + assert_eq!(name, "notes.lexical"); + let document = json::parse(&text).unwrap(); + assert_eq!( + document.get("source").and_then(Value::as_str), + Some("Lexical") + ); + assert_eq!( + document.get("version").and_then(Value::as_str), + Some(UPSTREAM_VERSION) + ); + + let other = create_editor(CreateEditorArgs::default()); + import_file(&other, |accept, callback| { + assert_eq!(accept, ".lexical"); + callback(&text).unwrap(); + }); + assert_eq!(other.read(|| get_root().get_text_content()).unwrap(), "Hi"); + } +} diff --git a/rust/lexical-editor/src/file/mod.rs b/rust/lexical-editor/src/file/mod.rs new file mode 100644 index 00000000000..00eb0ef848e --- /dev/null +++ b/rust/lexical-editor/src/file/mod.rs @@ -0,0 +1,13 @@ +//! `@lexical/file` (the `file` feature), from `packages/lexical-file/src`: +//! the `.lexical` document format ([`serialized_document_from_editor_state`], +//! [`editor_state_from_serialized_document`]) and importing and exporting +//! files ([`import_file`], [`export_file`]), where the app picks and saves +//! the file, as upstream's DOM file input and download link do. + +mod file_import_export; + +pub use file_import_export::{ + ExportFileConfig, MaybeStringifiedDocument, ReadTextFileCallback, SerializedDocument, + SerializedDocumentConfig, editor_state_from_serialized_document, export_file, import_file, + serialized_document_from_editor_state, +}; diff --git a/rust/lexical-editor/src/lexical_utils.rs b/rust/lexical-editor/src/lexical_utils.rs index d932c321e48..7f1d13000f3 100644 --- a/rust/lexical-editor/src/lexical_utils.rs +++ b/rust/lexical-editor/src/lexical_utils.rs @@ -24,6 +24,22 @@ pub fn reset_random_key() { KEY_COUNTER.with(|c| c.set(1)); } +/// `createUID()`: five random lowercase letters, as JS takes them from +/// `Math.random().toString(36)` (which can give fewer). +pub(crate) fn create_uid() -> String { + use std::hash::{BuildHasher, Hasher}; + let mut bits = std::collections::hash_map::RandomState::new() + .build_hasher() + .finish(); + (0..5) + .map(|_| { + let letter = b'a' + (bits % 26) as u8; + bits /= 26; + char::from(letter) + }) + .collect() +} + /// `generateRandomKey()`. pub(crate) fn generate_random_key() -> String { KEY_COUNTER.with(|c| { diff --git a/rust/lexical-editor/src/lib.rs b/rust/lexical-editor/src/lib.rs index ebe6f9e1f4e..5d85599c547 100644 --- a/rust/lexical-editor/src/lib.rs +++ b/rust/lexical-editor/src/lib.rs @@ -51,11 +51,15 @@ mod caret_utils; pub mod code; mod commands; pub mod constants; +#[cfg(feature = "devtools")] +pub mod devtools; mod editor; mod editor_state; mod error; #[cfg(feature = "extension")] pub mod extension; +#[cfg(feature = "file")] +pub mod file; mod gc; mod generated_json; #[cfg(feature = "hashtag")] diff --git a/rust/lexical-editor/src/node.rs b/rust/lexical-editor/src/node.rs index bb427fa1b64..157c494888d 100644 --- a/rust/lexical-editor/src/node.rs +++ b/rust/lexical-editor/src/node.rs @@ -1,6 +1,9 @@ //! `LexicalNode.ts`: the node trait every node implements, and [`NodeRef`], //! the handle through which code outside a node reads and changes one. +use crate::caret::{ + CommonAncestorResult, get_common_ancestor, get_common_ancestor_result_branch_order, +}; use crate::caret_tree::{ detach_node_with_selection, error_on_insert_text_node_on_root, insert_node_between, insert_sibling, select_adjacent_node, @@ -18,6 +21,7 @@ use crate::nodes::text::TextData; use crate::updates::{error_on_read_only, get_active_editor_state}; use crate::{LexicalError, json::Object, utf16}; use std::any::Any; +use std::cmp::Ordering; use std::fmt; use std::rc::Rc; @@ -653,6 +657,76 @@ impl NodeRef { false } + /// `isSelected(selection)`: whether the node is among the nodes of + /// `selection`, or of the current selection when `None`. An element or + /// decorator isn't selected by a collapsed range between children, and + /// an inline decorator that is its parent's last child isn't selected by + /// an element range starting at the parent's end. + pub fn is_selected(&self, selection: Option<&SelectionRef>) -> Result { + let current; + let target_selection = match selection { + Some(selection) => selection, + None => match get_selection() { + Some(selection) => { + current = selection; + ¤t + } + None => return Ok(false), + }, + }; + // `$isKeyInSelectionNodes(targetSelection, this.__key)`. + let is_selected = target_selection + .get_nodes()? + .iter() + .any(|node| node.get_key() == self.key); + if !is_selected || self.is_text() { + return Ok(is_selected); + } + // For inline images inside of element nodes. + // Without this change the image will be selected if the cursor is + // before or after it. + if let Some(range) = target_selection.range() + && range.anchor.point_type == PointType::Element + && range.focus.point_type == PointType::Element + { + if range.is_collapsed() { + return Ok(false); + } + let parent_node = self.get_parent(); + if let Some(parent_node) = + parent_node.filter(|_| self.is_decorator() && self.is_inline()) + { + let first_point = if target_selection.is_backward()? { + &range.focus + } else { + &range.anchor + }; + if first_point.key == parent_node.get_key() + && first_point.offset == parent_node.get_children_size() + && parent_node.get_last_child().as_ref() == Some(self) + { + return Ok(false); + } + } + } + Ok(is_selected) + } + + /// `isBefore(targetNode)`: whether this node comes before `target` in + /// the document. + pub fn is_before(&self, target: &NodeRef) -> bool { + match get_common_ancestor(self, target) { + None => false, + Some(CommonAncestorResult::Descendant { .. }) => true, + Some(CommonAncestorResult::Branch { a, b, .. }) => { + get_common_ancestor_result_branch_order(&a, &b) == Ordering::Less + } + Some(CommonAncestorResult::Same { .. } | CommonAncestorResult::Ancestor { .. }) => { + false + } + } + } + /// `isSelected()` for a node selection. Range selections come with /// `getNodes`. fn is_selected_by_node_selection(&self, selection: &SelectionRef) -> bool { diff --git a/rust/lexical-editor/src/node_state.rs b/rust/lexical-editor/src/node_state.rs index c1878d504b1..7f0b690045a 100644 --- a/rust/lexical-editor/src/node_state.rs +++ b/rust/lexical-editor/src/node_state.rs @@ -1056,13 +1056,13 @@ pub(crate) fn carry_node_state(from: &NodeRef, to: &NodeRef) -> Result<(), Lexic /// The value of a known config for a JSON read, as `unparse` writes it /// (what `registerLexicalTextEntity`'s `$carryNodeState` needs). -#[cfg(feature = "text")] +#[cfg(any(feature = "text", feature = "devtools"))] pub(crate) fn unparse(config: &AnyStateConfig, value: &Rc) -> Value { config.0.unparse_any(&**value) } /// Whether `value` is the default of `config`. -#[cfg(feature = "text")] +#[cfg(any(feature = "text", feature = "devtools"))] pub(crate) fn is_default(config: &AnyStateConfig, value: &Rc) -> bool { config.0.is_equal_any(&**value, &*config.0.default_any()) } diff --git a/rust/lexical-editor/src/text/can_show_placeholder.rs b/rust/lexical-editor/src/text/can_show_placeholder.rs new file mode 100644 index 00000000000..98e029b7c94 --- /dev/null +++ b/rust/lexical-editor/src/text/can_show_placeholder.rs @@ -0,0 +1,55 @@ +//! `packages/lexical-text/src/canShowPlaceholder.ts`. + +use super::is_root_text_content_empty; +use crate::get_root; +use crate::nodes::paragraph::ParagraphNode; + +/// `$canShowPlaceholder(isComposing)`: whether the input should show its +/// placeholder: the root has no text and at most one child, which is not a +/// decorator and, if an element, an unindented paragraph of text nodes. +pub fn can_show_placeholder(is_composing: bool) -> bool { + if !is_root_text_content_empty(is_composing, false) { + return false; + } + + let root = get_root(); + let children = root.get_children(); + let children_length = children.len(); + + if children_length > 1 { + return false; + } + + for top_block in &children { + if top_block.is_decorator() { + return false; + } + + let latest = top_block.get_latest(); + if let Some(element) = latest.element() { + if !latest.is::() { + return false; + } + + if element.get_indent() != 0.0 { + return false; + } + + if top_block + .get_children() + .iter() + .any(|child| !child.is_text()) + { + return false; + } + } + } + + true +} + +/// `$canShowPlaceholderCurry(isEditorComposing)`: a function that runs +/// [`can_show_placeholder`] with it. +pub fn can_show_placeholder_curry(is_editor_composing: bool) -> impl Fn() -> bool { + move || can_show_placeholder(is_editor_composing) +} diff --git a/rust/lexical-editor/src/text/find_text_intersection_from_characters.rs b/rust/lexical-editor/src/text/find_text_intersection_from_characters.rs new file mode 100644 index 00000000000..0995dc83254 --- /dev/null +++ b/rust/lexical-editor/src/text/find_text_intersection_from_characters.rs @@ -0,0 +1,52 @@ +//! `packages/lexical-text/src/findTextIntersectionFromCharacters.ts`. + +use super::TextNodeWithOffset; +use crate::NodeRef; + +/// `$findTextIntersectionFromCharacters(root, targetCharacters)`: the first +/// text node, in document order, whose text takes the count of the text +/// nodes' characters (UTF-16 code units) past `target_characters`, and the +/// offset of that character in it; `None` when there's no such node. +/// +/// Deprecated upstream: "has never worked correctly and will be removed". +pub fn find_text_intersection_from_characters( + root: &NodeRef, + target_characters: usize, +) -> Option { + let mut node = root.get_first_child(); + let mut current_characters = 0; + + 'main_loop: while let Some(current) = node { + if current.is_element() { + if let Some(child) = current.get_first_child() { + node = Some(child); + continue; + } + } else if current.is_text() { + let characters = current.get_text_content_size(); + + if current_characters + characters > target_characters { + return Some(TextNodeWithOffset { + node: current, + offset: target_characters - current_characters, + }); + } + current_characters += characters; + } + if let Some(sibling) = current.get_next_sibling() { + node = Some(sibling); + continue; + } + let mut parent = current.get_parent(); + while let Some(p) = parent { + if let Some(parent_sibling) = p.get_next_sibling() { + node = Some(parent_sibling); + continue 'main_loop; + } + parent = p.get_parent(); + } + break; + } + + None +} diff --git a/rust/lexical-editor/src/text/is_root_text_content_empty.rs b/rust/lexical-editor/src/text/is_root_text_content_empty.rs new file mode 100644 index 00000000000..dc9fe27131c --- /dev/null +++ b/rust/lexical-editor/src/text/is_root_text_content_empty.rs @@ -0,0 +1,30 @@ +//! `packages/lexical-text/src/isRootTextContentEmpty.ts`. + +use super::root_text_content; +use crate::selection_deletion::is_js_whitespace; + +/// `$isRootTextContentEmpty(isEditorComposing, trim)`: whether the root has +/// no text, or only whitespace when `trim` (JS's default; `String#trim`'s +/// whitespace). `false` while composing. +pub fn is_root_text_content_empty(is_editor_composing: bool, trim: bool) -> bool { + if is_editor_composing { + return false; + } + let text = root_text_content(); + let text = if trim { + text.trim_matches(is_js_whitespace) + } else { + &text + }; + text.is_empty() +} + +/// `$isRootTextContentEmptyCurry(isEditorComposing, trim)`: a function that +/// runs [`is_root_text_content_empty`] with these arguments (JS's `trim` +/// left `undefined` is `true`). +pub fn is_root_text_content_empty_curry( + is_editor_composing: bool, + trim: bool, +) -> impl Fn() -> bool { + move || is_root_text_content_empty(is_editor_composing, trim) +} diff --git a/rust/lexical-editor/src/text/mod.rs b/rust/lexical-editor/src/text/mod.rs index cce829928d4..2acbc9163d0 100644 --- a/rust/lexical-editor/src/text/mod.rs +++ b/rust/lexical-editor/src/text/mod.rs @@ -1,13 +1,29 @@ //! `@lexical/text` (the `text` feature), from `packages/lexical-text/src`: //! [`register_lexical_text_entity`], the transforms that make text entities //! (such as hashtags) of the text a matcher finds, and turn them back into -//! text when an edit leaves them no match. -//! -//! Not ported: the rest of the package (`$canShowPlaceholder`, -//! `$isRootTextContentEmpty`, `$rootTextContent`, -//! `$findTextIntersectionFromCharacters`), which no vector exercises yet -//! (rust/PARITY.md). +//! text when an edit leaves them no match; and the root helpers +//! ([`root_text_content`], [`is_root_text_content_empty`], +//! [`can_show_placeholder`], [`find_text_intersection_from_characters`]). +mod can_show_placeholder; +mod find_text_intersection_from_characters; +mod is_root_text_content_empty; mod register_lexical_text_entity; +mod root_text_content; +use crate::NodeRef; + +pub use can_show_placeholder::{can_show_placeholder, can_show_placeholder_curry}; +pub use find_text_intersection_from_characters::find_text_intersection_from_characters; +pub use is_root_text_content_empty::{ + is_root_text_content_empty, is_root_text_content_empty_curry, +}; pub use register_lexical_text_entity::{EntityMatch, register_lexical_text_entity}; +pub use root_text_content::root_text_content; + +/// `TextNodeWithOffset`: a text node and a UTF-16 offset in it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNodeWithOffset { + pub node: NodeRef, + pub offset: usize, +} diff --git a/rust/lexical-editor/src/text/root_text_content.rs b/rust/lexical-editor/src/text/root_text_content.rs new file mode 100644 index 00000000000..ceec6dcef85 --- /dev/null +++ b/rust/lexical-editor/src/text/root_text_content.rs @@ -0,0 +1,9 @@ +//! `packages/lexical-text/src/rootTextContent.ts`. + +use crate::get_root; + +/// `$rootTextContent()`: the root's text content. +pub fn root_text_content() -> String { + let root = get_root(); + root.get_text_content() +} diff --git a/swift/Tests/ConformanceTests/Ops.swift b/swift/Tests/ConformanceTests/Ops.swift index d21eeefc2f9..73b3dad3153 100644 --- a/swift/Tests/ConformanceTests/Ops.swift +++ b/swift/Tests/ConformanceTests/Ops.swift @@ -10,6 +10,7 @@ import LexicalOverflow import LexicalPlainText import LexicalRichText import LexicalSelection +import LexicalText // The Swift interpreter of the script vectors that // packages/lexical-conformance/src/ops.ts writes. It must do exactly what @@ -593,6 +594,34 @@ private func runAction(_ action: JSONValue, _ ctx: ScriptContext) throws { try setBlocksType(getSelection()) { try createHeadingNode(tag) } case let block: throw ScriptError("unknown block \(String(describing: block))") } + case "logRootTextContent": + ctx.log.append(.array([.string("rootTextContent"), .string(try rootTextContent())])) + case "logRootTextContentEmpty": + // `trim` absent is `undefined` in JS, so the default (`true`) applies. + let isComposing = try action["isComposing"]?.boolValue.orThrow() ?? false + let trim = action["trim"]?.boolValue + ctx.log.append( + .array([ + .string("rootTextContentEmpty"), .bool(isComposing), trim.map(JSONValue.bool) ?? .null, + .bool(try isRootTextContentEmpty(isComposing, trim: trim ?? true)), + .bool(try isRootTextContentEmptyCurry(isComposing, trim: trim ?? true)()), + ])) + case "logCanShowPlaceholder": + let isComposing = try action["isComposing"]?.boolValue.orThrow() ?? false + ctx.log.append( + .array([ + .string("canShowPlaceholder"), .bool(isComposing), + .bool(try canShowPlaceholder(isComposing)), + .bool(try canShowPlaceholderCurry(isComposing)()), + ])) + case "logTextIntersection": + let characters = try action["characters"]?.int.orThrow() ?? 0 + let found = try findTextIntersectionFromCharacters(getRoot(), characters) + ctx.log.append( + .array([ + .string("textIntersection"), .number(Double(characters)), + found.map { .array([.string($0.node.getKey()), .number(Double($0.offset))]) } ?? .null, + ])) case "dispatch": let name = try action["command"]?.stringValue.orThrow() ?? "" let handled = try ctx.dispatch(name, action["payload"] ?? .null) diff --git a/swift/Tests/ConformanceTests/ScriptTests.swift b/swift/Tests/ConformanceTests/ScriptTests.swift index 96daa3b1aa9..10555b9469b 100644 --- a/swift/Tests/ConformanceTests/ScriptTests.swift +++ b/swift/Tests/ConformanceTests/ScriptTests.swift @@ -19,7 +19,7 @@ struct ScriptTests { "scripts/listeners", "scripts/caret", "scripts/rich-text", "scripts/rich-text-commands", "scripts/plain-text", "scripts/history", "scripts/list", "scripts/link", "scripts/autolink", "scripts/code", "scripts/overflow", "scripts/node-state", - "scripts/set-blocks-type", "scripts/decorator", "fuzz/core", + "scripts/set-blocks-type", "scripts/decorator", "scripts/text", "fuzz/core", ] static let cases: [Case] = files.flatMap { file in