feat(editor): save leptos island and page aggregate alignment progress

- switch main document flow toward leptos tiptap island host and generated runtime assets

- align page aggregate loading, page head single-source updates, and AI tool result recovery

- add tests and smoke scripts for title sync, AI route recovery, and editor host cutover
This commit is contained in:
lix-2026
2026-04-22 05:57:06 +08:00
parent 5d1c94eb9e
commit 8353aea2f9
105 changed files with 14768 additions and 4186 deletions
+1
View File
@@ -62,3 +62,4 @@ design
# pnpm 本地缓存 # pnpm 本地缓存
/.pnpm-store/ /.pnpm-store/
.playwright-mcp
+1
View File
@@ -976,6 +976,7 @@ dependencies = [
"leptos-tiptap", "leptos-tiptap",
"leptos_dom", "leptos_dom",
"serde", "serde",
"serde-wasm-bindgen",
"serde_json", "serde_json",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
+10 -1
View File
@@ -5,15 +5,24 @@ edition = "2021"
[workspace] [workspace]
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "mnote-leptos-tiptap-spike"
path = "src/main.rs"
[dependencies] [dependencies]
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
leptos = { version = "0.8.19", features = ["csr"] } leptos = { version = "0.8.19", features = ["csr"] }
leptos_dom = "0.8.8" leptos_dom = "0.8.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde-wasm-bindgen = "0.6"
js-sys = "0.3.77" js-sys = "0.3.77"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
web-sys = { version = "0.3.77", features = ["DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MouseEvent", "Node", "Range", "Selection", "Storage", "Window"] } web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MessageEvent", "MouseEvent", "Node", "Range", "Selection", "Storage", "Window"] }
[dependencies.leptos-tiptap] [dependencies.leptos-tiptap]
path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap" path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap"
@@ -0,0 +1,102 @@
/* tslint:disable */
/* eslint-disable */
/**
* The `ReadableStreamType` enum.
*
* *This API requires the following crate features to be activated: `ReadableStreamType`*
*/
type ReadableStreamType = "bytes";
export class IntoUnderlyingByteSource {
private constructor();
free(): void;
[Symbol.dispose](): void;
cancel(): void;
pull(controller: ReadableByteStreamController): Promise<any>;
start(controller: ReadableByteStreamController): void;
readonly autoAllocateChunkSize: number;
readonly type: ReadableStreamType;
}
export class IntoUnderlyingSink {
private constructor();
free(): void;
[Symbol.dispose](): void;
abort(reason: any): Promise<any>;
close(): Promise<any>;
write(chunk: any): Promise<any>;
}
export class IntoUnderlyingSource {
private constructor();
free(): void;
[Symbol.dispose](): void;
cancel(): void;
pull(controller: ReadableStreamDefaultController): Promise<any>;
}
export function mount(container: Element, options: any): number;
export function unmount(mount_id: number): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly mount: (a: any, b: any) => [number, number, number];
readonly unmount: (a: number) => [number, number];
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
readonly intounderlyingbytesource_type: (a: number) => number;
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
readonly intounderlyingbytesource_cancel: (a: number) => void;
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
readonly intounderlyingsink_write: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
readonly intounderlyingsource_pull: (a: number, b: any) => any;
readonly intounderlyingsource_cancel: (a: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_destroy_closure: (a: number, b: number) => void;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -0,0 +1,36 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const mount: (a: any, b: any) => [number, number, number];
export const unmount: (a: number) => [number, number];
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
export const intounderlyingbytesource_type: (a: number) => number;
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
export const intounderlyingbytesource_start: (a: number, b: any) => void;
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
export const intounderlyingbytesource_cancel: (a: number) => void;
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
export const intounderlyingsink_write: (a: number, b: any) => any;
export const intounderlyingsink_close: (a: number) => any;
export const intounderlyingsink_abort: (a: number, b: any) => any;
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
export const intounderlyingsource_pull: (a: number, b: any) => any;
export const intounderlyingsource_cancel: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __externref_table_alloc: () => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_exn_store: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_destroy_closure: (a: number, b: number) => void;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var x=t.CommandManager,m=t.Editor,k=t.Extension,b=t.InputRule,y=t.Mark,r=t.Node,h=t.NodePos,E=t.NodeView,R=t.PasteRule,f=t.Tracker,P=t.callOrReturn,C=t.canInsertNode,T=t.combineTransactionSteps,A=t.createChainableState,S=t.createDocument,B=t.createNodeFromContent,N=t.createStyleTag,O=t.defaultBlockAt,w=t.deleteProps,v=t.elementFromString,M=t.escapeForRegEx,I=t.extensions,q=t.findChildren,F=t.findChildrenInRange,D=t.findDuplicates,j=t.findParentNode,L=t.findParentNodeClosestToPos,K=t.fromString,H=t.generateHTML,G=t.generateJSON,z=t.generateText,J=t.getAttributes,U=t.getAttributesFromExtensions,V=t.getChangedRanges,Y=t.getDebugJSON,W=t.getExtensionField,$=t.getHTMLFromFragment,Q=t.getMarkAttributes,X=t.getMarkRange,Z=t.getMarkType,tt=t.getMarksBetween,et=t.getNodeAtPosition,nt=t.getNodeAttributes,ot=t.getNodeType,it=t.getRenderedAttributes,rt=t.getSchema,st=t.getSchemaByResolvedExtensions,lt=t.getSchemaTypeByName,at=t.getSchemaTypeNameByName,dt=t.getSplittedAttributes,pt=t.getText,ct=t.getTextBetween,ut=t.getTextContentFromNodes,gt=t.getTextSerializersFromSchema,_t=t.injectExtensionAttributesToParseRule,xt=t.inputRulesPlugin,mt=t.isActive,kt=t.isAtEndOfNode,bt=t.isAtStartOfNode,yt=t.isEmptyObject,ht=t.isExtensionRulesEnabled,Et=t.isFunction,Rt=t.isList,ft=t.isMacOS,Pt=t.isMarkActive,Ct=t.isNodeActive,Tt=t.isNodeEmpty,At=t.isNodeSelection,St=t.isNumber,Bt=t.isPlainObject,Nt=t.isRegExp,Ot=t.isSafari,wt=t.isString,vt=t.isTextSelection,Mt=t.isiOS,It=t.markInputRule,qt=t.markPasteRule,s=t.mergeAttributes,Ft=t.mergeDeep,Dt=t.minMax,jt=t.nodeInputRule,Lt=t.nodePasteRule,Kt=t.objectIncludes,Ht=t.pasteRulesPlugin,Gt=t.posToDOMRect,zt=t.removeDuplicates,Jt=t.resolveFocusPosition,Ut=t.rewriteUnknownContent,Vt=t.selectionToInsertionEnd,Yt=t.splitExtensions,Wt=t.textInputRule,$t=t.textPasteRule,Qt=t.textblockTypeInputRule,l=t.wrappingInputRule;var u=/^\s*>\s$/,a=r.create({name:"blockquote",addOptions(){return{HTMLAttributes:{}}},content:"block+",group:"block",defining:!0,parseHTML(){return[{tag:"blockquote"}]},renderHTML({HTMLAttributes:e}){return["blockquote",s(this.options.HTMLAttributes,e),0]},addCommands(){return{setBlockquote:()=>({commands:e})=>e.wrapIn(this.name),toggleBlockquote:()=>({commands:e})=>e.toggleWrap(this.name),unsetBlockquote:()=>({commands:e})=>e.lift(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-b":()=>this.editor.commands.toggleBlockquote()}},addInputRules(){return[l({find:u,type:this.type})]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function g(){let e=globalThis,o=e[d];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[d]=i,i}function p(){return g()}function c(e){p().registerExtension(e)}var _={name:"blockquote",create:()=>a,commands:{set_blockquote:e=>e.chain().focus().setBlockquote().run(),toggle_blockquote:e=>e.chain().focus().toggleBlockquote().run(),unset_blockquote:e=>e.chain().focus().unsetBlockquote().run()},selection_keys:["blockquote"],selection_state:e=>({blockquote:e.isActive("blockquote")})};function se(){c(_)}export{se as register_blockquote};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var y=t.CommandManager,h=t.Editor,R=t.Extension,f=t.InputRule,l=t.Mark,E=t.Node,P=t.NodePos,A=t.NodeView,C=t.PasteRule,T=t.Tracker,S=t.callOrReturn,B=t.canInsertNode,N=t.combineTransactionSteps,O=t.createChainableState,w=t.createDocument,M=t.createNodeFromContent,v=t.createStyleTag,I=t.defaultBlockAt,F=t.deleteProps,D=t.elementFromString,j=t.escapeForRegEx,L=t.extensions,K=t.findChildren,H=t.findChildrenInRange,q=t.findDuplicates,G=t.findParentNode,z=t.findParentNodeClosestToPos,J=t.fromString,$=t.generateHTML,U=t.generateJSON,V=t.generateText,Y=t.getAttributes,W=t.getAttributesFromExtensions,Q=t.getChangedRanges,X=t.getDebugJSON,Z=t.getExtensionField,tt=t.getHTMLFromFragment,et=t.getMarkAttributes,nt=t.getMarkRange,ot=t.getMarkType,rt=t.getMarksBetween,it=t.getNodeAtPosition,st=t.getNodeAttributes,lt=t.getNodeType,at=t.getRenderedAttributes,dt=t.getSchema,pt=t.getSchemaByResolvedExtensions,ct=t.getSchemaTypeByName,ut=t.getSchemaTypeNameByName,gt=t.getSplittedAttributes,_t=t.getText,xt=t.getTextBetween,mt=t.getTextContentFromNodes,kt=t.getTextSerializersFromSchema,bt=t.injectExtensionAttributesToParseRule,yt=t.inputRulesPlugin,ht=t.isActive,Rt=t.isAtEndOfNode,ft=t.isAtStartOfNode,Et=t.isEmptyObject,Pt=t.isExtensionRulesEnabled,At=t.isFunction,Ct=t.isList,Tt=t.isMacOS,St=t.isMarkActive,Bt=t.isNodeActive,Nt=t.isNodeEmpty,Ot=t.isNodeSelection,wt=t.isNumber,Mt=t.isPlainObject,vt=t.isRegExp,It=t.isSafari,Ft=t.isString,Dt=t.isTextSelection,jt=t.isiOS,o=t.markInputRule,r=t.markPasteRule,a=t.mergeAttributes,Lt=t.mergeDeep,Kt=t.minMax,Ht=t.nodeInputRule,qt=t.nodePasteRule,Gt=t.objectIncludes,zt=t.pasteRulesPlugin,Jt=t.posToDOMRect,$t=t.removeDuplicates,Ut=t.resolveFocusPosition,Vt=t.rewriteUnknownContent,Yt=t.selectionToInsertionEnd,Wt=t.splitExtensions,Qt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,te=t.wrappingInputRule;var g=/(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/,_=/(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))/g,x=/(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/,m=/(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))/g,d=l.create({name:"bold",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"strong"},{tag:"b",getAttrs:e=>e.style.fontWeight!=="normal"&&null},{style:"font-weight=400",clearMark:e=>e.type.name===this.name},{style:"font-weight",getAttrs:e=>/^(bold(er)?|[5-9]\d{2,})$/.test(e)&&null}]},renderHTML({HTMLAttributes:e}){return["strong",a(this.options.HTMLAttributes,e),0]},addCommands(){return{setBold:()=>({commands:e})=>e.setMark(this.name),toggleBold:()=>({commands:e})=>e.toggleMark(this.name),unsetBold:()=>({commands:e})=>e.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-b":()=>this.editor.commands.toggleBold(),"Mod-B":()=>this.editor.commands.toggleBold()}},addInputRules(){return[o({find:g,type:this.type}),o({find:x,type:this.type})]},addPasteRules(){return[r({find:_,type:this.type}),r({find:m,type:this.type})]}});var p="__LEPTOS_TIPTAP_BRIDGE__";function k(){let e=globalThis,i=e[p];if(i!=null)return i;let s={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[p]=s,s}function c(){return k()}function u(e){c().registerExtension(e)}var b={name:"bold",create:()=>d,commands:{set_bold:e=>e.chain().focus().setBold().run(),toggle_bold:e=>e.chain().focus().toggleBold().run(),unset_bold:e=>e.chain().focus().unsetBold().run()},selection_keys:["bold"],selection_state:e=>({bold:e.isActive("bold")})};function de(){u(b)}export{de as register_bold};
@@ -0,0 +1 @@
var o=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(o==null||o.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=o.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,y=t.Extension,h=t.InputRule,E=t.Mark,s=t.Node,R=t.NodePos,f=t.NodeView,A=t.PasteRule,T=t.Tracker,P=t.callOrReturn,C=t.canInsertNode,S=t.combineTransactionSteps,N=t.createChainableState,B=t.createDocument,O=t.createNodeFromContent,w=t.createStyleTag,M=t.defaultBlockAt,v=t.deleteProps,I=t.elementFromString,F=t.escapeForRegEx,D=t.extensions,L=t.findChildren,j=t.findChildrenInRange,K=t.findDuplicates,H=t.findParentNode,q=t.findParentNodeClosestToPos,G=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,$=t.getChangedRanges,Q=t.getDebugJSON,W=t.getExtensionField,X=t.getHTMLFromFragment,Z=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,it=t.getNodeAttributes,rt=t.getNodeType,st=t.getRenderedAttributes,lt=t.getSchema,at=t.getSchemaByResolvedExtensions,pt=t.getSchemaTypeByName,dt=t.getSchemaTypeNameByName,ut=t.getSplittedAttributes,ct=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ht=t.isAtStartOfNode,Et=t.isEmptyObject,Rt=t.isExtensionRulesEnabled,ft=t.isFunction,At=t.isList,Tt=t.isMacOS,Pt=t.isMarkActive,Ct=t.isNodeActive,St=t.isNodeEmpty,Nt=t.isNodeSelection,Bt=t.isNumber,Ot=t.isPlainObject,wt=t.isRegExp,Mt=t.isSafari,vt=t.isString,It=t.isTextSelection,Ft=t.isiOS,Dt=t.markInputRule,Lt=t.markPasteRule,l=t.mergeAttributes,jt=t.mergeDeep,Kt=t.minMax,Ht=t.nodeInputRule,qt=t.nodePasteRule,Gt=t.objectIncludes,zt=t.pasteRulesPlugin,Jt=t.posToDOMRect,Ut=t.removeDuplicates,Vt=t.resolveFocusPosition,Yt=t.rewriteUnknownContent,$t=t.selectionToInsertionEnd,Qt=t.splitExtensions,Wt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,i=t.wrappingInputRule;var _="listItem",a="textStyle",p=/^\s*([-+*])\s$/,d=s.create({name:"bulletList",addOptions(){return{itemTypeName:"listItem",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:"block list",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:"ul"}]},renderHTML({HTMLAttributes:e}){return["ul",l(this.options.HTMLAttributes,e),0]},addCommands(){return{toggleBulletList:()=>({commands:e,chain:n})=>this.options.keepAttributes?n().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(_,this.editor.getAttributes(a)).run():e.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{"Mod-Shift-8":()=>this.editor.commands.toggleBulletList()}},addInputRules(){let e=i({find:p,type:this.type});return(this.options.keepMarks||this.options.keepAttributes)&&(e=i({find:p,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:()=>this.editor.getAttributes(a),editor:this.editor})),[e]}});var u="__LEPTOS_TIPTAP_BRIDGE__";function x(){let e=globalThis,n=e[u];if(n!=null)return n;let r={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[u]=r,r}function c(){return x()}function g(e){c().registerExtension(e)}var m={name:"bullet_list",create:()=>d,commands:{toggle_bullet_list:e=>e.chain().focus().toggleBulletList().run()},selection_keys:["bullet_list"],selection_state:e=>({bullet_list:e.isActive("bulletList")})};function ae(){g(m)}export{ae as register_bullet_list};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,y=t.Extension,h=t.InputRule,i=t.Mark,R=t.Node,E=t.NodePos,f=t.NodeView,C=t.PasteRule,P=t.Tracker,T=t.callOrReturn,A=t.canInsertNode,S=t.combineTransactionSteps,N=t.createChainableState,O=t.createDocument,B=t.createNodeFromContent,w=t.createStyleTag,M=t.defaultBlockAt,v=t.deleteProps,I=t.elementFromString,F=t.escapeForRegEx,D=t.extensions,j=t.findChildren,L=t.findChildrenInRange,K=t.findDuplicates,H=t.findParentNode,q=t.findParentNodeClosestToPos,G=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,Q=t.getChangedRanges,W=t.getDebugJSON,X=t.getExtensionField,Z=t.getHTMLFromFragment,$=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,rt=t.getNodeAttributes,it=t.getNodeType,st=t.getRenderedAttributes,at=t.getSchema,lt=t.getSchemaByResolvedExtensions,dt=t.getSchemaTypeByName,pt=t.getSchemaTypeNameByName,ct=t.getSplittedAttributes,ut=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ht=t.isAtStartOfNode,Rt=t.isEmptyObject,Et=t.isExtensionRulesEnabled,ft=t.isFunction,Ct=t.isList,Pt=t.isMacOS,Tt=t.isMarkActive,At=t.isNodeActive,St=t.isNodeEmpty,Nt=t.isNodeSelection,Ot=t.isNumber,Bt=t.isPlainObject,wt=t.isRegExp,Mt=t.isSafari,vt=t.isString,It=t.isTextSelection,Ft=t.isiOS,s=t.markInputRule,a=t.markPasteRule,l=t.mergeAttributes,Dt=t.mergeDeep,jt=t.minMax,Lt=t.nodeInputRule,Kt=t.nodePasteRule,Ht=t.objectIncludes,qt=t.pasteRulesPlugin,Gt=t.posToDOMRect,zt=t.removeDuplicates,Jt=t.resolveFocusPosition,Ut=t.rewriteUnknownContent,Vt=t.selectionToInsertionEnd,Yt=t.splitExtensions,Qt=t.textInputRule,Wt=t.textPasteRule,Xt=t.textblockTypeInputRule,Zt=t.wrappingInputRule;var g=/(^|[^`])`([^`]+)`(?!`)/,_=/(^|[^`])`([^`]+)`(?!`)/g,d=i.create({name:"code",addOptions(){return{HTMLAttributes:{}}},excludes:"_",code:!0,exitable:!0,parseHTML(){return[{tag:"code"}]},renderHTML({HTMLAttributes:e}){return["code",l(this.options.HTMLAttributes,e),0]},addCommands(){return{setCode:()=>({commands:e})=>e.setMark(this.name),toggleCode:()=>({commands:e})=>e.toggleMark(this.name),unsetCode:()=>({commands:e})=>e.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-e":()=>this.editor.commands.toggleCode()}},addInputRules(){return[s({find:g,type:this.type})]},addPasteRules(){return[a({find:_,type:this.type})]}});var p="__LEPTOS_TIPTAP_BRIDGE__";function x(){let e=globalThis,o=e[p];if(o!=null)return o;let r={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[p]=r,r}function c(){return x()}function u(e){c().registerExtension(e)}var m={name:"code",create:()=>d,commands:{set_code:e=>e.chain().focus().setCode().run(),toggle_code:e=>e.chain().focus().toggleCode().run(),unset_code:e=>e.chain().focus().unsetCode().run()}};function ae(){u(m)}export{ae as register_code};
@@ -0,0 +1,4 @@
var p=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(p==null||p.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=p.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var B=e.CommandManager,O=e.Editor,w=e.Extension,v=e.InputRule,M=e.Mark,x=e.Node,D=e.NodePos,I=e.NodeView,F=e.PasteRule,j=e.Tracker,L=e.callOrReturn,K=e.canInsertNode,H=e.combineTransactionSteps,G=e.createChainableState,q=e.createDocument,z=e.createNodeFromContent,$=e.createStyleTag,J=e.defaultBlockAt,W=e.deleteProps,V=e.elementFromString,U=e.escapeForRegEx,Y=e.extensions,Q=e.findChildren,X=e.findChildrenInRange,Z=e.findDuplicates,ee=e.findParentNode,te=e.findParentNodeClosestToPos,ne=e.fromString,oe=e.generateHTML,re=e.generateJSON,ie=e.generateText,se=e.getAttributes,ae=e.getAttributesFromExtensions,le=e.getChangedRanges,de=e.getDebugJSON,pe=e.getExtensionField,ce=e.getHTMLFromFragment,ue=e.getMarkAttributes,ge=e.getMarkRange,xe=e.getMarkType,_e=e.getMarksBetween,me=e.getNodeAtPosition,ke=e.getNodeAttributes,be=e.getNodeType,ye=e.getRenderedAttributes,fe=e.getSchema,he=e.getSchemaByResolvedExtensions,Ee=e.getSchemaTypeByName,Se=e.getSchemaTypeNameByName,Pe=e.getSplittedAttributes,Ce=e.getText,Ae=e.getTextBetween,Re=e.getTextContentFromNodes,Te=e.getTextSerializersFromSchema,Ne=e.injectExtensionAttributesToParseRule,Be=e.inputRulesPlugin,Oe=e.isActive,we=e.isAtEndOfNode,ve=e.isAtStartOfNode,Me=e.isEmptyObject,De=e.isExtensionRulesEnabled,Ie=e.isFunction,Fe=e.isList,je=e.isMacOS,Le=e.isMarkActive,Ke=e.isNodeActive,He=e.isNodeEmpty,Ge=e.isNodeSelection,qe=e.isNumber,ze=e.isPlainObject,$e=e.isRegExp,Je=e.isSafari,We=e.isString,Ve=e.isTextSelection,Ue=e.isiOS,Ye=e.markInputRule,Qe=e.markPasteRule,_=e.mergeAttributes,Xe=e.mergeDeep,Ze=e.minMax,et=e.nodeInputRule,tt=e.nodePasteRule,nt=e.objectIncludes,ot=e.pasteRulesPlugin,rt=e.posToDOMRect,it=e.removeDuplicates,st=e.resolveFocusPosition,at=e.rewriteUnknownContent,lt=e.selectionToInsertionEnd,dt=e.splitExtensions,pt=e.textInputRule,ct=e.textPasteRule,c=e.textblockTypeInputRule,ut=e.wrappingInputRule;var u=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(u==null||u.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var a=u.modules["@tiptap/pm/state"];if(a==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/state" is unavailable');var xt=a.AllSelection,_t=a.EditorState,mt=a.NodeSelection,m=a.Plugin,k=a.PluginKey,b=a.Selection,kt=a.SelectionRange,y=a.TextSelection,bt=a.Transaction;var A=/^```([a-z]+)?[\s\n]$/,R=/^~~~([a-z]+)?[\s\n]$/,f=x.create({name:"codeBlock",addOptions(){return{languageClassPrefix:"language-",exitOnTripleEnter:!0,exitOnArrowDown:!0,defaultLanguage:null,HTMLAttributes:{}}},content:"text*",marks:"",group:"block",code:!0,defining:!0,addAttributes(){return{language:{default:this.options.defaultLanguage,parseHTML:t=>{var n;let{languageClassPrefix:o}=this.options,l=[...((n=t.firstElementChild)===null||n===void 0?void 0:n.classList)||[]].filter(r=>r.startsWith(o)).map(r=>r.replace(o,""))[0];return l||null},rendered:!1}}},parseHTML(){return[{tag:"pre",preserveWhitespace:"full"}]},renderHTML({node:t,HTMLAttributes:n}){return["pre",_(this.options.HTMLAttributes,n),["code",{class:t.attrs.language?this.options.languageClassPrefix+t.attrs.language:null},0]]},addCommands(){return{setCodeBlock:t=>({commands:n})=>n.setNode(this.name,t),toggleCodeBlock:t=>({commands:n})=>n.toggleNode(this.name,"paragraph",t)}},addKeyboardShortcuts(){return{"Mod-Alt-c":()=>this.editor.commands.toggleCodeBlock(),Backspace:()=>{let{empty:t,$anchor:n}=this.editor.state.selection,o=n.pos===1;return!t||n.parent.type.name!==this.name?!1:o||!n.parent.textContent.length?this.editor.commands.clearNodes():!1},Enter:({editor:t})=>{if(!this.options.exitOnTripleEnter)return!1;let{state:n}=t,{selection:o}=n,{$from:i,empty:s}=o;if(!s||i.parent.type!==this.type)return!1;let l=i.parentOffset===i.parent.nodeSize-2,r=i.parent.textContent.endsWith(`
`);return!l||!r?!1:t.chain().command(({tr:d})=>(d.delete(i.pos-2,i.pos),!0)).exitCode().run()},ArrowDown:({editor:t})=>{if(!this.options.exitOnArrowDown)return!1;let{state:n}=t,{selection:o,doc:i}=n,{$from:s,empty:l}=o;if(!l||s.parent.type!==this.type||!(s.parentOffset===s.parent.nodeSize-2))return!1;let d=s.after();return d===void 0?!1:i.nodeAt(d)?t.commands.command(({tr:C})=>(C.setSelection(b.near(i.resolve(d))),!0)):t.commands.exitCode()}}},addInputRules(){return[c({find:A,type:this.type,getAttributes:t=>({language:t[1]})}),c({find:R,type:this.type,getAttributes:t=>({language:t[1]})})]},addProseMirrorPlugins(){return[new m({key:new k("codeBlockVSCodeHandler"),props:{handlePaste:(t,n)=>{if(!n.clipboardData||this.editor.isActive(this.type.name))return!1;let o=n.clipboardData.getData("text/plain"),i=n.clipboardData.getData("vscode-editor-data"),s=i?JSON.parse(i):void 0,l=s?.mode;if(!o||!l)return!1;let{tr:r,schema:d}=t.state,g=d.text(o.replace(/\r\n?/g,`
`));return r.replaceSelectionWith(this.type.create({language:l},g)),r.selection.$from.parent.type!==this.type&&r.setSelection(y.near(r.doc.resolve(Math.max(0,r.selection.from-2)))),r.setMeta("paste",!0),t.dispatch(r),!0}}})]}});var h="__LEPTOS_TIPTAP_BRIDGE__";function T(){let t=globalThis,n=t[h];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[h]=o,o}function E(){return T()}function S(t){E().registerExtension(t)}function P(t){if(t?.language!=null)return{language:t.language}}var N={name:"code_block",create:()=>f,commands:{set_code_block:(t,n)=>n.kind==="set_code_block"?t.chain().focus().setCodeBlock(P(n.attributes)).run():!1,toggle_code_block:(t,n)=>n.kind==="toggle_code_block"?t.chain().focus().toggleCodeBlock(P(n.attributes)).run():!1}};function Tt(){S(N)}export{Tt as register_code_block};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var u=t.CommandManager,g=t.Editor,_=t.Extension,x=t.InputRule,m=t.Mark,r=t.Node,k=t.NodePos,b=t.NodeView,y=t.PasteRule,E=t.Tracker,R=t.callOrReturn,h=t.canInsertNode,f=t.combineTransactionSteps,P=t.createChainableState,C=t.createDocument,S=t.createNodeFromContent,T=t.createStyleTag,A=t.defaultBlockAt,N=t.deleteProps,O=t.elementFromString,B=t.escapeForRegEx,w=t.extensions,v=t.findChildren,I=t.findChildrenInRange,M=t.findDuplicates,D=t.findParentNode,F=t.findParentNodeClosestToPos,j=t.fromString,L=t.generateHTML,K=t.generateJSON,q=t.generateText,G=t.getAttributes,H=t.getAttributesFromExtensions,z=t.getChangedRanges,J=t.getDebugJSON,U=t.getExtensionField,V=t.getHTMLFromFragment,Y=t.getMarkAttributes,Q=t.getMarkRange,W=t.getMarkType,X=t.getMarksBetween,Z=t.getNodeAtPosition,$=t.getNodeAttributes,tt=t.getNodeType,et=t.getRenderedAttributes,nt=t.getSchema,ot=t.getSchemaByResolvedExtensions,it=t.getSchemaTypeByName,rt=t.getSchemaTypeNameByName,st=t.getSplittedAttributes,lt=t.getText,at=t.getTextBetween,dt=t.getTextContentFromNodes,pt=t.getTextSerializersFromSchema,ct=t.injectExtensionAttributesToParseRule,ut=t.inputRulesPlugin,gt=t.isActive,_t=t.isAtEndOfNode,xt=t.isAtStartOfNode,mt=t.isEmptyObject,kt=t.isExtensionRulesEnabled,bt=t.isFunction,yt=t.isList,Et=t.isMacOS,Rt=t.isMarkActive,ht=t.isNodeActive,ft=t.isNodeEmpty,Pt=t.isNodeSelection,Ct=t.isNumber,St=t.isPlainObject,Tt=t.isRegExp,At=t.isSafari,Nt=t.isString,Ot=t.isTextSelection,Bt=t.isiOS,wt=t.markInputRule,vt=t.markPasteRule,It=t.mergeAttributes,Mt=t.mergeDeep,Dt=t.minMax,Ft=t.nodeInputRule,jt=t.nodePasteRule,Lt=t.objectIncludes,Kt=t.pasteRulesPlugin,qt=t.posToDOMRect,Gt=t.removeDuplicates,Ht=t.resolveFocusPosition,zt=t.rewriteUnknownContent,Jt=t.selectionToInsertionEnd,Ut=t.splitExtensions,Vt=t.textInputRule,Yt=t.textPasteRule,Qt=t.textblockTypeInputRule,Wt=t.wrappingInputRule;var s=r.create({name:"doc",topNode:!0,content:"block+"});var l="__LEPTOS_TIPTAP_BRIDGE__";function p(){let e=globalThis,o=e[l];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[l]=i,i}function a(){return p()}function d(e){a().registerExtension(e)}var c={name:"document",create:()=>s};function re(){d(c)}export{re as register_document};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var x=t.CommandManager,m=t.Editor,s=t.Extension,k=t.InputRule,b=t.Mark,y=t.Node,E=t.NodePos,h=t.NodeView,R=t.PasteRule,P=t.Tracker,f=t.callOrReturn,C=t.canInsertNode,T=t.combineTransactionSteps,S=t.createChainableState,A=t.createDocument,O=t.createNodeFromContent,N=t.createStyleTag,w=t.defaultBlockAt,B=t.deleteProps,v=t.elementFromString,I=t.escapeForRegEx,M=t.extensions,D=t.findChildren,F=t.findChildrenInRange,j=t.findDuplicates,L=t.findParentNode,K=t.findParentNodeClosestToPos,G=t.fromString,q=t.generateHTML,H=t.generateJSON,z=t.generateText,J=t.getAttributes,U=t.getAttributesFromExtensions,V=t.getChangedRanges,Y=t.getDebugJSON,Q=t.getExtensionField,W=t.getHTMLFromFragment,X=t.getMarkAttributes,Z=t.getMarkRange,$=t.getMarkType,tt=t.getMarksBetween,et=t.getNodeAtPosition,nt=t.getNodeAttributes,ot=t.getNodeType,rt=t.getRenderedAttributes,it=t.getSchema,st=t.getSchemaByResolvedExtensions,lt=t.getSchemaTypeByName,at=t.getSchemaTypeNameByName,pt=t.getSplittedAttributes,dt=t.getText,ct=t.getTextBetween,ut=t.getTextContentFromNodes,gt=t.getTextSerializersFromSchema,_t=t.injectExtensionAttributesToParseRule,xt=t.inputRulesPlugin,mt=t.isActive,kt=t.isAtEndOfNode,bt=t.isAtStartOfNode,yt=t.isEmptyObject,Et=t.isExtensionRulesEnabled,ht=t.isFunction,Rt=t.isList,Pt=t.isMacOS,ft=t.isMarkActive,Ct=t.isNodeActive,Tt=t.isNodeEmpty,St=t.isNodeSelection,At=t.isNumber,Ot=t.isPlainObject,Nt=t.isRegExp,wt=t.isSafari,Bt=t.isString,vt=t.isTextSelection,It=t.isiOS,Mt=t.markInputRule,Dt=t.markPasteRule,Ft=t.mergeAttributes,jt=t.mergeDeep,Lt=t.minMax,Kt=t.nodeInputRule,Gt=t.nodePasteRule,qt=t.objectIncludes,Ht=t.pasteRulesPlugin,zt=t.posToDOMRect,Jt=t.removeDuplicates,Ut=t.resolveFocusPosition,Vt=t.rewriteUnknownContent,Yt=t.selectionToInsertionEnd,Qt=t.splitExtensions,Wt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,$t=t.wrappingInputRule;var o=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(o==null||o.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var l=o.modules["@tiptap/pm/dropcursor"];if(l==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/dropcursor" is unavailable');var a=l.dropCursor;var p=s.create({name:"dropCursor",addOptions(){return{color:"currentColor",width:1,class:void 0}},addProseMirrorPlugins(){return[a(this.options)]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function g(){let e=globalThis,r=e[d];if(r!=null)return r;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[d]=i,i}function c(){return g()}function u(e){c().registerExtension(e)}var _={name:"dropcursor",create:()=>p};function de(){u(_)}export{de as register_dropcursor};
@@ -0,0 +1 @@
var r=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(r==null||r.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=r.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,a=t.Extension,y=t.InputRule,E=t.Mark,R=t.Node,h=t.NodePos,P=t.NodeView,f=t.PasteRule,C=t.Tracker,l=t.callOrReturn,T=t.canInsertNode,S=t.combineTransactionSteps,A=t.createChainableState,N=t.createDocument,O=t.createNodeFromContent,w=t.createStyleTag,B=t.defaultBlockAt,v=t.deleteProps,I=t.elementFromString,M=t.escapeForRegEx,F=t.extensions,D=t.findChildren,j=t.findChildrenInRange,G=t.findDuplicates,L=t.findParentNode,K=t.findParentNodeClosestToPos,q=t.fromString,H=t.generateHTML,z=t.generateJSON,J=t.generateText,U=t.getAttributes,V=t.getAttributesFromExtensions,Y=t.getChangedRanges,Q=t.getDebugJSON,p=t.getExtensionField,W=t.getHTMLFromFragment,X=t.getMarkAttributes,Z=t.getMarkRange,$=t.getMarkType,tt=t.getMarksBetween,et=t.getNodeAtPosition,nt=t.getNodeAttributes,ot=t.getNodeType,rt=t.getRenderedAttributes,it=t.getSchema,st=t.getSchemaByResolvedExtensions,at=t.getSchemaTypeByName,lt=t.getSchemaTypeNameByName,pt=t.getSplittedAttributes,dt=t.getText,ct=t.getTextBetween,ut=t.getTextContentFromNodes,gt=t.getTextSerializersFromSchema,_t=t.injectExtensionAttributesToParseRule,xt=t.inputRulesPlugin,mt=t.isActive,kt=t.isAtEndOfNode,bt=t.isAtStartOfNode,yt=t.isEmptyObject,Et=t.isExtensionRulesEnabled,Rt=t.isFunction,ht=t.isList,Pt=t.isMacOS,ft=t.isMarkActive,Ct=t.isNodeActive,Tt=t.isNodeEmpty,St=t.isNodeSelection,At=t.isNumber,Nt=t.isPlainObject,Ot=t.isRegExp,wt=t.isSafari,Bt=t.isString,vt=t.isTextSelection,It=t.isiOS,Mt=t.markInputRule,Ft=t.markPasteRule,Dt=t.mergeAttributes,jt=t.mergeDeep,Gt=t.minMax,Lt=t.nodeInputRule,Kt=t.nodePasteRule,qt=t.objectIncludes,Ht=t.pasteRulesPlugin,zt=t.posToDOMRect,Jt=t.removeDuplicates,Ut=t.resolveFocusPosition,Vt=t.rewriteUnknownContent,Yt=t.selectionToInsertionEnd,Qt=t.splitExtensions,Wt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,$t=t.wrappingInputRule;var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var s=i.modules["@tiptap/pm/gapcursor"];if(s==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/gapcursor" is unavailable');var ee=s.GapCursor,d=s.gapCursor;var c=a.create({name:"gapCursor",addProseMirrorPlugins(){return[d()]},extendNodeSchema(e){var n;let o={name:e.name,options:e.options,storage:e.storage};return{allowGapCursor:(n=l(p(e,"allowGapCursor",o)))!==null&&n!==void 0?n:null}}});var u="__LEPTOS_TIPTAP_BRIDGE__";function x(){let e=globalThis,n=e[u];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[u]=o,o}function g(){return x()}function _(e){g().registerExtension(e)}var m={name:"gapcursor",create:()=>c};function ce(){_(m)}export{ce as register_gapcursor};
@@ -0,0 +1,2 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=i.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var R=t.CommandManager,P=t.Editor,C=t.Extension,T=t.InputRule,S=t.Mark,a=t.Node,A=t.NodePos,B=t.NodeView,N=t.PasteRule,O=t.Tracker,M=t.callOrReturn,w=t.canInsertNode,v=t.combineTransactionSteps,I=t.createChainableState,F=t.createDocument,D=t.createNodeFromContent,j=t.createStyleTag,H=t.defaultBlockAt,L=t.deleteProps,K=t.elementFromString,q=t.escapeForRegEx,G=t.extensions,z=t.findChildren,J=t.findChildrenInRange,$=t.findDuplicates,U=t.findParentNode,V=t.findParentNodeClosestToPos,Y=t.fromString,Q=t.generateHTML,W=t.generateJSON,X=t.generateText,Z=t.getAttributes,tt=t.getAttributesFromExtensions,et=t.getChangedRanges,nt=t.getDebugJSON,ot=t.getExtensionField,rt=t.getHTMLFromFragment,it=t.getMarkAttributes,st=t.getMarkRange,at=t.getMarkType,lt=t.getMarksBetween,dt=t.getNodeAtPosition,pt=t.getNodeAttributes,ct=t.getNodeType,ut=t.getRenderedAttributes,gt=t.getSchema,_t=t.getSchemaByResolvedExtensions,xt=t.getSchemaTypeByName,mt=t.getSchemaTypeNameByName,kt=t.getSplittedAttributes,bt=t.getText,yt=t.getTextBetween,ft=t.getTextContentFromNodes,ht=t.getTextSerializersFromSchema,Et=t.injectExtensionAttributesToParseRule,Rt=t.inputRulesPlugin,Pt=t.isActive,Ct=t.isAtEndOfNode,Tt=t.isAtStartOfNode,St=t.isEmptyObject,At=t.isExtensionRulesEnabled,Bt=t.isFunction,Nt=t.isList,Ot=t.isMacOS,Mt=t.isMarkActive,wt=t.isNodeActive,vt=t.isNodeEmpty,It=t.isNodeSelection,Ft=t.isNumber,Dt=t.isPlainObject,jt=t.isRegExp,Ht=t.isSafari,Lt=t.isString,Kt=t.isTextSelection,qt=t.isiOS,Gt=t.markInputRule,zt=t.markPasteRule,l=t.mergeAttributes,Jt=t.mergeDeep,$t=t.minMax,Ut=t.nodeInputRule,Vt=t.nodePasteRule,Yt=t.objectIncludes,Qt=t.pasteRulesPlugin,Wt=t.posToDOMRect,Xt=t.removeDuplicates,Zt=t.resolveFocusPosition,te=t.rewriteUnknownContent,ee=t.selectionToInsertionEnd,ne=t.splitExtensions,oe=t.textInputRule,re=t.textPasteRule,ie=t.textblockTypeInputRule,se=t.wrappingInputRule;var d=a.create({name:"hardBreak",addOptions(){return{keepMarks:!0,HTMLAttributes:{}}},inline:!0,group:"inline",selectable:!1,linebreakReplacement:!0,parseHTML(){return[{tag:"br"}]},renderHTML({HTMLAttributes:e}){return["br",l(this.options.HTMLAttributes,e)]},renderText(){return`
`},addCommands(){return{setHardBreak:()=>({commands:e,chain:n,state:o,editor:g})=>e.first([()=>e.exitCode(),()=>e.command(()=>{let{selection:r,storedMarks:_}=o;if(r.$from.parent.type.spec.isolating)return!1;let{keepMarks:x}=this.options,{splittableMarks:m}=g.extensionManager,s=_||r.$to.parentOffset&&r.$from.marks();return n().insertContent({type:this.name}).command(({tr:k,dispatch:b})=>{if(b&&s&&x){let y=s.filter(f=>m.includes(f.type.name));k.ensureMarks(y)}return!0}).run()})])}},addKeyboardShortcuts(){return{"Mod-Enter":()=>this.editor.commands.setHardBreak(),"Shift-Enter":()=>this.editor.commands.setHardBreak()}}});var p="__LEPTOS_TIPTAP_BRIDGE__";function h(){let e=globalThis,n=e[p];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[p]=o,o}function c(){return h()}function u(e){c().registerExtension(e)}var E={name:"hard_break",create:()=>d,commands:{set_hard_break:e=>e.chain().focus().setHardBreak().run()}};function xe(){u(E)}export{xe as register_hard_break};
@@ -0,0 +1 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=i.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var x=e.CommandManager,m=e.Editor,k=e.Extension,b=e.InputRule,y=e.Mark,r=e.Node,h=e.NodePos,f=e.NodeView,E=e.PasteRule,R=e.Tracker,A=e.callOrReturn,P=e.canInsertNode,v=e.combineTransactionSteps,T=e.createChainableState,C=e.createDocument,S=e.createNodeFromContent,N=e.createStyleTag,O=e.defaultBlockAt,B=e.deleteProps,w=e.elementFromString,M=e.escapeForRegEx,I=e.extensions,F=e.findChildren,D=e.findChildrenInRange,j=e.findDuplicates,H=e.findParentNode,L=e.findParentNodeClosestToPos,K=e.fromString,q=e.generateHTML,G=e.generateJSON,$=e.generateText,z=e.getAttributes,J=e.getAttributesFromExtensions,U=e.getChangedRanges,V=e.getDebugJSON,Y=e.getExtensionField,Q=e.getHTMLFromFragment,W=e.getMarkAttributes,X=e.getMarkRange,Z=e.getMarkType,ee=e.getMarksBetween,te=e.getNodeAtPosition,ne=e.getNodeAttributes,oe=e.getNodeType,ie=e.getRenderedAttributes,re=e.getSchema,se=e.getSchemaByResolvedExtensions,le=e.getSchemaTypeByName,ae=e.getSchemaTypeNameByName,de=e.getSplittedAttributes,pe=e.getText,ce=e.getTextBetween,ue=e.getTextContentFromNodes,ge=e.getTextSerializersFromSchema,_e=e.injectExtensionAttributesToParseRule,xe=e.inputRulesPlugin,me=e.isActive,ke=e.isAtEndOfNode,be=e.isAtStartOfNode,ye=e.isEmptyObject,he=e.isExtensionRulesEnabled,fe=e.isFunction,Ee=e.isList,Re=e.isMacOS,Ae=e.isMarkActive,Pe=e.isNodeActive,ve=e.isNodeEmpty,Te=e.isNodeSelection,Ce=e.isNumber,Se=e.isPlainObject,Ne=e.isRegExp,Oe=e.isSafari,Be=e.isString,we=e.isTextSelection,Me=e.isiOS,Ie=e.markInputRule,Fe=e.markPasteRule,s=e.mergeAttributes,De=e.mergeDeep,je=e.minMax,He=e.nodeInputRule,Le=e.nodePasteRule,Ke=e.objectIncludes,qe=e.pasteRulesPlugin,Ge=e.posToDOMRect,$e=e.removeDuplicates,ze=e.resolveFocusPosition,Je=e.rewriteUnknownContent,Ue=e.selectionToInsertionEnd,Ve=e.splitExtensions,Ye=e.textInputRule,Qe=e.textPasteRule,l=e.textblockTypeInputRule,We=e.wrappingInputRule;var a=r.create({name:"heading",addOptions(){return{levels:[1,2,3,4,5,6],HTMLAttributes:{}}},content:"inline*",group:"block",defining:!0,addAttributes(){return{level:{default:1,rendered:!1}}},parseHTML(){return this.options.levels.map(t=>({tag:`h${t}`,attrs:{level:t}}))},renderHTML({node:t,HTMLAttributes:n}){return[`h${this.options.levels.includes(t.attrs.level)?t.attrs.level:this.options.levels[0]}`,s(this.options.HTMLAttributes,n),0]},addCommands(){return{setHeading:t=>({commands:n})=>this.options.levels.includes(t.level)?n.setNode(this.name,t):!1,toggleHeading:t=>({commands:n})=>this.options.levels.includes(t.level)?n.toggleNode(this.name,"paragraph",t):!1}},addKeyboardShortcuts(){return this.options.levels.reduce((t,n)=>({...t,[`Mod-Alt-${n}`]:()=>this.editor.commands.toggleHeading({level:n})}),{})},addInputRules(){return this.options.levels.map(t=>l({find:new RegExp(`^(#{${Math.min(...this.options.levels)},${t}})\\s$`),type:this.type,getAttributes:{level:t}}))}});var d="__LEPTOS_TIPTAP_BRIDGE__";function u(){let t=globalThis,n=t[d];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[d]=o,o}function p(){return u()}function c(t){p().registerExtension(t)}var g={name:"heading",create:()=>a,commands:{set_heading:(t,n)=>n.kind==="set_heading"?t.chain().focus().setHeading({level:n.level}).run():!1,toggle_heading:(t,n)=>n.kind==="toggle_heading"?t.chain().focus().toggleHeading({level:n.level}).run():!1},selection_keys:["h1","h2","h3","h4","h5","h6"],selection_state:t=>({h1:t.isActive("heading",{level:1}),h2:t.isActive("heading",{level:2}),h3:t.isActive("heading",{level:3}),h4:t.isActive("heading",{level:4}),h5:t.isActive("heading",{level:5}),h6:t.isActive("heading",{level:6})})};function st(){c(g)}export{st as register_heading};
@@ -0,0 +1 @@
var o=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(o==null||o.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=o.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var b=t.CommandManager,h=t.Editor,y=t.Extension,f=t.InputRule,r=t.Mark,R=t.Node,E=t.NodePos,P=t.NodeView,T=t.PasteRule,A=t.Tracker,C=t.callOrReturn,S=t.canInsertNode,N=t.combineTransactionSteps,O=t.createChainableState,B=t.createDocument,w=t.createNodeFromContent,M=t.createStyleTag,v=t.defaultBlockAt,I=t.deleteProps,F=t.elementFromString,D=t.escapeForRegEx,H=t.extensions,j=t.findChildren,L=t.findChildrenInRange,K=t.findDuplicates,q=t.findParentNode,G=t.findParentNodeClosestToPos,z=t.fromString,J=t.generateHTML,U=t.generateJSON,V=t.generateText,Y=t.getAttributes,$=t.getAttributesFromExtensions,Q=t.getChangedRanges,W=t.getDebugJSON,X=t.getExtensionField,Z=t.getHTMLFromFragment,tt=t.getMarkAttributes,et=t.getMarkRange,nt=t.getMarkType,ot=t.getMarksBetween,it=t.getNodeAtPosition,rt=t.getNodeAttributes,st=t.getNodeType,lt=t.getRenderedAttributes,at=t.getSchema,dt=t.getSchemaByResolvedExtensions,pt=t.getSchemaTypeByName,ut=t.getSchemaTypeNameByName,ct=t.getSplittedAttributes,gt=t.getText,_t=t.getTextBetween,xt=t.getTextContentFromNodes,mt=t.getTextSerializersFromSchema,kt=t.injectExtensionAttributesToParseRule,bt=t.inputRulesPlugin,ht=t.isActive,yt=t.isAtEndOfNode,ft=t.isAtStartOfNode,Rt=t.isEmptyObject,Et=t.isExtensionRulesEnabled,Pt=t.isFunction,Tt=t.isList,At=t.isMacOS,Ct=t.isMarkActive,St=t.isNodeActive,Nt=t.isNodeEmpty,Ot=t.isNodeSelection,Bt=t.isNumber,wt=t.isPlainObject,Mt=t.isRegExp,vt=t.isSafari,It=t.isString,Ft=t.isTextSelection,Dt=t.isiOS,s=t.markInputRule,l=t.markPasteRule,a=t.mergeAttributes,Ht=t.mergeDeep,jt=t.minMax,Lt=t.nodeInputRule,Kt=t.nodePasteRule,qt=t.objectIncludes,Gt=t.pasteRulesPlugin,zt=t.posToDOMRect,Jt=t.removeDuplicates,Ut=t.resolveFocusPosition,Vt=t.rewriteUnknownContent,Yt=t.selectionToInsertionEnd,$t=t.splitExtensions,Qt=t.textInputRule,Wt=t.textPasteRule,Xt=t.textblockTypeInputRule,Zt=t.wrappingInputRule;var _=/(?:^|\s)(==(?!\s+==)((?:[^=]+))==(?!\s+==))$/,x=/(?:^|\s)(==(?!\s+==)((?:[^=]+))==(?!\s+==))/g,d=r.create({name:"highlight",addOptions(){return{multicolor:!1,HTMLAttributes:{}}},addAttributes(){return this.options.multicolor?{color:{default:null,parseHTML:e=>e.getAttribute("data-color")||e.style.backgroundColor,renderHTML:e=>e.color?{"data-color":e.color,style:`background-color: ${e.color}; color: inherit`}:{}}}:{}},parseHTML(){return[{tag:"mark"}]},renderHTML({HTMLAttributes:e}){return["mark",a(this.options.HTMLAttributes,e),0]},addCommands(){return{setHighlight:e=>({commands:n})=>n.setMark(this.name,e),toggleHighlight:e=>({commands:n})=>n.toggleMark(this.name,e),unsetHighlight:()=>({commands:e})=>e.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-h":()=>this.editor.commands.toggleHighlight()}},addInputRules(){return[s({find:_,type:this.type})]},addPasteRules(){return[l({find:x,type:this.type})]}});var p="__LEPTOS_TIPTAP_BRIDGE__";function m(){let e=globalThis,n=e[p];if(n!=null)return n;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[p]=i,i}function u(){return m()}function c(e){u().registerExtension(e)}function g(e){if(e?.color!=null)return{color:e.color}}var k={name:"highlight",create:()=>d.configure({multicolor:!0}),commands:{set_highlight:(e,n)=>n.kind==="set_highlight"?e.chain().focus().setHighlight(g(n.attributes)).run():!1,toggle_highlight:(e,n)=>n.kind==="toggle_highlight"?e.chain().focus().toggleHighlight(g(n.attributes)).run():!1,unset_highlight:e=>e.chain().focus().unsetHighlight().run()},selection_keys:["highlight"],selection_state:e=>({highlight:e.isActive("highlight")})};function ae(){c(k)}export{ae as register_highlight};
@@ -0,0 +1 @@
var r=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(r==null||r.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=r.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,l=t.Extension,y=t.InputRule,h=t.Mark,E=t.Node,f=t.NodePos,R=t.NodeView,P=t.PasteRule,S=t.Tracker,T=t.callOrReturn,C=t.canInsertNode,A=t.combineTransactionSteps,N=t.createChainableState,O=t.createDocument,w=t.createNodeFromContent,B=t.createStyleTag,v=t.defaultBlockAt,M=t.deleteProps,D=t.elementFromString,I=t.escapeForRegEx,F=t.extensions,j=t.findChildren,H=t.findChildrenInRange,L=t.findDuplicates,K=t.findParentNode,G=t.findParentNodeClosestToPos,q=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,Q=t.getChangedRanges,W=t.getDebugJSON,X=t.getExtensionField,Z=t.getHTMLFromFragment,$=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,rt=t.getNodeAttributes,it=t.getNodeType,st=t.getRenderedAttributes,lt=t.getSchema,at=t.getSchemaByResolvedExtensions,dt=t.getSchemaTypeByName,pt=t.getSchemaTypeNameByName,ct=t.getSplittedAttributes,ut=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ht=t.isAtStartOfNode,Et=t.isEmptyObject,ft=t.isExtensionRulesEnabled,Rt=t.isFunction,Pt=t.isList,St=t.isMacOS,Tt=t.isMarkActive,Ct=t.isNodeActive,At=t.isNodeEmpty,Nt=t.isNodeSelection,Ot=t.isNumber,wt=t.isPlainObject,Bt=t.isRegExp,vt=t.isSafari,Mt=t.isString,Dt=t.isTextSelection,It=t.isiOS,Ft=t.markInputRule,jt=t.markPasteRule,Ht=t.mergeAttributes,Lt=t.mergeDeep,Kt=t.minMax,Gt=t.nodeInputRule,qt=t.nodePasteRule,zt=t.objectIncludes,Jt=t.pasteRulesPlugin,Ut=t.posToDOMRect,Vt=t.removeDuplicates,Yt=t.resolveFocusPosition,Qt=t.rewriteUnknownContent,Wt=t.selectionToInsertionEnd,Xt=t.splitExtensions,Zt=t.textInputRule,$t=t.textPasteRule,te=t.textblockTypeInputRule,ee=t.wrappingInputRule;var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var n=i.modules["@tiptap/pm/history"];if(n==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/history" is unavailable');var oe=n.closeHistory,a=n.history,re=n.isHistoryTransaction,d=n.redo,ie=n.redoDepth,se=n.redoNoScroll,p=n.undo,le=n.undoDepth,ae=n.undoNoScroll;var c=l.create({name:"history",addOptions(){return{depth:100,newGroupDelay:500}},addCommands(){return{undo:()=>({state:e,dispatch:o})=>p(e,o),redo:()=>({state:e,dispatch:o})=>d(e,o)}},addProseMirrorPlugins(){return[a(this.options)]},addKeyboardShortcuts(){return{"Mod-z":()=>this.editor.commands.undo(),"Shift-Mod-z":()=>this.editor.commands.redo(),"Mod-y":()=>this.editor.commands.redo(),"Mod-\u044F":()=>this.editor.commands.undo(),"Shift-Mod-\u044F":()=>this.editor.commands.redo()}}});var u="__LEPTOS_TIPTAP_BRIDGE__";function x(){let e=globalThis,o=e[u];if(o!=null)return o;let s={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[u]=s,s}function g(){return x()}function _(e){g().registerExtension(e)}var m={name:"history",create:()=>c,commands:{undo:e=>e.chain().focus().undo().run(),redo:e=>e.chain().focus().redo().run()}};function be(){_(m)}export{be as register_history};
@@ -0,0 +1 @@
var d=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(d==null||d.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=d.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var N=e.CommandManager,O=e.Editor,B=e.Extension,w=e.InputRule,v=e.Mark,m=e.Node,I=e.NodePos,M=e.NodeView,F=e.PasteRule,D=e.Tracker,j=e.callOrReturn,k=e.canInsertNode,L=e.combineTransactionSteps,H=e.createChainableState,K=e.createDocument,z=e.createNodeFromContent,G=e.createStyleTag,q=e.defaultBlockAt,$=e.deleteProps,J=e.elementFromString,V=e.escapeForRegEx,U=e.extensions,Y=e.findChildren,Q=e.findChildrenInRange,W=e.findDuplicates,X=e.findParentNode,Z=e.findParentNodeClosestToPos,ee=e.fromString,te=e.generateHTML,ne=e.generateJSON,oe=e.generateText,ie=e.getAttributes,re=e.getAttributesFromExtensions,se=e.getChangedRanges,le=e.getDebugJSON,ae=e.getExtensionField,pe=e.getHTMLFromFragment,de=e.getMarkAttributes,ce=e.getMarkRange,ue=e.getMarkType,ge=e.getMarksBetween,_e=e.getNodeAtPosition,xe=e.getNodeAttributes,me=e.getNodeType,ke=e.getRenderedAttributes,be=e.getSchema,ye=e.getSchemaByResolvedExtensions,fe=e.getSchemaTypeByName,he=e.getSchemaTypeNameByName,Ee=e.getSplittedAttributes,Re=e.getText,Se=e.getTextBetween,Te=e.getTextContentFromNodes,Pe=e.getTextSerializersFromSchema,Ae=e.injectExtensionAttributesToParseRule,Ce=e.inputRulesPlugin,Ne=e.isActive,Oe=e.isAtEndOfNode,Be=e.isAtStartOfNode,we=e.isEmptyObject,ve=e.isExtensionRulesEnabled,Ie=e.isFunction,Me=e.isList,Fe=e.isMacOS,De=e.isMarkActive,je=e.isNodeActive,Le=e.isNodeEmpty,b=e.isNodeSelection,He=e.isNumber,Ke=e.isPlainObject,ze=e.isRegExp,Ge=e.isSafari,qe=e.isString,$e=e.isTextSelection,Je=e.isiOS,Ve=e.markInputRule,Ue=e.markPasteRule,y=e.mergeAttributes,Ye=e.mergeDeep,Qe=e.minMax,f=e.nodeInputRule,We=e.nodePasteRule,Xe=e.objectIncludes,Ze=e.pasteRulesPlugin,et=e.posToDOMRect,tt=e.removeDuplicates,nt=e.resolveFocusPosition,ot=e.rewriteUnknownContent,it=e.selectionToInsertionEnd,rt=e.splitExtensions,st=e.textInputRule,lt=e.textPasteRule,at=e.textblockTypeInputRule,pt=e.wrappingInputRule;var c=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(c==null||c.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var o=c.modules["@tiptap/pm/state"];if(o==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/state" is unavailable');var ct=o.AllSelection,ut=o.EditorState,h=o.NodeSelection,gt=o.Plugin,_t=o.PluginKey,xt=o.Selection,mt=o.SelectionRange,a=o.TextSelection,kt=o.Transaction;var E=m.create({name:"horizontalRule",addOptions(){return{HTMLAttributes:{}}},group:"block",parseHTML(){return[{tag:"hr"}]},renderHTML({HTMLAttributes:n}){return["hr",y(this.options.HTMLAttributes,n)]},addCommands(){return{setHorizontalRule:()=>({chain:n,state:r})=>{if(!k(r,r.schema.nodes[this.name]))return!1;let{selection:s}=r,{$from:u,$to:g}=s,l=n();return u.parentOffset===0?l.insertContentAt({from:Math.max(u.pos-1,0),to:g.pos},{type:this.name}):b(s)?l.insertContentAt(g.pos,{type:this.name}):l.insertContent({type:this.name}),l.command(({tr:t,dispatch:P})=>{var p;if(P){let{$to:i}=t.selection,_=i.end();if(i.nodeAfter)i.nodeAfter.isTextblock?t.setSelection(a.create(t.doc,i.pos+1)):i.nodeAfter.isBlock?t.setSelection(h.create(t.doc,i.pos)):t.setSelection(a.create(t.doc,i.pos));else{let x=(p=i.parent.type.contentMatch.defaultType)===null||p===void 0?void 0:p.create();x&&(t.insert(_,x),t.setSelection(a.create(t.doc,_+1)))}t.scrollIntoView()}return!0}).run()}}},addInputRules(){return[f({find:/^(?:---|—-|___\s|\*\*\*\s)$/,type:this.type})]}});var R="__LEPTOS_TIPTAP_BRIDGE__";function A(){let n=globalThis,r=n[R];if(r!=null)return r;let s={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return n[R]=s,s}function S(){return A()}function T(n){S().registerExtension(n)}var C={name:"horizontal_rule",create:()=>E,commands:{set_horizontal_rule:n=>n.chain().focus().setHorizontalRule().run()}};function At(){T(C)}export{At as register_horizontal_rule};
@@ -0,0 +1 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=i.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,y=t.Extension,f=t.InputRule,E=t.Mark,r=t.Node,h=t.NodePos,R=t.NodeView,P=t.PasteRule,C=t.Tracker,A=t.callOrReturn,T=t.canInsertNode,S=t.combineTransactionSteps,N=t.createChainableState,O=t.createDocument,B=t.createNodeFromContent,w=t.createStyleTag,I=t.defaultBlockAt,v=t.deleteProps,M=t.elementFromString,F=t.escapeForRegEx,D=t.extensions,j=t.findChildren,L=t.findChildrenInRange,H=t.findDuplicates,K=t.findParentNode,q=t.findParentNodeClosestToPos,G=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,$=t.getChangedRanges,Q=t.getDebugJSON,W=t.getExtensionField,X=t.getHTMLFromFragment,Z=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,it=t.getNodeAttributes,rt=t.getNodeType,st=t.getRenderedAttributes,lt=t.getSchema,at=t.getSchemaByResolvedExtensions,dt=t.getSchemaTypeByName,pt=t.getSchemaTypeNameByName,ut=t.getSplittedAttributes,ct=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ft=t.isAtStartOfNode,Et=t.isEmptyObject,ht=t.isExtensionRulesEnabled,Rt=t.isFunction,Pt=t.isList,Ct=t.isMacOS,At=t.isMarkActive,Tt=t.isNodeActive,St=t.isNodeEmpty,Nt=t.isNodeSelection,Ot=t.isNumber,Bt=t.isPlainObject,wt=t.isRegExp,It=t.isSafari,vt=t.isString,Mt=t.isTextSelection,Ft=t.isiOS,Dt=t.markInputRule,jt=t.markPasteRule,s=t.mergeAttributes,Lt=t.mergeDeep,Ht=t.minMax,l=t.nodeInputRule,Kt=t.nodePasteRule,qt=t.objectIncludes,Gt=t.pasteRulesPlugin,zt=t.posToDOMRect,Jt=t.removeDuplicates,Ut=t.resolveFocusPosition,Vt=t.rewriteUnknownContent,Yt=t.selectionToInsertionEnd,$t=t.splitExtensions,Qt=t.textInputRule,Wt=t.textPasteRule,Xt=t.textblockTypeInputRule,Zt=t.wrappingInputRule;var g=/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/,a=r.create({name:"image",addOptions(){return{inline:!1,allowBase64:!1,HTMLAttributes:{}}},inline(){return this.options.inline},group(){return this.options.inline?"inline":"block"},draggable:!0,addAttributes(){return{src:{default:null},alt:{default:null},title:{default:null}}},parseHTML(){return[{tag:this.options.allowBase64?"img[src]":'img[src]:not([src^="data:"])'}]},renderHTML({HTMLAttributes:e}){return["img",s(this.options.HTMLAttributes,e)]},addCommands(){return{setImage:e=>({commands:n})=>n.insertContent({type:this.name,attrs:e})}},addInputRules(){return[l({find:g,type:this.type,getAttributes:e=>{let[,,n,o,c]=e;return{src:o,alt:n,title:c}}})]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function _(){let e=globalThis,n=e[d];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[d]=o,o}function p(){return _()}function u(e){p().registerExtension(e)}function x(e){return{src:e.src,alt:e.alt??void 0,title:e.title??void 0}}var m={name:"image",create:()=>a,commands:{set_image:(e,n)=>n.kind==="set_image"?e.chain().focus().setImage(x(n)).run():!1}};function ae(){u(m)}export{ae as register_image};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var y=t.CommandManager,h=t.Editor,R=t.Extension,f=t.InputRule,a=t.Mark,E=t.Node,P=t.NodePos,C=t.NodeView,T=t.PasteRule,A=t.Tracker,S=t.callOrReturn,N=t.canInsertNode,O=t.combineTransactionSteps,I=t.createChainableState,B=t.createDocument,w=t.createNodeFromContent,M=t.createStyleTag,v=t.defaultBlockAt,F=t.deleteProps,D=t.elementFromString,j=t.escapeForRegEx,L=t.extensions,K=t.findChildren,H=t.findChildrenInRange,q=t.findDuplicates,G=t.findParentNode,z=t.findParentNodeClosestToPos,J=t.fromString,U=t.generateHTML,V=t.generateJSON,Y=t.generateText,$=t.getAttributes,Q=t.getAttributesFromExtensions,W=t.getChangedRanges,X=t.getDebugJSON,Z=t.getExtensionField,tt=t.getHTMLFromFragment,et=t.getMarkAttributes,nt=t.getMarkRange,ot=t.getMarkType,it=t.getMarksBetween,rt=t.getNodeAtPosition,st=t.getNodeAttributes,at=t.getNodeType,lt=t.getRenderedAttributes,dt=t.getSchema,pt=t.getSchemaByResolvedExtensions,ct=t.getSchemaTypeByName,ut=t.getSchemaTypeNameByName,gt=t.getSplittedAttributes,_t=t.getText,xt=t.getTextBetween,mt=t.getTextContentFromNodes,kt=t.getTextSerializersFromSchema,bt=t.injectExtensionAttributesToParseRule,yt=t.inputRulesPlugin,ht=t.isActive,Rt=t.isAtEndOfNode,ft=t.isAtStartOfNode,Et=t.isEmptyObject,Pt=t.isExtensionRulesEnabled,Ct=t.isFunction,Tt=t.isList,At=t.isMacOS,St=t.isMarkActive,Nt=t.isNodeActive,Ot=t.isNodeEmpty,It=t.isNodeSelection,Bt=t.isNumber,wt=t.isPlainObject,Mt=t.isRegExp,vt=t.isSafari,Ft=t.isString,Dt=t.isTextSelection,jt=t.isiOS,o=t.markInputRule,i=t.markPasteRule,l=t.mergeAttributes,Lt=t.mergeDeep,Kt=t.minMax,Ht=t.nodeInputRule,qt=t.nodePasteRule,Gt=t.objectIncludes,zt=t.pasteRulesPlugin,Jt=t.posToDOMRect,Ut=t.removeDuplicates,Vt=t.resolveFocusPosition,Yt=t.rewriteUnknownContent,$t=t.selectionToInsertionEnd,Qt=t.splitExtensions,Wt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,te=t.wrappingInputRule;var g=/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/,_=/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))/g,x=/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/,m=/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))/g,d=a.create({name:"italic",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"em"},{tag:"i",getAttrs:e=>e.style.fontStyle!=="normal"&&null},{style:"font-style=normal",clearMark:e=>e.type.name===this.name},{style:"font-style=italic"}]},renderHTML({HTMLAttributes:e}){return["em",l(this.options.HTMLAttributes,e),0]},addCommands(){return{setItalic:()=>({commands:e})=>e.setMark(this.name),toggleItalic:()=>({commands:e})=>e.toggleMark(this.name),unsetItalic:()=>({commands:e})=>e.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-i":()=>this.editor.commands.toggleItalic(),"Mod-I":()=>this.editor.commands.toggleItalic()}},addInputRules(){return[o({find:g,type:this.type}),o({find:x,type:this.type})]},addPasteRules(){return[i({find:_,type:this.type}),i({find:m,type:this.type})]}});var p="__LEPTOS_TIPTAP_BRIDGE__";function k(){let e=globalThis,r=e[p];if(r!=null)return r;let s={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[p]=s,s}function c(){return k()}function u(e){c().registerExtension(e)}var b={name:"italic",create:()=>d,commands:{set_italic:e=>e.chain().focus().setItalic().run(),toggle_italic:e=>e.chain().focus().toggleItalic().run(),unset_italic:e=>e.chain().focus().unsetItalic().run()},selection_keys:["italic"],selection_state:e=>({italic:e.isActive("italic")})};function de(){u(b)}export{de as register_italic};
@@ -0,0 +1 @@
var o=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(o==null||o.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=o.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var g=t.CommandManager,_=t.Editor,m=t.Extension,x=t.InputRule,k=t.Mark,r=t.Node,b=t.NodePos,y=t.NodeView,h=t.PasteRule,E=t.Tracker,f=t.callOrReturn,R=t.canInsertNode,P=t.combineTransactionSteps,T=t.createChainableState,A=t.createDocument,C=t.createNodeFromContent,S=t.createStyleTag,N=t.defaultBlockAt,O=t.deleteProps,I=t.elementFromString,B=t.escapeForRegEx,w=t.extensions,v=t.findChildren,M=t.findChildrenInRange,L=t.findDuplicates,F=t.findParentNode,D=t.findParentNodeClosestToPos,j=t.fromString,K=t.generateHTML,H=t.generateJSON,q=t.generateText,G=t.getAttributes,z=t.getAttributesFromExtensions,J=t.getChangedRanges,U=t.getDebugJSON,V=t.getExtensionField,Y=t.getHTMLFromFragment,Q=t.getMarkAttributes,W=t.getMarkRange,X=t.getMarkType,Z=t.getMarksBetween,$=t.getNodeAtPosition,tt=t.getNodeAttributes,et=t.getNodeType,nt=t.getRenderedAttributes,ot=t.getSchema,it=t.getSchemaByResolvedExtensions,rt=t.getSchemaTypeByName,st=t.getSchemaTypeNameByName,lt=t.getSplittedAttributes,at=t.getText,dt=t.getTextBetween,pt=t.getTextContentFromNodes,ct=t.getTextSerializersFromSchema,ut=t.injectExtensionAttributesToParseRule,gt=t.inputRulesPlugin,_t=t.isActive,mt=t.isAtEndOfNode,xt=t.isAtStartOfNode,kt=t.isEmptyObject,bt=t.isExtensionRulesEnabled,yt=t.isFunction,ht=t.isList,Et=t.isMacOS,ft=t.isMarkActive,Rt=t.isNodeActive,Pt=t.isNodeEmpty,Tt=t.isNodeSelection,At=t.isNumber,Ct=t.isPlainObject,St=t.isRegExp,Nt=t.isSafari,Ot=t.isString,It=t.isTextSelection,Bt=t.isiOS,wt=t.markInputRule,vt=t.markPasteRule,s=t.mergeAttributes,Mt=t.mergeDeep,Lt=t.minMax,Ft=t.nodeInputRule,Dt=t.nodePasteRule,jt=t.objectIncludes,Kt=t.pasteRulesPlugin,Ht=t.posToDOMRect,qt=t.removeDuplicates,Gt=t.resolveFocusPosition,zt=t.rewriteUnknownContent,Jt=t.selectionToInsertionEnd,Ut=t.splitExtensions,Vt=t.textInputRule,Yt=t.textPasteRule,Qt=t.textblockTypeInputRule,Wt=t.wrappingInputRule;var l=r.create({name:"listItem",addOptions(){return{HTMLAttributes:{},bulletListTypeName:"bulletList",orderedListTypeName:"orderedList"}},content:"paragraph block*",defining:!0,parseHTML(){return[{tag:"li"}]},renderHTML({HTMLAttributes:e}){return["li",s(this.options.HTMLAttributes,e),0]},addKeyboardShortcuts(){return{Enter:()=>this.editor.commands.splitListItem(this.name),Tab:()=>this.editor.commands.sinkListItem(this.name),"Shift-Tab":()=>this.editor.commands.liftListItem(this.name)}}});var a="__LEPTOS_TIPTAP_BRIDGE__";function c(){let e=globalThis,n=e[a];if(n!=null)return n;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[a]=i,i}function d(){return c()}function p(e){d().registerExtension(e)}var u={name:"list_item",create:()=>l,commands:{split_list_item:(e,n)=>n.kind==="split_list_item"?e.chain().focus().splitListItem(e.isActive("taskList")?"taskItem":"listItem",n.attributes??{}).run():!1,sink_list_item:e=>e.chain().focus().sinkListItem(e.isActive("taskList")?"taskItem":"listItem").run(),lift_list_item:e=>e.chain().focus().liftListItem(e.isActive("taskList")?"taskItem":"listItem").run()}};function re(){p(u)}export{re as register_list_item};
@@ -0,0 +1 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=i.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,y=t.Extension,h=t.InputRule,f=t.Mark,a=t.Node,E=t.NodePos,R=t.NodeView,A=t.PasteRule,T=t.Tracker,P=t.callOrReturn,C=t.canInsertNode,S=t.combineTransactionSteps,N=t.createChainableState,O=t.createDocument,B=t.createNodeFromContent,M=t.createStyleTag,w=t.defaultBlockAt,I=t.deleteProps,v=t.elementFromString,F=t.escapeForRegEx,L=t.extensions,D=t.findChildren,j=t.findChildrenInRange,H=t.findDuplicates,K=t.findParentNode,q=t.findParentNodeClosestToPos,G=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,$=t.getChangedRanges,W=t.getDebugJSON,Q=t.getExtensionField,X=t.getHTMLFromFragment,Z=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,it=t.getNodeAttributes,rt=t.getNodeType,st=t.getRenderedAttributes,at=t.getSchema,lt=t.getSchemaByResolvedExtensions,dt=t.getSchemaTypeByName,pt=t.getSchemaTypeNameByName,ut=t.getSplittedAttributes,ct=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ht=t.isAtStartOfNode,ft=t.isEmptyObject,Et=t.isExtensionRulesEnabled,Rt=t.isFunction,At=t.isList,Tt=t.isMacOS,Pt=t.isMarkActive,Ct=t.isNodeActive,St=t.isNodeEmpty,Nt=t.isNodeSelection,Ot=t.isNumber,Bt=t.isPlainObject,Mt=t.isRegExp,wt=t.isSafari,It=t.isString,vt=t.isTextSelection,Ft=t.isiOS,Lt=t.markInputRule,Dt=t.markPasteRule,r=t.mergeAttributes,jt=t.mergeDeep,Ht=t.minMax,Kt=t.nodeInputRule,qt=t.nodePasteRule,Gt=t.objectIncludes,zt=t.pasteRulesPlugin,Jt=t.posToDOMRect,Ut=t.removeDuplicates,Vt=t.resolveFocusPosition,Yt=t.rewriteUnknownContent,$t=t.selectionToInsertionEnd,Wt=t.splitExtensions,Qt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,s=t.wrappingInputRule;var _="listItem",l="textStyle",d=/^(\d+)\.\s$/,p=a.create({name:"orderedList",addOptions(){return{itemTypeName:"listItem",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:"block list",content(){return`${this.options.itemTypeName}+`},addAttributes(){return{start:{default:1,parseHTML:e=>e.hasAttribute("start")?parseInt(e.getAttribute("start")||"",10):1},type:{default:null,parseHTML:e=>e.getAttribute("type")}}},parseHTML(){return[{tag:"ol"}]},renderHTML({HTMLAttributes:e}){let{start:n,...o}=e;return n===1?["ol",r(this.options.HTMLAttributes,o),0]:["ol",r(this.options.HTMLAttributes,e),0]},addCommands(){return{toggleOrderedList:()=>({commands:e,chain:n})=>this.options.keepAttributes?n().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(_,this.editor.getAttributes(l)).run():e.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{"Mod-Shift-7":()=>this.editor.commands.toggleOrderedList()}},addInputRules(){let e=s({find:d,type:this.type,getAttributes:n=>({start:+n[1]}),joinPredicate:(n,o)=>o.childCount+o.attrs.start===+n[1]});return(this.options.keepMarks||this.options.keepAttributes)&&(e=s({find:d,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:n=>({start:+n[1],...this.editor.getAttributes(l)}),joinPredicate:(n,o)=>o.childCount+o.attrs.start===+n[1],editor:this.editor})),[e]}});var u="__LEPTOS_TIPTAP_BRIDGE__";function x(){let e=globalThis,n=e[u];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[u]=o,o}function c(){return x()}function g(e){c().registerExtension(e)}var m={name:"ordered_list",create:()=>p,commands:{toggle_ordered_list:e=>e.chain().focus().toggleOrderedList().run()},selection_keys:["ordered_list"],selection_state:e=>({ordered_list:e.isActive("orderedList")})};function le(){g(m)}export{le as register_ordered_list};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var g=t.CommandManager,_=t.Editor,x=t.Extension,m=t.InputRule,k=t.Mark,i=t.Node,b=t.NodePos,y=t.NodeView,h=t.PasteRule,E=t.Tracker,P=t.callOrReturn,R=t.canInsertNode,f=t.combineTransactionSteps,C=t.createChainableState,T=t.createDocument,A=t.createNodeFromContent,S=t.createStyleTag,N=t.defaultBlockAt,O=t.deleteProps,B=t.elementFromString,w=t.escapeForRegEx,v=t.extensions,M=t.findChildren,I=t.findChildrenInRange,F=t.findDuplicates,D=t.findParentNode,j=t.findParentNodeClosestToPos,L=t.fromString,K=t.generateHTML,H=t.generateJSON,q=t.generateText,G=t.getAttributes,z=t.getAttributesFromExtensions,J=t.getChangedRanges,U=t.getDebugJSON,V=t.getExtensionField,Y=t.getHTMLFromFragment,Q=t.getMarkAttributes,W=t.getMarkRange,X=t.getMarkType,Z=t.getMarksBetween,$=t.getNodeAtPosition,tt=t.getNodeAttributes,et=t.getNodeType,nt=t.getRenderedAttributes,ot=t.getSchema,rt=t.getSchemaByResolvedExtensions,it=t.getSchemaTypeByName,st=t.getSchemaTypeNameByName,at=t.getSplittedAttributes,lt=t.getText,pt=t.getTextBetween,dt=t.getTextContentFromNodes,ct=t.getTextSerializersFromSchema,ut=t.injectExtensionAttributesToParseRule,gt=t.inputRulesPlugin,_t=t.isActive,xt=t.isAtEndOfNode,mt=t.isAtStartOfNode,kt=t.isEmptyObject,bt=t.isExtensionRulesEnabled,yt=t.isFunction,ht=t.isList,Et=t.isMacOS,Pt=t.isMarkActive,Rt=t.isNodeActive,ft=t.isNodeEmpty,Ct=t.isNodeSelection,Tt=t.isNumber,At=t.isPlainObject,St=t.isRegExp,Nt=t.isSafari,Ot=t.isString,Bt=t.isTextSelection,wt=t.isiOS,vt=t.markInputRule,Mt=t.markPasteRule,s=t.mergeAttributes,It=t.mergeDeep,Ft=t.minMax,Dt=t.nodeInputRule,jt=t.nodePasteRule,Lt=t.objectIncludes,Kt=t.pasteRulesPlugin,Ht=t.posToDOMRect,qt=t.removeDuplicates,Gt=t.resolveFocusPosition,zt=t.rewriteUnknownContent,Jt=t.selectionToInsertionEnd,Ut=t.splitExtensions,Vt=t.textInputRule,Yt=t.textPasteRule,Qt=t.textblockTypeInputRule,Wt=t.wrappingInputRule;var a=i.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:e}){return["p",s(this.options.HTMLAttributes,e),0]},addCommands(){return{setParagraph:()=>({commands:e})=>e.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var l="__LEPTOS_TIPTAP_BRIDGE__";function c(){let e=globalThis,o=e[l];if(o!=null)return o;let r={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[l]=r,r}function p(){return c()}function d(e){p().registerExtension(e)}var u={name:"paragraph",create:()=>a,commands:{set_paragraph:e=>e.chain().focus().setParagraph().run()},selection_keys:["paragraph"],selection_state:e=>({paragraph:e.isActive("paragraph")})};function ie(){d(u)}export{ie as register_paragraph};
@@ -0,0 +1 @@
var a=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(a==null||a.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=a.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var O=t.CommandManager,N=t.Editor,m=t.Extension,B=t.InputRule,v=t.Mark,D=t.Node,I=t.NodePos,F=t.NodeView,M=t.PasteRule,j=t.Tracker,L=t.callOrReturn,K=t.canInsertNode,G=t.combineTransactionSteps,q=t.createChainableState,H=t.createDocument,z=t.createNodeFromContent,J=t.createStyleTag,V=t.defaultBlockAt,W=t.deleteProps,U=t.elementFromString,Y=t.escapeForRegEx,Q=t.extensions,X=t.findChildren,Z=t.findChildrenInRange,$=t.findDuplicates,tt=t.findParentNode,et=t.findParentNodeClosestToPos,nt=t.fromString,ot=t.generateHTML,it=t.generateJSON,rt=t.generateText,st=t.getAttributes,lt=t.getAttributesFromExtensions,at=t.getChangedRanges,pt=t.getDebugJSON,dt=t.getExtensionField,ct=t.getHTMLFromFragment,ut=t.getMarkAttributes,gt=t.getMarkRange,_t=t.getMarkType,xt=t.getMarksBetween,mt=t.getNodeAtPosition,kt=t.getNodeAttributes,bt=t.getNodeType,yt=t.getRenderedAttributes,ht=t.getSchema,Et=t.getSchemaByResolvedExtensions,Pt=t.getSchemaTypeByName,ft=t.getSchemaTypeNameByName,St=t.getSplittedAttributes,Rt=t.getText,Ct=t.getTextBetween,Tt=t.getTextContentFromNodes,At=t.getTextSerializersFromSchema,wt=t.injectExtensionAttributesToParseRule,Ot=t.inputRulesPlugin,Nt=t.isActive,Bt=t.isAtEndOfNode,vt=t.isAtStartOfNode,Dt=t.isEmptyObject,It=t.isExtensionRulesEnabled,Ft=t.isFunction,Mt=t.isList,jt=t.isMacOS,Lt=t.isMarkActive,Kt=t.isNodeActive,k=t.isNodeEmpty,Gt=t.isNodeSelection,qt=t.isNumber,Ht=t.isPlainObject,zt=t.isRegExp,Jt=t.isSafari,Vt=t.isString,Wt=t.isTextSelection,Ut=t.isiOS,Yt=t.markInputRule,Qt=t.markPasteRule,Xt=t.mergeAttributes,Zt=t.mergeDeep,$t=t.minMax,te=t.nodeInputRule,ee=t.nodePasteRule,ne=t.objectIncludes,oe=t.pasteRulesPlugin,ie=t.posToDOMRect,re=t.removeDuplicates,se=t.resolveFocusPosition,le=t.rewriteUnknownContent,ae=t.selectionToInsertionEnd,pe=t.splitExtensions,de=t.textInputRule,ce=t.textPasteRule,ue=t.textblockTypeInputRule,ge=t.wrappingInputRule;var p=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(p==null||p.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var n=p.modules["@tiptap/pm/state"];if(n==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/state" is unavailable');var xe=n.AllSelection,me=n.EditorState,ke=n.NodeSelection,b=n.Plugin,y=n.PluginKey,be=n.Selection,ye=n.SelectionRange,he=n.TextSelection,Ee=n.Transaction;var d=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(d==null||d.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var o=d.modules["@tiptap/pm/view"];if(o==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/view" is unavailable');var h=o.Decoration,E=o.DecorationSet,fe=o.EditorView,Se=o.__endComposition,Re=o.__parseFromClipboard;var c=m.create({name:"placeholder",addOptions(){return{emptyEditorClass:"is-editor-empty",emptyNodeClass:"is-empty",placeholder:"Write something \u2026",showOnlyWhenEditable:!0,showOnlyCurrent:!0,includeChildren:!1}},addProseMirrorPlugins(){return[new b({key:new y("placeholder"),props:{decorations:({doc:e,selection:s})=>{let l=this.editor.isEditable||!this.options.showOnlyWhenEditable,{anchor:u}=s,g=[];if(!l)return null;let R=this.editor.isEmpty;return e.descendants((i,r)=>{let _=u>=r&&u<=r+i.nodeSize,C=!i.isLeaf&&k(i);if((_||!this.options.showOnlyCurrent)&&C){let x=[this.options.emptyNodeClass];R&&x.push(this.options.emptyEditorClass);let T=h.node(r,r+i.nodeSize,{class:x.join(" "),"data-placeholder":typeof this.options.placeholder=="function"?this.options.placeholder({editor:this.editor,node:i,pos:r,hasAnchor:_}):this.options.placeholder});g.push(T)}return this.options.includeChildren}),E.create(e,g)}}})]}});var P="__LEPTOS_TIPTAP_BRIDGE__";function A(){let e=globalThis,s=e[P];if(s!=null)return s;let l={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[P]=l,l}function f(){return A()}function S(e){f().registerExtension(e)}var w={name:"placeholder",create:({placeholder:e})=>e==null?c:c.configure({placeholder:e})};function Fe(){S(w)}export{Fe as register_placeholder};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,y=t.Extension,h=t.InputRule,r=t.Mark,R=t.Node,E=t.NodePos,f=t.NodeView,S=t.PasteRule,P=t.Tracker,C=t.callOrReturn,T=t.canInsertNode,A=t.combineTransactionSteps,N=t.createChainableState,O=t.createDocument,B=t.createNodeFromContent,w=t.createStyleTag,M=t.defaultBlockAt,v=t.deleteProps,I=t.elementFromString,F=t.escapeForRegEx,D=t.extensions,j=t.findChildren,L=t.findChildrenInRange,K=t.findDuplicates,H=t.findParentNode,q=t.findParentNodeClosestToPos,G=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,$=t.getChangedRanges,Q=t.getDebugJSON,W=t.getExtensionField,X=t.getHTMLFromFragment,Z=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,it=t.getNodeAttributes,rt=t.getNodeType,st=t.getRenderedAttributes,at=t.getSchema,lt=t.getSchemaByResolvedExtensions,dt=t.getSchemaTypeByName,pt=t.getSchemaTypeNameByName,ct=t.getSplittedAttributes,ut=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ht=t.isAtStartOfNode,Rt=t.isEmptyObject,Et=t.isExtensionRulesEnabled,ft=t.isFunction,St=t.isList,Pt=t.isMacOS,Ct=t.isMarkActive,Tt=t.isNodeActive,At=t.isNodeEmpty,Nt=t.isNodeSelection,Ot=t.isNumber,Bt=t.isPlainObject,wt=t.isRegExp,Mt=t.isSafari,vt=t.isString,It=t.isTextSelection,Ft=t.isiOS,s=t.markInputRule,a=t.markPasteRule,l=t.mergeAttributes,Dt=t.mergeDeep,jt=t.minMax,Lt=t.nodeInputRule,Kt=t.nodePasteRule,Ht=t.objectIncludes,qt=t.pasteRulesPlugin,Gt=t.posToDOMRect,zt=t.removeDuplicates,Jt=t.resolveFocusPosition,Ut=t.rewriteUnknownContent,Vt=t.selectionToInsertionEnd,Yt=t.splitExtensions,$t=t.textInputRule,Qt=t.textPasteRule,Wt=t.textblockTypeInputRule,Xt=t.wrappingInputRule;var g=/(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))$/,_=/(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))/g,d=r.create({name:"strike",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"s"},{tag:"del"},{tag:"strike"},{style:"text-decoration",consuming:!1,getAttrs:e=>e.includes("line-through")?{}:!1}]},renderHTML({HTMLAttributes:e}){return["s",l(this.options.HTMLAttributes,e),0]},addCommands(){return{setStrike:()=>({commands:e})=>e.setMark(this.name),toggleStrike:()=>({commands:e})=>e.toggleMark(this.name),unsetStrike:()=>({commands:e})=>e.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-s":()=>this.editor.commands.toggleStrike()}},addInputRules(){return[s({find:g,type:this.type})]},addPasteRules(){return[a({find:_,type:this.type})]}});var p="__LEPTOS_TIPTAP_BRIDGE__";function x(){let e=globalThis,o=e[p];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[p]=i,i}function c(){return x()}function u(e){c().registerExtension(e)}var m={name:"strike",create:()=>d,commands:{set_strike:e=>e.chain().focus().setStrike().run(),toggle_strike:e=>e.chain().focus().toggleStrike().run(),unset_strike:e=>e.chain().focus().unsetStrike().run()},selection_keys:["strike"],selection_state:e=>({strike:e.isActive("strike")})};function ae(){u(m)}export{ae as register_strike};
@@ -0,0 +1 @@
var u=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(u==null||u.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=u.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var P=t.CommandManager,S=t.Editor,O=t.Extension,N=t.InputRule,v=t.Mark,x=t.Node,w=t.NodePos,B=t.NodeView,I=t.PasteRule,M=t.Tracker,D=t.callOrReturn,F=t.canInsertNode,L=t.combineTransactionSteps,j=t.createChainableState,H=t.createDocument,K=t.createNodeFromContent,q=t.createStyleTag,G=t.defaultBlockAt,z=t.deleteProps,J=t.elementFromString,V=t.escapeForRegEx,Y=t.extensions,$=t.findChildren,U=t.findChildrenInRange,W=t.findDuplicates,Q=t.findParentNode,X=t.findParentNodeClosestToPos,Z=t.fromString,tt=t.generateHTML,et=t.generateJSON,nt=t.generateText,ot=t.getAttributes,it=t.getAttributesFromExtensions,rt=t.getChangedRanges,st=t.getDebugJSON,at=t.getExtensionField,lt=t.getHTMLFromFragment,dt=t.getMarkAttributes,ct=t.getMarkRange,pt=t.getMarkType,ut=t.getMarksBetween,gt=t.getNodeAtPosition,mt=t.getNodeAttributes,_t=t.getNodeType,xt=t.getRenderedAttributes,kt=t.getSchema,bt=t.getSchemaByResolvedExtensions,yt=t.getSchemaTypeByName,ht=t.getSchemaTypeNameByName,ft=t.getSplittedAttributes,Et=t.getText,Rt=t.getTextBetween,Tt=t.getTextContentFromNodes,At=t.getTextSerializersFromSchema,Ct=t.injectExtensionAttributesToParseRule,Pt=t.inputRulesPlugin,St=t.isActive,Ot=t.isAtEndOfNode,Nt=t.isAtStartOfNode,vt=t.isEmptyObject,wt=t.isExtensionRulesEnabled,Bt=t.isFunction,It=t.isList,Mt=t.isMacOS,Dt=t.isMarkActive,Ft=t.isNodeActive,Lt=t.isNodeEmpty,jt=t.isNodeSelection,Ht=t.isNumber,Kt=t.isPlainObject,qt=t.isRegExp,Gt=t.isSafari,zt=t.isString,Jt=t.isTextSelection,Vt=t.isiOS,Yt=t.markInputRule,$t=t.markPasteRule,k=t.mergeAttributes,Ut=t.mergeDeep,Wt=t.minMax,Qt=t.nodeInputRule,Xt=t.nodePasteRule,Zt=t.objectIncludes,te=t.pasteRulesPlugin,ee=t.posToDOMRect,ne=t.removeDuplicates,oe=t.resolveFocusPosition,ie=t.rewriteUnknownContent,re=t.selectionToInsertionEnd,se=t.splitExtensions,ae=t.textInputRule,le=t.textPasteRule,de=t.textblockTypeInputRule,b=t.wrappingInputRule;var T=/^\s*(\[([( |x])?\])\s$/,y=x.create({name:"taskItem",addOptions(){return{nested:!1,HTMLAttributes:{},taskListTypeName:"taskList",a11y:void 0}},content(){return this.options.nested?"paragraph block*":"paragraph+"},defining:!0,addAttributes(){return{checked:{default:!1,keepOnSplit:!1,parseHTML:e=>{let r=e.getAttribute("data-checked");return r===""||r==="true"},renderHTML:e=>({"data-checked":e.checked})}}},parseHTML(){return[{tag:`li[data-type="${this.name}"]`,priority:51}]},renderHTML({node:e,HTMLAttributes:r}){return["li",k(this.options.HTMLAttributes,r,{"data-type":this.name}),["label",["input",{type:"checkbox",checked:e.attrs.checked?"checked":null}],["span"]],["div",0]]},addKeyboardShortcuts(){let e={Enter:()=>this.editor.commands.splitListItem(this.name),"Shift-Tab":()=>this.editor.commands.liftListItem(this.name)};return this.options.nested?{...e,Tab:()=>this.editor.commands.sinkListItem(this.name)}:e},addNodeView(){return({node:e,HTMLAttributes:r,getPos:a,editor:l})=>{let s=document.createElement("li"),d=document.createElement("label"),R=document.createElement("span"),o=document.createElement("input"),g=document.createElement("div"),m=()=>{var n,i;o.ariaLabel=((i=(n=this.options.a11y)===null||n===void 0?void 0:n.checkboxLabel)===null||i===void 0?void 0:i.call(n,e,o.checked))||`Task item checkbox for ${e.textContent||"empty task item"}`};return m(),d.contentEditable="false",o.type="checkbox",o.addEventListener("mousedown",n=>n.preventDefault()),o.addEventListener("change",n=>{if(!l.isEditable&&!this.options.onReadOnlyChecked){o.checked=!o.checked;return}let{checked:i}=n.target;l.isEditable&&typeof a=="function"&&l.chain().focus(void 0,{scrollIntoView:!1}).command(({tr:_})=>{let c=a();if(typeof c!="number")return!1;let p=_.doc.nodeAt(c);return _.setNodeMarkup(c,void 0,{...p?.attrs,checked:i}),!0}).run(),!l.isEditable&&this.options.onReadOnlyChecked&&(this.options.onReadOnlyChecked(e,i)||(o.checked=!o.checked))}),Object.entries(this.options.HTMLAttributes).forEach(([n,i])=>{s.setAttribute(n,i)}),s.dataset.checked=e.attrs.checked,o.checked=e.attrs.checked,d.append(o,R),s.append(d,g),Object.entries(r).forEach(([n,i])=>{s.setAttribute(n,i)}),{dom:s,contentDOM:g,update:n=>n.type!==this.type?!1:(s.dataset.checked=n.attrs.checked,o.checked=n.attrs.checked,m(),!0)}}},addInputRules(){return[b({find:T,type:this.type,getAttributes:e=>({checked:e[e.length-1]==="x"})})]}});var h="__LEPTOS_TIPTAP_BRIDGE__";function A(){let e=globalThis,r=e[h];if(r!=null)return r;let a={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[h]=a,a}function f(){return A()}function E(e){f().registerExtension(e)}var C={name:"task_item",create:()=>y.configure({nested:!0})};function be(){E(C)}export{be as register_task_item};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var g=t.CommandManager,_=t.Editor,x=t.Extension,m=t.InputRule,k=t.Mark,r=t.Node,b=t.NodePos,y=t.NodeView,h=t.PasteRule,E=t.Tracker,R=t.callOrReturn,T=t.canInsertNode,f=t.combineTransactionSteps,P=t.createChainableState,C=t.createDocument,A=t.createNodeFromContent,S=t.createStyleTag,N=t.defaultBlockAt,O=t.deleteProps,B=t.elementFromString,w=t.escapeForRegEx,v=t.extensions,M=t.findChildren,I=t.findChildrenInRange,F=t.findDuplicates,D=t.findParentNode,L=t.findParentNodeClosestToPos,j=t.fromString,K=t.generateHTML,H=t.generateJSON,q=t.generateText,G=t.getAttributes,z=t.getAttributesFromExtensions,J=t.getChangedRanges,U=t.getDebugJSON,V=t.getExtensionField,Y=t.getHTMLFromFragment,$=t.getMarkAttributes,Q=t.getMarkRange,W=t.getMarkType,X=t.getMarksBetween,Z=t.getNodeAtPosition,tt=t.getNodeAttributes,et=t.getNodeType,nt=t.getRenderedAttributes,ot=t.getSchema,it=t.getSchemaByResolvedExtensions,rt=t.getSchemaTypeByName,st=t.getSchemaTypeNameByName,at=t.getSplittedAttributes,lt=t.getText,dt=t.getTextBetween,pt=t.getTextContentFromNodes,ct=t.getTextSerializersFromSchema,ut=t.injectExtensionAttributesToParseRule,gt=t.inputRulesPlugin,_t=t.isActive,xt=t.isAtEndOfNode,mt=t.isAtStartOfNode,kt=t.isEmptyObject,bt=t.isExtensionRulesEnabled,yt=t.isFunction,ht=t.isList,Et=t.isMacOS,Rt=t.isMarkActive,Tt=t.isNodeActive,ft=t.isNodeEmpty,Pt=t.isNodeSelection,Ct=t.isNumber,At=t.isPlainObject,St=t.isRegExp,Nt=t.isSafari,Ot=t.isString,Bt=t.isTextSelection,wt=t.isiOS,vt=t.markInputRule,Mt=t.markPasteRule,s=t.mergeAttributes,It=t.mergeDeep,Ft=t.minMax,Dt=t.nodeInputRule,Lt=t.nodePasteRule,jt=t.objectIncludes,Kt=t.pasteRulesPlugin,Ht=t.posToDOMRect,qt=t.removeDuplicates,Gt=t.resolveFocusPosition,zt=t.rewriteUnknownContent,Jt=t.selectionToInsertionEnd,Ut=t.splitExtensions,Vt=t.textInputRule,Yt=t.textPasteRule,$t=t.textblockTypeInputRule,Qt=t.wrappingInputRule;var a=r.create({name:"taskList",addOptions(){return{itemTypeName:"taskItem",HTMLAttributes:{}}},group:"block list",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:`ul[data-type="${this.name}"]`,priority:51}]},renderHTML({HTMLAttributes:e}){return["ul",s(this.options.HTMLAttributes,e,{"data-type":this.name}),0]},addCommands(){return{toggleTaskList:()=>({commands:e})=>e.toggleList(this.name,this.options.itemTypeName)}},addKeyboardShortcuts(){return{"Mod-Shift-9":()=>this.editor.commands.toggleTaskList()}}});var l="__LEPTOS_TIPTAP_BRIDGE__";function c(){let e=globalThis,o=e[l];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[l]=i,i}function d(){return c()}function p(e){d().registerExtension(e)}var u={name:"task_list",create:()=>a,commands:{toggle_task_list:e=>e.chain().focus().toggleTaskList().run()},selection_keys:["task_list"],selection_state:e=>({task_list:e.isActive("taskList")})};function re(){p(u)}export{re as register_task_list};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var u=t.CommandManager,g=t.Editor,_=t.Extension,x=t.InputRule,m=t.Mark,r=t.Node,k=t.NodePos,b=t.NodeView,y=t.PasteRule,E=t.Tracker,R=t.callOrReturn,h=t.canInsertNode,f=t.combineTransactionSteps,P=t.createChainableState,C=t.createDocument,T=t.createNodeFromContent,S=t.createStyleTag,A=t.defaultBlockAt,N=t.deleteProps,O=t.elementFromString,B=t.escapeForRegEx,w=t.extensions,v=t.findChildren,I=t.findChildrenInRange,M=t.findDuplicates,F=t.findParentNode,D=t.findParentNodeClosestToPos,j=t.fromString,L=t.generateHTML,K=t.generateJSON,q=t.generateText,G=t.getAttributes,H=t.getAttributesFromExtensions,z=t.getChangedRanges,J=t.getDebugJSON,U=t.getExtensionField,V=t.getHTMLFromFragment,Y=t.getMarkAttributes,Q=t.getMarkRange,W=t.getMarkType,X=t.getMarksBetween,Z=t.getNodeAtPosition,$=t.getNodeAttributes,tt=t.getNodeType,et=t.getRenderedAttributes,nt=t.getSchema,ot=t.getSchemaByResolvedExtensions,it=t.getSchemaTypeByName,rt=t.getSchemaTypeNameByName,st=t.getSplittedAttributes,lt=t.getText,at=t.getTextBetween,dt=t.getTextContentFromNodes,pt=t.getTextSerializersFromSchema,ct=t.injectExtensionAttributesToParseRule,ut=t.inputRulesPlugin,gt=t.isActive,_t=t.isAtEndOfNode,xt=t.isAtStartOfNode,mt=t.isEmptyObject,kt=t.isExtensionRulesEnabled,bt=t.isFunction,yt=t.isList,Et=t.isMacOS,Rt=t.isMarkActive,ht=t.isNodeActive,ft=t.isNodeEmpty,Pt=t.isNodeSelection,Ct=t.isNumber,Tt=t.isPlainObject,St=t.isRegExp,At=t.isSafari,Nt=t.isString,Ot=t.isTextSelection,Bt=t.isiOS,wt=t.markInputRule,vt=t.markPasteRule,It=t.mergeAttributes,Mt=t.mergeDeep,Ft=t.minMax,Dt=t.nodeInputRule,jt=t.nodePasteRule,Lt=t.objectIncludes,Kt=t.pasteRulesPlugin,qt=t.posToDOMRect,Gt=t.removeDuplicates,Ht=t.resolveFocusPosition,zt=t.rewriteUnknownContent,Jt=t.selectionToInsertionEnd,Ut=t.splitExtensions,Vt=t.textInputRule,Yt=t.textPasteRule,Qt=t.textblockTypeInputRule,Wt=t.wrappingInputRule;var s=r.create({name:"text",group:"inline"});var l="__LEPTOS_TIPTAP_BRIDGE__";function p(){let e=globalThis,o=e[l];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[l]=i,i}function a(){return p()}function d(e){a().registerExtension(e)}var c={name:"text",create:()=>s};function re(){d(c)}export{re as register_text};
@@ -0,0 +1 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=i.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var u=t.CommandManager,x=t.Editor,r=t.Extension,_=t.InputRule,m=t.Mark,k=t.Node,b=t.NodePos,y=t.NodeView,f=t.PasteRule,h=t.Tracker,A=t.callOrReturn,E=t.canInsertNode,T=t.combineTransactionSteps,R=t.createChainableState,P=t.createDocument,S=t.createNodeFromContent,C=t.createStyleTag,N=t.defaultBlockAt,O=t.deleteProps,B=t.elementFromString,w=t.escapeForRegEx,v=t.extensions,M=t.findChildren,I=t.findChildrenInRange,j=t.findDuplicates,F=t.findParentNode,D=t.findParentNodeClosestToPos,L=t.fromString,K=t.generateHTML,G=t.generateJSON,H=t.generateText,q=t.getAttributes,z=t.getAttributesFromExtensions,J=t.getChangedRanges,U=t.getDebugJSON,V=t.getExtensionField,Y=t.getHTMLFromFragment,$=t.getMarkAttributes,Q=t.getMarkRange,W=t.getMarkType,X=t.getMarksBetween,Z=t.getNodeAtPosition,tt=t.getNodeAttributes,et=t.getNodeType,nt=t.getRenderedAttributes,ot=t.getSchema,it=t.getSchemaByResolvedExtensions,rt=t.getSchemaTypeByName,st=t.getSchemaTypeNameByName,lt=t.getSplittedAttributes,at=t.getText,dt=t.getTextBetween,pt=t.getTextContentFromNodes,gt=t.getTextSerializersFromSchema,ct=t.injectExtensionAttributesToParseRule,ut=t.inputRulesPlugin,xt=t.isActive,_t=t.isAtEndOfNode,mt=t.isAtStartOfNode,kt=t.isEmptyObject,bt=t.isExtensionRulesEnabled,yt=t.isFunction,ft=t.isList,ht=t.isMacOS,At=t.isMarkActive,Et=t.isNodeActive,Tt=t.isNodeEmpty,Rt=t.isNodeSelection,Pt=t.isNumber,St=t.isPlainObject,Ct=t.isRegExp,Nt=t.isSafari,Ot=t.isString,Bt=t.isTextSelection,wt=t.isiOS,vt=t.markInputRule,Mt=t.markPasteRule,It=t.mergeAttributes,jt=t.mergeDeep,Ft=t.minMax,Dt=t.nodeInputRule,Lt=t.nodePasteRule,Kt=t.objectIncludes,Gt=t.pasteRulesPlugin,Ht=t.posToDOMRect,qt=t.removeDuplicates,zt=t.resolveFocusPosition,Jt=t.rewriteUnknownContent,Ut=t.selectionToInsertionEnd,Vt=t.splitExtensions,Yt=t.textInputRule,$t=t.textPasteRule,Qt=t.textblockTypeInputRule,Wt=t.wrappingInputRule;var s=r.create({name:"textAlign",addOptions(){return{types:[],alignments:["left","center","right","justify"],defaultAlignment:null}},addGlobalAttributes(){return[{types:this.options.types,attributes:{textAlign:{default:this.options.defaultAlignment,parseHTML:e=>{let n=e.style.textAlign;return this.options.alignments.includes(n)?n:this.options.defaultAlignment},renderHTML:e=>e.textAlign?{style:`text-align: ${e.textAlign}`}:{}}}}]},addCommands(){return{setTextAlign:e=>({commands:n})=>this.options.alignments.includes(e)?this.options.types.map(o=>n.updateAttributes(o,{textAlign:e})).every(o=>o):!1,unsetTextAlign:()=>({commands:e})=>this.options.types.map(n=>e.resetAttributes(n,"textAlign")).every(n=>n),toggleTextAlign:e=>({editor:n,commands:o})=>this.options.alignments.includes(e)?n.isActive({textAlign:e})?o.unsetTextAlign():o.setTextAlign(e):!1}},addKeyboardShortcuts(){return{"Mod-Shift-l":()=>this.editor.commands.setTextAlign("left"),"Mod-Shift-e":()=>this.editor.commands.setTextAlign("center"),"Mod-Shift-r":()=>this.editor.commands.setTextAlign("right"),"Mod-Shift-j":()=>this.editor.commands.setTextAlign("justify")}}});var l="__LEPTOS_TIPTAP_BRIDGE__";function g(){let e=globalThis,n=e[l];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[l]=o,o}function a(){return g()}function d(e){a().registerExtension(e)}var p=["left","center","right","justify"],c={name:"text_align",create:()=>s.configure({types:["heading","paragraph"]}),commands:{set_text_align:(e,n)=>n.kind==="set_text_align"&&p.includes(n.alignment)?e.chain().focus().setTextAlign(n.alignment).run():!1,toggle_text_align:(e,n)=>n.kind==="toggle_text_align"&&p.includes(n.alignment)?e.chain().focus().toggleTextAlign(n.alignment).run():!1,unset_text_align:e=>e.chain().focus().unsetTextAlign().run()},selection_keys:["align_left","align_center","align_right","align_justify"],selection_state:e=>({align_left:e.isActive({textAlign:"left"}),align_center:e.isActive({textAlign:"center"}),align_right:e.isActive({textAlign:"right"}),align_justify:e.isActive({textAlign:"justify"})})};function se(){d(c)}export{se as register_text_align};
@@ -0,0 +1 @@
var l=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(l==null||l.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=l.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var f=t.CommandManager,h=t.Editor,a=t.Extension,E=t.InputRule,d=t.Mark,S=t.Node,R=t.NodePos,C=t.NodeView,T=t.PasteRule,A=t.Tracker,P=t.callOrReturn,N=t.canInsertNode,O=t.combineTransactionSteps,v=t.createChainableState,B=t.createDocument,w=t.createNodeFromContent,M=t.createStyleTag,I=t.defaultBlockAt,F=t.deleteProps,D=t.elementFromString,j=t.escapeForRegEx,L=t.extensions,H=t.findChildren,K=t.findChildrenInRange,q=t.findDuplicates,G=t.findParentNode,z=t.findParentNodeClosestToPos,J=t.fromString,$=t.generateHTML,U=t.generateJSON,V=t.generateText,Y=t.getAttributes,Q=t.getAttributesFromExtensions,W=t.getChangedRanges,X=t.getDebugJSON,Z=t.getExtensionField,tt=t.getHTMLFromFragment,et=t.getMarkAttributes,nt=t.getMarkRange,ot=t.getMarkType,rt=t.getMarksBetween,it=t.getNodeAtPosition,st=t.getNodeAttributes,lt=t.getNodeType,at=t.getRenderedAttributes,dt=t.getSchema,pt=t.getSchemaByResolvedExtensions,ct=t.getSchemaTypeByName,ut=t.getSchemaTypeNameByName,gt=t.getSplittedAttributes,_t=t.getText,xt=t.getTextBetween,mt=t.getTextContentFromNodes,kt=t.getTextSerializersFromSchema,bt=t.injectExtensionAttributesToParseRule,yt=t.inputRulesPlugin,ft=t.isActive,ht=t.isAtEndOfNode,Et=t.isAtStartOfNode,St=t.isEmptyObject,Rt=t.isExtensionRulesEnabled,Ct=t.isFunction,Tt=t.isList,At=t.isMacOS,Pt=t.isMarkActive,Nt=t.isNodeActive,Ot=t.isNodeEmpty,vt=t.isNodeSelection,Bt=t.isNumber,wt=t.isPlainObject,Mt=t.isRegExp,It=t.isSafari,Ft=t.isString,Dt=t.isTextSelection,jt=t.isiOS,Lt=t.markInputRule,Ht=t.markPasteRule,p=t.mergeAttributes,Kt=t.mergeDeep,qt=t.minMax,Gt=t.nodeInputRule,zt=t.nodePasteRule,Jt=t.objectIncludes,$t=t.pasteRulesPlugin,Ut=t.posToDOMRect,Vt=t.removeDuplicates,Yt=t.resolveFocusPosition,Qt=t.rewriteUnknownContent,Wt=t.selectionToInsertionEnd,Xt=t.splitExtensions,Zt=t.textInputRule,te=t.textPasteRule,ee=t.textblockTypeInputRule,ne=t.wrappingInputRule;var k=e=>{if(!e.children.length)return;let n=e.querySelectorAll("span");n&&n.forEach(o=>{var i,r;let s=o.getAttribute("style"),m=(r=(i=o.parentElement)===null||i===void 0?void 0:i.closest("span"))===null||r===void 0?void 0:r.getAttribute("style");o.setAttribute("style",`${m};${s}`)})},c=d.create({name:"textStyle",priority:101,addOptions(){return{HTMLAttributes:{},mergeNestedSpanStyles:!1}},parseHTML(){return[{tag:"span",getAttrs:e=>e.hasAttribute("style")?(this.options.mergeNestedSpanStyles&&k(e),{}):!1}]},renderHTML({HTMLAttributes:e}){return["span",p(this.options.HTMLAttributes,e),0]},addCommands(){return{removeEmptyTextStyle:()=>({tr:e})=>{let{selection:n}=e;return e.doc.nodesBetween(n.from,n.to,(o,i)=>{if(o.isTextblock)return!0;o.marks.filter(r=>r.type===this.type).some(r=>Object.values(r.attrs).some(s=>!!s))||e.removeMark(i,i+o.nodeSize,this.type)}),!0}}}});var u=a.create({name:"color",addOptions(){return{types:["textStyle"]}},addGlobalAttributes(){return[{types:this.options.types,attributes:{color:{default:null,parseHTML:e=>{var n;return(n=e.style.color)===null||n===void 0?void 0:n.replace(/['"]+/g,"")},renderHTML:e=>e.color?{style:`color: ${e.color}`}:{}}}}]},addCommands(){return{setColor:e=>({chain:n})=>n().setMark("textStyle",{color:e}).run(),unsetColor:()=>({chain:e})=>e().setMark("textStyle",{color:null}).removeEmptyTextStyle().run()}}});var g="__LEPTOS_TIPTAP_BRIDGE__";function b(){let e=globalThis,n=e[g];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[g]=o,o}function _(){return b()}function x(e){_().registerExtension(e)}var y={name:"text_style",create:()=>[c,u],commands:{set_color:(e,n)=>n.kind==="set_color"?e.chain().focus().setColor(n.attributes.color).run():!1,unset_color:e=>e.chain().focus().unsetColor().run()},selection_keys:["text_style"],selection_state:e=>({text_style:e.isActive("textStyle")})};function xe(){x(y)}export{xe as register_text_style};
@@ -0,0 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=n.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var g=e.CommandManager,_=e.Editor,x=e.Extension,m=e.InputRule,r=e.Mark,k=e.Node,b=e.NodePos,y=e.NodeView,E=e.PasteRule,h=e.Tracker,R=e.callOrReturn,f=e.canInsertNode,P=e.combineTransactionSteps,C=e.createChainableState,T=e.createDocument,A=e.createNodeFromContent,S=e.createStyleTag,N=e.defaultBlockAt,O=e.deleteProps,B=e.elementFromString,w=e.escapeForRegEx,M=e.extensions,v=e.findChildren,I=e.findChildrenInRange,F=e.findDuplicates,D=e.findParentNode,j=e.findParentNodeClosestToPos,L=e.fromString,K=e.generateHTML,H=e.generateJSON,U=e.generateText,q=e.getAttributes,G=e.getAttributesFromExtensions,z=e.getChangedRanges,J=e.getDebugJSON,V=e.getExtensionField,Y=e.getHTMLFromFragment,Q=e.getMarkAttributes,W=e.getMarkRange,X=e.getMarkType,Z=e.getMarksBetween,$=e.getNodeAtPosition,ee=e.getNodeAttributes,te=e.getNodeType,ne=e.getRenderedAttributes,oe=e.getSchema,ie=e.getSchemaByResolvedExtensions,re=e.getSchemaTypeByName,se=e.getSchemaTypeNameByName,le=e.getSplittedAttributes,ae=e.getText,de=e.getTextBetween,pe=e.getTextContentFromNodes,ce=e.getTextSerializersFromSchema,ue=e.injectExtensionAttributesToParseRule,ge=e.inputRulesPlugin,_e=e.isActive,xe=e.isAtEndOfNode,me=e.isAtStartOfNode,ke=e.isEmptyObject,be=e.isExtensionRulesEnabled,ye=e.isFunction,Ee=e.isList,he=e.isMacOS,Re=e.isMarkActive,fe=e.isNodeActive,Pe=e.isNodeEmpty,Ce=e.isNodeSelection,Te=e.isNumber,Ae=e.isPlainObject,Se=e.isRegExp,Ne=e.isSafari,Oe=e.isString,Be=e.isTextSelection,we=e.isiOS,Me=e.markInputRule,ve=e.markPasteRule,s=e.mergeAttributes,Ie=e.mergeDeep,Fe=e.minMax,De=e.nodeInputRule,je=e.nodePasteRule,Le=e.objectIncludes,Ke=e.pasteRulesPlugin,He=e.posToDOMRect,Ue=e.removeDuplicates,qe=e.resolveFocusPosition,Ge=e.rewriteUnknownContent,ze=e.selectionToInsertionEnd,Je=e.splitExtensions,Ve=e.textInputRule,Ye=e.textPasteRule,Qe=e.textblockTypeInputRule,We=e.wrappingInputRule;var l=r.create({name:"underline",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"u"},{style:"text-decoration",consuming:!1,getAttrs:t=>t.includes("underline")?{}:!1}]},renderHTML({HTMLAttributes:t}){return["u",s(this.options.HTMLAttributes,t),0]},addCommands(){return{setUnderline:()=>({commands:t})=>t.setMark(this.name),toggleUnderline:()=>({commands:t})=>t.toggleMark(this.name),unsetUnderline:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-u":()=>this.editor.commands.toggleUnderline(),"Mod-U":()=>this.editor.commands.toggleUnderline()}}});var a="__LEPTOS_TIPTAP_BRIDGE__";function c(){let t=globalThis,o=t[a];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[a]=i,i}function d(){return c()}function p(t){d().registerExtension(t)}var u={name:"underline",create:()=>l,selection_keys:["underline"],selection_state:t=>({underline:t.isActive("underline")})};function rt(){p(u)}export{rt as register_underline};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,237 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
function readDocumentIdFromUrl(url) {
try {
const parsed = new URL(url);
const match = parsed.pathname.match(/^\/documents\/([^/]+)$/);
return match ? match[1] : null;
} catch {
return null;
}
}
async function collectRuntimeIslandDiagnostics(page) {
return page.evaluate(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = document.querySelector("[data-editor-host-observability]");
const editor = host?.querySelector(".editor-surface .ProseMirror");
const textareaCount = host?.querySelectorAll("textarea").length ?? 0;
const contenteditableCount = host?.querySelectorAll(".editor-surface .ProseMirror[contenteditable]").length ?? 0;
return {
hostKind: host?.getAttribute("data-editor-host-kind") ?? null,
runtimeStatus: host?.getAttribute("data-runtime-editor-status") ?? null,
activeHostKind: observability?.getAttribute("data-editor-host-active") ?? null,
observability: observability?.getAttribute("data-editor-host-observability") ?? null,
editorTagName: editor instanceof HTMLElement ? editor.tagName : null,
editorIsContentEditable: editor instanceof HTMLElement ? editor.isContentEditable : false,
editorContentEditableAttr: editor instanceof HTMLElement ? editor.getAttribute("contenteditable") : null,
editorCount: host?.querySelectorAll(".editor-surface .ProseMirror").length ?? 0,
textareaCount,
contenteditableCount,
hostHTML: host?.innerHTML?.slice(0, 2000) ?? null,
};
});
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
try {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = document.querySelector("[data-editor-host-observability]");
const editor = host?.querySelector(".editor-surface .ProseMirror");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
observability?.getAttribute("data-editor-host-active") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable === true
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostics, null, 2)}`,
);
}
const editorCount = await root.locator(".editor-surface .ProseMirror").count();
const textareaCount = await root.locator("textarea").count();
const contenteditableCount = await root.locator(".editor-surface .ProseMirror[contenteditable]").count();
if (editorCount === 0 || contenteditableCount === 0 || textareaCount > 0) {
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
throw new Error(
[
editorCount === 0 ? "island 主编辑器根节点内缺少 `.editor-surface .ProseMirror` surface" : null,
contenteditableCount === 0 ? "island 主编辑器 surface 未暴露真实 contenteditable" : null,
textareaCount > 0 ? "island 主编辑器根节点内不应回退为 textarea" : null,
]
.filter(Boolean)
.join("") +
`\n${JSON.stringify(diagnostics, null, 2)}`,
);
}
}
async function waitForSaved(page) {
await page.waitForFunction(
() =>
document
.querySelector('[data-editor-host-kind="leptos_tiptap_island"]')
?.getAttribute("data-runtime-editor-status") === "saved",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return editor?.textContent ?? "";
});
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let createdDocumentId = null;
let createdWorkspaceId = null;
try {
await ensureAuthenticated(page, context.request);
const created = await createTempDocument(context.request, null);
createdDocumentId = created.documentId;
createdWorkspaceId = created.workspaceId;
await page.goto(
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}`,
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
);
assert(
page.url().includes(`/documents/${createdDocumentId}`),
`未进入新建页面:${page.url()}`,
);
await waitForRuntimeIsland(page);
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
const text = `task108-island-${Date.now().toString().slice(-6)}`;
await editor.evaluate((el) => {
if (el instanceof HTMLElement) {
el.focus();
}
});
await page.keyboard.type(text, { delay: 30 });
await waitForSaved(page);
assert((await readEditorText(page)).includes(text), "默认 runtime island 未写入文本");
await page.keyboard.press("Control+z");
await page.waitForFunction(
(expected) => {
const editorNode = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
);
return !(editorNode?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
await page.keyboard.press("Control+y");
await page.waitForFunction(
(expected) => {
const editorNode = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
);
return (editorNode?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
await waitForSaved(page);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(text), "刷新后未回填 runtime island 保存内容");
await page.goto(
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}&editorHost=blocknote`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() =>
document
.querySelector("[data-editor-host-observability]")
?.getAttribute("data-editor-host-active") === "blocknote",
null,
{ timeout: UI_TIMEOUT_MS },
);
const runtimeIslandCount = await page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
.count();
assert(runtimeIslandCount === 0, "显式 blocknote 回退下不应继续挂载 island 主编辑器");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: createdDocumentId,
workspaceId: createdWorkspaceId,
text,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (createdDocumentId) {
try {
await purgeDocument(context.request, createdDocumentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,217 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForSaved(page) {
await page.waitForFunction(
() =>
document
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
?.getAttribute("data-runtime-editor-status") === "saved",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForTextPresent(page, text) {
await page.waitForFunction(
(expected) => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPersisted(page, saveRequests, documentId, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const editorText = await readEditorText(page);
const runtimeStatus = await page.evaluate(() => {
return (
document
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
?.getAttribute("data-runtime-editor-status") ?? null
);
});
const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId);
if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) {
return;
}
await page.waitForTimeout(250);
}
throw new Error(`等待持久化超时:${documentId}`);
}
async function typeIntoEditor(page, text) {
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text, { delay: 30 });
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return editor?.textContent ?? "";
});
}
async function openDocument(page, documentId, workspaceId) {
await page.goto(
`${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
);
await waitForRuntimeIsland(page);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let docA = null;
let docB = null;
let caughtError = null;
const saveRequests = [];
page.on("request", (request) => {
if (!request.url().includes("/api/documents/save") || request.method() !== "POST") {
return;
}
const payload = request.postDataJSON();
saveRequests.push({
documentId: payload?.documentId ?? null,
workspaceId: payload?.workspaceId ?? null,
revision: payload?.revision ?? null,
});
});
try {
await ensureAuthenticated(page, context.request);
docA = await createTempDocument(context.request, null);
docB = await createTempDocument(context.request, null);
const textA = `doc-a-${Date.now().toString().slice(-6)}`;
const textB = `doc-b-${(Date.now() + 1).toString().slice(-6)}`;
await openDocument(page, docA.documentId, docA.workspaceId);
await typeIntoEditor(page, textA);
await waitForTextPresent(page, textA);
await waitForPersisted(page, saveRequests, docA.documentId, textA);
assert((await readEditorText(page)).includes(textA), "A 页文本未进入编辑区");
await openDocument(page, docB.documentId, docB.workspaceId);
await typeIntoEditor(page, textB);
await waitForTextPresent(page, textB);
await waitForPersisted(page, saveRequests, docB.documentId, textB);
assert((await readEditorText(page)).includes(textB), "B 页文本未进入编辑区");
await openDocument(page, docA.documentId, docA.workspaceId);
assert((await readEditorText(page)).includes(textA), "切回 A 页后内容串页或丢失");
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(textA), "A 页刷新后未回填");
await openDocument(page, docB.documentId, docB.workspaceId);
assert((await readEditorText(page)).includes(textB), "切回 B 页后内容串页或丢失");
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(textB), "B 页刷新后未回填");
const savedDocumentIds = new Set(saveRequests.map((item) => item.documentId).filter(Boolean));
assert(savedDocumentIds.has(docA.documentId), "未观察到 A 页保存请求");
assert(savedDocumentIds.has(docB.documentId), "未观察到 B 页保存请求");
assert(
saveRequests.some(
(item) => item.documentId === docA.documentId && item.workspaceId === docA.workspaceId,
),
"A 页保存请求缺少正确 workspaceId/documentId 绑定",
);
assert(
saveRequests.some(
(item) => item.documentId === docB.documentId && item.workspaceId === docB.workspaceId,
),
"B 页保存请求缺少正确 workspaceId/documentId 绑定",
);
console.log(
JSON.stringify(
{
ok: true,
docA,
docB,
saveRequests,
textA,
textB,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
for (const doc of [docA, docB]) {
if (!doc?.documentId) {
continue;
}
try {
await purgeDocument(context.request, doc.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,217 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
openDocument,
openFilesystemView,
openSectionView,
renameDocument,
} = require("./tree-shell-smoke-helpers");
async function waitForPageTitleInput(page) {
const input = page.getByLabel("页面标题");
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function waitForTitleSave(page, documentId, expectedTitle) {
await page.waitForResponse(
async (response) => {
if (!response.url().includes("/api/documents/title") || response.request().method() !== "POST") {
return false;
}
const payload = response.request().postDataJSON();
return payload?.documentId === documentId && payload?.title === expectedTitle && response.ok();
},
{ timeout: UI_TIMEOUT_MS },
);
}
async function renameThroughPageHead(page, documentId, title) {
const titleInput = await waitForPageTitleInput(page);
await titleInput.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.type(title, { delay: 20 });
await titleInput.blur();
await waitForTitleSave(page, documentId, title);
}
async function waitForBreadcrumbTitle(page, title) {
await page.waitForFunction(
(expectedTitle) => {
const nav = document.querySelector("header nav");
return (nav?.textContent ?? "").includes(expectedTitle);
},
title,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForSidebarRowTitle(page, documentId, title) {
await page.waitForFunction(
({ docId, expectedTitle }) => {
const row = document.querySelector(`aside a[href="/documents/${docId}"]`);
return (row?.textContent ?? "").includes(expectedTitle);
},
{ docId: documentId, expectedTitle: title },
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForPageTreeTitle(page, documentId, title) {
await openSectionView(page);
const shellHost = page.getByTestId("sidebar-page-tree-shell");
await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ nodeId, expectedTitle }) => {
const row = document.querySelector(
`[data-testid="page-tree-row"][data-node-id="${nodeId}"]`,
);
return (row?.textContent ?? "").includes(expectedTitle);
},
{ nodeId: documentId, expectedTitle: title },
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForFileTreeTitle(page, documentId, title) {
await openFilesystemView(page);
const shellHost = page.getByTestId("sidebar-file-tree-shell");
await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
({ docId, expectedTitle }) => {
const row = document.querySelector(
`[data-testid="filetree-doc-row"][data-doc-id="${docId}"]`,
);
return (row?.textContent ?? "").includes(expectedTitle);
},
{ docId: documentId, expectedTitle: title },
{ timeout: UI_TIMEOUT_MS },
);
}
async function readVisibleTitle(page) {
const titleInput = page.getByLabel("页面标题");
if (await titleInput.isVisible().catch(() => false)) {
return (await titleInput.inputValue()).trim();
}
const heading = page.locator("h1").first();
return ((await heading.textContent()) ?? "").trim();
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let fixture = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
const first = await createTempDocument(context.request, null);
const second = await createTempDocument(context.request, null);
const initialTitle = `phase-i-initial-${Date.now().toString().slice(-6)}`;
const renamedTitle = `phase-i-renamed-${(Date.now() + 1).toString().slice(-6)}`;
const siblingTitle = `phase-i-sibling-${(Date.now() + 2).toString().slice(-6)}`;
await renameDocument(context.request, first.workspaceId, first.documentId, initialTitle);
await renameDocument(context.request, second.workspaceId, second.documentId, siblingTitle);
fixture = {
workspaceId: first.workspaceId,
primaryId: first.documentId,
siblingId: second.documentId,
createdIds: [first.documentId, second.documentId],
initialTitle,
renamedTitle,
siblingTitle,
};
await openDocument(page, fixture.workspaceId, fixture.primaryId);
await waitForPageTitleInput(page);
assert((await readVisibleTitle(page)).includes(initialTitle), "页头未回填初始标题");
await renameThroughPageHead(page, fixture.primaryId, fixture.renamedTitle);
assert((await readVisibleTitle(page)).includes(fixture.renamedTitle), "页头未显示最新标题");
await waitForBreadcrumbTitle(page, fixture.renamedTitle);
await waitForSidebarRowTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForPageTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForFileTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
await openDocument(page, fixture.workspaceId, fixture.siblingId);
await waitForPageTitleInput(page);
assert((await readVisibleTitle(page)).includes(fixture.siblingTitle), "切到兄弟页后标题不正确");
await openDocument(page, fixture.workspaceId, fixture.primaryId);
await waitForPageTitleInput(page);
await waitForBreadcrumbTitle(page, fixture.renamedTitle);
await waitForSidebarRowTitle(page, fixture.primaryId, fixture.renamedTitle);
assert((await readVisibleTitle(page)).includes(fixture.renamedTitle), "切页往返后页头标题回闪");
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForPageTitleInput(page);
await waitForBreadcrumbTitle(page, fixture.renamedTitle);
await waitForSidebarRowTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForPageTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
await waitForFileTreeTitle(page, fixture.primaryId, fixture.renamedTitle);
assert((await readVisibleTitle(page)).includes(fixture.renamedTitle), "刷新后页头标题未保持一致");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
primaryId: fixture.primaryId,
siblingId: fixture.siblingId,
renamedTitle: fixture.renamedTitle,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${
cleanupError instanceof Error
? cleanupError.stack || cleanupError.message
: String(cleanupError)
}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,115 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs/promises");
const path = require("path");
const { spawnSync } = require("child_process");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const SPIKE_ROOT = path.join(REPO_ROOT, "rust", "spikes", "leptos-tiptap-spike");
const GENERATED_ROOT = path.join(SPIKE_ROOT, "generated", "island");
const TOOLCHAIN = "1.89.0-x86_64-unknown-linux-gnu";
const TARGET = "wasm32-unknown-unknown";
const OUT_NAME = "mnote-leptos-tiptap-spike-island";
const MANIFEST_PATH = path.join(SPIKE_ROOT, "Cargo.toml");
const WASM_PATH = path.join(
SPIKE_ROOT,
"target",
TARGET,
"debug",
"mnote_leptos_tiptap_spike.wasm",
);
const ENTRY_PATH = path.join(GENERATED_ROOT, `${OUT_NAME}.js`);
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? REPO_ROOT,
env: process.env,
encoding: "utf8",
stdio: "pipe",
});
if (result.status === 0) {
return result;
}
const stdout = (result.stdout || "").trim();
const stderr = (result.stderr || "").trim();
const details = [stdout, stderr].filter(Boolean).join("\n");
throw new Error(
[`命令失败:${command} ${args.join(" ")}`, details].filter(Boolean).join("\n"),
);
}
async function ensureGeneratedDir() {
await fs.rm(GENERATED_ROOT, { recursive: true, force: true });
await fs.mkdir(GENERATED_ROOT, { recursive: true });
}
async function verifyEntry() {
const source = await fs.readFile(ENTRY_PATH, "utf8");
if (!source.includes("export function mount(container, options)")) {
throw new Error("island 入口缺少 mount 导出");
}
if (!source.includes("export function unmount(mount_id)")) {
throw new Error("island 入口缺少 unmount 导出");
}
}
async function buildLeptosTiptapIsland() {
// 说明:正式主链直接消费 lib.rs 的 wasm-bindgen 产物,不再把 trunk 演示页产物当作页面内 island 入口。
run("rustup", ["target", "add", TARGET, "--toolchain", TOOLCHAIN]);
run(
"cargo",
[
`+${TOOLCHAIN}`,
"build",
"--manifest-path",
MANIFEST_PATH,
"--lib",
"--target",
TARGET,
],
{ cwd: SPIKE_ROOT },
);
await ensureGeneratedDir();
run(
"wasm-bindgen",
[
WASM_PATH,
"--target",
"web",
"--out-dir",
GENERATED_ROOT,
"--out-name",
OUT_NAME,
],
{ cwd: SPIKE_ROOT },
);
await verifyEntry();
return {
generatedRoot: GENERATED_ROOT,
entryPath: ENTRY_PATH,
wasmPath: path.join(GENERATED_ROOT, `${OUT_NAME}_bg.wasm`),
};
}
module.exports = {
buildLeptosTiptapIsland,
GENERATED_ROOT,
OUT_NAME,
};
if (require.main === module) {
buildLeptosTiptapIsland()
.then(({ generatedRoot }) => {
process.stdout.write(`leptos-tiptap island 已生成:${generatedRoot}\n`);
})
.catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});
}
+4
View File
@@ -17,6 +17,7 @@ const net = require("net");
const path = require("path"); const path = require("path");
const next = require("next"); const next = require("next");
const { parse: parseUrl } = require("url"); const { parse: parseUrl } = require("url");
const { buildLeptosTiptapIsland } = require("./build-leptos-tiptap-island");
const ONLYOFFICE_PREFIX = "/onlyoffice-server"; const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex"; const CONVEX_PREFIX = "/convex";
@@ -389,6 +390,9 @@ async function main() {
const hostname = resolveHostname(); const hostname = resolveHostname();
const dev = true; const dev = true;
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
await buildLeptosTiptapIsland();
const app = next({ dev, dir: path.join(__dirname, "..") }); const app = next({ dev, dir: path.join(__dirname, "..") });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
@@ -9,7 +9,14 @@ import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/do
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree"; import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response"; import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime"; import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
import { normalizeEditorHostKind, type EditorHostKind } from "@/components/editor/editor-host-config"; import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
resolveEditorHostKind,
type EditorHostKind,
} from "@/components/editor/editor-host-config";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
interface DocumentPageProps { interface DocumentPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -128,9 +135,36 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
const resolvedSearch = (await searchParams) ?? {}; const resolvedSearch = (await searchParams) ?? {};
const openTableIdRaw = resolvedSearch?.openTableId; const openTableIdRaw = resolvedSearch?.openTableId;
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null; const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
const editorHostRaw = resolvedSearch?.editorHost; const runtimeConfig = getMnoteRuntimeConfig();
const editorHostKind: EditorHostKind = const queryEditorHostRaw = resolvedSearch?.editorHost;
typeof editorHostRaw === "string" ? normalizeEditorHostKind(editorHostRaw) : "blocknote"; const queryHostAliasRaw = resolvedSearch?.host;
const queryHostRaw =
typeof queryEditorHostRaw === "string"
? queryEditorHostRaw
: typeof queryHostAliasRaw === "string"
? queryHostAliasRaw
: undefined;
const runtimeHostRaw = runtimeConfig.documentEditorHost;
const runtimeBlocknoteKillSwitch =
runtimeConfig.documentEditorBlocknoteKillSwitch === true;
// 说明:优先级保持稳定且可预期:
// 1) query(兼容 editorHost,并支持 host 别名);
// 2) 运行时配置 documentEditorHost
// 3) kill switchruntime/env)回退到 blocknote
// 4) 默认正式主链 leptos_tiptap_island。
const editorHostKind: EditorHostKind = (() => {
if (typeof queryHostRaw === "string") {
return resolveEditorHostKind({
override: queryHostRaw,
runtimeDefault: runtimeHostRaw,
});
}
if (runtimeBlocknoteKillSwitch) {
return "blocknote";
}
return normalizeEditorHostKind(runtimeHostRaw, DEFAULT_EDITOR_HOST_KIND);
})();
if (isConvexEnabled()) { if (isConvexEnabled()) {
const workspaceIdRaw = resolvedSearch?.workspaceId; const workspaceIdRaw = resolvedSearch?.workspaceId;
@@ -176,25 +210,29 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
title: doc.title ?? "无标题", title: doc.title ?? "无标题",
}); });
const page = buildPageAggregate({
documentId: doc.id,
workspaceId: doc.workspace_id,
title: doc.title ?? "无标题",
updatedAt: doc.updated_at,
readOnly,
disableDownload,
disableCopy,
pageOptions: initialOptions,
content: initialDocumentContent.content,
revision: initialDocumentContent.revision,
conflictDetectionKey: initialDocumentContent.conflictDetectionKey,
pageSubtree: initialDocumentContent.pageSubtree,
stats: initialStats,
});
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<DocumentShell <DocumentShell
documentId={doc.id} page={page}
workspaceId={doc.workspace_id}
title={doc.title ?? "无标题"}
updatedAt={doc.updated_at}
initialContent={initialDocumentContent.content}
initialContentRevision={initialDocumentContent.revision}
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
initialPageSubtree={initialDocumentContent.pageSubtree}
initialOptions={initialOptions}
initialStats={initialStats}
openTableId={openTableId} openTableId={openTableId}
editorHostKind={editorHostKind} editorHostKind={editorHostKind}
readOnly={readOnly}
disableDownload={disableDownload}
disableCopy={disableCopy}
/> />
</div> </div>
</div> </div>
+14 -16
View File
@@ -1,9 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar/sidebar"; import { AppLayoutShell } from "@/components/app-layout-shell";
import { Breadcrumb } from "@/components/breadcrumb";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import { SearchPalette } from "@/components/search/search-palette";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data"; import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
@@ -12,25 +9,26 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
const { const {
documents,
sidebarInitialData, sidebarInitialData,
} = await loadSidebarDataFromConvex({ } = await loadSidebarDataFromConvex({
client, client,
fallbackName: auth.name ?? auth.email ?? "我的空间", fallbackName: auth.name ?? auth.email ?? "我的空间",
}); });
return ( if (!sidebarInitialData) {
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary"> return (
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />} <div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
<div className="flex min-w-0 flex-1 flex-col h-full bg-white relative overflow-hidden"> <div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-white">
<header className="h-[44px] w-full flex items-center px-4 text-wolai-text-secondary text-sm bg-white/80 backdrop-blur-sm sticky top-0 z-50"> <main className="flex-1 overflow-hidden bg-white">{children}</main>
<MobileSidebarTrigger /> </div>
<Breadcrumb documents={documents} />
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
</div> </div>
</div> );
}
return (
<AppLayoutShell initialData={sidebarInitialData}>
{children}
</AppLayoutShell>
); );
} }
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockSafeGetJsonBody = vi.fn();
const mockValidateRequestBody = vi.fn();
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
return {
...actual,
safeGetJsonBody: mockSafeGetJsonBody,
validateRequestBody: mockValidateRequestBody,
};
});
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: mockIsConvexEnabled,
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
startHermesRun: mockStartHermesRun,
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
vi.mock("@/lib/server/mnote-web-hermes", () => ({
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
}));
describe("/api/ai-agent/run route", () => {
beforeEach(() => {
mockSafeGetJsonBody.mockReset();
mockValidateRequestBody.mockReset();
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成 AI 标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "tool.started",
tool: "slash_run",
preview: '{"text":"/rename doc-1 AI 标题"}',
});
await onEvent({
event: "tool.completed",
tool: "slash_run",
duration: 0.12,
error: false,
});
await onEvent({
event: "run.completed",
output: "已完成",
});
});
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
tool: "slash_run",
argsJson: {
text: "/rename doc-1 AI 标题",
},
}),
);
expect(text).toContain("event: tool_result");
expect(text).toContain('"tool":"slash_run"');
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
});
@@ -13,6 +13,11 @@ import {
startCodexJsonRun, startCodexJsonRun,
} from "@/lib/ai/codex/codexExec"; } from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge"; import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge";
import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -223,15 +228,76 @@ const buildHermesInput = (messages: AgentMessage[]) => {
.map((item) => ({ role: item.role, content: String(item.content ?? "") })); .map((item) => ({ role: item.role, content: String(item.content ?? "") }));
}; };
type PendingHermesToolCall = {
preview: string;
argsJson: Record<string, unknown> | null;
};
const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
fallbackTraceId: string;
}): Promise<unknown | null> => {
if (!input.argsJson) {
return null;
}
if (
input.tool !== "slash_run" &&
input.tool !== "doc_insert_blocks" &&
input.tool !== "doc_replace_range"
) {
return null;
}
const documentId = String(input.payload.context?.documentId ?? "").trim() || null;
const data =
input.tool === "slash_run"
? { source: "ai-agent-route" }
: input.payload.context?.documentBlocks ?? null;
if ((input.tool === "doc_insert_blocks" || input.tool === "doc_replace_range") && data == null) {
return null;
}
return fetchHermesStructuredToolResultFromMnoteWeb({
request: input.request,
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data,
requestId: input.fallbackRequestId,
traceId: input.fallbackTraceId,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}).catch(() => null);
};
const streamHermesLegacyEvents = async ({ const streamHermesLegacyEvents = async ({
messages, messages,
instructions, instructions,
sessionId, sessionId,
request,
payload,
userId,
onEvent, onEvent,
}: { }: {
messages: AgentMessage[]; messages: AgentMessage[];
instructions: string; instructions: string;
sessionId: string | null; sessionId: string | null;
request: Request;
payload: RequestPayload;
userId: string;
onEvent: (event: LegacyStreamEvent) => Promise<void> | void; onEvent: (event: LegacyStreamEvent) => Promise<void> | void;
}) => { }) => {
const input = buildHermesInput(messages); const input = buildHermesInput(messages);
@@ -242,7 +308,7 @@ const streamHermesLegacyEvents = async ({
}); });
const pendingToolIds = new Map<string, string[]>(); const pendingToolIds = new Map<string, string[]>();
const previewById = new Map<string, string>(); const pendingToolCalls = new Map<string, PendingHermesToolCall>();
let toolCount = 0; let toolCount = 0;
let assistantBuffer = ""; let assistantBuffer = "";
let failureMessage = ""; let failureMessage = "";
@@ -256,7 +322,10 @@ const streamHermesLegacyEvents = async ({
queue.push(id); queue.push(id);
pendingToolIds.set(tool, queue); pendingToolIds.set(tool, queue);
const preview = typeof event.preview === "string" ? event.preview : ""; const preview = typeof event.preview === "string" ? event.preview : "";
previewById.set(id, preview); pendingToolCalls.set(id, {
preview,
argsJson: readHermesToolArgsFromEvent(event, tool),
});
await onEvent({ await onEvent({
type: "tool_call", type: "tool_call",
data: { data: {
@@ -273,8 +342,22 @@ const streamHermesLegacyEvents = async ({
const queue = pendingToolIds.get(tool) ?? []; const queue = pendingToolIds.get(tool) ?? [];
const id = queue.shift() ?? `hermes_${runId}_${toolCount}`; const id = queue.shift() ?? `hermes_${runId}_${toolCount}`;
pendingToolIds.set(tool, queue); pendingToolIds.set(tool, queue);
const preview = previewById.get(id) ?? ""; const pendingToolCall = pendingToolCalls.get(id) ?? null;
pendingToolCalls.delete(id);
const preview = pendingToolCall?.preview ?? "";
const duration = Number(event.duration ?? 0); const duration = Number(event.duration ?? 0);
const structuredResultFromEvent = !Boolean(event.error) ? readHermesToolResultFromEvent(event) : null;
const recoveredResult =
structuredResultFromEvent ??
(await recoverStructuredHermesToolResult({
request,
payload,
userId,
tool,
argsJson: pendingToolCall?.argsJson ?? null,
fallbackRequestId: makeRunId(),
fallbackTraceId: makeRunId(),
}));
await onEvent({ await onEvent({
type: "tool_result", type: "tool_result",
data: { data: {
@@ -282,7 +365,9 @@ const streamHermesLegacyEvents = async ({
tool, tool,
ok: !Boolean(event.error), ok: !Boolean(event.error),
ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0, ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0,
result: preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }, result:
recoveredResult ??
(preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }),
}, },
}); });
return; return;
@@ -551,6 +636,9 @@ export async function POST(request: Request) {
messages: payload.messages, messages: payload.messages,
instructions, instructions,
sessionId, sessionId,
request,
payload,
userId,
onEvent: (event) => { onEvent: (event) => {
events.push(event); events.push(event);
}, },
@@ -589,6 +677,9 @@ export async function POST(request: Request) {
messages: payload.messages, messages: payload.messages,
instructions, instructions,
sessionId, sessionId,
request,
payload,
userId,
onEvent: (event) => { onEvent: (event) => {
send(event.type, event.data ?? null); send(event.type, event.data ?? null);
}, },
@@ -5,18 +5,24 @@ import { NextResponse } from "next/server";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type RuntimeManifest = { type IslandManifest = {
bridgeRuntimePath: string | null; entryAssetPath: string | null;
extensionModulePaths: string[]; wasmAssetPath: string | null;
assetPaths: string[];
generatedRootPath: string | null; generatedRootPath: string | null;
entryScriptPath: string | null;
wasmPath: string | null;
}; };
const DIST_ROOT = path.resolve(process.cwd(), "..", "rust", "spikes", "leptos-tiptap-spike", "dist"); const GENERATED_ROOT = path.resolve(
const ENTRY_SCRIPT_PATTERN = /^mnote-leptos-tiptap-spike-.*\.js$/; process.cwd(),
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-.*_bg\.wasm$/; "..",
const EXTENSION_MODULE_PATTERN = /^tiptap_[a-z0-9_]+\.js$/; "rust",
"spikes",
"leptos-tiptap-spike",
"generated",
"island",
);
const ENTRY_ASSET_PATTERN = /^mnote-leptos-tiptap-spike-island\.js$/;
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-island_bg\.wasm$/;
function toPosixPath(value: string): string { function toPosixPath(value: string): string {
return value.split(path.sep).join("/"); return value.split(path.sep).join("/");
@@ -65,29 +71,26 @@ async function walkFiles(rootDir: string): Promise<string[]> {
return output.sort((a, b) => a.localeCompare(b)); return output.sort((a, b) => a.localeCompare(b));
} }
async function buildRuntimeManifest(): Promise<RuntimeManifest> { async function buildIslandManifest(): Promise<IslandManifest> {
const files = await walkFiles(DIST_ROOT); const files = await walkFiles(GENERATED_ROOT);
const bridgeRuntimePath = files.find((item) => item.endsWith("/bridge_runtime.js") || item === "bridge_runtime.js") ?? null; const entryAssetPath = files.find((item) => ENTRY_ASSET_PATTERN.test(path.posix.basename(item))) ?? null;
const generatedRootPath = bridgeRuntimePath ? path.posix.dirname(bridgeRuntimePath) : null; const wasmAssetPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
const extensionModulePaths = generatedRootPath const generatedRootPath = entryAssetPath ? path.posix.dirname(entryAssetPath) : null;
? files.filter((item) => { const assetPaths = files.filter((item) => {
if (!item.startsWith(`${generatedRootPath}/`)) { const basename = path.posix.basename(item);
return false; return (
} basename.endsWith(".js") ||
const basename = path.posix.basename(item); basename.endsWith(".wasm") ||
return EXTENSION_MODULE_PATTERN.test(basename); basename.endsWith(".css") ||
}) basename.endsWith(".json")
: []; );
const entryScriptPath = });
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
const wasmPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
return { return {
bridgeRuntimePath, entryAssetPath,
extensionModulePaths, wasmAssetPath,
assetPaths,
generatedRootPath, generatedRootPath,
entryScriptPath,
wasmPath,
}; };
} }
@@ -120,14 +123,14 @@ export async function GET(
if (asset.length === 1 && asset[0] === "manifest.json") { if (asset.length === 1 && asset[0] === "manifest.json") {
try { try {
const manifest = await buildRuntimeManifest(); const manifest = await buildIslandManifest();
return NextResponse.json(manifest, { return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" }, headers: { "Cache-Control": "no-store" },
}); });
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
error: "无法生成 leptos-tiptap runtime 清单", error: "无法生成 leptos-tiptap island 清单",
detail: error instanceof Error ? error.message : "unknown", detail: error instanceof Error ? error.message : "unknown",
}, },
{ status: 500 }, { status: 500 },
@@ -137,12 +140,12 @@ export async function GET(
const relativePath = sanitizeRelativePath(asset); const relativePath = sanitizeRelativePath(asset);
if (!relativePath) { if (!relativePath) {
return NextResponse.json({ error: "非法 runtime 资源路径" }, { status: 400 }); return NextResponse.json({ error: "非法 island 资源路径" }, { status: 400 });
} }
const absolutePath = path.resolve(DIST_ROOT, relativePath); const absolutePath = path.resolve(GENERATED_ROOT, relativePath);
if (!absolutePath.startsWith(DIST_ROOT + path.sep)) { if (!absolutePath.startsWith(GENERATED_ROOT + path.sep)) {
return NextResponse.json({ error: "越界访问 runtime 资源被拒绝" }, { status: 403 }); return NextResponse.json({ error: "越界访问 island 资源被拒绝" }, { status: 403 });
} }
try { try {
@@ -156,11 +159,11 @@ export async function GET(
}); });
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return NextResponse.json({ error: "runtime 资源不存在" }, { status: 404 }); return NextResponse.json({ error: "island 资源不存在" }, { status: 404 });
} }
return NextResponse.json( return NextResponse.json(
{ {
error: "读取 runtime 资源失败", error: "读取 island 资源失败",
detail: error instanceof Error ? error.message : "unknown", detail: error instanceof Error ? error.message : "unknown",
}, },
{ status: 500 }, { status: 500 },
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockBuildMnoteWebForwardHeaders = vi.fn();
const mockBuildMnoteWebStreamUrl = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
),
);
vi.mock("@/lib/server/mnote-web", () => ({
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
}));
vi.mock("@/lib/documents/bridge", () => ({
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
}));
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockBuildMnoteWebForwardHeaders.mockReset();
mockBuildMnoteWebStreamUrl.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
vi.unstubAllGlobals();
});
it("透传上游 SSE 并去掉 set-cookie", async () => {
const upstreamHeaders = new Headers({
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"set-cookie": "secret=1",
});
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
status: 200,
headers: upstreamHeaders,
});
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
mockBuildMnoteWebStreamUrl.mockReturnValue(
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
);
const fetchMock = vi.fn(async () => upstreamResponse);
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const response = await GET(
new Request("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1", {
method: "GET",
headers: { cookie: "a=1" },
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
redirect: "follow",
headers: expect.any(Headers),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("set-cookie")).toBeNull();
await expect(response.text()).resolves.toContain("event: snapshot");
});
});
@@ -0,0 +1,43 @@
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import {
buildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl,
} from "@/lib/server/mnote-web";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const workspaceId = String(requestUrl.searchParams.get("workspaceId") || "").trim();
const cursor = String(requestUrl.searchParams.get("cursor") || "").trim() || null;
if (!workspaceId) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
const headers = await buildMnoteWebForwardHeaders(request);
headers.set("accept", "text/event-stream");
headers.set("x-mnote-workspace-id", workspaceId);
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "follow",
});
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
+12
View File
@@ -137,6 +137,18 @@ body {
margin: 0 auto; margin: 0 auto;
} }
/* 说明leptos-tiptap / ProseMirror 需要最基础的 white-space 与换行样式
否则浏览器输入与光标行为会不稳定并触发 ProseMirror 警告 */
.ProseMirror {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
+1 -7
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { QueryProvider } from "@/components/providers/query-provider"; import { QueryProvider } from "@/components/providers/query-provider";
import { ConvexClientProvider } from "@/components/providers/convex-provider"; import { ConvexClientProvider } from "@/components/providers/convex-provider";
@@ -8,11 +7,6 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server"; import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Wolai Clone", title: "Wolai Clone",
description: "Convex 自部署模式", description: "Convex 自部署模式",
@@ -40,7 +34,7 @@ export default async function RootLayout({
}} }}
/> />
</head> </head>
<body className={`${inter.variable} antialiased`}> <body className="antialiased">
<ConvexAuthNextjsServerProvider> <ConvexAuthNextjsServerProvider>
<ConvexClientProvider> <ConvexClientProvider>
<AppPreferencesHydrator /> <AppPreferencesHydrator />
@@ -0,0 +1,205 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { usePreferredSidebarSnapshotData } from "@/components/sidebar/preferred-sidebar-snapshot-context";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import { AppLayoutShell } from "./app-layout-shell";
const mockUseSidebarData = vi.fn();
const mockUseSidebarTreeStream = vi.fn();
const mockUsePreferredSidebarSnapshot = vi.fn();
let capturedSidebarProps: Record<string, unknown> | null = null;
let capturedBreadcrumbProps: Record<string, unknown> | null = null;
let capturedSearchPaletteProps: Record<string, unknown> | null = null;
vi.mock("@/hooks/use-sidebar-data", () => ({
useSidebarData: (...args: unknown[]) => mockUseSidebarData(...args),
}));
vi.mock("@/lib/tree-stream/use-sidebar-tree-stream", () => ({
useSidebarTreeStream: (...args: unknown[]) => mockUseSidebarTreeStream(...args),
}));
vi.mock("@/components/sidebar/use-preferred-sidebar-snapshot", () => ({
usePreferredSidebarSnapshot: (...args: unknown[]) => mockUsePreferredSidebarSnapshot(...args),
}));
vi.mock("@/components/sidebar/sidebar", () => ({
Sidebar: (props: Record<string, unknown>) => {
capturedSidebarProps = props;
return <div data-testid="sidebar" />;
},
}));
vi.mock("@/components/breadcrumb", () => ({
Breadcrumb: (props: Record<string, unknown>) => {
capturedBreadcrumbProps = props;
return <div data-testid="breadcrumb" />;
},
}));
vi.mock("@/components/mobile-sidebar-trigger", () => ({
MobileSidebarTrigger: () => <div data-testid="mobile-trigger" />,
}));
vi.mock("@/components/search/search-palette", () => ({
SearchPalette: (props: Record<string, unknown>) => {
capturedSearchPaletteProps = props;
return <div data-testid="search-palette" />;
},
}));
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
return {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "标题 A",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
return buildSidebarInitialData({
activeWorkspaceId: "ws-1",
workspaces: [],
documents,
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
}
function SnapshotProbe() {
const snapshot = usePreferredSidebarSnapshotData();
return <div data-testid="snapshot-probe">{snapshot.documents[0]?.title ?? "空"}</div>;
}
describe("AppLayoutShell", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
capturedSidebarProps = null;
capturedBreadcrumbProps = null;
capturedSearchPaletteProps = null;
mockUseSidebarData.mockReset();
mockUseSidebarTreeStream.mockReset();
mockUsePreferredSidebarSnapshot.mockReset();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("应把同一份 preferred sidebar snapshot 同时提供给 Sidebar 与 Breadcrumb", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const preferredData = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const sidebarQuery = {
data: preferredData,
isLoading: false,
error: null,
refetch: vi.fn(async () => preferredData),
source: "convex-live" as const,
};
const treeStream = {
data: preferredData,
status: "live" as const,
cursor: "evt-1",
error: null,
};
mockUseSidebarData.mockReturnValue(sidebarQuery);
mockUseSidebarTreeStream.mockReturnValue(treeStream);
mockUsePreferredSidebarSnapshot.mockReturnValue({
data: preferredData,
source: "tree_stream",
syncKey: "sync-key-1",
});
await act(async () => {
root.render(
<AppLayoutShell initialData={initialData}>
<div></div>
</AppLayoutShell>,
);
});
expect(capturedSidebarProps).toMatchObject({
initialData,
sidebarQuery,
sidebarData: preferredData,
treeStream,
});
expect(capturedBreadcrumbProps).toMatchObject({
documents: preferredData.documents,
});
expect(capturedSearchPaletteProps).toMatchObject({
workspaceId: "ws-1",
});
});
it("应把同一份 preferred sidebar snapshot 继续提供给页面 children", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const preferredData = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
mockUseSidebarData.mockReturnValue({
data: preferredData,
isLoading: false,
error: null,
refetch: vi.fn(async () => preferredData),
source: "convex-live" as const,
});
mockUseSidebarTreeStream.mockReturnValue({
data: preferredData,
status: "live" as const,
cursor: "evt-1",
error: null,
});
mockUsePreferredSidebarSnapshot.mockReturnValue({
data: preferredData,
source: "tree_stream",
syncKey: "sync-key-2",
});
await act(async () => {
root.render(
<AppLayoutShell initialData={initialData}>
<SnapshotProbe />
</AppLayoutShell>,
);
});
const probe = container.querySelector("[data-testid='snapshot-probe']");
expect(probe?.textContent).toBe("标题 B");
});
});
@@ -0,0 +1,48 @@
"use client";
import type { ReactNode } from "react";
import { Breadcrumb } from "@/components/breadcrumb";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import { SearchPalette } from "@/components/search/search-palette";
import { Sidebar } from "@/components/sidebar/sidebar";
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
interface AppLayoutShellProps {
initialData: SidebarInitialData;
children: ReactNode;
}
export function AppLayoutShell({ initialData, children }: AppLayoutShellProps) {
const sidebarQuery = useSidebarData(initialData);
const treeStream = useSidebarTreeStream(initialData);
const preferredSidebarSnapshot = usePreferredSidebarSnapshot({
initialData,
sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
});
return (
<PreferredSidebarSnapshotProvider data={preferredSidebarSnapshot.data}>
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
<Sidebar
initialData={initialData}
sidebarData={preferredSidebarSnapshot.data}
sidebarQuery={sidebarQuery}
treeStream={treeStream}
/>
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-white">
<header className="sticky top-0 z-50 flex h-[44px] w-full items-center bg-white/80 px-4 text-sm text-wolai-text-secondary backdrop-blur-sm">
<MobileSidebarTrigger />
<Breadcrumb documents={preferredSidebarSnapshot.data.documents} />
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<SearchPalette workspaceId={preferredSidebarSnapshot.data.activeWorkspaceId ?? null} />
</div>
</div>
</PreferredSidebarSnapshotProvider>
);
}
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import {
applyDocWriteToolResultToPageBody,
buildAiAgentSessionsStoragePayload,
extractCurrentPageTitleFromSlashToolResult,
shouldSyncActiveSessionSnapshot,
} from "./DocumentAiAgentPanel.runtime";
type TestSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: Array<{ role: "user" | "assistant"; content: string }>;
toolLogs: Array<{ type: string; id?: string; tool?: string; ok?: boolean; ms?: number; result?: unknown }>;
codexSessionId?: string | null;
codexMode?: "chat" | "test" | "dev" | null;
};
function buildSession(overrides: Partial<TestSession> = {}): TestSession {
return {
id: "session-1",
title: "新会话",
createdAt: 1,
updatedAt: 2,
messages: [{ role: "assistant", content: "欢迎语" }],
toolLogs: [],
codexSessionId: null,
codexMode: null,
...overrides,
};
}
describe("DocumentAiAgentPanel.runtime 会话回写保护", () => {
it("当前会话快照与本地消息和日志一致时,不应触发回写", () => {
const session = buildSession();
expect(shouldSyncActiveSessionSnapshot(session, session.messages, session.toolLogs)).toBe(false);
});
it("当前会话快照与本地消息或日志不同时时,才应触发回写", () => {
const session = buildSession({
messages: [{ role: "assistant", content: "旧内容" }],
toolLogs: [{ type: "info", message: "old" }],
});
expect(
shouldSyncActiveSessionSnapshot(
session,
[{ role: "assistant", content: "新内容" }],
[{ type: "info", message: "new" }],
),
).toBe(true);
});
it("持久化 payload 应稳定包含标准化后的会话列表", () => {
const payload = buildAiAgentSessionsStoragePayload("session-1", [
buildSession({
updatedAt: 999,
messages: [{ role: "assistant", content: "欢迎语" }],
}),
]);
const parsed = JSON.parse(payload) as {
activeSessionId: string;
sessions: Array<{ id: string; title: string; createdAt: number; updatedAt: number; messages: Json; toolLogs: Json }>;
};
expect(parsed.activeSessionId).toBe("session-1");
expect(parsed.sessions).toHaveLength(1);
expect(parsed.sessions[0]?.id).toBe("session-1");
expect(parsed.sessions[0]?.messages).toEqual([{ role: "assistant", content: "欢迎语" }]);
});
});
describe("applyDocWriteToolResultToPageBody", () => {
it("doc 写工具成功时应按最新持久化元信息执行 page body save", async () => {
const applyPageBodyCommandImpl = vi.fn(async () => ({
revision: 6,
conflictDetectionKey: "doc-1:6",
}));
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const applied = await applyDocWriteToolResultToPageBody({
tool: "doc_insert_blocks",
ok: true,
result: {
data: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
},
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
}),
applyEditorSnapshot,
onPersistedMetaChange,
applyPageBodyCommandImpl,
});
expect(applied).toBe(true);
expect(applyPageBodyCommandImpl).toHaveBeenCalledWith({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
blocks: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
applyEditorSnapshot,
});
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 6,
conflictDetectionKey: "doc-1:6",
});
});
it("非 doc 写工具或无 data 时应直接忽略", async () => {
const applyPageBodyCommandImpl = vi.fn();
await expect(
applyDocWriteToolResultToPageBody({
tool: "docs_read",
ok: true,
result: { data: [] },
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
applyPageBodyCommandImpl,
}),
).resolves.toBe(false);
expect(applyPageBodyCommandImpl).not.toHaveBeenCalled();
});
});
describe("extractCurrentPageTitleFromSlashToolResult", () => {
it("slash_run 成功重命名当前页时应返回新标题", () => {
expect(
extractCurrentPageTitleFromSlashToolResult({
tool: "slash_run",
ok: true,
documentId: "doc-1",
result: {
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 新标题",
},
},
},
}),
).toBe("AI 新标题");
});
it("非当前页或非 rename 结果时应忽略", () => {
expect(
extractCurrentPageTitleFromSlashToolResult({
tool: "slash_run",
ok: true,
documentId: "doc-1",
result: {
parsed: {
command: "rename_doc",
params: {
documentId: "doc-2",
title: "别的页面",
},
},
},
}),
).toBeNull();
});
});
@@ -15,6 +15,12 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants"; import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared"; import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree"; import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
applyPageBodyCommand,
type ApplyPageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-body-command";
type AgentMessage = { role: "user" | "assistant"; content: string }; type AgentMessage = { role: "user" | "assistant"; content: string };
type CodexMode = "chat" | "test" | "dev"; type CodexMode = "chat" | "test" | "dev";
@@ -113,6 +119,110 @@ const normalizeSessions = (sessions: ChatSession[]) => {
.sort((a, b) => b.updatedAt - a.updatedAt); .sort((a, b) => b.updatedAt - a.updatedAt);
}; };
function buildSessionSnapshotKey(input: {
messages: ChatSession["messages"];
toolLogs: ChatSession["toolLogs"];
}): string {
return JSON.stringify({
messages: input.messages,
toolLogs: input.toolLogs,
});
}
export function shouldSyncActiveSessionSnapshot(
session: Pick<ChatSession, "messages" | "toolLogs"> | null | undefined,
messages: ChatSession["messages"],
toolLogs: ChatSession["toolLogs"],
): boolean {
if (!session) {
return true;
}
return (
buildSessionSnapshotKey({
messages: session.messages,
toolLogs: session.toolLogs,
}) !==
buildSessionSnapshotKey({
messages,
toolLogs,
})
);
}
export function buildAiAgentSessionsStoragePayload(
activeSessionId: string,
sessions: ChatSession[],
): string {
return JSON.stringify({
activeSessionId,
sessions: normalizeSessions(sessions),
});
}
export async function applyDocWriteToolResultToPageBody(input: {
tool: string;
ok: boolean;
result: unknown;
documentId: string;
getLatestPersistedMeta: () => PageBodyPersistedState;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
applyPageBodyCommandImpl?: (input: ApplyPageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
}): Promise<boolean> {
if (!input.ok || (input.tool !== "doc_insert_blocks" && input.tool !== "doc_replace_range")) {
return false;
}
const resultRecord =
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
const dataNode = resultRecord && "data" in resultRecord ? resultRecord.data : null;
if (dataNode == null) {
return false;
}
const latestPersistedMeta = input.getLatestPersistedMeta();
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? applyPageBodyCommand;
const persistedMeta = await applyPageBodyCommandImpl({
documentId: input.documentId,
workspaceId: latestPersistedMeta.workspaceId,
revision: latestPersistedMeta.revision,
conflictDetectionKey: latestPersistedMeta.conflictDetectionKey,
blocks: dataNode as Json,
applyEditorSnapshot: input.applyEditorSnapshot,
});
input.onPersistedMetaChange?.(persistedMeta);
return true;
}
export function extractCurrentPageTitleFromSlashToolResult(input: {
tool: string;
ok: boolean;
result: unknown;
documentId: string;
}): string | null {
if (!input.ok || input.tool !== "slash_run") {
return null;
}
const resultRecord =
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
const parsed =
resultRecord?.parsed && typeof resultRecord.parsed === "object"
? (resultRecord.parsed as Record<string, unknown>)
: null;
if (!parsed || String(parsed.command ?? "") !== "rename_doc") {
return null;
}
const params =
parsed.params && typeof parsed.params === "object" ? (parsed.params as Record<string, unknown>) : null;
if (!params || String(params.documentId ?? "") !== input.documentId) {
return null;
}
const title = String(params.title ?? "").trim();
return title || null;
}
const safeJsonStringify = (value: unknown) => { const safeJsonStringify = (value: unknown) => {
try { try {
return JSON.stringify(value); return JSON.stringify(value);
@@ -125,10 +235,16 @@ export function DocumentAiAgentPanelRuntime({
documentId, documentId,
getLatestBlocks, getLatestBlocks,
getLatestPageSubtree, getLatestPageSubtree,
getLatestPersistedMeta,
onPersistedMetaChange,
onPageHeadTitleChange,
}: { }: {
documentId: string; documentId: string;
getLatestBlocks: () => Json | null; getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null; getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
}) { }) {
const editorBridge = useEditorBridgeStore((s) => s.bridge); const editorBridge = useEditorBridgeStore((s) => s.bridge);
@@ -158,6 +274,7 @@ export function DocumentAiAgentPanelRuntime({
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null); const syncTimerRef = useRef<number | null>(null);
const hydratedSessionsRef = useRef(false);
useEffect(() => { useEffect(() => {
if (flightMode) { if (flightMode) {
@@ -207,6 +324,7 @@ export function DocumentAiAgentPanelRuntime({
setActiveSessionId(id); setActiveSessionId(id);
setMessages(session.messages); setMessages(session.messages);
setToolLogs([]); setToolLogs([]);
hydratedSessionsRef.current = true;
return; return;
} }
@@ -237,8 +355,10 @@ export function DocumentAiAgentPanelRuntime({
const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!; const cur = loaded.find((s) => s.id === picked) ?? loaded[0]!;
setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES); setMessages(cur.messages?.length ? cur.messages : DEFAULT_SESSION_MESSAGES);
setToolLogs(cur.toolLogs ?? []); setToolLogs(cur.toolLogs ?? []);
hydratedSessionsRef.current = true;
} catch { } catch {
// ignore // ignore
hydratedSessionsRef.current = true;
} }
// 只在 documentId 变化时读取一次 // 只在 documentId 变化时读取一次
@@ -246,9 +366,14 @@ export function DocumentAiAgentPanelRuntime({
useEffect(() => { useEffect(() => {
if (!activeSessionId) return; if (!activeSessionId) return;
if (!hydratedSessionsRef.current) return;
if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current); if (syncTimerRef.current) window.clearTimeout(syncTimerRef.current);
syncTimerRef.current = window.setTimeout(() => { syncTimerRef.current = window.setTimeout(() => {
setSessions((prev) => { setSessions((prev) => {
const current = prev.find((s) => s.id === activeSessionId) ?? null;
if (shouldSyncActiveSessionSnapshot(current, messages, toolLogs) === false) {
return prev;
}
const now = Date.now(); const now = Date.now();
const next = prev.some((s) => s.id === activeSessionId) const next = prev.some((s) => s.id === activeSessionId)
? prev.map((s) => ? prev.map((s) =>
@@ -277,9 +402,10 @@ export function DocumentAiAgentPanelRuntime({
useEffect(() => { useEffect(() => {
if (!documentId) return; if (!documentId) return;
if (!hydratedSessionsRef.current) return;
try { try {
const key = `doc_ai_sessions:${documentId}`; const key = `doc_ai_sessions:${documentId}`;
const payload = JSON.stringify({ activeSessionId, sessions: normalizeSessions(sessions) }); const payload = buildAiAgentSessionsStoragePayload(activeSessionId, sessions);
if (payload.length <= 900_000) window.localStorage.setItem(key, payload); if (payload.length <= 900_000) window.localStorage.setItem(key, payload);
} catch { } catch {
// ignore // ignore
@@ -582,19 +708,32 @@ export function DocumentAiAgentPanelRuntime({
{ type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result }, { type: "tool_result", id: String(obj.id ?? ""), tool, ok: Boolean(obj.ok), ms: Number(obj.ms ?? 0), result },
]); ]);
// doc 写工具返回 data=blocks 时,立即落入编辑器 const nextPageTitle = extractCurrentPageTitleFromSlashToolResult({
if ((tool === "doc_insert_blocks" || tool === "doc_replace_range") && obj.ok) { tool,
const r = result as unknown; ok: Boolean(obj.ok),
const dataNode = result,
typeof r === "object" && r && "data" in (r as Record<string, unknown>) ? (r as Record<string, unknown>).data : null; documentId,
if (dataNode && editorBridge?.replaceWithSnapshot) { });
try { if (nextPageTitle) {
editorBridge.replaceWithSnapshot(dataNode as Json); onPageHeadTitleChange?.(nextPageTitle);
} catch {
// ignore
}
}
} }
void applyDocWriteToolResultToPageBody({
tool,
ok: Boolean(obj.ok),
result,
documentId,
getLatestPersistedMeta,
applyEditorSnapshot: editorBridge?.replaceWithSnapshot
? (blocks) => {
editorBridge.replaceWithSnapshot(blocks);
}
: undefined,
onPersistedMetaChange,
}).catch((error) => {
const message = error instanceof Error ? error.message : "AI 页面正文保存失败";
setToolLogs((prev) => [...prev, { type: "error", message }]);
});
} catch { } catch {
// ignore // ignore
} }
@@ -3,6 +3,7 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useEffect } from "react"; import { useEffect } from "react";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree"; import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageBodyPersistedMeta, PageBodyPersistedState } from "@/lib/documents/page-body-command";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
import { useAiAgentUiStore } from "@/store/ai-agent-ui"; import { useAiAgentUiStore } from "@/store/ai-agent-ui";
@@ -10,6 +11,9 @@ type DocumentAiAgentPanelProps = {
documentId: string; documentId: string;
getLatestBlocks: () => Json | null; getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null; getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
}; };
const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>( const DocumentAiAgentPanelRuntime = dynamic<DocumentAiAgentPanelProps>(
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { persistPageTitleAndNotifyDocumentsChanged } from "@/components/editor/document-content";
describe("persistPageTitleAndNotifyDocumentsChanged", () => {
it("标题提交成功后应广播 documents-changed", async () => {
const persistTitleCommand = vi.fn(async () => ({ ok: true as const }));
const emitDocumentsChanged = vi.fn();
const result = await persistPageTitleAndNotifyDocumentsChanged({
documentId: "doc-1",
workspaceId: "ws-1",
title: " 新标题 ",
persistTitleCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
expect(result).toBe("新标题");
expect(persistTitleCommand).toHaveBeenCalledWith({
documentId: "doc-1",
workspaceId: "ws-1",
title: "新标题",
});
expect(emitDocumentsChanged).toHaveBeenCalledWith("doc-1");
});
});
@@ -20,15 +20,27 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc"; import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view"; import { DocumentReadView } from "@/components/editor/document-read-view";
import { emitDocumentsChanged } from "@/lib/events";
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree"; import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { import {
deleteDocumentCommand, deleteDocumentCommand,
embedDocumentCommand, embedDocumentCommand,
moveDocumentCommand, moveDocumentCommand,
renameDocumentCommand, updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client"; } from "@/lib/documents/tree-command-client";
import { EditorHost } from "@/components/editor/editor-host"; import { EditorHost } from "@/components/editor/editor-host";
import type { EditorHostKind } from "@/components/editor/editor-host-config"; import {
DEFAULT_EDITOR_HOST_KIND,
isLeptosTiptapHostKind,
type EditorHostKind,
} from "@/components/editor/editor-host-config";
import type {
EditorHostEvent,
EditorHostFallbackReason,
} from "@/components/editor/editor-host-types";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
const BlockNoteEditor = dynamic( const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor), () => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -78,21 +90,9 @@ const DocumentCommentsDrawer = dynamic(
); );
export interface DocumentContentProps { export interface DocumentContentProps {
documentId: string; page: PageAggregateProjection;
workspaceId: string;
title: string | null;
updatedAt: string | null;
initialContent: unknown;
initialContentRevision?: number | null;
initialConflictDetectionKey?: string | null;
initialPageSubtree?: PageSubtreeProjection | null;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
openTableId?: string | null; openTableId?: string | null;
editorHostKind?: EditorHostKind; editorHostKind?: EditorHostKind;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
} }
const defaultOptions: PageOptionsState = { const defaultOptions: PageOptionsState = {
@@ -112,24 +112,43 @@ const defaultOptions: PageOptionsState = {
}; };
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 }; const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
const EDITOR_UNMOUNT_GRACE_MS = 1000; const EDITOR_UNMOUNT_GRACE_MS = 1000;
const FALLBACK_TRIGGER_HISTORY_LIMIT = 20;
export async function persistPageTitleAndNotifyDocumentsChanged(input: {
documentId: string;
workspaceId: string;
title: string;
persistTitleCommand: (payload: { documentId: string; workspaceId: string; title: string }) => Promise<unknown>;
notifyDocumentsChanged: (documentId?: string) => void;
}): Promise<string> {
const payload = input.title.trim() || "无标题";
await input.persistTitleCommand({
documentId: input.documentId,
workspaceId: input.workspaceId,
title: payload,
});
input.notifyDocumentsChanged(input.documentId);
return payload;
}
export function DocumentContent({ export function DocumentContent({
documentId, page,
workspaceId,
title,
updatedAt,
initialContent,
initialContentRevision = null,
initialConflictDetectionKey = null,
initialPageSubtree = null,
initialOptions,
initialStats,
openTableId, openTableId,
editorHostKind = "blocknote", editorHostKind = DEFAULT_EDITOR_HOST_KIND,
readOnly = false,
disableDownload = false,
disableCopy = false,
}: DocumentContentProps) { }: DocumentContentProps) {
const documentId = page.identity.documentId;
const workspaceId = page.identity.workspaceId;
const initialTitle = page.head.title;
const updatedAt = page.head.updatedAt;
const readOnly = page.head.permissions.readOnly;
const disableDownload = page.head.permissions.disableDownload;
const disableCopy = page.head.permissions.disableCopy;
const initialOptions = page.layout.pageOptions;
const initialContent = page.body.content;
const initialContentRevision = page.body.revision;
const initialConflictDetectionKey = page.body.conflictDetectionKey;
const initialPageSubtree = page.tree.pageSubtree;
const initialStats = page.stats;
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent); const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch); const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly; const canEditDocument = !readOnly;
@@ -143,22 +162,42 @@ export function DocumentContent({
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage); const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck); const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker); const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题"); const {
displayTitle: pageTitle,
committedTitle: committedPageTitle,
setDraftTitle: setPageTitleDraft,
commitPersistedTitle,
} = usePageHeadTitle({
documentId,
fallbackTitle: initialTitle,
});
const [content, setContent] = useState<unknown>(initialContent); const [content, setContent] = useState<unknown>(initialContent);
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent); const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>( const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
initialPageSubtree, initialPageSubtree,
); );
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(title ?? "无标题"); const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(initialTitle);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision); const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey); const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const [contentLoading, setContentLoading] = useState(() => initialContent == null); const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null); const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0); const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false); const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const shouldUseExperimentalHost = const shouldUseRuntimeHost = isLeptosTiptapHostKind(editorHostKind);
editorHostKind === "leptos_tiptap_inline" || const requestedHostKind = shouldUseRuntimeHost ? editorHostKind : "blocknote";
editorHostKind === "leptos_tiptap_iframe_debug"; const [activeHostKind, setActiveHostKind] = useState<"blocknote" | EditorHostKind>(() =>
requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind,
);
const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState<string | null>(null);
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
const [hostCommandFailure, setHostCommandFailure] = useState<string | null>(null);
const [hostSaveFailure, setHostSaveFailure] = useState<string | null>(null);
const [hostFallbackCount, setHostFallbackCount] = useState<number>(0);
const [lastFallbackReason, setLastFallbackReason] = useState<EditorHostFallbackReason | null>(null);
const [lastFallbackAt, setLastFallbackAt] = useState<string | null>(null);
const [hostStatus, setHostStatus] = useState<string>("idle");
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
const fallbackTriggerHistoryRef = useRef<string[]>([]);
const shouldStartEditing = canEditDocument; const shouldStartEditing = canEditDocument;
const [isEditing, setIsEditing] = useState(() => shouldStartEditing); const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing); const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
@@ -170,6 +209,51 @@ export function DocumentContent({
const readViewRootRef = useRef<HTMLDivElement>(null); const readViewRootRef = useRef<HTMLDivElement>(null);
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null); const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
const lastCopyBlockedAtRef = useRef<number>(0); const lastCopyBlockedAtRef = useRef<number>(0);
const hasRequestedFallbackRef = useRef(false);
const resetHostObservability = useCallback((nextHost: "blocknote" | EditorHostKind) => {
setHostStatus(nextHost === "blocknote" ? "blocknote_active" : "booting");
setHostEventAt(new Date().toISOString());
setHostRuntimeLoadFailure(null);
setHostInitFailure(null);
setHostCommandFailure(null);
setHostSaveFailure(null);
setHostFallbackCount(0);
setLastFallbackReason(null);
setLastFallbackAt(null);
fallbackTriggerHistoryRef.current = [];
hasRequestedFallbackRef.current = false;
}, []);
const requestFallbackToBlockNote = useCallback(
(reason: EditorHostFallbackReason, error?: string | null) => {
if (hasRequestedFallbackRef.current) {
return;
}
hasRequestedFallbackRef.current = true;
const now = new Date().toISOString();
setActiveHostKind("blocknote");
setHostFallbackCount((prev) => prev + 1);
setLastFallbackReason(reason);
setLastFallbackAt(now);
setHostStatus("blocknote_fallback");
setHostEventAt(now);
fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice(
0,
FALLBACK_TRIGGER_HISTORY_LIMIT,
);
if (reason === "runtime_load_failed") {
setHostRuntimeLoadFailure(error ?? "runtime 加载失败");
} else if (reason === "host_init_failed") {
setHostInitFailure(error ?? "host 初始化失败");
} else if (reason === "command_failed") {
setHostCommandFailure(error ?? "命令执行失败");
} else if (reason === "save_failed") {
setHostSaveFailure(error ?? "保存失败");
}
},
[],
);
useEffect(() => { useEffect(() => {
setCurrentDocument(documentId, readOnly, disableDownload, disableCopy); setCurrentDocument(documentId, readOnly, disableDownload, disableCopy);
@@ -243,6 +327,11 @@ export function DocumentContent({
}; };
}, [disableCopy]); }, [disableCopy]);
useEffect(() => {
setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
}, [requestedHostKind, resetHostObservability]);
useEffect(() => { useEffect(() => {
const tableId = (openTableId ?? "").trim(); const tableId = (openTableId ?? "").trim();
if (!tableId) return; if (!tableId) return;
@@ -264,12 +353,8 @@ export function DocumentContent({
}, [documentId, editorBridge, openTableId, router]); }, [documentId, editorBridge, openTableId, router]);
useEffect(() => { useEffect(() => {
setPageTitle(title ?? "无标题"); setServerPageSubtreeTitle(committedPageTitle);
}, [title]); }, [committedPageTitle]);
useEffect(() => {
setServerPageSubtreeTitle(title ?? "无标题");
}, [title]);
useEffect(() => { useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree); setServerPageSubtreeSnapshot(initialPageSubtree);
@@ -393,7 +478,7 @@ export function DocumentContent({
typeof payload.pageSubtree?.rootNode.metadata.title === "string" && typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim() payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title ? payload.pageSubtree.rootNode.metadata.title
: title ?? "无标题", : committedPageTitle,
); );
} catch (error) { } catch (error) {
if (canceled) return; if (canceled) return;
@@ -421,19 +506,36 @@ export function DocumentContent({
contentLoadingTimerRef.current = null; contentLoadingTimerRef.current = null;
} }
}; };
}, [documentId, initialContent, contentReloadKey, title, workspaceId]); }, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
const persistTitle = useCallback( const persistTitle = useCallback(
async (nextTitle: string) => { async (nextTitle: string) => {
if (readOnly) return; if (readOnly) return;
const payload = nextTitle.trim() || "无标题";
try { try {
await renameDocumentCommand({ documentId, workspaceId, title: payload }); const payload = await persistPageTitleAndNotifyDocumentsChanged({
documentId,
workspaceId,
title: nextTitle,
persistTitleCommand: updatePageTitleCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
commitPersistedTitle(payload);
setServerPageSubtreeTitle(payload);
} catch (error) { } catch (error) {
console.error("更新页面标题失败", error); console.error("更新页面标题失败", error);
} }
}, },
[documentId, readOnly, workspaceId], [commitPersistedTitle, documentId, readOnly, workspaceId],
);
const handleAiPageHeadTitleChange = useCallback(
(nextTitle: string) => {
setPageTitleDraft(nextTitle);
commitPersistedTitle(nextTitle);
setServerPageSubtreeTitle(nextTitle);
emitDocumentsChanged(documentId);
},
[commitPersistedTitle, documentId, setPageTitleDraft],
); );
const debouncedPersistTitle = useDebouncedCallback((value: string) => { const debouncedPersistTitle = useDebouncedCallback((value: string) => {
@@ -443,7 +545,7 @@ export function DocumentContent({
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => { const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!canEditDocument) return; if (!canEditDocument) return;
const value = event.target.value; const value = event.target.value;
setPageTitle(value); setPageTitleDraft(value);
debouncedPersistTitle(value); debouncedPersistTitle(value);
}; };
@@ -463,15 +565,11 @@ export function DocumentContent({
async (patch: Partial<PageOptionsState>) => { async (patch: Partial<PageOptionsState>) => {
if (readOnly) return; if (readOnly) return;
try { try {
const response = await fetch("/api/documents/options", { await updatePageOptionsCommand({
method: "POST", documentId,
headers: { "Content-Type": "application/json" }, workspaceId,
body: JSON.stringify({ documentId, workspaceId, options: patch }), pageOptions: patch,
}); });
if (!response.ok) {
const payload = await response.json().catch(() => null);
console.error(payload?.error ?? "更新页面选项失败");
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -742,10 +840,10 @@ export function DocumentContent({
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
anchor.download = `${title ?? "未命名页面"}-${new Date().toISOString()}.json`; anchor.download = `${pageTitle ?? "未命名页面"}-${new Date().toISOString()}.json`;
anchor.click(); anchor.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [disableDownload, history, title]); }, [disableDownload, history, pageTitle]);
const pageRootClass = cn( const pageRootClass = cn(
"flex h-full overflow-hidden bg-wolai-bg", "flex h-full overflow-hidden bg-wolai-bg",
@@ -784,6 +882,16 @@ export function DocumentContent({
})), })),
[pageSubtree], [pageSubtree],
); );
const getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
const getLatestPersistedMeta = useCallback(
() => ({
workspaceId,
revision: contentRevision,
conflictDetectionKey,
}),
[conflictDetectionKey, contentRevision, workspaceId],
);
const inspectorCanUseEditorBridge = canEditDocument && isEditing; const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => { const jumpToHeading = useCallback((headingId: string) => {
@@ -861,6 +969,68 @@ export function DocumentContent({
editorBridge.replaceWithSnapshot(pendingSnapshot.blocks); editorBridge.replaceWithSnapshot(pendingSnapshot.blocks);
setHistoryOpen(false); setHistoryOpen(false);
}, [editorBridge, isEditing]); }, [editorBridge, isEditing]);
const handleHostEvent = useCallback((event: EditorHostEvent) => {
setHostEventAt(event.at);
if (event.kind === "status_changed") {
setHostStatus(event.status);
return;
}
if (event.kind === "runtime_load_failed") {
setHostRuntimeLoadFailure(event.message);
return;
}
if (event.kind === "host_init_failed") {
setHostInitFailure(event.message);
return;
}
if (event.kind === "command_failed") {
setHostCommandFailure(event.message);
return;
}
if (event.kind === "save_failed") {
setHostSaveFailure(event.message);
return;
}
}, []);
const hostObservability = useMemo(
() => ({
requestedHostKind,
activeHostKind,
status: hostStatus,
runtimeLoadFailed: hostRuntimeLoadFailure,
hostInitFailed: hostInitFailure,
commandFailed: hostCommandFailure,
saveFailed: hostSaveFailure,
fallbackCount: hostFallbackCount,
lastFallbackReason,
lastFallbackAt,
lastEventAt: hostEventAt,
fallbackTimestamps: fallbackTriggerHistoryRef.current,
}),
[
activeHostKind,
hostEventAt,
hostFallbackCount,
hostInitFailure,
hostCommandFailure,
hostRuntimeLoadFailure,
hostSaveFailure,
hostStatus,
lastFallbackAt,
lastFallbackReason,
requestedHostKind,
],
);
const activeHostFailureMessage =
hostRuntimeLoadFailure ?? hostInitFailure ?? hostCommandFailure ?? hostSaveFailure;
const showFallbackBanner =
requestedHostKind !== "blocknote" && activeHostKind === "blocknote" && lastFallbackReason != null;
const showFailureBanner =
requestedHostKind !== "blocknote" &&
activeHostKind !== "blocknote" &&
activeHostFailureMessage != null;
return ( return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}> <ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className={pageRootClass} ref={pageRootRef}> <div className={pageRootClass} ref={pageRootRef}>
@@ -935,15 +1105,55 @@ export function DocumentContent({
</div> </div>
) : ( ) : (
<div className="relative"> <div className="relative">
{showFallbackBanner ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div>
<div className="font-medium">
island 退 BlockNote
</div>
<div className="mt-1 text-xs text-amber-700">
{lastFallbackReason}
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setActiveHostKind(requestedHostKind);
resetHostObservability(requestedHostKind);
}}
>
island
</Button>
</div>
) : null}
{showFailureBanner ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div>
<div className="font-medium">island </div>
<div className="mt-1 text-xs text-red-600">{activeHostFailureMessage}</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => requestFallbackToBlockNote("explicit_fallback", activeHostFailureMessage)}
>
BlockNote
</Button>
</div>
) : null}
{keepEditorMounted && ( {keepEditorMounted && (
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}> <div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
{shouldUseExperimentalHost ? ( {activeHostKind !== "blocknote" ? (
<EditorHost <EditorHost
documentId={documentId} documentId={documentId}
workspaceId={workspaceId} workspaceId={workspaceId}
initialContent={content} initialContent={content}
title={title} title={pageTitle}
hostKind={editorHostKind} hostKind={activeHostKind}
initialRevision={contentRevision} initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey} initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options} pageOptions={options}
@@ -955,13 +1165,17 @@ export function DocumentContent({
setContentRevision(meta.revision); setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey); setConflictDetectionKey(meta.conflictDetectionKey);
}} }}
onHostEvent={handleHostEvent}
onRequestFallback={(payload) => {
requestFallbackToBlockNote(payload.reason, payload.error);
}}
/> />
) : ( ) : (
<BlockNoteEditor <BlockNoteEditor
documentId={documentId} documentId={documentId}
workspaceId={workspaceId} workspaceId={workspaceId}
initialContent={content} initialContent={content}
title={title} title={pageTitle}
initialRevision={contentRevision} initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey} initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options} pageOptions={options}
@@ -977,6 +1191,21 @@ export function DocumentContent({
)} )}
</div> </div>
)} )}
<div
className="sr-only"
data-editor-host-observability={JSON.stringify(hostObservability)}
data-editor-host-active={hostObservability.activeHostKind}
data-editor-host-requested={hostObservability.requestedHostKind}
data-editor-host-status={hostObservability.status}
data-editor-host-runtime-load-failed={
hostObservability.runtimeLoadFailed ? "1" : "0"
}
data-editor-host-init-failed={hostObservability.hostInitFailed ? "1" : "0"}
data-editor-host-command-failed={hostObservability.commandFailed ? "1" : "0"}
data-editor-host-save-failed={hostObservability.saveFailed ? "1" : "0"}
data-editor-host-fallback-count={String(hostObservability.fallbackCount)}
data-editor-host-last-fallback-reason={hostObservability.lastFallbackReason ?? ""}
/>
{!isEditing && ( {!isEditing && (
<div className="relative" ref={readViewRootRef}> <div className="relative" ref={readViewRootRef}>
<DocumentReadView <DocumentReadView
@@ -1037,8 +1266,14 @@ export function DocumentContent({
<DocumentCommentsDrawer /> <DocumentCommentsDrawer />
<DocumentAiAgentPanel <DocumentAiAgentPanel
documentId={documentId} documentId={documentId}
getLatestBlocks={() => latestBlocksRef.current} getLatestBlocks={getLatestBlocks}
getLatestPageSubtree={() => pageSubtree} getLatestPageSubtree={getLatestPageSubtree}
getLatestPersistedMeta={getLatestPersistedMeta}
onPersistedMetaChange={(meta) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
onPageHeadTitleChange={handleAiPageHeadTitleChange}
/> />
</ImagePickerProvider> </ImagePickerProvider>
); );
@@ -3,6 +3,8 @@
import type { DocumentContentProps } from "@/components/editor/document-content"; import type { DocumentContentProps } from "@/components/editor/document-content";
import { DocumentContent } from "@/components/editor/document-content"; import { DocumentContent } from "@/components/editor/document-content";
export function DocumentShell(props: DocumentContentProps) { export interface DocumentShellProps extends DocumentContentProps {}
export function DocumentShell(props: DocumentShellProps) {
return <DocumentContent {...props} />; return <DocumentContent {...props} />;
} }
@@ -0,0 +1,37 @@
import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
resolveEditorHostKind,
} from "@/components/editor/editor-host-config";
import { describe, expect, it } from "vitest";
describe("editor-host-config", () => {
it("保持 island 为默认正式 host", () => {
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
});
it("将 inline 和旧 runtime 兼容别名收敛到正式 island host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_inline")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap_runtime")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap")).toBe("leptos_tiptap_island");
});
it("保留 iframe debug 作为显式调试 host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_iframe_debug");
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
});
it("优先尊重 query override 的 debug host 选择", () => {
expect(
resolveEditorHostKind({
override: "leptos_tiptap_iframe_debug",
runtimeDefault: "leptos_tiptap_island",
}),
).toBe("leptos_tiptap_iframe_debug");
});
});
@@ -1,23 +1,59 @@
export type EditorHostKind = export type EditorHostKind =
| "blocknote" | "blocknote"
| "leptos_tiptap_inline" | "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug"; | "leptos_tiptap_iframe_debug";
export interface EditorHostConfig { export interface EditorHostConfig {
kind: EditorHostKind; kind: EditorHostKind;
} }
const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "blocknote"; export const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "leptos_tiptap_island";
export function normalizeEditorHostKind(value: unknown): EditorHostKind { export function normalizeEditorHostKind(
value: unknown,
fallback: EditorHostKind = DEFAULT_EDITOR_HOST_KIND,
): EditorHostKind {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "leptos_tiptap_inline") { if (normalized === "blocknote") {
return "leptos_tiptap_inline"; return "blocknote";
} }
if (normalized === "leptos_tiptap_iframe_debug" || normalized === "leptos_tiptap") { if (
normalized === "leptos_tiptap_runtime" ||
normalized === "leptos_tiptap_inline" ||
normalized === "leptos_tiptap"
) {
return "leptos_tiptap_island";
}
// 说明:iframe 只保留给显式调试桥,不再混入正式 runtime 主链。
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug"; return "leptos_tiptap_iframe_debug";
} }
return DEFAULT_EDITOR_HOST_KIND; // 说明:保留历史 query 值兼容;未显式声明 debug 时,一律回到正式 runtime host。
return fallback;
}
export function resolveEditorHostKind(input: {
override?: unknown;
runtimeDefault?: unknown;
}): EditorHostKind {
const runtimeDefault = normalizeEditorHostKind(
input.runtimeDefault,
DEFAULT_EDITOR_HOST_KIND,
);
const hasOverride =
typeof input.override === "string" && input.override.trim().length > 0;
if (hasOverride) {
return normalizeEditorHostKind(input.override, runtimeDefault);
}
return runtimeDefault;
}
export function isLeptosTiptapHostKind(kind: EditorHostKind): boolean {
return kind !== "blocknote";
} }
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind { export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
@@ -22,10 +22,44 @@ export interface DocumentEditorHostProps {
revision: number | null; revision: number | null;
conflictDetectionKey: string | null; conflictDetectionKey: string | null;
}) => void; }) => void;
onHostEvent?: (event: EditorHostEvent) => void;
onRequestFallback?: (payload: EditorHostFallbackRequest) => void;
} }
export type BlockNoteEditorProps = DocumentEditorHostProps; export type BlockNoteEditorProps = DocumentEditorHostProps;
export type EditorHostFallbackReason =
| "runtime_load_failed"
| "host_init_failed"
| "command_failed"
| "save_failed"
| "explicit_fallback";
export type EditorHostFallbackRequest = {
reason: EditorHostFallbackReason;
error?: string | null;
at: string;
};
export type EditorHostEvent =
| {
kind: "status_changed";
status: string;
at: string;
message?: string | null;
}
| {
kind: "runtime_load_failed" | "host_init_failed" | "command_failed" | "save_failed";
at: string;
message: string;
}
| {
kind: "fallback_triggered";
at: string;
reason: EditorHostFallbackReason;
message?: string | null;
};
export type LeptosTiptapHostBridgeState = { export type LeptosTiptapHostBridgeState = {
ready: boolean; ready: boolean;
runtimeName: string; runtimeName: string;
@@ -52,3 +86,8 @@ export type LeptosTiptapHostBridgeEventDetail = {
error?: string | null; error?: string | null;
at?: string; at?: string;
}; };
export type LeptosTiptapRuntimePageOptions = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
} from "@/components/editor/editor-host-config";
describe("editor-host", () => {
it("默认正式主链是 island host", () => {
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap_runtime")).toBe("leptos_tiptap_island");
});
});
@@ -4,6 +4,19 @@ import dynamic from "next/dynamic";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types"; import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
const LeptosTiptapIslandEditor = dynamic(
() =>
import("@/components/editor/leptos-tiptap-island-editor-host").then(
(mod) => mod.LeptosTiptapIslandEditorHost,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapIframeDebugEditor = dynamic( const LeptosTiptapIframeDebugEditor = dynamic(
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost), () => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
{ {
@@ -14,22 +27,9 @@ const LeptosTiptapIframeDebugEditor = dynamic(
}, },
) as ComponentType<DocumentEditorHostProps>; ) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapInlineEditor = dynamic(
() =>
import("@/components/editor/leptos-tiptap-inline-editor-host").then(
(mod) => mod.LeptosTiptapInlineEditorHost,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
export function EditorHost(props: DocumentEditorHostProps) { export function EditorHost(props: DocumentEditorHostProps) {
if (props.hostKind === "leptos_tiptap_inline") { if (props.hostKind === "leptos_tiptap_iframe_debug") {
return <LeptosTiptapInlineEditor {...props} />; return <LeptosTiptapIframeDebugEditor {...props} />;
} }
return <LeptosTiptapIframeDebugEditor {...props} />; return <LeptosTiptapIslandEditor {...props} />;
} }
@@ -193,6 +193,9 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const revisionRef = useRef<number | null>(props.initialRevision ?? null); const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null); const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
const bootstrapPayloadRef = useRef<BootstrapPayload | null>(null); const bootstrapPayloadRef = useRef<BootstrapPayload | null>(null);
const runtimeDocRef = useRef<Json>(
normalizeRuntimeDoc(tiptapDocFromBlocks(props.initialContent as Json) as Json) as Json,
);
const [runtimeDoc, setRuntimeDoc] = useState<Json>(() => tiptapDocFromBlocks(props.initialContent as Json) as Json); const [runtimeDoc, setRuntimeDoc] = useState<Json>(() => tiptapDocFromBlocks(props.initialContent as Json) as Json);
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const [iframeHeight, setIframeHeight] = useState(FALLBACK_IFRAME_MIN_HEIGHT); const [iframeHeight, setIframeHeight] = useState(FALLBACK_IFRAME_MIN_HEIGHT);
@@ -222,6 +225,10 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null; conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [props.initialConflictDetectionKey, props.initialRevision, props.onPersistedMetaChange, props.onSnapshot, props.onStatsChange]); }, [props.initialConflictDetectionKey, props.initialRevision, props.onPersistedMetaChange, props.onSnapshot, props.onStatsChange]);
useEffect(() => {
runtimeDocRef.current = runtimeDoc;
}, [runtimeDoc]);
useEffect(() => { useEffect(() => {
const nextRuntimeDoc = tiptapDocFromBlocks(props.initialContent as Json) as Json; const nextRuntimeDoc = tiptapDocFromBlocks(props.initialContent as Json) as Json;
setRuntimeDoc(nextRuntimeDoc); setRuntimeDoc(nextRuntimeDoc);
@@ -246,15 +253,6 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
})); }));
}, [props.documentId, props.initialContent, props.initialConflictDetectionKey, props.initialRevision, props.readOnly, props.title, props.workspaceId]); }, [props.documentId, props.initialContent, props.initialConflictDetectionKey, props.initialRevision, props.readOnly, props.title, props.workspaceId]);
useEffect(() => {
const iframeWindow = iframeRef.current?.contentWindow;
if (!iframeWindow) return;
const payload = bootstrapPayloadRef.current;
if (!payload) return;
postBridgeMessage(iframeWindow, REPLACE_DOCUMENT_EVENT, payload);
setBridgeState((prev) => ({ ...prev, status: prev.ready ? "reloading" : prev.status }));
}, [runtimeDoc]);
useEffect(() => { useEffect(() => {
const bridge: EditorReferenceBridge = { const bridge: EditorReferenceBridge = {
insertInlineReference: () => ({ blockId: null }), insertInlineReference: () => ({ blockId: null }),
@@ -304,7 +302,9 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const publishSnapshot = (payload: HostDocumentPayload) => { const publishSnapshot = (payload: HostDocumentPayload) => {
const blocks = blocksFromTiptapDoc(payload.content) as Json; const blocks = blocksFromTiptapDoc(payload.content) as Json;
const stats = buildStats(blocks); const stats = buildStats(blocks);
setRuntimeDoc(normalizeRuntimeDoc(payload.content as Json) as Json); const nextRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
runtimeDocRef.current = nextRuntimeDoc;
setRuntimeDoc(nextRuntimeDoc);
onSnapshotRef.current?.({ blocks, stats }); onSnapshotRef.current?.({ blocks, stats });
onStatsChangeRef.current?.(stats); onStatsChangeRef.current?.(stats);
return { blocks, stats }; return { blocks, stats };
@@ -423,14 +423,14 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
data-editor-host-kind="leptos_tiptap_iframe_debug" data-editor-host-kind="leptos_tiptap_iframe_debug"
> >
<div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 text-xs text-amber-800"> <div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 text-xs text-amber-800">
<span>`iframe + postMessage` prototype</span> <span>`leptos-tiptap` iframe debug host</span>
<span>{bridgeState.status}</span> <span>{bridgeState.status}</span>
</div> </div>
<iframe <iframe
key={`${props.documentId}:${reloadKey}`} key={`${props.documentId}:${reloadKey}`}
ref={iframeRef} ref={iframeRef}
src={iframeSrc} src={iframeSrc}
title="Leptos Tiptap Editor Debug Bridge" title="Leptos Tiptap Main Editor Runtime"
className="w-full border-0" className="w-full border-0"
style={{ height: `${iframeHeight}px`, minHeight: `${FALLBACK_IFRAME_MIN_HEIGHT}px` }} style={{ height: `${iframeHeight}px`, minHeight: `${FALLBACK_IFRAME_MIN_HEIGHT}px` }}
data-bridge-status={bridgeState.status} data-bridge-status={bridgeState.status}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
import { act, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { LeptosTiptapIslandEditorHost } from "@/components/editor/leptos-tiptap-island-editor-host";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
const runtimeModuleUrl = `data:text/javascript;base64,${Buffer.from(`
const mounts = new Map();
export default async function init() {
globalThis.__MNOTE_ISLAND_TEST_STATE__.initCalls += 1;
}
export function mount(container, options) {
const state = globalThis.__MNOTE_ISLAND_TEST_STATE__;
const mountId = state.nextMountId++;
const surface = document.createElement("div");
surface.className = "editor-surface";
const editor = document.createElement("div");
editor.className = "ProseMirror";
editor.setAttribute("contenteditable", "true");
editor.textContent = options?.title ?? "untitled";
surface.appendChild(editor);
container.replaceChildren(surface);
container.addEventListener("mnote:leptos-tiptap-spike:command", (event) => {
const detail = event?.detail ?? {};
state.commandCalls.push({
command: detail?.payload?.command ?? null,
payload: detail?.payload ?? null,
});
});
mounts.set(mountId, container);
state.mountCalls.push({
mountId,
title: options?.title ?? null,
pageOptions: options?.pageOptions ?? null,
});
return mountId;
}
export function unmount(mountId) {
const state = globalThis.__MNOTE_ISLAND_TEST_STATE__;
const container = mounts.get(mountId);
if (container) {
container.replaceChildren();
mounts.delete(mountId);
}
state.unmountCalls.push(mountId);
}
`).toString("base64")}`;
vi.mock("@/components/editor/leptos-tiptap-island-loader", () => ({
loadLeptosTiptapIslandAssets: vi.fn(async () => ({
manifest: null,
entryAssetUrl: runtimeModuleUrl,
wasmAssetUrl: null,
})),
}));
type RuntimeTestState = {
initCalls: number;
nextMountId: number;
mountCalls: Array<{ mountId: number; title: string | null; pageOptions?: unknown }>;
unmountCalls: number[];
commandCalls: Array<{ command: string | null; payload?: unknown }>;
};
declare global {
interface Window {
__MNOTE_ISLAND_TEST_STATE__?: RuntimeTestState;
}
}
function flushEffects() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function buildProps(overrides: Partial<DocumentEditorHostProps> = {}): DocumentEditorHostProps {
return {
documentId: "doc-1",
workspaceId: "ws-1",
initialContent: [],
title: "标题 A",
hostKind: "leptos_tiptap_island",
initialRevision: 1,
initialConflictDetectionKey: "doc-1:1",
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
readOnly: false,
onStatsChange: vi.fn(),
onSnapshot: vi.fn(),
onCloseToc: vi.fn(),
onPersistedMetaChange: vi.fn(),
onHostEvent: vi.fn(),
onRequestFallback: vi.fn(),
...overrides,
};
}
function HostEchoHarness({
initialContent,
onSnapshotSpy,
}: {
initialContent: DocumentEditorHostProps["initialContent"];
onSnapshotSpy?: ReturnType<typeof vi.fn>;
}) {
const [content, setContent] = useState(initialContent);
return (
<LeptosTiptapIslandEditorHost
{...buildProps({
initialContent: content,
onSnapshot: (payload) => {
onSnapshotSpy?.(payload);
setContent(payload.blocks);
},
})}
/>
);
}
describe("leptos-tiptap-island-editor-host", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
window.__MNOTE_ISLAND_TEST_STATE__ = {
initCalls: 0,
nextMountId: 1,
mountCalls: [],
unmountCalls: [],
commandCalls: [],
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => {
root.unmount();
await flushEffects();
});
container.remove();
delete window.__MNOTE_ISLAND_TEST_STATE__;
});
it("同一文档仅标题变化时不应重复卸载并重挂 island", async () => {
const initialProps = buildProps();
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...initialProps} />);
await flushEffects();
await flushEffects();
});
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls).toHaveLength(1);
expect(window.__MNOTE_ISLAND_TEST_STATE__?.unmountCalls).toHaveLength(0);
expect(
container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
),
).not.toBeNull();
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
title: "标题 B",
})}
/>,
);
await flushEffects();
await flushEffects();
});
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls).toHaveLength(1);
expect(window.__MNOTE_ISLAND_TEST_STATE__?.unmountCalls).toHaveLength(0);
});
it("页面内 island 宿主不应再额外施加横向 padding", async () => {
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...buildProps()} />);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
expect(host?.className).toContain("min-h-[720px]");
expect(host?.className).toContain("py-6");
expect(host?.className).not.toContain("px-8");
});
it("mount 时应把 pageOptions 传给 island runtime", async () => {
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
pageOptions: {
...buildProps().pageOptions,
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
},
})}
/>,
);
await flushEffects();
await flushEffects();
});
expect(window.__MNOTE_ISLAND_TEST_STATE__?.mountCalls[0]?.pageOptions).toEqual({
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
});
});
it("pageOptions 变化时应向 runtime 发送 setPageOptions 命令", async () => {
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...buildProps()} />);
await flushEffects();
await flushEffects();
});
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
pageOptions: {
...buildProps().pageOptions,
wideLayout: true,
layoutDensity: "spacious",
},
})}
/>,
);
await flushEffects();
await flushEffects();
});
const latestPageOptionsCommand = [...(window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls ?? [])]
.reverse()
.find((entry) => entry.command === "setPageOptions");
expect(latestPageOptionsCommand?.payload).toMatchObject({
command: "setPageOptions",
pageOptions: {
wideLayout: true,
smallText: false,
layoutDensity: "spacious",
showHeadingNumbers: true,
embedDefaultBlockId: null,
},
});
});
it("runtime 快照回流为同一内容时不应再次发送 replaceContent", async () => {
const onSnapshot = vi.fn();
const initialProps = buildProps({
initialContent: [
{
id: "block-1",
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
props: {},
children: [],
},
],
onSnapshot,
});
await act(async () => {
root.render(<LeptosTiptapIslandEditorHost {...initialProps} />);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
const initialReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
},
],
},
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
});
const nextReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
expect(onSnapshot).toHaveBeenCalled();
expect(nextReplaceCount).toBe(initialReplaceCount);
});
it("空文档初始化后收到等价空段落快照时不应再次发送 replaceContent", async () => {
const onSnapshot = vi.fn();
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
initialContent: [],
onSnapshot,
})}
/>,
);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
const initialReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [],
},
],
},
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
await flushEffects();
});
const nextReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
expect(onSnapshot).toHaveBeenCalledTimes(1);
expect(nextReplaceCount).toBe(initialReplaceCount);
});
it("父组件回灌 runtime 等价 blocks 时不应再次发送 replaceContent", async () => {
const onSnapshotSpy = vi.fn();
await act(async () => {
root.render(
<HostEchoHarness
initialContent={[
{
id: "block-1",
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
props: {},
children: [],
},
]}
onSnapshotSpy={onSnapshotSpy}
/>,
);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
const initialReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "hello island" }],
},
],
},
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
await flushEffects();
});
const nextReplaceCount =
window.__MNOTE_ISLAND_TEST_STATE__?.commandCalls.filter(
(entry) => entry.command === "replaceContent",
).length ?? 0;
expect(onSnapshotSpy).toHaveBeenCalledTimes(1);
expect(nextReplaceCount).toBe(initialReplaceCount);
});
it("应兼容 runtime 把 tiptap 文档作为 Map 回传", async () => {
const onSnapshot = vi.fn();
await act(async () => {
root.render(
<LeptosTiptapIslandEditorHost
{...buildProps({
initialContent: [],
onSnapshot,
})}
/>,
);
await flushEffects();
await flushEffects();
});
const host = container.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"]',
) as HTMLDivElement | null;
expect(host).not.toBeNull();
await act(async () => {
host?.dispatchEvent(
new CustomEvent("mnote:leptos-tiptap-spike:change", {
bubbles: true,
detail: {
protocol: "mnote.leptos_tiptap.bridge.v1",
source: "mnote:leptos-tiptap-spike",
payload: {
title: "标题 A",
content: new Map([
["type", "doc"],
[
"content",
[
{
type: "paragraph",
content: [{ type: "text", text: "hello from map" }],
},
],
],
]),
meta: {
readOnly: false,
revision: 1,
conflictDetectionKey: "doc-1:1",
},
},
},
}),
);
await flushEffects();
await flushEffects();
});
expect(onSnapshot).toHaveBeenCalledTimes(1);
expect(onSnapshot.mock.calls[0]?.[0]?.blocks).toEqual([
{
id: "block-1",
type: "paragraph",
content: "hello from map",
},
]);
});
});
@@ -0,0 +1,832 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
DocumentEditorHostProps,
EditorHostFallbackReason,
} from "@/components/editor/editor-host-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import {
loadLeptosTiptapIslandAssets,
} from "@/components/editor/leptos-tiptap-island-loader";
const EVENT_PREFIX = "mnote:leptos-tiptap-spike";
const PROTOCOL = "mnote.leptos_tiptap.bridge.v1";
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const STATE_EVENT = `${EVENT_PREFIX}:state`;
const STATUS_EVENT = `${EVENT_PREFIX}:status`;
const SELECTION_EVENT = `${EVENT_PREFIX}:selection`;
const HEIGHT_EVENT = `${EVENT_PREFIX}:height`;
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
const FALLBACK_MIN_HEIGHT = 720;
const SAVE_DEBOUNCE_MS = 900;
type IslandRuntimeModule = {
default: (input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module) => Promise<unknown>;
mount: (container: Element, options: unknown) => number;
unmount: (mountId: number) => void;
};
type RuntimeEnvelope<T = unknown> = {
protocol?: string;
runtime?: string;
version?: string;
source?: string;
event?: string;
payload?: T;
};
type ChangePayload = {
documentId?: string | null;
workspaceId?: string | null;
title?: string;
content?: unknown;
meta?: {
dirtyCount?: number;
editorFocused?: boolean;
slashOpen?: boolean;
toolbarOpen?: boolean;
selectedBlockIndex?: number | null;
revision?: number | null;
conflictDetectionKey?: string | null;
readOnly?: boolean;
};
};
type StatePayload = {
title?: string;
dirtyCount?: number;
selectedBlockIndex?: number | null;
editorFocused?: boolean;
slashOpen?: boolean;
toolbarOpen?: boolean;
readOnly?: boolean;
};
type StatusPayload = {
currentBlockId?: string | null;
selectedBlockIndex?: number | null;
};
type SelectionPayload = {
currentBlockId?: string | null;
currentBlockIndex?: number | null;
};
type HeightPayload = {
height?: number;
};
type ErrorPayload = {
message?: string;
};
type RuntimeBridgeState = {
ready: boolean;
runtimeName: string;
runtimeVersion: string;
runtimeUrl: string;
documentId: string;
workspaceId: string;
status: string;
lastChangeAt?: string | null;
lastSaveRequestAt?: string | null;
lastError?: string | null;
};
type IslandBootstrapPayload = {
documentId: string;
workspaceId: string;
title: string | null;
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
readOnly: boolean;
pageOptions: RuntimePageOptionsPayload;
};
type RuntimePageOptionsPayload = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
function buildRuntimePageOptions(pageOptions: PageOptionsState): RuntimePageOptionsPayload {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
function toIsoNow(): string {
return new Date().toISOString();
}
function flattenText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(flattenText).join("");
if (value && typeof value === "object") {
const record = value as { text?: unknown; content?: unknown };
return `${flattenText(record.text)}${flattenText(record.content)}`;
}
return "";
}
function serializeHostSyncValue(value: unknown): string {
try {
return JSON.stringify(value) ?? "null";
} catch {
return String(value);
}
}
function normalizeRuntimeValue(value: unknown): unknown {
if (value instanceof Map) {
return Object.fromEntries(
Array.from(value.entries()).map(([key, nestedValue]) => [
key,
normalizeRuntimeValue(nestedValue),
]),
);
}
if (Array.isArray(value)) {
return value.map((item) => normalizeRuntimeValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, nestedValue]) => [
key,
normalizeRuntimeValue(nestedValue),
]),
);
}
return value;
}
function buildHostSyncKey(input: {
documentId: string;
workspaceId: string;
title: string | null;
content: unknown;
readOnly: boolean;
}): string {
return serializeHostSyncValue({
documentId: input.documentId,
workspaceId: input.workspaceId,
title: input.title,
content: input.content,
readOnly: input.readOnly,
});
}
function canonicalizeTiptapDoc(value: unknown): Json {
const normalizedValue = normalizeRuntimeValue(value);
if (
normalizedValue &&
typeof normalizedValue === "object" &&
(normalizedValue as { type?: unknown }).type === "doc"
) {
return tiptapDocFromBlocks(blocksFromTiptapDoc(normalizedValue) as Json) as Json;
}
return tiptapDocFromBlocks(normalizedValue as Json) as Json;
}
function buildStats(blocks: Json): DocumentStats {
const items = Array.isArray(blocks) ? blocks : [];
const text = items
.map((item) =>
item && typeof item === "object"
? flattenText((item as { content?: unknown }).content)
: "",
)
.join("\n")
.trim();
const todoItems = items.filter(
(item) => item && typeof item === "object" && (item as { type?: unknown }).type === "todo",
);
const todoDone = todoItems.filter(
(item) =>
item &&
typeof item === "object" &&
Boolean((item as { props?: { checked?: unknown } }).props?.checked),
).length;
return {
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
characterCount: text.length,
blockCount: items.length,
todoTotal: todoItems.length,
todoDone,
};
}
function isRuntimeEnvelope(value: unknown): value is RuntimeEnvelope {
if (!value || typeof value !== "object") {
return false;
}
const maybe = value as RuntimeEnvelope;
return maybe.protocol === PROTOCOL && maybe.source === EVENT_PREFIX;
}
async function loadIslandRuntime(): Promise<{
runtimeModule: IslandRuntimeModule;
entryAssetUrl: string;
wasmAssetUrl: string | null;
}> {
const { entryAssetUrl, wasmAssetUrl } = await loadLeptosTiptapIslandAssets();
if (!entryAssetUrl) {
throw new Error("island manifest 缺少 entryAssetPath");
}
const runtimeModule = (await import(
/* webpackIgnore: true */ entryAssetUrl
)) as IslandRuntimeModule;
if (typeof runtimeModule.default !== "function") {
throw new Error("island entry 缺少默认 wasm 初始化函数");
}
if (typeof runtimeModule.mount !== "function") {
throw new Error("island entry 缺少 mount 导出");
}
if (typeof runtimeModule.unmount !== "function") {
throw new Error("island entry 缺少 unmount 导出");
}
await runtimeModule.default(wasmAssetUrl ?? undefined);
return {
runtimeModule,
entryAssetUrl,
wasmAssetUrl,
};
}
function dispatchRuntimeCommand(target: EventTarget, payload: unknown) {
const envelope: RuntimeEnvelope = {
protocol: PROTOCOL,
runtime: "leptos-tiptap-island-host",
version: "1.0.0",
source: EVENT_PREFIX,
event: COMMAND_EVENT,
payload,
};
const event = new CustomEvent(COMMAND_EVENT, {
bubbles: true,
detail: envelope,
});
target.dispatchEvent(event);
}
export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
const mountRef = useRef<HTMLDivElement | null>(null);
const mountIdRef = useRef<number | null>(null);
const runtimeModuleRef = useRef<IslandRuntimeModule | null>(null);
const runtimeTargetRef = useRef<EventTarget | null>(null);
const latestDocRef = useRef<Json>(tiptapDocFromBlocks(props.initialContent as Json) as Json);
const latestBlocksRef = useRef<Json>(props.initialContent as Json);
const currentBlockIdRef = useRef<string | null>(null);
const hostIdentityRef = useRef({
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
readOnly: Boolean(props.readOnly),
});
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
const lastHostSyncKeyRef = useRef<string | null>(null);
const onSnapshotRef = useRef(props.onSnapshot);
const onStatsChangeRef = useRef(props.onStatsChange);
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
const onHostEventRef = useRef(props.onHostEvent);
const onRequestFallbackRef = useRef(props.onRequestFallback);
const bootstrapPayloadRef = useRef<IslandBootstrapPayload>({
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: tiptapDocFromBlocks(props.initialContent as Json) as Json,
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
});
const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<RuntimeBridgeState>({
ready: false,
runtimeName: "leptos-tiptap-island",
runtimeVersion: "1.0.0",
runtimeUrl: "",
documentId: props.documentId,
workspaceId: props.workspaceId,
status: "booting",
lastChangeAt: null,
lastSaveRequestAt: null,
lastError: null,
});
const debouncedPersistRef = useRef<ReturnType<typeof useDebouncedCallback> | null>(null);
const mountIdentity = useMemo(
() => `${props.workspaceId}:${props.documentId}`,
[props.documentId, props.workspaceId],
);
useEffect(() => {
onSnapshotRef.current = props.onSnapshot;
onStatsChangeRef.current = props.onStatsChange;
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
onHostEventRef.current = props.onHostEvent;
onRequestFallbackRef.current = props.onRequestFallback;
hostIdentityRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
readOnly: Boolean(props.readOnly),
};
revisionRef.current = props.initialRevision ?? null;
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [
props.documentId,
props.initialConflictDetectionKey,
props.initialRevision,
props.onHostEvent,
props.onPersistedMetaChange,
props.onRequestFallback,
props.onSnapshot,
props.onStatsChange,
props.readOnly,
props.title,
props.workspaceId,
]);
useEffect(() => {
bootstrapPayloadRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: tiptapDocFromBlocks(props.initialContent as Json) as Json,
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
};
}, [
mountIdentity,
props.documentId,
props.initialConflictDetectionKey,
props.initialContent,
props.initialRevision,
props.pageOptions,
props.readOnly,
props.title,
props.workspaceId,
]);
const requestFallback = useCallback(
(reason: EditorHostFallbackReason, error: string) => {
const at = toIsoNow();
if (reason !== "explicit_fallback") {
onHostEventRef.current?.({
kind:
reason === "save_failed"
? "save_failed"
: reason === "runtime_load_failed"
? "runtime_load_failed"
: reason === "command_failed"
? "command_failed"
: "host_init_failed",
at,
message: error,
});
}
onRequestFallbackRef.current?.({
reason,
error,
at,
});
},
[],
);
const persistDocument = useCallback(async () => {
const normalizedDoc = latestDocRef.current;
const normalizedBlocks = blocksFromTiptapDoc(normalizedDoc) as Json;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
content: normalizedBlocks,
tiptapDocument: normalizedDoc,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
}),
),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
typeof payload?.error === "string" ? payload.error : `保存失败(${response.status}`,
);
}
revisionRef.current =
typeof payload?.revision === "number" ? payload.revision : revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string"
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setBridgeState((prev) => ({
...prev,
ready: true,
status: "saved",
lastSaveRequestAt: toIsoNow(),
lastError: null,
}));
}, [props.documentId, props.workspaceId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistDocument().catch((error) => {
const message = error instanceof Error ? error.message : "保存失败";
setBridgeState((prev) => ({
...prev,
status: "error",
lastError: message,
}));
requestFallback("save_failed", message);
});
}, SAVE_DEBOUNCE_MS);
useEffect(() => {
debouncedPersistRef.current = debouncedPersist;
}, [debouncedPersist]);
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
const bootstrapPayload = bootstrapPayloadRef.current;
let disposed = false;
let removeListeners: Array<() => void> = [];
const attachEvent = <T,>(eventName: string, handler: (payload: T) => void) => {
const listener = (event: Event) => {
const customEvent = event as CustomEvent<RuntimeEnvelope<T>>;
if (!isRuntimeEnvelope(customEvent.detail)) {
return;
}
handler((customEvent.detail.payload ?? {}) as T);
};
container.addEventListener(eventName, listener);
removeListeners.push(() => container.removeEventListener(eventName, listener));
};
void loadIslandRuntime()
.then(({ runtimeModule, entryAssetUrl }) => {
if (disposed) {
return;
}
runtimeModuleRef.current = runtimeModule;
runtimeTargetRef.current = container;
container.dataset.editorHostKind = "leptos_tiptap_island";
container.dataset.mnoteRuntime = "leptos_tiptap_island";
container.dataset.mnoteRuntimeBridge = "island";
container.dataset.mnoteRuntimeUrl = entryAssetUrl;
attachEvent<ChangePayload>(CHANGE_EVENT, (payload) => {
const hostIdentity = hostIdentityRef.current;
const nextDoc = canonicalizeTiptapDoc(payload.content ?? latestDocRef.current);
const nextBlocks = blocksFromTiptapDoc(nextDoc) as Json;
const nextStats = buildStats(nextBlocks);
const nextReadOnly = payload.meta?.readOnly ?? hostIdentity.readOnly;
const nextTitle = payload.title ?? hostIdentity.title;
latestDocRef.current = nextDoc;
latestBlocksRef.current = nextBlocks;
lastHostSyncKeyRef.current = buildHostSyncKey({
documentId: hostIdentity.documentId,
workspaceId: hostIdentity.workspaceId,
title: nextTitle,
content: nextDoc,
readOnly: nextReadOnly,
});
onSnapshotRef.current?.({ blocks: nextBlocks, stats: nextStats });
onStatsChangeRef.current?.(nextStats);
if (payload.meta) {
revisionRef.current =
typeof payload.meta.revision === "number"
? payload.meta.revision
: revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload.meta.conflictDetectionKey === "string"
? payload.meta.conflictDetectionKey
: conflictDetectionKeyRef.current;
}
setBridgeState((prev) => ({
...prev,
ready: true,
status: "dirty",
lastChangeAt: toIsoNow(),
lastError: null,
}));
debouncedPersistRef.current?.();
});
attachEvent<StatePayload>(STATE_EVENT, (payload) => {
setBridgeState((prev) => ({
...prev,
ready: true,
status:
payload.readOnly === true
? "read_only"
: payload.slashOpen || payload.toolbarOpen
? "interacting"
: "ready",
lastError: null,
}));
onHostEventRef.current?.({
kind: "status_changed",
status:
payload.readOnly === true
? "read_only"
: payload.slashOpen || payload.toolbarOpen
? "interacting"
: "ready",
at: toIsoNow(),
message: null,
});
});
attachEvent<StatusPayload>(STATUS_EVENT, (payload) => {
currentBlockIdRef.current =
typeof payload.currentBlockId === "string" ? payload.currentBlockId : null;
});
attachEvent<SelectionPayload>(SELECTION_EVENT, (payload) => {
currentBlockIdRef.current =
typeof payload.currentBlockId === "string" ? payload.currentBlockId : null;
});
attachEvent<HeightPayload>(HEIGHT_EVENT, (payload) => {
const nextHeight = Number(payload.height);
if (Number.isFinite(nextHeight)) {
setEditorHeight(Math.max(FALLBACK_MIN_HEIGHT, Math.ceil(nextHeight)));
}
});
attachEvent<ErrorPayload>(ERROR_EVENT, (payload) => {
const message =
typeof payload.message === "string" ? payload.message : "leptos-tiptap island 初始化失败";
setBridgeState((prev) => ({
...prev,
status: "error",
lastError: message,
}));
requestFallback("host_init_failed", message);
});
attachEvent(READY_EVENT, () => {
setBridgeState((prev) => ({
...prev,
ready: true,
status: "ready",
lastError: null,
}));
});
latestDocRef.current = bootstrapPayload.content as Json;
latestBlocksRef.current = blocksFromTiptapDoc(bootstrapPayload.content) as Json;
lastHostSyncKeyRef.current = buildHostSyncKey({
documentId: bootstrapPayload.documentId,
workspaceId: bootstrapPayload.workspaceId,
title: bootstrapPayload.title,
content: bootstrapPayload.content,
readOnly: bootstrapPayload.readOnly,
});
mountIdRef.current = runtimeModule.mount(container, {
documentId: bootstrapPayload.documentId,
workspaceId: bootstrapPayload.workspaceId,
title: bootstrapPayload.title,
content: bootstrapPayload.content,
readOnly: bootstrapPayload.readOnly,
revision: bootstrapPayload.revision,
conflictDetectionKey: bootstrapPayload.conflictDetectionKey,
pageOptions: bootstrapPayload.pageOptions,
editable: !bootstrapPayload.readOnly,
});
setBridgeState((prev) => ({
...prev,
runtimeUrl: entryAssetUrl,
status: "mounting",
lastError: null,
}));
})
.catch((error) => {
if (disposed) {
return;
}
const message = error instanceof Error ? error.message : "加载 leptos-tiptap island 失败";
setBridgeState((prev) => ({
...prev,
status: "error",
lastError: message,
}));
requestFallback("runtime_load_failed", message);
});
return () => {
disposed = true;
debouncedPersistRef.current?.cancel();
removeListeners.forEach((dispose) => dispose());
removeListeners = [];
if (mountIdRef.current != null && runtimeModuleRef.current) {
try {
runtimeModuleRef.current.unmount(mountIdRef.current);
} catch {
// 说明:页面卸载时不再追加错误提示,避免离场噪音。
}
}
mountIdRef.current = null;
runtimeTargetRef.current = null;
runtimeModuleRef.current = null;
useEditorBridgeStore.getState().registerBridge(null);
};
}, [
mountIdentity,
requestFallback,
]);
useEffect(() => {
const target = runtimeTargetRef.current;
if (!target || mountIdRef.current == null) {
return;
}
const nextDoc = tiptapDocFromBlocks(props.initialContent as Json);
const nextHostSyncKey = buildHostSyncKey({
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: nextDoc,
readOnly: Boolean(props.readOnly),
});
if (lastHostSyncKeyRef.current === nextHostSyncKey) {
return;
}
lastHostSyncKeyRef.current = nextHostSyncKey;
dispatchRuntimeCommand(target, {
command: "replaceContent",
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: nextDoc,
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
editable: !props.readOnly,
});
}, [
props.documentId,
props.initialConflictDetectionKey,
props.initialContent,
props.initialRevision,
props.readOnly,
props.title,
props.workspaceId,
]);
useEffect(() => {
const target = runtimeTargetRef.current;
if (!target || mountIdRef.current == null) {
return;
}
dispatchRuntimeCommand(target, {
command: "setPageOptions",
pageOptions: buildRuntimePageOptions(props.pageOptions),
});
}, [props.pageOptions]);
useEffect(() => {
const target = runtimeTargetRef.current;
if (!target || mountIdRef.current == null) {
return;
}
const editorBridge: EditorReferenceBridge = {
insertInlineReference: (targetDocument, aliasText) => {
try {
dispatchRuntimeCommand(target, {
command: "insertInlineReference",
referenceDocumentId: targetDocument.id,
text: aliasText?.trim() || targetDocument.title || "无标题",
});
} catch (error) {
requestFallback(
"command_failed",
error instanceof Error ? error.message : "插入行内引用失败",
);
}
return { blockId: currentBlockIdRef.current };
},
insertEmbedReference: (targetDocument) => {
try {
dispatchRuntimeCommand(target, {
command: "insertEmbedReference",
referenceDocumentId: targetDocument.id,
text: targetDocument.title || "无标题",
});
} catch (error) {
requestFallback(
"command_failed",
error instanceof Error ? error.message : "插入嵌入引用失败",
);
}
return { blockId: currentBlockIdRef.current };
},
undo: () => {
try {
dispatchRuntimeCommand(target, { command: "undo" });
} catch (error) {
requestFallback("command_failed", error instanceof Error ? error.message : "撤销失败");
}
},
redo: () => {
try {
dispatchRuntimeCommand(target, { command: "redo" });
} catch (error) {
requestFallback("command_failed", error instanceof Error ? error.message : "重做失败");
}
},
getCursorBlockId: () => currentBlockIdRef.current,
replaceWithSnapshot: (blocks: Json) => {
const hostIdentity = hostIdentityRef.current;
const nextDoc = tiptapDocFromBlocks(blocks) as Json;
latestDocRef.current = nextDoc;
latestBlocksRef.current = Array.isArray(blocks) ? blocks : blocksFromTiptapDoc(nextDoc);
lastHostSyncKeyRef.current = buildHostSyncKey({
documentId: hostIdentity.documentId,
workspaceId: hostIdentity.workspaceId,
title: hostIdentity.title,
content: nextDoc,
readOnly: hostIdentity.readOnly,
});
try {
dispatchRuntimeCommand(target, {
command: "replaceContent",
documentId: hostIdentity.documentId,
workspaceId: hostIdentity.workspaceId,
title: hostIdentity.title,
content: nextDoc,
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
readOnly: hostIdentity.readOnly,
editable: !hostIdentity.readOnly,
});
} catch (error) {
requestFallback(
"command_failed",
error instanceof Error ? error.message : "替换编辑器快照失败",
);
}
},
requestFallbackToBlockNote: () => {
requestFallback("explicit_fallback", "通过页面壳显式切回 BlockNote");
},
};
useEditorBridgeStore.getState().registerBridge(editorBridge);
return () => {
useEditorBridgeStore.getState().registerBridge(null);
};
}, [
props.documentId,
props.readOnly,
props.title,
props.workspaceId,
requestFallback,
]);
return (
<div
ref={mountRef}
className="min-h-[720px] py-6"
data-editor-host-kind="leptos_tiptap_island"
data-runtime-editor-status={bridgeState.status}
data-testid="mnote-leptos-tiptap-island-editor-root"
style={{ minHeight: `${editorHeight}px` }}
/>
);
}
@@ -0,0 +1,40 @@
"use client";
const ISLAND_MANIFEST_URL = "/api/leptos-tiptap-runtime/manifest.json";
export type LeptosTiptapIslandManifest = {
entryAssetPath: string | null;
wasmAssetPath: string | null;
assetPaths: string[];
generatedRootPath: string | null;
};
export type LeptosTiptapIslandAssetUrls = {
manifest: LeptosTiptapIslandManifest | null;
entryAssetUrl: string | null;
wasmAssetUrl: string | null;
};
export function buildLeptosTiptapIslandAssetUrl(assetPath: string | null): string | null {
if (!assetPath) {
return null;
}
return `/api/leptos-tiptap-runtime/${assetPath}`;
}
export async function loadLeptosTiptapIslandAssets(): Promise<LeptosTiptapIslandAssetUrls> {
const response = await fetch(ISLAND_MANIFEST_URL, { cache: "no-store" }).catch(() => null);
if (!response || !response.ok) {
return {
manifest: null,
entryAssetUrl: null,
wasmAssetUrl: null,
};
}
const manifest = (await response.json().catch(() => null)) as LeptosTiptapIslandManifest | null;
return {
manifest,
entryAssetUrl: buildLeptosTiptapIslandAssetUrl(manifest?.entryAssetPath ?? null),
wasmAssetUrl: buildLeptosTiptapIslandAssetUrl(manifest?.wasmAssetPath ?? null),
};
}
@@ -47,6 +47,11 @@ export type LoadedLeptosTiptapRuntime = {
let runtimePromise: Promise<LoadedLeptosTiptapRuntime> | null = null; let runtimePromise: Promise<LoadedLeptosTiptapRuntime> | null = null;
function formatRuntimeLoaderError(stage: string, error: unknown): Error {
const message = error instanceof Error ? error.message : "未知错误";
return new Error(`[leptos_tiptap_runtime_loader:${stage}] ${message}`);
}
function toRuntimeAssetUrl(relativePath: string): string { function toRuntimeAssetUrl(relativePath: string): string {
return `/api/leptos-tiptap-runtime/${relativePath}`; return `/api/leptos-tiptap-runtime/${relativePath}`;
} }
@@ -65,26 +70,46 @@ function extractRegisterFunction(module: Record<string, unknown>): (() => void)
} }
async function fetchRuntimeManifest(): Promise<RuntimeManifest> { async function fetchRuntimeManifest(): Promise<RuntimeManifest> {
const response = await fetch(MANIFEST_URL, { cache: "no-store" }); const response = await fetch(MANIFEST_URL, { cache: "no-store" }).catch((error) => {
throw formatRuntimeLoaderError("manifest_fetch", error);
});
if (!response.ok) { if (!response.ok) {
throw new Error(`读取 leptos-tiptap runtime manifest 失败(${response.status}`); throw new Error(
`[leptos_tiptap_runtime_loader:manifest_fetch] 读取 leptos-tiptap runtime manifest 失败(${response.status}`,
);
} }
const manifest = (await response.json()) as RuntimeManifest; const manifest = (await response.json().catch((error) => {
throw formatRuntimeLoaderError("manifest_parse", error);
})) as RuntimeManifest;
if (!manifest.bridgeRuntimePath) { if (!manifest.bridgeRuntimePath) {
throw new Error("runtime manifest 缺少 bridgeRuntimePath"); throw new Error("[leptos_tiptap_runtime_loader:manifest_validate] runtime manifest 缺少 bridgeRuntimePath");
} }
return manifest; return manifest;
} }
async function loadRuntimeModules(): Promise<LoadedLeptosTiptapRuntime> { async function loadRuntimeModules(): Promise<LoadedLeptosTiptapRuntime> {
const manifest = await fetchRuntimeManifest(); const manifest = await fetchRuntimeManifest().catch((error) => {
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!); throw formatRuntimeLoaderError("manifest", error);
bridge.init_bridge_runtime(); });
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!).catch((error) => {
throw formatRuntimeLoaderError("bridge_import", error);
});
try {
bridge.init_bridge_runtime();
} catch (error) {
throw formatRuntimeLoaderError("bridge_init", error);
}
for (const modulePath of manifest.extensionModulePaths) { for (const modulePath of manifest.extensionModulePaths) {
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath); const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath).catch((error) => {
throw formatRuntimeLoaderError(`extension_import:${modulePath}`, error);
});
const register = extractRegisterFunction(extensionModule); const register = extractRegisterFunction(extensionModule);
register?.(); try {
register?.();
} catch (error) {
throw formatRuntimeLoaderError(`extension_register:${modulePath}`, error);
}
} }
return { manifest, bridge }; return { manifest, bridge };
@@ -0,0 +1,84 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { PageOptionsSidebar } from "./page-options-sidebar";
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
function buildOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
return {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
...overrides,
};
}
function buildStats(): DocumentStats {
return {
wordCount: 3,
characterCount: 12,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
};
}
describe("PageOptionsSidebar", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("对已保存但未完成编辑器语义的设置应显示降级说明", () => {
act(() => {
root.render(
<PageOptionsSidebar
documentId="doc-1"
options={buildOptions({ showHeadingNumbers: true, embedDefaultBlockId: "block-1" })}
stats={buildStats()}
onToggle={() => undefined}
onExport={() => undefined}
onOpenHistory={() => undefined}
/>,
);
});
expect(container.textContent).toContain("标题编号");
expect(container.textContent).toContain("已保存字段");
expect(container.textContent).toContain("编辑器语义暂未正式接通");
const customTab = container.querySelectorAll("button")[1];
expect(customTab).not.toBeNull();
act(() => {
customTab?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("嵌入默认位置");
expect(container.textContent).toContain("这是已保存字段");
expect(container.textContent).toContain("当前编辑器语义暂未正式接通");
});
});
@@ -32,7 +32,7 @@ const OPTION_META: Record<
}, },
showHeadingNumbers: { showHeadingNumbers: {
label: "标题编号", label: "标题编号",
description: "自动为标题添加编号", description: "自动为标题添加编号(已保存字段,编辑器语义暂未正式接通)",
icon: ListOrdered, icon: ListOrdered,
}, },
showToc: { showToc: {
@@ -289,6 +289,9 @@ export function PageOptionsSidebar({
<p className="mt-1 text-xs text-gray-400"> <p className="mt-1 text-xs text-gray-400">
/... /...
</p> </p>
<p className="mt-1 text-xs text-amber-600">
</p>
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600"> <div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"} {options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
</div> </div>
@@ -0,0 +1,156 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import { usePageHeadTitle } from "./use-page-head-title";
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
return {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "标题 A",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
return buildSidebarInitialData({
activeWorkspaceId: "ws-1",
workspaces: [],
documents,
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
}
function HookProbe(props: { documentId: string; fallbackTitle: string }) {
const state = usePageHeadTitle(props);
return (
<>
<div
data-testid="page-head-title"
data-display-title={state.displayTitle}
data-committed-title={state.committedTitle}
data-has-draft={state.hasDraft ? "1" : "0"}
/>
<button type="button" data-testid="set-draft" onClick={() => state.setDraftTitle(" 新标题 ")}>
稿
</button>
<button type="button" data-testid="commit-persisted" onClick={() => state.commitPersistedTitle("新标题")}>
</button>
</>
);
}
describe("usePageHeadTitle", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("应优先使用 live sidebar snapshot 中的标题作为 committed title", async () => {
const snapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "树标题",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={snapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
const probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-committed-title")).toBe("树标题");
expect(probe?.getAttribute("data-display-title")).toBe("树标题");
expect(probe?.getAttribute("data-has-draft")).toBe("0");
});
it("应只把本地输入保留为短暂 draft,并在 live 标题追平后自动清空", async () => {
const initialSnapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "旧标题",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={initialSnapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
const setDraftButton = container.querySelector<HTMLButtonElement>("[data-testid='set-draft']");
act(() => {
setDraftButton?.click();
});
let probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe(" 新标题 ");
expect(probe?.getAttribute("data-committed-title")).toBe("旧标题");
expect(probe?.getAttribute("data-has-draft")).toBe("1");
const commitPersistedButton = container.querySelector<HTMLButtonElement>("[data-testid='commit-persisted']");
act(() => {
commitPersistedButton?.click();
});
probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
expect(probe?.getAttribute("data-has-draft")).toBe("1");
const syncedSnapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "新标题",
updated_at: "2026-04-21T00:00:02.000Z",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={syncedSnapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
expect(probe?.getAttribute("data-committed-title")).toBe("新标题");
expect(probe?.getAttribute("data-has-draft")).toBe("0");
});
});
@@ -0,0 +1,44 @@
"use client";
import { useMemo, useState } from "react";
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
function normalizePageHeadTitle(title: string | null | undefined): string {
const normalized = String(title ?? "").trim();
return normalized || "无标题";
}
export function usePageHeadTitle(input: { documentId: string; fallbackTitle: string }) {
const liveSidebarTitle = usePreferredSidebarDocumentTitle(input.documentId);
const committedTitle = useMemo(
() => normalizePageHeadTitle(liveSidebarTitle ?? input.fallbackTitle),
[input.fallbackTitle, liveSidebarTitle],
);
const [draftState, setDraftState] = useState<{
documentId: string;
title: string | null;
}>({
documentId: input.documentId,
title: null,
});
const draftTitle = draftState.documentId === input.documentId ? draftState.title : null;
const hasDraft = draftTitle != null && normalizePageHeadTitle(draftTitle) !== committedTitle;
return {
displayTitle: hasDraft ? draftTitle ?? committedTitle : committedTitle,
committedTitle,
hasDraft,
setDraftTitle: (title: string) => {
setDraftState({
documentId: input.documentId,
title,
});
},
commitPersistedTitle: (title: string) => {
setDraftState({
documentId: input.documentId,
title: normalizePageHeadTitle(title),
});
},
};
}
@@ -0,0 +1,52 @@
"use client";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { DocumentRecord } from "@/lib/documents";
import type { SidebarInitialData } from "@/components/sidebar/types";
const PreferredSidebarSnapshotContext = createContext<SidebarInitialData | null>(null);
interface PreferredSidebarSnapshotProviderProps {
data: SidebarInitialData;
children: ReactNode;
}
export function PreferredSidebarSnapshotProvider({
data,
children,
}: PreferredSidebarSnapshotProviderProps) {
return (
<PreferredSidebarSnapshotContext.Provider value={data}>
{children}
</PreferredSidebarSnapshotContext.Provider>
);
}
export function useOptionalPreferredSidebarSnapshotData() {
return useContext(PreferredSidebarSnapshotContext);
}
export function usePreferredSidebarSnapshotData() {
const context = useOptionalPreferredSidebarSnapshotData();
if (!context) {
throw new Error("usePreferredSidebarSnapshotData 必须在 PreferredSidebarSnapshotProvider 中使用");
}
return context;
}
export function usePreferredSidebarDocument(documentId: string): DocumentRecord | null {
const snapshot = useOptionalPreferredSidebarSnapshotData();
return useMemo(() => {
if (!snapshot) {
return null;
}
return snapshot.documents.find((item) => item.id === documentId) ?? null;
}, [documentId, snapshot]);
}
export function usePreferredSidebarDocumentTitle(documentId: string): string | null {
const document = usePreferredSidebarDocument(documentId);
const title = document?.title?.trim();
return title ? title : null;
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import type { MediaAsset } from "@/types/media";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT } from "@/lib/events";
import { bindSidebarRefreshEvents } from "./sidebar-events";
function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
return {
id: "asset-1",
workspace_id: "ws-1",
document_id: "doc-1",
asset_type: "image",
file_url: "/files/a.png",
thumbnail_url: null,
bucket: null,
storage_path: "uploads/a.png",
file_name: "a.png",
file_size: 1,
mime_type: "image/png",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
describe("bindSidebarRefreshEvents", () => {
it("收到 documents-changed 时应触发 sidebar 与共享摘要刷新", () => {
const target = new EventTarget();
const onAsset = vi.fn();
const sidebarRefetch = vi.fn();
const refreshShareSummary = vi.fn();
const refreshGroupPublicSummary = vi.fn();
const dispose = bindSidebarRefreshEvents({
target,
onAsset,
sidebarRefetch,
refreshShareSummary,
refreshGroupPublicSummary,
});
target.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId: "doc-1" } }));
expect(onAsset).not.toHaveBeenCalled();
expect(sidebarRefetch).toHaveBeenCalledTimes(1);
expect(refreshShareSummary).toHaveBeenCalledTimes(1);
expect(refreshGroupPublicSummary).toHaveBeenCalledTimes(1);
dispose();
});
it("收到 assets-changed 且带 asset 时应先透传 asset 再刷新", () => {
const target = new EventTarget();
const onAsset = vi.fn();
const sidebarRefetch = vi.fn();
const refreshShareSummary = vi.fn();
const refreshGroupPublicSummary = vi.fn();
const asset = buildAsset({ asset_type: "mindmap" });
const dispose = bindSidebarRefreshEvents({
target,
onAsset,
sidebarRefetch,
refreshShareSummary,
refreshGroupPublicSummary,
});
target.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail: { docId: "doc-1", asset } }));
expect(onAsset).toHaveBeenCalledWith(asset);
expect(sidebarRefetch).toHaveBeenCalledTimes(1);
expect(refreshShareSummary).toHaveBeenCalledTimes(1);
expect(refreshGroupPublicSummary).toHaveBeenCalledTimes(1);
dispose();
});
});
@@ -0,0 +1,34 @@
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT } from "@/lib/events";
import type { MediaAsset } from "@/types/media";
type SidebarRefreshEventDetail = {
docId?: string;
asset?: MediaAsset;
};
export function bindSidebarRefreshEvents(input: {
target?: EventTarget;
onAsset?: (asset?: MediaAsset) => void;
sidebarRefetch: () => void | Promise<unknown>;
refreshShareSummary: () => void | Promise<unknown>;
refreshGroupPublicSummary: () => void | Promise<unknown>;
}): () => void {
const target = input.target ?? window;
const handler = (event: Event) => {
const custom = event as CustomEvent<SidebarRefreshEventDetail>;
if (custom.detail?.asset) {
input.onAsset?.(custom.detail.asset);
}
void input.sidebarRefetch();
void input.refreshShareSummary();
void input.refreshGroupPublicSummary();
};
target.addEventListener(ASSETS_CHANGED_EVENT, handler);
target.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
return () => {
target.removeEventListener(ASSETS_CHANGED_EVENT, handler);
target.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { buildSidebarTreeSyncKey, buildMediaAssetListSyncKey } from "./sidebar-sync";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
function buildNode(overrides: Partial<SidebarTreeNode> = {}): SidebarTreeNode {
return {
id: "doc-1",
workspace_id: "ws-1",
title: "标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
access_scope: "private",
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: 0,
childCount: 0,
expandedByDefault: true,
},
...overrides,
};
}
function buildAsset(overrides: Partial<MediaAsset> = {}): MediaAsset {
return {
id: "asset-1",
workspace_id: "ws-1",
document_id: "doc-1",
asset_type: "image",
file_url: "/files/a.png",
thumbnail_url: null,
bucket: null,
storage_path: "uploads/a.png",
file_name: "a.png",
file_size: 1,
mime_type: "image/png",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
describe("sidebar-sync", () => {
it("相同侧边栏树内容应产生相同 sync key", () => {
const left = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
const right = [buildNode({ children: [buildNode({ id: "doc-2", parent_id: "doc-1" })] })];
expect(buildSidebarTreeSyncKey(left)).toBe(buildSidebarTreeSyncKey(right));
});
it("侧边栏树语义变化时应产生不同 sync key", () => {
const left = [buildNode({ title: "标题 A" })];
const right = [buildNode({ title: "标题 B" })];
expect(buildSidebarTreeSyncKey(left)).not.toBe(buildSidebarTreeSyncKey(right));
});
it("相同附件列表内容应产生相同 sync key", () => {
const left = [buildAsset()];
const right = [buildAsset()];
expect(buildMediaAssetListSyncKey(left)).toBe(buildMediaAssetListSyncKey(right));
});
it("附件列表语义变化时应产生不同 sync key", () => {
const left = [buildAsset({ file_name: "a.png" })];
const right = [buildAsset({ file_name: "b.png" })];
expect(buildMediaAssetListSyncKey(left)).not.toBe(buildMediaAssetListSyncKey(right));
});
});
@@ -0,0 +1,93 @@
import type { SidebarInitialData } from "@/components/sidebar/types";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
type SidebarTreeSnapshot = {
id: string;
title: string | null;
parentId: string | null;
sortOrder: number | null;
updatedAt: string | null;
children: SidebarTreeSnapshot[];
};
function toSidebarTreeSnapshot(nodes: SidebarTreeNode[]): SidebarTreeSnapshot[] {
return nodes.map((node) => ({
id: node.id,
title: node.title ?? null,
parentId: node.parent_id ?? null,
sortOrder: node.sort_order ?? null,
updatedAt: node.updated_at ?? null,
children: toSidebarTreeSnapshot(node.children ?? []),
}));
}
function toMediaAssetSnapshot(assets: MediaAsset[]) {
return assets.map((asset) => ({
id: asset.id,
assetType: asset.asset_type,
documentId: asset.document_id,
fileName: asset.file_name ?? null,
updatedAt: asset.updated_at ?? null,
storagePath: asset.storage_path ?? null,
fileUrl: asset.file_url ?? null,
}));
}
function toMillis(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
export function buildSidebarTreeSyncKey(nodes: SidebarTreeNode[]): string {
return JSON.stringify(toSidebarTreeSnapshot(nodes));
}
export function buildMediaAssetListSyncKey(assets: MediaAsset[]): string {
return JSON.stringify(toMediaAssetSnapshot(assets));
}
export function buildSidebarDataSyncKey(data: SidebarInitialData): string {
return JSON.stringify({
activeWorkspaceId: data.activeWorkspaceId,
tree: toSidebarTreeSnapshot(data.kernelSidebarTree),
mediaAssets: toMediaAssetSnapshot(data.mediaAssets ?? []),
mindmapAssets: toMediaAssetSnapshot(data.mindmapAssets ?? []),
tableAssets: toMediaAssetSnapshot(data.tableAssets ?? []),
trashedDocuments: (data.trashedDocuments ?? []).map((item) => ({
id: item.id,
title: item.title ?? null,
parentId: item.parent_id ?? null,
deletedAt: item.deleted_at,
accessScope: item.access_scope,
})),
trashedMediaAssets: toMediaAssetSnapshot(data.trashedMediaAssets ?? []),
trashedMindmapAssets: toMediaAssetSnapshot(data.trashedMindmapAssets ?? []),
trashedTableAssets: toMediaAssetSnapshot(data.trashedTableAssets ?? []),
});
}
export function getSidebarDataFreshness(data: SidebarInitialData): number {
const documentTimes = data.documents.map((item) => toMillis(item.updated_at ?? item.created_at));
const treeTimes = data.kernelSidebarTree.map((item) => toMillis(item.updated_at ?? item.created_at));
const assetTimes = [
...(data.mediaAssets ?? []),
...(data.mindmapAssets ?? []),
...(data.tableAssets ?? []),
...(data.trashedMediaAssets ?? []),
...(data.trashedMindmapAssets ?? []),
...(data.trashedTableAssets ?? []),
].map((item) => toMillis(item.updated_at ?? item.created_at));
const trashedDocumentTimes = (data.trashedDocuments ?? []).map((item) => toMillis(item.deleted_at));
return Math.max(
0,
...documentTimes,
...treeTimes,
...assetTimes,
...trashedDocumentTimes,
);
}
+100 -31
View File
@@ -39,6 +39,12 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar"; import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data"; import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
import {
buildMediaAssetListSyncKey,
buildSidebarTreeSyncKey,
} from "@/components/sidebar/sidebar-sync";
import { usePreferredSidebarSnapshot } from "@/components/sidebar/use-preferred-sidebar-snapshot";
import { bindSidebarRefreshEvents } from "@/components/sidebar/sidebar-events";
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface"; import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree"; import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
import { import {
@@ -65,7 +71,7 @@ import {
} from "@/components/sidebar/tree-pane-bindings"; } from "@/components/sidebar/tree-pane-bindings";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events"; import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream"; import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog"; import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -139,6 +145,9 @@ const extractMindmapIdFromStoragePath = (
interface SidebarProps { interface SidebarProps {
initialData: SidebarInitialData; initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
sidebarQuery?: SidebarDataResult;
treeStream?: ReturnType<typeof useSidebarTreeStream>;
} }
interface ContextMenuState { interface ContextMenuState {
@@ -147,7 +156,17 @@ interface ContextMenuState {
y: number; y: number;
} }
export function Sidebar({ initialData }: SidebarProps) { export function Sidebar({ initialData, sidebarData, sidebarQuery, treeStream }: SidebarProps) {
if (sidebarQuery && treeStream) {
return (
<SidebarContent
initialData={initialData}
sidebarData={sidebarData}
sidebarQuery={sidebarQuery}
treeStream={treeStream}
/>
);
}
return <SidebarConvex initialData={initialData} />; return <SidebarConvex initialData={initialData} />;
} }
@@ -161,11 +180,12 @@ function SidebarConvex({ initialData }: SidebarProps) {
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
interface SidebarContentProps { interface SidebarContentProps {
initialData: SidebarInitialData; initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
sidebarQuery: SidebarDataResult; sidebarQuery: SidebarDataResult;
treeStream: ReturnType<typeof useSidebarTreeStream>; treeStream: ReturnType<typeof useSidebarTreeStream>;
} }
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) { function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebarQuery, treeStream }: SidebarContentProps) {
const convex = useConvex(); const convex = useConvex();
useConvexAuth(); useConvexAuth();
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } = const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
@@ -175,10 +195,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
// 处理数据 // 处理数据
const sidebarData = useMemo(() => { const preferredSidebarSnapshot = usePreferredSidebarSnapshot({
return treeStream.data ?? sidebarQuery.data ?? initialData; initialData,
}, [treeStream.data, sidebarQuery, initialData]); sidebarQueryData: sidebarQuery.data,
treeStreamData: treeStream.data,
});
const sidebarData = externalSidebarData ?? preferredSidebarSnapshot.data;
const sidebarHydrate = useSidebarStore.persist?.rehydrate;
const sidebarRefetch = sidebarQuery.refetch;
// isLoading 判断 // isLoading 判断
const isLoading = sidebarQuery.isLoading; const isLoading = sidebarQuery.isLoading;
const segments = useSelectedLayoutSegments(); const segments = useSelectedLayoutSegments();
@@ -250,6 +275,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
anchorRowId: null, anchorRowId: null,
focusedRowId: null, focusedRowId: null,
})); }));
const [sidebarHydrated, setSidebarHydrated] = useState(false);
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
const resourcePaneContainerRef = useRef<HTMLDivElement>(null); const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set()); const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
@@ -257,19 +287,36 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map()); const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
useEffect(() => { useEffect(() => {
const nextTree = sidebarData.kernelSidebarTree;
const nextSyncKey = buildSidebarTreeSyncKey(nextTree);
if (treeSyncKeyRef.current === nextSyncKey) {
return;
}
treeSyncKeyRef.current = nextSyncKey;
setTree(() => { setTree(() => {
const nextTree = sidebarData.kernelSidebarTree;
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev))); setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
return nextTree; return nextTree;
}); });
}, [sidebarData.kernelSidebarTree]); }, [sidebarData.kernelSidebarTree]);
useEffect(() => { useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []); const nextAssets = sidebarData.mediaAssets ?? [];
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
if (mediaAssetsSyncKeyRef.current === nextSyncKey) {
return;
}
mediaAssetsSyncKeyRef.current = nextSyncKey;
setMediaAssets(nextAssets);
}, [sidebarData.mediaAssets]); }, [sidebarData.mediaAssets]);
useEffect(() => { useEffect(() => {
setMindmapAssets(sidebarData.mindmapAssets ?? []); const nextAssets = sidebarData.mindmapAssets ?? [];
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
if (mindmapAssetsSyncKeyRef.current === nextSyncKey) {
return;
}
mindmapAssetsSyncKeyRef.current = nextSyncKey;
setMindmapAssets(nextAssets);
}, [sidebarData.mindmapAssets]); }, [sidebarData.mindmapAssets]);
useEffect(() => { useEffect(() => {
@@ -280,13 +327,35 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
hidden.delete(id); hidden.delete(id);
} }
}); });
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id))); const nextAssets = (sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id));
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
if (tableAssetsSyncKeyRef.current === nextSyncKey) {
return;
}
tableAssetsSyncKeyRef.current = nextSyncKey;
setTableAssets(nextAssets);
}, [sidebarData.tableAssets]); }, [sidebarData.tableAssets]);
useEffect(() => { useEffect(() => {
setOpen(false); setOpen(false);
}, [activeId, setOpen]); }, [activeId, setOpen]);
useEffect(() => {
if (sidebarHydrated) {
return;
}
let cancelled = false;
void (async () => {
await sidebarHydrate?.();
if (!cancelled) {
setSidebarHydrated(true);
}
})();
return () => {
cancelled = true;
};
}, [sidebarHydrate, sidebarHydrated]);
useEffect(() => { useEffect(() => {
const onSaved = (event: Event) => { const onSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail; const detail = (event as CustomEvent<{ tableId?: string }>).detail;
@@ -294,7 +363,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
if (tableId) { if (tableId) {
hiddenTableIdsRef.current.delete(tableId); hiddenTableIdsRef.current.delete(tableId);
} }
void sidebarQuery.refetch(); void sidebarRefetch();
}; };
const onDeleted = (event: Event) => { const onDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail; const detail = (event as CustomEvent<{ tableId?: string }>).detail;
@@ -303,7 +372,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
hiddenTableIdsRef.current.set(tableId, Date.now()); hiddenTableIdsRef.current.set(tableId, Date.now());
setTableAssets((prev) => prev.filter((item) => item.id !== tableId)); setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
} }
void sidebarQuery.refetch(); void sidebarRefetch();
}; };
window.addEventListener("online-table-saved", onSaved as EventListener); window.addEventListener("online-table-saved", onSaved as EventListener);
window.addEventListener("online-table-deleted", onDeleted as EventListener); window.addEventListener("online-table-deleted", onDeleted as EventListener);
@@ -311,11 +380,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
window.removeEventListener("online-table-saved", onSaved as EventListener); window.removeEventListener("online-table-saved", onSaved as EventListener);
window.removeEventListener("online-table-deleted", onDeleted as EventListener); window.removeEventListener("online-table-deleted", onDeleted as EventListener);
}; };
}, [sidebarQuery]); }, [sidebarRefetch]);
const refreshTree = useCallback(async () => { const refreshTree = useCallback(async () => {
await sidebarQuery.refetch(); await sidebarRefetch();
}, [sidebarQuery]); }, [sidebarRefetch]);
const refreshShareSummary = useCallback(async () => { const refreshShareSummary = useCallback(async () => {
try { try {
@@ -379,9 +448,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}, [refreshGroupPublicSummary]); }, [refreshGroupPublicSummary]);
useEffect(() => { useEffect(() => {
const handler = (event: Event) => { const dispose = bindSidebarRefreshEvents({
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>; onAsset: (asset) => {
const asset = custom.detail?.asset as MediaAsset | undefined;
if (asset?.id) { if (asset?.id) {
if (asset.asset_type === "mindmap") { if (asset.asset_type === "mindmap") {
setMindmapAssets((prev) => { setMindmapAssets((prev) => {
@@ -400,18 +468,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
}); });
} }
} }
void sidebarQuery.refetch(); },
sidebarRefetch,
// 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。 // 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。
void refreshShareSummary(); refreshShareSummary,
void refreshGroupPublicSummary(); refreshGroupPublicSummary,
}; });
window.addEventListener(ASSETS_CHANGED_EVENT, handler); return dispose;
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler); }, [sidebarRefetch, refreshShareSummary, refreshGroupPublicSummary]);
return () => {
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
};
}, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]);
useEffect(() => { useEffect(() => {
// 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state // 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state
@@ -1450,11 +1514,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
await refreshTree(); await refreshTree();
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set("edit", "1");
if (nextNode.workspace_id) { if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id); query.set("workspaceId", nextNode.workspace_id);
} }
router.push(`/documents/${nextNode.id}?${query.toString()}`); const nextQuery = query.toString();
router.push(
nextQuery
? `/documents/${nextNode.id}?${nextQuery}`
: `/documents/${nextNode.id}`,
);
} catch (error) { } catch (error) {
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试"); window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
} finally { } finally {
@@ -2603,6 +2671,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
return ( return (
<> <>
{!sidebarHydrated ? null : null}
<aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}> <aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar"> <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
{sidebarBody} {sidebarBody}
@@ -0,0 +1,144 @@
import { act, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import { usePreferredSidebarSnapshot } from "./use-preferred-sidebar-snapshot";
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
return {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "标题 A",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
return buildSidebarInitialData({
activeWorkspaceId: "ws-1",
workspaces: [],
documents,
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
}
function Harness(props: {
initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData;
treeStreamData: SidebarInitialData | null;
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
}) {
const state = usePreferredSidebarSnapshot(props);
useEffect(() => {
props.onState(state);
}, [props, state]);
return null;
}
describe("usePreferredSidebarSnapshot", () => {
let container: HTMLDivElement;
let root: Root;
const onState = vi.fn();
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onState.mockClear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("tree stream 落后于 query refetch 时应优先使用更新后的 query 快照", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
const refreshedQuery = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const caughtUpTreeStream = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={initialData}
treeStreamData={staleTreeStream}
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
}),
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={staleTreeStream}
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "query",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={caughtUpTreeStream}
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
});
});
@@ -0,0 +1,77 @@
import { useMemo } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import {
buildSidebarDataSyncKey,
getSidebarDataFreshness,
} from "@/components/sidebar/sidebar-sync";
export type PreferredSidebarSnapshotSource = "initial" | "query" | "tree_stream";
export function usePreferredSidebarSnapshot(input: {
initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
}) {
const querySyncKey = useMemo(
() => (input.sidebarQueryData ? buildSidebarDataSyncKey(input.sidebarQueryData) : null),
[input.sidebarQueryData],
);
const treeStreamSyncKey = useMemo(
() => (input.treeStreamData ? buildSidebarDataSyncKey(input.treeStreamData) : null),
[input.treeStreamData],
);
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
const queryFreshness = useMemo(
() => (input.sidebarQueryData ? getSidebarDataFreshness(input.sidebarQueryData) : Number.NEGATIVE_INFINITY),
[input.sidebarQueryData],
);
const treeStreamFreshness = useMemo(
() => (input.treeStreamData ? getSidebarDataFreshness(input.treeStreamData) : Number.NEGATIVE_INFINITY),
[input.treeStreamData],
);
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (input.treeStreamData && input.sidebarQueryData) {
if (treeStreamSyncKey === querySyncKey) {
return "tree_stream";
}
return queryFreshness > treeStreamFreshness ? "query" : "tree_stream";
}
if (input.treeStreamData) {
return "tree_stream";
}
if (input.sidebarQueryData) {
return "query";
}
return "initial";
}, [
input.sidebarQueryData,
input.treeStreamData,
queryFreshness,
querySyncKey,
treeStreamFreshness,
treeStreamSyncKey,
]);
const data =
source === "tree_stream" && input.treeStreamData
? input.treeStreamData
: source === "query" && input.sidebarQueryData
? input.sidebarQueryData
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
const syncKey =
source === "tree_stream"
? treeStreamSyncKey ?? initialSyncKey
: source === "query"
? querySyncKey ?? initialSyncKey
: initialSyncKey;
return useMemo(
() => ({
data,
source,
syncKey,
}),
[data, source, syncKey],
);
}
@@ -0,0 +1,172 @@
import { act, useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { useSidebarData } from "./use-sidebar-data";
import type { SidebarInitialData } from "@/components/sidebar/types";
const mockUseConvexSidebarData = vi.fn();
const mockUseQuery = vi.fn();
vi.mock("@/hooks/use-convex-sidebar-data", () => ({
useConvexSidebarData: (...args: unknown[]) => mockUseConvexSidebarData(...args),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (...args: unknown[]) => mockUseQuery(...args),
}));
function buildInitialData(): SidebarInitialData {
return {
activeWorkspaceId: "ws_1",
workspaces: [],
documents: [],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [],
edges: [],
},
kernelSidebarTree: [],
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
trashedTableAssets: [],
tableAssets: [],
mindmapDocs: [],
mindmapAssets: [],
mindmapAssetChildren: {},
mediaAssets: [],
};
}
function Harness({
initialData,
onState,
}: {
initialData: SidebarInitialData;
onState: (state: ReturnType<typeof useSidebarData>) => void;
}) {
const state = useSidebarData(initialData);
useEffect(() => {
onState(state);
}, [onState, state]);
return null;
}
describe("useSidebarData", () => {
let container: HTMLDivElement;
let root: Root;
const onState = vi.fn();
const stableRefetch = vi.fn(async () => undefined);
const originalFetch = global.fetch;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
onState.mockClear();
mockUseConvexSidebarData.mockImplementation(() => ({
data: buildInitialData(),
isLoading: false,
isAuthLoading: false,
isAuthenticated: true,
hasLiveSubscription: true,
canUseHttpFallback: false,
error: null,
refetch: stableRefetch,
}));
mockUseQuery.mockImplementation(() => ({
data: buildInitialData(),
isLoading: false,
error: null,
refetch: stableRefetch,
}));
global.fetch = vi.fn();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
global.fetch = originalFetch;
});
it("底层 hook 语义未变时应保持返回对象稳定", async () => {
const initialData = buildInitialData();
await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />);
});
const firstState = onState.mock.lastCall?.[0];
await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />);
});
const secondState = onState.mock.lastCall?.[0];
expect(firstState).toBeDefined();
expect(secondState).toBe(firstState);
});
it("Convex live 模式下手动 refetch 应强制刷新一份 HTTP sidebar snapshot", async () => {
const initialData = buildInitialData();
const refreshedData: SidebarInitialData = {
...buildInitialData(),
documents: [
{
access_scope: "private",
id: "doc-1",
workspace_id: "ws_1",
title: "新标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:01.000Z",
},
],
kernelSidebarTree: [
{
id: "doc-1",
workspace_id: "ws_1",
title: "新标题",
parent_id: null,
sort_order: 0,
access_scope: "private",
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:01.000Z",
children: [],
},
],
};
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => refreshedData,
});
await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />);
});
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
await act(async () => {
await state.refetch();
});
expect(global.fetch).toHaveBeenCalledWith("/api/sidebar?workspaceId=ws_1", {
method: "GET",
credentials: "include",
});
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
expect(refreshedState.data.documents[0]?.title).toBe("新标题");
});
});
+133 -30
View File
@@ -1,5 +1,9 @@
import { useMemo } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {
buildSidebarDataSyncKey,
getSidebarDataFreshness,
} from "@/components/sidebar/sidebar-sync";
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data"; import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
@@ -11,6 +15,24 @@ export interface SidebarDataResult {
source: "convex-live" | "http-fallback" | "initial"; source: "convex-live" | "http-fallback" | "initial";
} }
const STABLE_SIDEBAR_CACHE_LIMIT = 12;
const stableSidebarDataCache = new Map<string, SidebarInitialData>();
function getStableSidebarData(syncKey: string, data: SidebarInitialData): SidebarInitialData {
const cached = stableSidebarDataCache.get(syncKey);
if (cached) {
return cached;
}
stableSidebarDataCache.set(syncKey, data);
if (stableSidebarDataCache.size > STABLE_SIDEBAR_CACHE_LIMIT) {
const oldestKey = stableSidebarDataCache.keys().next().value;
if (typeof oldestKey === "string") {
stableSidebarDataCache.delete(oldestKey);
}
}
return data;
}
async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> { async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, { const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
method: "GET", method: "GET",
@@ -31,6 +53,13 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
const convexSidebar = useConvexSidebarData(workspaceId); const convexSidebar = useConvexSidebarData(workspaceId);
const shouldUseHttpFallback = const shouldUseHttpFallback =
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback; !convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
const [manualSnapshotState, setManualSnapshotState] = useState<{
workspaceId: string;
data: SidebarInitialData | null;
}>({
workspaceId,
data: null,
});
const httpQuery = useQuery({ const httpQuery = useQuery({
queryKey: ["sidebar", workspaceId], queryKey: ["sidebar", workspaceId],
@@ -40,41 +69,115 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
enabled: shouldUseHttpFallback, enabled: shouldUseHttpFallback,
}); });
return useMemo<SidebarDataResult>(() => { const manualSnapshot =
const liveData = convexSidebar.data ?? httpQuery.data ?? initialData; manualSnapshotState.workspaceId === workspaceId
const isLoading = ? manualSnapshotState.data
convexSidebar.hasLiveSubscription : null;
? convexSidebar.isLoading
: shouldUseHttpFallback
? httpQuery.isLoading
: false;
const source: SidebarDataResult["source"] = convexSidebar.data
? "convex-live"
: shouldUseHttpFallback && httpQuery.data
? "http-fallback"
: "initial";
const refetch = async () => {
if (convexSidebar.hasLiveSubscription) {
await convexSidebar.refetch();
return liveData;
}
if (shouldUseHttpFallback) {
return httpQuery.refetch();
}
return liveData;
};
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
const baseLiveDataSyncKey = useMemo(
() => buildSidebarDataSyncKey(baseLiveData),
[baseLiveData],
);
const baseLiveDataFreshness = useMemo(
() => getSidebarDataFreshness(baseLiveData),
[baseLiveData],
);
const manualSnapshotSyncKey = useMemo(
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
[manualSnapshot],
);
const manualSnapshotFreshness = useMemo(
() => (manualSnapshot ? getSidebarDataFreshness(manualSnapshot) : Number.NEGATIVE_INFINITY),
[manualSnapshot],
);
const liveData = useMemo(() => {
if (!manualSnapshot) {
return baseLiveData;
}
if (manualSnapshotSyncKey === baseLiveDataSyncKey) {
return baseLiveData;
}
return manualSnapshotFreshness >= baseLiveDataFreshness
? manualSnapshot
: baseLiveData;
}, [
baseLiveData,
baseLiveDataFreshness,
baseLiveDataSyncKey,
manualSnapshot,
manualSnapshotFreshness,
manualSnapshotSyncKey,
]);
const isLoading =
convexSidebar.hasLiveSubscription
? convexSidebar.isLoading
: shouldUseHttpFallback
? httpQuery.isLoading
: false;
const source: SidebarDataResult["source"] = convexSidebar.data
? "convex-live"
: shouldUseHttpFallback && httpQuery.data
? "http-fallback"
: "initial";
const error = convexSidebar.error ?? httpQuery.error ?? null;
const liveDataRef = useRef(liveData);
const convexRefetchRef = useRef(convexSidebar.refetch);
const httpRefetchRef = useRef(httpQuery.refetch);
const liveDataSyncKey = useMemo(() => buildSidebarDataSyncKey(liveData), [liveData]);
useEffect(() => {
liveDataRef.current = liveData;
}, [liveData]);
useEffect(() => {
convexRefetchRef.current = convexSidebar.refetch;
}, [convexSidebar]);
useEffect(() => {
httpRefetchRef.current = httpQuery.refetch;
}, [httpQuery]);
const refetch = useCallback(async () => {
if (workspaceId) {
try {
const refreshedSnapshot = await requestSidebarData(workspaceId);
setManualSnapshotState({
workspaceId,
data: refreshedSnapshot,
});
return refreshedSnapshot;
} catch {
}
}
if (convexSidebar.hasLiveSubscription) {
await convexRefetchRef.current();
return liveDataRef.current;
}
if (shouldUseHttpFallback) {
return httpRefetchRef.current();
}
return liveDataRef.current;
}, [
convexSidebar.hasLiveSubscription,
shouldUseHttpFallback,
workspaceId,
]);
const stableLiveData = getStableSidebarData(liveDataSyncKey, liveData);
return useMemo<SidebarDataResult>(() => {
return { return {
data: liveData, data: stableLiveData,
isLoading, isLoading,
error: convexSidebar.error ?? httpQuery.error ?? null, error,
refetch, refetch,
source, source,
}; };
}, [ }, [
convexSidebar, error,
httpQuery, isLoading,
initialData, refetch,
shouldUseHttpFallback, stableLiveData,
source,
]); ]);
} }
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
buildHermesRuntimeToolResultRequest,
parseHermesToolPreviewArgs,
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "./tool-result-recovery";
describe("tool-result-recovery", () => {
it("应从 JSON preview 中解析工具参数", () => {
expect(
parseHermesToolPreviewArgs('{"blockId":"block_1","text":"新的正文","mode":"replace"}', "doc_replace_range"),
).toEqual({
blockId: "block_1",
text: "新的正文",
mode: "replace",
});
});
it("应从 slash preview 中恢复 text 参数", () => {
expect(parseHermesToolPreviewArgs("slash_run /rename doc-1 新标题", "slash_run")).toEqual({
text: "/rename doc-1 新标题",
});
});
it("应优先读取 Hermes 事件里直接给出的 argsJson", () => {
expect(
readHermesToolArgsFromEvent(
{
preview: "ignored",
argsJson: { text: "/rename doc-1 直接参数" },
},
"slash_run",
),
).toEqual({
text: "/rename doc-1 直接参数",
});
});
it("应读取 Hermes 事件里直接给出的结构化 result", () => {
expect(
readHermesToolResultFromEvent({
result: {
ok: true,
parsed: {
command: "rename_doc",
},
},
}),
).toEqual({
ok: true,
parsed: {
command: "rename_doc",
},
});
});
it("应构造 mnote-web runtime tool result 请求体", () => {
expect(
buildHermesRuntimeToolResultRequest({
userId: "user-1",
tool: "doc_replace_range",
argsJson: {
blockId: "block_1",
text: "新的正文",
mode: "replace",
},
data: [
{
id: "block_1",
type: "paragraph",
content: "旧正文",
},
],
requestId: "req-1",
traceId: "trace-1",
target: {
pageId: "doc-1",
blockId: "block_1",
},
}),
).toEqual({
kind: "tool",
context: {
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next_route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
},
tool: {
tool: "doc_replace_range",
kind: "command",
mode: "result",
argsJson: {
blockId: "block_1",
text: "新的正文",
mode: "replace",
},
target: {
workspaceId: null,
pageId: "doc-1",
blockId: "block_1",
},
reason: null,
refs: [],
},
data: [
{
id: "block_1",
type: "paragraph",
content: "旧正文",
},
],
});
});
});
@@ -0,0 +1,165 @@
type PlainObject = Record<string, unknown>;
function isPlainObject(value: unknown): value is PlainObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeParseObject(raw: string): PlainObject | null {
const text = raw.trim();
if (!text) return null;
try {
const parsed = JSON.parse(text) as unknown;
if (typeof parsed === "string") {
return safeParseObject(parsed);
}
return isPlainObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function extractJsonCandidate(raw: string): string | null {
const text = raw.trim();
if (!text) return null;
if (text.startsWith("{") && text.endsWith("}")) {
return text;
}
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) {
return null;
}
return text.slice(start, end + 1);
}
export function parseHermesToolPreviewArgs(
preview: string,
tool?: string | null,
): PlainObject | null {
const direct = safeParseObject(preview);
if (direct) {
return direct;
}
const candidate = extractJsonCandidate(preview);
if (candidate) {
const parsed = safeParseObject(candidate);
if (parsed) {
return parsed;
}
}
if (tool === "slash_run") {
const slashMatch = preview.match(/\/(?:new|new-doc|newdoc|rename|rename-doc|renamedoc)\b[\s\S]*/i);
if (slashMatch) {
return { text: slashMatch[0].trim() };
}
}
return null;
}
export function readHermesToolArgsFromEvent(
event: unknown,
tool?: string | null,
): PlainObject | null {
if (!isPlainObject(event)) {
return null;
}
const directArgs =
event.argsJson ??
event.args_json ??
event.args;
if (isPlainObject(directArgs)) {
return directArgs;
}
if (typeof directArgs === "string") {
const parsed = parseHermesToolPreviewArgs(directArgs, tool);
if (parsed) {
return parsed;
}
}
const preview = typeof event.preview === "string" ? event.preview : "";
return parseHermesToolPreviewArgs(preview, tool);
}
export function readHermesToolResultFromEvent(event: unknown): unknown | null {
if (!isPlainObject(event)) {
return null;
}
if ("result" in event && event.result != null) {
return event.result;
}
if ("data" in event && event.data != null) {
return event.data;
}
if (typeof event.output === "string") {
const parsed = safeParseObject(event.output);
if (parsed) {
return parsed;
}
}
return null;
}
export function buildHermesRuntimeToolResultRequest(input: {
userId: string;
tool: string;
argsJson: PlainObject;
data?: unknown;
requestId: string;
traceId: string;
workspaceId?: string | null;
target?: {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
} | null;
reason?: string | null;
refs?: string[];
}): PlainObject {
return {
kind: "tool",
context: {
deploymentId: null,
projectId: null,
workspaceId: input.workspaceId ?? null,
requestId: input.requestId,
traceId: input.traceId,
actor: {
actorType: "user",
actorId: input.userId,
sessionId: null,
},
source: {
channel: "next_route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
},
tool: {
tool: input.tool,
kind: "command",
mode: "result",
argsJson: input.argsJson,
target: input.target
? {
workspaceId: input.target.workspaceId ?? null,
pageId: input.target.pageId ?? null,
blockId: input.target.blockId ?? null,
}
: null,
reason: input.reason ?? null,
refs: Array.isArray(input.refs) ? input.refs : [],
},
...(typeof input.data === "undefined" ? {} : { data: input.data }),
};
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import type { PageOptionsState } from "@/types/page-options";
const pageOptions: PageOptionsState = {
wideLayout: true,
smallText: false,
showHeadingNumbers: true,
showToc: true,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
describe("page-aggregate", () => {
it("将页面 route 所需真相收口为统一聚合对象", () => {
const aggregate = buildPageAggregate({
documentId: "doc_1",
workspaceId: "ws_1",
title: " 页面标题 ",
updatedAt: "2026-04-21T12:00:00.000Z",
readOnly: true,
disableDownload: true,
disableCopy: false,
pageOptions,
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 7,
conflictDetectionKey: "doc_1:7",
pageSubtree: null,
stats: {
wordCount: 10,
characterCount: 20,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
},
});
expect(aggregate).toEqual({
identity: {
documentId: "doc_1",
workspaceId: "ws_1",
},
head: {
title: "页面标题",
updatedAt: "2026-04-21T12:00:00.000Z",
permissions: {
readOnly: true,
disableDownload: true,
disableCopy: false,
},
},
layout: {
pageOptions,
},
body: {
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 7,
conflictDetectionKey: "doc_1:7",
},
tree: {
pageSubtree: null,
},
stats: {
wordCount: 10,
characterCount: 20,
blockCount: 1,
todoTotal: 0,
todoDone: 0,
},
});
});
it("缺省标题与可选字段时回退到稳定默认值", () => {
const aggregate = buildPageAggregate({
documentId: "doc_2",
workspaceId: "ws_2",
pageOptions,
content: null,
});
expect(aggregate.head.title).toBe("无标题");
expect(aggregate.head.permissions).toEqual({
readOnly: false,
disableDownload: false,
disableCopy: false,
});
expect(aggregate.body).toEqual({
content: null,
revision: null,
conflictDetectionKey: null,
});
expect(aggregate.tree.pageSubtree).toBeNull();
expect(aggregate.stats).toBeNull();
});
});
@@ -0,0 +1,86 @@
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
export interface PageAggregateIdentity {
documentId: string;
workspaceId: string;
}
export interface PageAggregatePermissions {
readOnly: boolean;
disableDownload: boolean;
disableCopy: boolean;
}
export interface PageAggregateHead {
title: string;
updatedAt: string | null;
permissions: PageAggregatePermissions;
}
export interface PageAggregateLayout {
pageOptions: PageOptionsState;
}
export interface PageAggregateBody {
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
}
export interface PageAggregateTree {
pageSubtree: PageSubtreeProjection | null;
}
export interface PageAggregateProjection {
identity: PageAggregateIdentity;
head: PageAggregateHead;
layout: PageAggregateLayout;
body: PageAggregateBody;
tree: PageAggregateTree;
stats: DocumentStats | null;
}
export function buildPageAggregate(input: {
documentId: string;
workspaceId: string;
title?: string | null;
updatedAt?: string | null;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
pageOptions: PageOptionsState;
content: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
stats?: DocumentStats | null;
}): PageAggregateProjection {
return {
identity: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
head: {
title: input.title?.trim() || "无标题",
updatedAt: input.updatedAt ?? null,
permissions: {
readOnly: Boolean(input.readOnly),
disableDownload: Boolean(input.disableDownload),
disableCopy: Boolean(input.disableCopy),
},
},
layout: {
pageOptions: input.pageOptions,
},
body: {
content: input.content ?? null,
revision: input.revision ?? null,
conflictDetectionKey: input.conflictDetectionKey ?? null,
},
tree: {
pageSubtree: input.pageSubtree ?? null,
},
stats: input.stats ?? null,
};
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import { applyPageBodyCommand } from "./page-body-command";
describe("applyPageBodyCommand", () => {
it("应先保存 page body,再回显编辑器快照并回传持久化元信息", async () => {
const blocks = [{ id: "block_1", type: "paragraph", content: "AI 生成正文" }] as Json;
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const result = await applyPageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("/api/documents/save");
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
const body = JSON.parse(String(init?.body ?? "{}")) as {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
content: Json;
blockCount: number | null;
};
expect(body.documentId).toBe("doc-1");
expect(body.workspaceId).toBe("ws-1");
expect(body.revision).toBe(4);
expect(body.conflictDetectionKey).toBe("doc-1:4");
expect(body.content).toEqual(blocks);
expect(body.blockCount).toBe(1);
expect(result).toEqual({ revision: 5, conflictDetectionKey: "doc-1:5" });
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 5,
conflictDetectionKey: "doc-1:5",
});
});
it("保存失败时不应回显未持久化的正文", async () => {
const blocks = [{ id: "block_1", type: "paragraph", content: "失败正文" }] as Json;
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ error: "conflict" }), {
status: 409,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
applyPageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
}),
).rejects.toThrow("conflict");
expect(applyEditorSnapshot).not.toHaveBeenCalled();
expect(onPersistedMetaChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ApplyPageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function persistPageBodyViaRoute(
payload: DocumentSavePayload,
fetchImpl: typeof fetch,
): Promise<PageBodyPersistedMeta> {
const response = await fetchImpl("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| {
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string;
}
| null;
if (!response.ok) {
const message = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : "页面正文保存失败";
throw new Error(message);
}
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function applyPageBodyCommand(input: ApplyPageBodyCommandInput): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await persistPageBodyViaRoute(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
@@ -0,0 +1,19 @@
import type { PageOptionsState } from "@/types/page-options";
export type PageTitleCommandInput = {
documentId: string;
workspaceId?: string | null;
title: string;
};
export type PageLayoutCommandInput = {
documentId: string;
workspaceId?: string | null;
pageOptions: Partial<PageOptionsState>;
};
export const PAGE_COMMAND_NAMES = {
updateTitle: "page.head.updateTitle",
updateLayout: "page.layout.updateOptions",
saveBody: "page.body.save",
} as const;
@@ -3,7 +3,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import { createDocumentCommand } from "@/lib/documents/tree-command-client"; import { createDocumentCommand } from "@/lib/documents/tree-command-client";
export function SidebarCreateDocumentEntry() { export function SidebarCreateDocumentEntry() {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
@@ -17,9 +16,8 @@ export function SidebarCreateDocumentEntry() {
export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) { export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) {
const payload = await createDocumentCommand(parentId); const payload = await createDocumentCommand(parentId);
const query = new URLSearchParams(); const query = new URLSearchParams();
query.set("edit", "1");
const workspaceId = payload.workspace_id ?? null; const workspaceId = payload.workspace_id ?? null;
if (workspaceId) query.set("workspaceId", workspaceId); if (workspaceId) query.set("workspaceId", workspaceId);
router.push(`/documents/${payload.id}?${query.toString()}`); const nextQuery = query.toString();
router.push(nextQuery ? `/documents/${payload.id}?${nextQuery}` : `/documents/${payload.id}`);
} }
@@ -8,6 +8,8 @@ import {
purgeDocumentCommand, purgeDocumentCommand,
renameDocumentCommand, renameDocumentCommand,
restoreDocumentCommand, restoreDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client"; } from "@/lib/documents/tree-command-client";
describe("tree-command-client", () => { describe("tree-command-client", () => {
@@ -29,8 +31,15 @@ describe("tree-command-client", () => {
await purgeDocumentCommand({ documentId: "doc_1" }); await purgeDocumentCommand({ documentId: "doc_1" });
await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" }); await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" });
await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }); await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] });
await updatePageTitleCommand({ documentId: "doc_1", title: "页面标题" });
await updatePageOptionsCommand({
documentId: "doc_1",
pageOptions: {
wideLayout: true,
},
});
expect(fetchMock).toHaveBeenCalledTimes(8); expect(fetchMock).toHaveBeenCalledTimes(10);
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
"/api/documents/create", "/api/documents/create",
"/api/documents/title", "/api/documents/title",
@@ -40,6 +49,8 @@ describe("tree-command-client", () => {
"/api/documents/purge", "/api/documents/purge",
"/api/documents/embed", "/api/documents/embed",
"/api/documents/copy-tree", "/api/documents/copy-tree",
"/api/documents/title",
"/api/documents/options",
]); ]);
}); });
@@ -1,5 +1,11 @@
"use client"; "use client";
import type {
PageLayoutCommandInput,
PageTitleCommandInput,
} from "@/lib/documents/page-command-contract";
import type { PageOptionsState } from "@/types/page-options";
type DocumentCommandMeta = { type DocumentCommandMeta = {
requestId?: string; requestId?: string;
traceId?: string; traceId?: string;
@@ -71,6 +77,12 @@ type RenameDocumentInput = {
title: string; title: string;
}; };
type UpdatePageOptionsInput = {
documentId: string;
workspaceId?: string | null;
pageOptions: Partial<PageOptionsState>;
};
type MoveDocumentInput = { type MoveDocumentInput = {
documentId: string; documentId: string;
parentId?: string | null; parentId?: string | null;
@@ -156,6 +168,27 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
); );
} }
export async function updatePageTitleCommand(
input: PageTitleCommandInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return renameDocumentCommand(input);
}
export async function updatePageOptionsCommand(
input: PageLayoutCommandInput | UpdatePageOptionsInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
const pageOptions = "pageOptions" in input ? input.pageOptions : {};
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: pageOptions,
},
"更新页面选项失败,请稍后再试",
);
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> { export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>( return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/move", "/api/documents/move",
+66 -2
View File
@@ -34,12 +34,17 @@ export type MnoteRuntimeConfig = {
mnoteWebTreeShellEnabled?: boolean; mnoteWebTreeShellEnabled?: boolean;
/** /**
* host * host
* host BlockNote * leptos_tiptap_island
*/ */
documentEditorHost?: documentEditorHost?:
| "blocknote" | "blocknote"
| "leptos_tiptap_inline" | "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug"; | "leptos_tiptap_iframe_debug";
/**
* BlockNote 退kill switch
* query host 退 blocknote
*/
documentEditorBlocknoteKillSwitch?: boolean;
/** /**
* Electron * Electron
*/ */
@@ -81,6 +86,37 @@ const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
return undefined; return undefined;
}; };
const parseDocumentEditorHost = (
value: unknown,
): MnoteRuntimeConfig["documentEditorHost"] | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (normalized === "blocknote") {
return "blocknote";
}
if (
normalized === "leptos_tiptap_island" ||
normalized === "leptos_tiptap_runtime" ||
normalized === "leptos_tiptap_inline" ||
normalized === "leptos_tiptap"
) {
return "leptos_tiptap_island";
}
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
return undefined;
};
function getServerNodeBuiltin<T>(moduleName: string): T | null { function getServerNodeBuiltin<T>(moduleName: string): T | null {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return null; return null;
@@ -135,6 +171,28 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
), ),
} }
: {}), : {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
) !== undefined
? {
documentEditorBlocknoteKillSwitch: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
),
}
: {}),
...(parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
) !== undefined
? {
documentEditorHost: parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST,
),
}
: {}),
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL, onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE, onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN, onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
@@ -223,12 +281,18 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, ""); const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled = const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false; parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
return { return {
...cfg, ...cfg,
isDesktop, isDesktop,
mnoteWebBaseUrl, mnoteWebBaseUrl,
mnoteWebTreeShellEnabled, mnoteWebTreeShellEnabled,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
onlyofficeBaseUrl, onlyofficeBaseUrl,
onlyofficeStorageHostOverride, onlyofficeStorageHostOverride,
onlyofficeProxyOrigin, onlyofficeProxyOrigin,
@@ -0,0 +1,79 @@
import { buildHermesRuntimeToolResultRequest } from "@/lib/ai-agent/hermes/tool-result-recovery";
import { buildMnoteWebForwardHeaders, getMnoteWebBaseUrl } from "@/lib/server/mnote-web";
type PlainObject = Record<string, unknown>;
function readErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string") {
return payload.error;
}
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
return payload.message;
}
return fallback;
}
export async function fetchHermesStructuredToolResultFromMnoteWeb(input: {
request?: Request;
userId: string;
tool: string;
argsJson: PlainObject;
data?: unknown;
requestId: string;
traceId: string;
workspaceId?: string | null;
target?: {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
} | null;
reason?: string | null;
refs?: string[];
}): Promise<unknown> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const headers = await buildMnoteWebForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const response = await fetch(new URL("/api/hermes/bridge", `${baseUrl}/`).toString(), {
method: "POST",
headers,
body: JSON.stringify(
buildHermesRuntimeToolResultRequest({
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data: input.data,
requestId: input.requestId,
traceId: input.traceId,
workspaceId: input.workspaceId,
target: input.target,
reason: input.reason,
refs: input.refs,
}),
),
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as
| {
ok?: boolean;
result?: unknown;
error?: string;
message?: string;
}
| null;
if (!response.ok) {
throw new Error(readErrorMessage(payload, "mnote-web Hermes runtime 请求失败"));
}
if (!payload || !("result" in payload)) {
throw new Error("mnote-web Hermes runtime 未返回 result");
}
return payload.result;
}
+21 -2
View File
@@ -20,7 +20,7 @@ function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
} }
} }
async function buildForwardHeaders(request?: Request): Promise<Headers> { export async function buildMnoteWebForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers()); const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers(); const forwarded = new Headers();
@@ -89,7 +89,7 @@ export async function fetchSidebarDatasetFromMnoteWeb(input: {
const url = new URL("/api/compat/next/sidebar", baseUrl); const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId); url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildForwardHeaders(input.request); const forwardedHeaders = await buildMnoteWebForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId); forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, { const response = await fetch(url, {
@@ -122,3 +122,22 @@ export async function fetchSidebarDatasetFromMnoteWeb(input: {
}, },
}; };
} }
export function buildMnoteWebStreamUrl(input: {
workspaceId: string;
cursor?: string | null;
}): URL {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/stream/events", `${baseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
url.searchParams.set("workspaceId", input.workspaceId.trim());
if (typeof input.cursor === "string" && input.cursor.trim()) {
url.searchParams.set("cursor", input.cursor.trim());
}
return url;
}
+14 -3
View File
@@ -69,9 +69,20 @@ export function buildWorkspaceTreeStreamUrl(
cursor?: string | null, cursor?: string | null,
): string { ): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const url = new URL("/api/stream/events", `${normalizedBaseUrl}/`); const shouldUseSameOriginProxy =
url.searchParams.set("stream", "workspace"); normalizedBaseUrl.length > 0 &&
url.searchParams.set("projection", "sidebar_tree"); typeof window !== "undefined" &&
(() => {
try {
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
return runtimeUrl.origin !== window.location.origin;
} catch {
return false;
}
})();
const url = shouldUseSameOriginProxy
? new URL("/api/mnote-web/stream", window.location.origin)
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
url.searchParams.set("workspaceId", workspaceId.trim()); url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) { if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim()); url.searchParams.set("cursor", cursor.trim());
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
buildWorkspaceTreeStreamUrl, buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot, normalizeTreeStreamSnapshot,
@@ -6,14 +6,38 @@ import {
} from "./protocol"; } from "./protocol";
describe("tree-stream/protocol", () => { describe("tree-stream/protocol", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("构造 workspace sidebar stream url", () => { it("构造 workspace sidebar stream url", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect( expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"), buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
).toBe( ).toBe(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1&cursor=evt_9", "http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
); );
}); });
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
it("解析 snapshot / delta / resync 协议消息", () => { it("解析 snapshot / delta / resync 协议消息", () => {
expect( expect(
parseTreeStreamMessage({ parseTreeStreamMessage({

Some files were not shown because too many files have changed in this diff Show More