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:
@@ -62,3 +62,4 @@ design
|
|||||||
|
|
||||||
# pnpm 本地缓存
|
# pnpm 本地缓存
|
||||||
/.pnpm-store/
|
/.pnpm-store/
|
||||||
|
.playwright-mcp
|
||||||
+1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+102
@@ -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>;
|
||||||
+1495
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
Vendored
+36
@@ -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;
|
||||||
+91
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+4
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+2
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+5
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
@@ -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};
|
||||||
+1
File diff suppressed because one or more lines are too long
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 switch(runtime/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>
|
||||||
|
|||||||
@@ -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,27 +9,28 @@ 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 ?? "我的空间",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!sidebarInitialData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
<div className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-white">
|
||||||
<div className="flex min-w-0 flex-1 flex-col h-full bg-white relative overflow-hidden">
|
|
||||||
<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">
|
|
||||||
<MobileSidebarTrigger />
|
|
||||||
<Breadcrumb documents={documents} />
|
|
||||||
</header>
|
|
||||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayoutShell initialData={sidebarInitialData}>
|
||||||
|
{children}
|
||||||
|
</AppLayoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/`)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const basename = path.posix.basename(item);
|
const basename = path.posix.basename(item);
|
||||||
return EXTENSION_MODULE_PATTERN.test(basename);
|
return (
|
||||||
})
|
basename.endsWith(".js") ||
|
||||||
: [];
|
basename.endsWith(".wasm") ||
|
||||||
const entryScriptPath =
|
basename.endsWith(".css") ||
|
||||||
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
|
basename.endsWith(".json")
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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);
|
||||||
|
});
|
||||||
|
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!).catch((error) => {
|
||||||
|
throw formatRuntimeLoaderError("bridge_import", error);
|
||||||
|
});
|
||||||
|
try {
|
||||||
bridge.init_bridge_runtime();
|
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);
|
||||||
|
try {
|
||||||
register?.();
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
setTree(() => {
|
|
||||||
const nextTree = sidebarData.kernelSidebarTree;
|
const nextTree = sidebarData.kernelSidebarTree;
|
||||||
|
const nextSyncKey = buildSidebarTreeSyncKey(nextTree);
|
||||||
|
if (treeSyncKeyRef.current === nextSyncKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
treeSyncKeyRef.current = nextSyncKey;
|
||||||
|
setTree(() => {
|
||||||
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("新标题");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,8 +69,46 @@ 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
|
||||||
|
? manualSnapshotState.data
|
||||||
|
: null;
|
||||||
|
|
||||||
|
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 =
|
const isLoading =
|
||||||
convexSidebar.hasLiveSubscription
|
convexSidebar.hasLiveSubscription
|
||||||
? convexSidebar.isLoading
|
? convexSidebar.isLoading
|
||||||
@@ -53,28 +120,64 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
|
|||||||
: shouldUseHttpFallback && httpQuery.data
|
: shouldUseHttpFallback && httpQuery.data
|
||||||
? "http-fallback"
|
? "http-fallback"
|
||||||
: "initial";
|
: "initial";
|
||||||
const refetch = async () => {
|
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) {
|
if (convexSidebar.hasLiveSubscription) {
|
||||||
await convexSidebar.refetch();
|
await convexRefetchRef.current();
|
||||||
return liveData;
|
return liveDataRef.current;
|
||||||
}
|
}
|
||||||
if (shouldUseHttpFallback) {
|
if (shouldUseHttpFallback) {
|
||||||
return httpQuery.refetch();
|
return httpRefetchRef.current();
|
||||||
}
|
}
|
||||||
return liveData;
|
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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user