fix(ios): preserve shared clip body during editor initialization (#601)

This commit is contained in:
tianma-if committed 2026-10-18 12:02:32 +08:00
1 parent df22f55d1c
commit dca7757caa
9 files changed
+183 -28

No files matched your search

+28 -19
View File
@@ -57,7 +57,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
private var lastEditorEmittedFingerprint: String?
private var lastAppliedConfiguration: String?
private var hydrateGeneration: UInt64 = 0
private var bodyReadyGeneration: UInt64 = 0
private var bodyReadyGeneration: UInt64?
private var contentGeneration: UInt64 = 0
/// Only auto-focus caret once per content generation (open edit), never while typing.
private var focusedGeneration: UInt64 = 0
@@ -133,6 +133,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
func detach(from container: UIView) {
guard hostContainer === container else { return }
bodyReadyGeneration = nil
// Keep the engine alive off-screen; only leave the hierarchy.
webView.removeFromSuperview()
hostContainer = nil
@@ -171,7 +172,7 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
if fp != previousFingerprint || needsForcePushOnBind {
contentGeneration &+= 1
hydrateGeneration &+= 1
// Do not zero bodyReadyGeneration here — pushContent / skip path will notify.
bodyReadyGeneration = nil
}
let modeChanged = previousMode != nil && previousMode != newSession.mode
let forcePush = modeChanged || needsForcePushOnBind
@@ -412,15 +413,15 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
/// Read current editor markdown + JSON after a mutation (avoids racing the async bridge onChange).
func snapshotContent() async -> (markdown: String, json: String)? {
guard ready else { return nil }
guard ready, bodyReadyGeneration == contentGeneration else { return nil }
let gen = contentGeneration
let js = """
(function(){
try {
if (!window.EdgeEverEditor) return null;
var md = '';
var json = '';
try { md = window.EdgeEverEditor.getMarkdown() || ''; } catch (e) {}
try { json = window.EdgeEverEditor.getDocument() || ''; } catch (e) {}
var md = window.EdgeEverEditor.getMarkdown();
var json = window.EdgeEverEditor.getDocument();
if (typeof md !== 'string' || typeof json !== 'string') return null;
return JSON.stringify({ md: md, json: json });
} catch (e) { return null; }
})();
@@ -430,7 +431,8 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
cont.resume(returning: value)
}
}
guard let text = raw as? String,
guard contentGeneration == gen, bodyReadyGeneration == gen,
let text = raw as? String,
let data = text.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
@@ -505,7 +507,9 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
ready = true
// Navigation completion does not acknowledge that the editor bridge exists.
// The bundle (including the fallback) posts `ready` after installing its API.
guard ready else { return }
lastAppliedConfiguration = nil
applyMode()
pushContentIfNeeded(force: true)
@@ -570,21 +574,22 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
let fn = useJSON ? "setDocumentFromJSON" : "setMarkdown"
let payload = decision.payload
let js = TipTapResourceLoader.jsCall(fn: fn, arg: payload)
// Capture callback now — detach may nil session callbacks before the JS completion runs.
let bodyReadyCb = session.onBodyReady
webView.evaluateJavaScript(js) { [weak self] _, error in
bodyReadyGeneration = nil
lastPushedJSON = nil
lastEditorEmittedFingerprint = nil
webView.evaluateJavaScript(js) { [weak self] result, error in
#if DEBUG
if let error { NSLog("SharedTipTapRuntime pushContent error: \(error)") }
#endif
guard let self else { return }
guard let self, error == nil, result as? Bool == true else { return }
Task { @MainActor in
// Only stamp fingerprints after a successful push for this generation.
// Stamping before JS completed caused empty-create to skip a later force
// while the WebView still held the previous note body.
guard self.contentGeneration == gen else { return }
guard self.contentGeneration == gen, self.hostContainer != nil else { return }
self.lastPushedJSON = fingerprint
self.lastEditorEmittedFingerprint = fingerprint
self.notifyBodyReady(generation: gen, callback: bodyReadyCb)
self.notifyBodyReady(generation: gen)
if shouldFocusAfterPush {
self.scheduleFocusEnd(for: gen)
}
@@ -593,12 +598,15 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
}
}
private func notifyBodyReady(generation: UInt64, callback: (() -> Void)? = nil) {
let cb = callback ?? session?.onBodyReady
private func notifyBodyReady(generation: UInt64) {
// Always invoke the *current* host callback when re-binding the same content,
// even if we already marked this generation ready (new SwiftUI view needs it).
bodyReadyGeneration = generation
DispatchQueue.main.async { cb?() }
DispatchQueue.main.async { [weak self] in
guard let self, self.bodyReadyGeneration == generation,
self.contentGeneration == generation else { return }
self.session?.onBodyReady?()
}
}
// MARK: - Image hydrate
@@ -687,7 +695,8 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD
case "change":
// Host dismantled (create/edit dismissed) — drop late events so they cannot
// resurrect the previous body in a later editor session.
guard let session, session.onChange != nil else { return }
guard bodyReadyGeneration == contentGeneration,
let session, session.onChange != nil else { return }
let md = body["contentMarkdown"] as? String ?? ""
let json = body["contentJson"] as? String ?? session.documentJSON
let emptyStub = "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\"}]}"
+3 -1
View File
@@ -154,14 +154,16 @@ enum TipTapResourceLoader {
return """
(function(){
try {
if (!window.EdgeEverEditor) return;
if (!window.EdgeEverEditor || typeof window.EdgeEverEditor.\(fn) !== 'function') return false;
var bin = atob('\(b64)');
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var text = new TextDecoder('utf-8').decode(bytes);
window.EdgeEverEditor.\(fn)(text);
return true;
} catch (e) {
try { window.webkit.messageHandlers.edgeever.postMessage({type:'error', message: String(e)}); } catch (_) {}
return false;
}
})();
"""
@@ -63,12 +63,13 @@ enum WebClipper {
static func build(
_ sourceURL: URL,
capturedAt: Date = Date(),
labels: WebClipLabels = .chinese
labels: WebClipLabels = .chinese,
session: URLSession = .shared
) async -> WebClipDraft {
var request = URLRequest(url: sourceURL)
request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await URLSession.shared.data(for: request)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse,
(200 ..< 300).contains(http.statusCode),
let html = String(data: data, encoding: .utf8)
@@ -301,10 +301,9 @@ struct MemoEditView: View {
.task {
await loadInitial()
contentHydrated = true
// editorReady flips true from TipTap onBodyReady (or fallback below).
// Only TipTap's acknowledged content push may mark the body ready.
try? await Task.sleep(nanoseconds: 800_000_000)
if !Task.isCancelled, !editorReady {
editorReady = true
if initialFocus == .title {
focusTitleOnce()
} else {
@@ -853,7 +852,6 @@ struct MemoEditView: View {
Task { @MainActor in
try? await Task.sleep(nanoseconds: 50_000_000)
SharedTipTapRuntime.editor.focusEnd()
editorReady = true
}
}
+1 -1
View File
@@ -3468,7 +3468,7 @@ l0,-`+(r+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,
`)},RF="edgeever-inline-sentinel",UWt=(t,e,r)=>{var i;const n=(i=t.storage.markdown)==null?void 0:i.manager;return n!=null&&n.serialize?n.serialize(Tte({type:"doc",content:[{type:"paragraph",content:e}]})).replaceAll(wte,"\\$"):r},yWt=t=>{const e=t.state.selection;if(e.empty||e.from>=e.to)return null;const r=[];if(t.state.doc.nodesBetween(e.from,e.to,(i,a)=>{if(!i.isTextblock)return!0;const s=a+1,o=s+i.content.size,l=Math.max(e.from,s),u=Math.min(e.to,o);return u>l&&r.push({node:i,contentFrom:s,contentTo:o,from:l,to:u}),!1}),r.length===1){const i=r[0],a=i.node.cut(i.from-i.contentFrom,i.to-i.contentFrom).toJSON(),s=t.state.doc.textBetween(i.from,i.to,`
`),o=UWt(t,a.content??[],s).trim();return o?{from:i.from,to:i.to,isInline:!0,markdown:o,text:s}:null}const n=mWt(t,e.from,e.to).trim();return n?{from:e.from,to:e.to,isInline:!1,markdown:n,text:t.state.doc.textBetween(e.from,e.to,`
`)}:null},bWt=(t,e,r)=>{var f,p,g;const n=(f=t.storage.markdown)==null?void 0:f.manager,i=e.trim(),a=((p=n==null?void 0:n.parse)==null?void 0:p.call(n,i).content)??[{type:"text",text:i}];if(!r)return a;const s=i.replace(/\s*\n+\s*/g," "),o=(g=n==null?void 0:n.parse)==null?void 0:g.call(n,`${RF}${s}`).content,l=(o==null?void 0:o.length)===1?o[0]:null,u=(l==null?void 0:l.type)==="paragraph"?l.content??[]:[],h=u[0];if((h==null?void 0:h.type)!=="text"||typeof h.text!="string"||!h.text.startsWith(RF))return[{type:"text",text:s}];const d=h.text.slice(RF.length);return[...d?[{...h,text:d}]:[],...u.slice(1)]};function Ud(t){try{const e=JSON.stringify(kee(t.getJSON())),r=HRe(t);fs({type:"change",contentMarkdown:r,contentJson:e})}catch(e){fs({type:"error",message:e instanceof Error?e.message:String(e)})}}function vWt(t,e){const r=e.trim().toLocaleLowerCase();if(r.length===0)return[];const n=[];let i=null;t.state.doc.descendants((l,u)=>{if(!(!l.isText||!l.text)){i!==null&&u>i&&n.push({char:"\0",pos:-1});for(let h=0;h<l.text.length;h+=1)n.push({char:l.text[h]??"",pos:u+h});i=u+l.text.length}});const a=n.map(l=>l.char).join("").toLocaleLowerCase(),s=[];let o=a.indexOf(r);for(;o!==-1;){const l=n[o],u=n[o+r.length-1];l&&u&&l.pos>=0&&u.pos>=0&&s.push({from:l.pos,to:u.pos+1}),o=a.indexOf(r,o+r.length)}return s}const bpe=()=>Ur.isActive("taskItem")?"taskItem":"listItem";function VRe(t){var r;if(lM.classList.toggle("editor-mode",t),lM.innerHTML="",!t)return;const e=[{id:"undo",label:"↩",run:()=>Ur.chain().focus().undo().run()},{id:"redo",label:"↪",run:()=>Ur.chain().focus().redo().run()},{id:"image",label:"▧+",run:()=>fs({type:"pickImage"})},{id:"bold",label:"B",run:()=>Ur.chain().focus().toggleBold().run()},{id:"bullet",label:"•",run:()=>Ur.chain().focus().toggleBulletList().run()},{id:"task",label:"☑",run:()=>Ur.chain().focus().toggleTaskList().run()},{id:"indent",label:"⇥",run:()=>Ur.chain().focus().sinkListItem(bpe()).run()},{id:"outdent",label:"⇤",run:()=>Ur.chain().focus().liftListItem(bpe()).run()},{id:"quote",label:"❝",run:()=>Ur.chain().focus().toggleBlockquote().run()},{id:"hr",label:"—",run:()=>Ur.chain().focus().setHorizontalRule().run()}];for(const n of e){const i=document.createElement("button");i.type="button",i.textContent=n.label,i.dataset.action=n.id;const a={undo:{"zh-CN":"撤销","en-US":"Undo",ja:"元に戻す",pl:"Cofnij"},redo:{"zh-CN":"重做","en-US":"Redo",ja:"やり直す",pl:"Ponów"},image:{"zh-CN":"插入图片","en-US":"Insert image",ja:"画像を挿入",pl:"Wstaw obraz"},bold:{"zh-CN":"粗体","en-US":"Bold",ja:"太字",pl:"Pogrubienie"},bullet:{"zh-CN":"项目符号列表","en-US":"Bullet list",ja:"箇条書き",pl:"Lista punktowana"},task:{"zh-CN":"任务清单","en-US":"Task list",ja:"タスクリスト",pl:"Lista zadań"},indent:{"zh-CN":"增加列表缩进","en-US":"Increase list indent",ja:"リストのインデントを増やす",pl:"Zwiększ wcięcie listy"},outdent:{"zh-CN":"减少列表缩进","en-US":"Decrease list indent",ja:"リストのインデントを減らす",pl:"Zmniejsz wcięcie listy"},quote:{"zh-CN":"引用","en-US":"Block quote",ja:"引用",pl:"Cytat"},hr:{"zh-CN":"分隔线","en-US":"Horizontal rule",ja:"区切り線",pl:"Linia pozioma"}};i.setAttribute("aria-label",((r=a[n.id])==null?void 0:r[LL])??n.id),i.addEventListener("click",()=>{n.run(),n.id!=="image"&&Ud(Ur),a4()}),lM.appendChild(i)}a4()}function a4(){const t={bold:Ur.isActive("bold"),bullet:Ur.isActive("bulletList"),task:Ur.isActive("taskList"),quote:Ur.isActive("blockquote")},e=n=>{try{return Ur.can().chain().focus()[n]().run()}catch{return!1}},r={undo:e("undo"),redo:e("redo")};lM.querySelectorAll("button[data-action]").forEach(n=>{const i=n.dataset.action??"";n.classList.toggle("is-active",t[i]??!1),i in r&&(n.disabled=!r[i])})}async function IF(t="light"){Rs.querySelectorAll("[data-placeholder]").forEach(e=>{e.dataset.placeholder=jq}),BRe(Rs),await cWt(Rs),Da==="viewer"&&(!DL||!hWt(Rs,DL,t))&&(xte(),await uWt(Rs,t))}async function xWt(t){var p;if(!t.requestId||t.format!=="png"&&t.format!=="jpeg")return;const e=LC(t.background,t.theme),r=t.fontStyle??"auto",n=t.fontSize??"lg",i=t.cardWidth??"wide",a=SV[i]||SV.wide,s=kV[e]||kV.aurora,o=Ur.view.dom.cloneNode(!0);o.removeAttribute("contenteditable"),o.querySelectorAll("button, [contenteditable='true']").forEach(g=>{g.removeAttribute("contenteditable"),g instanceof HTMLButtonElement&&g.remove()});const l=o.innerHTML,u=document.createElement("div");u.style.cssText=`position:fixed;left:-100000px;top:0;width:${a}px;pointer-events:none;`;const h=document.createElement("style");h.textContent=Fse({theme:e,fontStyle:r,fontSize:n,cardWidth:i});const d=MJe({title:t.title||t.fallbackTitle,notebook:t.notebook,tags:t.tags,updatedAt:t.updatedAt,bodyHtml:l,theme:e,showTitle:t.showTitle??!0,showNotebook:t.showNotebook??!1,showTags:t.showTags??!1,showUpdatedAt:t.showUpdatedAt??!0,showBranding:t.branding??!0});u.appendChild(h),u.insertAdjacentHTML("beforeend",Line truncated
`)}:null},bWt=(t,e,r)=>{var f,p,g;const n=(f=t.storage.markdown)==null?void 0:f.manager,i=e.trim(),a=((p=n==null?void 0:n.parse)==null?void 0:p.call(n,i).content)??[{type:"text",text:i}];if(!r)return a;const s=i.replace(/\s*\n+\s*/g," "),o=(g=n==null?void 0:n.parse)==null?void 0:g.call(n,`${RF}${s}`).content,l=(o==null?void 0:o.length)===1?o[0]:null,u=(l==null?void 0:l.type)==="paragraph"?l.content??[]:[],h=u[0];if((h==null?void 0:h.type)!=="text"||typeof h.text!="string"||!h.text.startsWith(RF))return[{type:"text",text:s}];const d=h.text.slice(RF.length);return[...d?[{...h,text:d}]:[],...u.slice(1)]};function Ud(t){try{const e=JSON.stringify(kee(t.getJSON())),r=HRe(t);fs({type:"change",contentMarkdown:r,contentJson:e})}catch(e){fs({type:"error",message:e instanceof Error?e.message:String(e)})}}function vWt(t,e){const r=e.trim().toLocaleLowerCase();if(r.length===0)return[];const n=[];let i=null;t.state.doc.descendants((l,u)=>{if(!(!l.isText||!l.text)){i!==null&&u>i&&n.push({char:"\0",pos:-1});for(let h=0;h<l.text.length;h+=1)n.push({char:l.text[h]??"",pos:u+h});i=u+l.text.length}});const a=n.map(l=>l.char).join("").toLocaleLowerCase(),s=[];let o=a.indexOf(r);for(;o!==-1;){const l=n[o],u=n[o+r.length-1];l&&u&&l.pos>=0&&u.pos>=0&&s.push({from:l.pos,to:u.pos+1}),o=a.indexOf(r,o+r.length)}return s}const bpe=()=>Ur.isActive("taskItem")?"taskItem":"listItem";function VRe(t){var r;if(lM.classList.toggle("editor-mode",t),lM.innerHTML="",!t)return;const e=[{id:"undo",label:"↩",run:()=>Ur.chain().focus().undo().run()},{id:"redo",label:"↪",run:()=>Ur.chain().focus().redo().run()},{id:"image",label:"▧+",run:()=>fs({type:"pickImage"})},{id:"bold",label:"B",run:()=>Ur.chain().focus().toggleBold().run()},{id:"bullet",label:"•",run:()=>Ur.chain().focus().toggleBulletList().run()},{id:"task",label:"☑",run:()=>Ur.chain().focus().toggleTaskList().run()},{id:"indent",label:"⇥",run:()=>Ur.chain().focus().sinkListItem(bpe()).run()},{id:"outdent",label:"⇤",run:()=>Ur.chain().focus().liftListItem(bpe()).run()},{id:"quote",label:"❝",run:()=>Ur.chain().focus().toggleBlockquote().run()},{id:"hr",label:"—",run:()=>Ur.chain().focus().setHorizontalRule().run()}];for(const n of e){const i=document.createElement("button");i.type="button",i.textContent=n.label,i.dataset.action=n.id;const a={undo:{"zh-CN":"撤销","en-US":"Undo",ja:"元に戻す",pl:"Cofnij"},redo:{"zh-CN":"重做","en-US":"Redo",ja:"やり直す",pl:"Ponów"},image:{"zh-CN":"插入图片","en-US":"Insert image",ja:"画像を挿入",pl:"Wstaw obraz"},bold:{"zh-CN":"粗体","en-US":"Bold",ja:"太字",pl:"Pogrubienie"},bullet:{"zh-CN":"项目符号列表","en-US":"Bullet list",ja:"箇条書き",pl:"Lista punktowana"},task:{"zh-CN":"任务清单","en-US":"Task list",ja:"タスクリスト",pl:"Lista zadań"},indent:{"zh-CN":"增加列表缩进","en-US":"Increase list indent",ja:"リストのインデントを増やす",pl:"Zwiększ wcięcie listy"},outdent:{"zh-CN":"减少列表缩进","en-US":"Decrease list indent",ja:"リストのインデントを減らす",pl:"Zmniejsz wcięcie listy"},quote:{"zh-CN":"引用","en-US":"Block quote",ja:"引用",pl:"Cytat"},hr:{"zh-CN":"分隔线","en-US":"Horizontal rule",ja:"区切り線",pl:"Linia pozioma"}};i.setAttribute("aria-label",((r=a[n.id])==null?void 0:r[LL])??n.id),i.addEventListener("click",()=>{n.run(),n.id!=="image"&&Ud(Ur),a4()}),lM.appendChild(i)}a4()}function a4(){const t={bold:Ur.isActive("bold"),bullet:Ur.isActive("bulletList"),task:Ur.isActive("taskList"),quote:Ur.isActive("blockquote")},e=n=>{try{return Ur.can().chain().focus()[n]().run()}catch{return!1}},r={undo:e("undo"),redo:e("redo")};lM.querySelectorAll("button[data-action]").forEach(n=>{const i=n.dataset.action??"";n.classList.toggle("is-active",t[i]??!1),i in r&&(n.disabled=!r[i])})}async function IF(t="light"){Rs.querySelectorAll("[data-placeholder]").forEach(e=>{e.dataset.placeholder=jq}),BRe(Rs),await cWt(Rs),Da==="viewer"&&(!DL||!hWt(Rs,DL,t))&&(xte(),await uWt(Rs,t))}async function xWt(t){var p;if(!t.requestId||t.format!=="png"&&t.format!=="jpeg")return;const e=LC(t.background,t.theme),r=t.fontStyle??"auto",n=t.fontSize??"lg",i=t.cardWidth??"wide",a=SV[i]||SV.wide,s=kV[e]||kV.aurora,o=Ur.view.dom.cloneNode(!0);o.removeAttribute("contenteditable"),o.querySelectorAll("button, [contenteditable='true']").forEach(g=>{g.removeAttribute("contenteditable"),g instanceof HTMLButtonElement&&g.remove()});const l=o.innerHTML,u=document.createElement("div");u.style.cssText=`position:fixed;left:-100000px;top:0;width:${a}px;pointer-events:none;`;const h=document.createElement("style");h.textContent=Fse({theme:e,fontStyle:r,fontSize:n,cardWidth:i});const d=MJe({title:t.title||t.fallbackTitle,notebook:t.notebook,tags:t.tags,updatedAt:t.updatedAt,bodyHtml:l,theme:e,showTitle:t.showTitle??!0,showNotebook:t.showNotebook??!1,showTags:t.showTags??!1,showUpdatedAt:t.showUpdatedAt??!0,showBranding:t.branding??!0});u.appendChild(h),u.insertAdjacentHTML("beforeend",Line truncated
c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14
c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54
c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10
+1 -1
View File
@@ -3468,7 +3468,7 @@ l0,-`+(r+144)+`c-2,-159.3,-10,-310.7,-24,-454c-53.3,-528,-210,-949.7,
`)},RF="edgeever-inline-sentinel",UWt=(t,e,r)=>{var i;const n=(i=t.storage.markdown)==null?void 0:i.manager;return n!=null&&n.serialize?n.serialize(Tte({type:"doc",content:[{type:"paragraph",content:e}]})).replaceAll(wte,"\\$"):r},yWt=t=>{const e=t.state.selection;if(e.empty||e.from>=e.to)return null;const r=[];if(t.state.doc.nodesBetween(e.from,e.to,(i,a)=>{if(!i.isTextblock)return!0;const s=a+1,o=s+i.content.size,l=Math.max(e.from,s),u=Math.min(e.to,o);return u>l&&r.push({node:i,contentFrom:s,contentTo:o,from:l,to:u}),!1}),r.length===1){const i=r[0],a=i.node.cut(i.from-i.contentFrom,i.to-i.contentFrom).toJSON(),s=t.state.doc.textBetween(i.from,i.to,`
`),o=UWt(t,a.content??[],s).trim();return o?{from:i.from,to:i.to,isInline:!0,markdown:o,text:s}:null}const n=mWt(t,e.from,e.to).trim();return n?{from:e.from,to:e.to,isInline:!1,markdown:n,text:t.state.doc.textBetween(e.from,e.to,`
`)}:null},bWt=(t,e,r)=>{var f,p,g;const n=(f=t.storage.markdown)==null?void 0:f.manager,i=e.trim(),a=((p=n==null?void 0:n.parse)==null?void 0:p.call(n,i).content)??[{type:"text",text:i}];if(!r)return a;const s=i.replace(/\s*\n+\s*/g," "),o=(g=n==null?void 0:n.parse)==null?void 0:g.call(n,`${RF}${s}`).content,l=(o==null?void 0:o.length)===1?o[0]:null,u=(l==null?void 0:l.type)==="paragraph"?l.content??[]:[],h=u[0];if((h==null?void 0:h.type)!=="text"||typeof h.text!="string"||!h.text.startsWith(RF))return[{type:"text",text:s}];const d=h.text.slice(RF.length);return[...d?[{...h,text:d}]:[],...u.slice(1)]};function Ud(t){try{const e=JSON.stringify(kee(t.getJSON())),r=HRe(t);fs({type:"change",contentMarkdown:r,contentJson:e})}catch(e){fs({type:"error",message:e instanceof Error?e.message:String(e)})}}function vWt(t,e){const r=e.trim().toLocaleLowerCase();if(r.length===0)return[];const n=[];let i=null;t.state.doc.descendants((l,u)=>{if(!(!l.isText||!l.text)){i!==null&&u>i&&n.push({char:"\0",pos:-1});for(let h=0;h<l.text.length;h+=1)n.push({char:l.text[h]??"",pos:u+h});i=u+l.text.length}});const a=n.map(l=>l.char).join("").toLocaleLowerCase(),s=[];let o=a.indexOf(r);for(;o!==-1;){const l=n[o],u=n[o+r.length-1];l&&u&&l.pos>=0&&u.pos>=0&&s.push({from:l.pos,to:u.pos+1}),o=a.indexOf(r,o+r.length)}return s}const bpe=()=>Ur.isActive("taskItem")?"taskItem":"listItem";function VRe(t){var r;if(lM.classList.toggle("editor-mode",t),lM.innerHTML="",!t)return;const e=[{id:"undo",label:"↩",run:()=>Ur.chain().focus().undo().run()},{id:"redo",label:"↪",run:()=>Ur.chain().focus().redo().run()},{id:"image",label:"▧+",run:()=>fs({type:"pickImage"})},{id:"bold",label:"B",run:()=>Ur.chain().focus().toggleBold().run()},{id:"bullet",label:"•",run:()=>Ur.chain().focus().toggleBulletList().run()},{id:"task",label:"☑",run:()=>Ur.chain().focus().toggleTaskList().run()},{id:"indent",label:"⇥",run:()=>Ur.chain().focus().sinkListItem(bpe()).run()},{id:"outdent",label:"⇤",run:()=>Ur.chain().focus().liftListItem(bpe()).run()},{id:"quote",label:"❝",run:()=>Ur.chain().focus().toggleBlockquote().run()},{id:"hr",label:"—",run:()=>Ur.chain().focus().setHorizontalRule().run()}];for(const n of e){const i=document.createElement("button");i.type="button",i.textContent=n.label,i.dataset.action=n.id;const a={undo:{"zh-CN":"撤销","en-US":"Undo",ja:"元に戻す",pl:"Cofnij"},redo:{"zh-CN":"重做","en-US":"Redo",ja:"やり直す",pl:"Ponów"},image:{"zh-CN":"插入图片","en-US":"Insert image",ja:"画像を挿入",pl:"Wstaw obraz"},bold:{"zh-CN":"粗体","en-US":"Bold",ja:"太字",pl:"Pogrubienie"},bullet:{"zh-CN":"项目符号列表","en-US":"Bullet list",ja:"箇条書き",pl:"Lista punktowana"},task:{"zh-CN":"任务清单","en-US":"Task list",ja:"タスクリスト",pl:"Lista zadań"},indent:{"zh-CN":"增加列表缩进","en-US":"Increase list indent",ja:"リストのインデントを増やす",pl:"Zwiększ wcięcie listy"},outdent:{"zh-CN":"减少列表缩进","en-US":"Decrease list indent",ja:"リストのインデントを減らす",pl:"Zmniejsz wcięcie listy"},quote:{"zh-CN":"引用","en-US":"Block quote",ja:"引用",pl:"Cytat"},hr:{"zh-CN":"分隔线","en-US":"Horizontal rule",ja:"区切り線",pl:"Linia pozioma"}};i.setAttribute("aria-label",((r=a[n.id])==null?void 0:r[LL])??n.id),i.addEventListener("click",()=>{n.run(),n.id!=="image"&&Ud(Ur),a4()}),lM.appendChild(i)}a4()}function a4(){const t={bold:Ur.isActive("bold"),bullet:Ur.isActive("bulletList"),task:Ur.isActive("taskList"),quote:Ur.isActive("blockquote")},e=n=>{try{return Ur.can().chain().focus()[n]().run()}catch{return!1}},r={undo:e("undo"),redo:e("redo")};lM.querySelectorAll("button[data-action]").forEach(n=>{const i=n.dataset.action??"";n.classList.toggle("is-active",t[i]??!1),i in r&&(n.disabled=!r[i])})}async function IF(t="light"){Rs.querySelectorAll("[data-placeholder]").forEach(e=>{e.dataset.placeholder=jq}),BRe(Rs),await cWt(Rs),Da==="viewer"&&(!DL||!hWt(Rs,DL,t))&&(xte(),await uWt(Rs,t))}async function xWt(t){var p;if(!t.requestId||t.format!=="png"&&t.format!=="jpeg")return;const e=LC(t.background,t.theme),r=t.fontStyle??"auto",n=t.fontSize??"lg",i=t.cardWidth??"wide",a=SV[i]||SV.wide,s=kV[e]||kV.aurora,o=Ur.view.dom.cloneNode(!0);o.removeAttribute("contenteditable"),o.querySelectorAll("button, [contenteditable='true']").forEach(g=>{g.removeAttribute("contenteditable"),g instanceof HTMLButtonElement&&g.remove()});const l=o.innerHTML,u=document.createElement("div");u.style.cssText=`position:fixed;left:-100000px;top:0;width:${a}px;pointer-events:none;`;const h=document.createElement("style");h.textContent=Fse({theme:e,fontStyle:r,fontSize:n,cardWidth:i});const d=MJe({title:t.title||t.fallbackTitle,notebook:t.notebook,tags:t.tags,updatedAt:t.updatedAt,bodyHtml:l,theme:e,showTitle:t.showTitle??!0,showNotebook:t.showNotebook??!1,showTags:t.showTags??!1,showUpdatedAt:t.showUpdatedAt??!0,showBranding:t.branding??!0});u.appendChild(h),u.insertAdjacentHTML("beforeend",Line truncated
`)}:null},bWt=(t,e,r)=>{var f,p,g;const n=(f=t.storage.markdown)==null?void 0:f.manager,i=e.trim(),a=((p=n==null?void 0:n.parse)==null?void 0:p.call(n,i).content)??[{type:"text",text:i}];if(!r)return a;const s=i.replace(/\s*\n+\s*/g," "),o=(g=n==null?void 0:n.parse)==null?void 0:g.call(n,`${RF}${s}`).content,l=(o==null?void 0:o.length)===1?o[0]:null,u=(l==null?void 0:l.type)==="paragraph"?l.content??[]:[],h=u[0];if((h==null?void 0:h.type)!=="text"||typeof h.text!="string"||!h.text.startsWith(RF))return[{type:"text",text:s}];const d=h.text.slice(RF.length);return[...d?[{...h,text:d}]:[],...u.slice(1)]};function Ud(t){try{const e=JSON.stringify(kee(t.getJSON())),r=HRe(t);fs({type:"change",contentMarkdown:r,contentJson:e})}catch(e){fs({type:"error",message:e instanceof Error?e.message:String(e)})}}function vWt(t,e){const r=e.trim().toLocaleLowerCase();if(r.length===0)return[];const n=[];let i=null;t.state.doc.descendants((l,u)=>{if(!(!l.isText||!l.text)){i!==null&&u>i&&n.push({char:"\0",pos:-1});for(let h=0;h<l.text.length;h+=1)n.push({char:l.text[h]??"",pos:u+h});i=u+l.text.length}});const a=n.map(l=>l.char).join("").toLocaleLowerCase(),s=[];let o=a.indexOf(r);for(;o!==-1;){const l=n[o],u=n[o+r.length-1];l&&u&&l.pos>=0&&u.pos>=0&&s.push({from:l.pos,to:u.pos+1}),o=a.indexOf(r,o+r.length)}return s}const bpe=()=>Ur.isActive("taskItem")?"taskItem":"listItem";function VRe(t){var r;if(lM.classList.toggle("editor-mode",t),lM.innerHTML="",!t)return;const e=[{id:"undo",label:"↩",run:()=>Ur.chain().focus().undo().run()},{id:"redo",label:"↪",run:()=>Ur.chain().focus().redo().run()},{id:"image",label:"▧+",run:()=>fs({type:"pickImage"})},{id:"bold",label:"B",run:()=>Ur.chain().focus().toggleBold().run()},{id:"bullet",label:"•",run:()=>Ur.chain().focus().toggleBulletList().run()},{id:"task",label:"☑",run:()=>Ur.chain().focus().toggleTaskList().run()},{id:"indent",label:"⇥",run:()=>Ur.chain().focus().sinkListItem(bpe()).run()},{id:"outdent",label:"⇤",run:()=>Ur.chain().focus().liftListItem(bpe()).run()},{id:"quote",label:"❝",run:()=>Ur.chain().focus().toggleBlockquote().run()},{id:"hr",label:"—",run:()=>Ur.chain().focus().setHorizontalRule().run()}];for(const n of e){const i=document.createElement("button");i.type="button",i.textContent=n.label,i.dataset.action=n.id;const a={undo:{"zh-CN":"撤销","en-US":"Undo",ja:"元に戻す",pl:"Cofnij"},redo:{"zh-CN":"重做","en-US":"Redo",ja:"やり直す",pl:"Ponów"},image:{"zh-CN":"插入图片","en-US":"Insert image",ja:"画像を挿入",pl:"Wstaw obraz"},bold:{"zh-CN":"粗体","en-US":"Bold",ja:"太字",pl:"Pogrubienie"},bullet:{"zh-CN":"项目符号列表","en-US":"Bullet list",ja:"箇条書き",pl:"Lista punktowana"},task:{"zh-CN":"任务清单","en-US":"Task list",ja:"タスクリスト",pl:"Lista zadań"},indent:{"zh-CN":"增加列表缩进","en-US":"Increase list indent",ja:"リストのインデントを増やす",pl:"Zwiększ wcięcie listy"},outdent:{"zh-CN":"减少列表缩进","en-US":"Decrease list indent",ja:"リストのインデントを減らす",pl:"Zmniejsz wcięcie listy"},quote:{"zh-CN":"引用","en-US":"Block quote",ja:"引用",pl:"Cytat"},hr:{"zh-CN":"分隔线","en-US":"Horizontal rule",ja:"区切り線",pl:"Linia pozioma"}};i.setAttribute("aria-label",((r=a[n.id])==null?void 0:r[LL])??n.id),i.addEventListener("click",()=>{n.run(),n.id!=="image"&&Ud(Ur),a4()}),lM.appendChild(i)}a4()}function a4(){const t={bold:Ur.isActive("bold"),bullet:Ur.isActive("bulletList"),task:Ur.isActive("taskList"),quote:Ur.isActive("blockquote")},e=n=>{try{return Ur.can().chain().focus()[n]().run()}catch{return!1}},r={undo:e("undo"),redo:e("redo")};lM.querySelectorAll("button[data-action]").forEach(n=>{const i=n.dataset.action??"";n.classList.toggle("is-active",t[i]??!1),i in r&&(n.disabled=!r[i])})}async function IF(t="light"){Rs.querySelectorAll("[data-placeholder]").forEach(e=>{e.dataset.placeholder=jq}),BRe(Rs),await cWt(Rs),Da==="viewer"&&(!DL||!hWt(Rs,DL,t))&&(xte(),await uWt(Rs,t))}async function xWt(t){var p;if(!t.requestId||t.format!=="png"&&t.format!=="jpeg")return;const e=LC(t.background,t.theme),r=t.fontStyle??"auto",n=t.fontSize??"lg",i=t.cardWidth??"wide",a=SV[i]||SV.wide,s=kV[e]||kV.aurora,o=Ur.view.dom.cloneNode(!0);o.removeAttribute("contenteditable"),o.querySelectorAll("button, [contenteditable='true']").forEach(g=>{g.removeAttribute("contenteditable"),g instanceof HTMLButtonElement&&g.remove()});const l=o.innerHTML,u=document.createElement("div");u.style.cssText=`position:fixed;left:-100000px;top:0;width:${a}px;pointer-events:none;`;const h=document.createElement("style");h.textContent=Fse({theme:e,fontStyle:r,fontSize:n,cardWidth:i});const d=MJe({title:t.title||t.fallbackTitle,notebook:t.notebook,tags:t.tags,updatedAt:t.updatedAt,bodyHtml:l,theme:e,showTitle:t.showTitle??!0,showNotebook:t.showNotebook??!1,showTags:t.showTags??!1,showUpdatedAt:t.showUpdatedAt??!0,showBranding:t.branding??!0});u.appendChild(h),u.insertAdjacentHTML("beforeend",Line truncated
c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14
c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54
c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10
+3 -1
View File
@@ -1437,7 +1437,9 @@ const api: EdgeEverEditorAPI = {
const modeChanged = nextMode !== mode;
mode = nextMode;
locale = opts.locale === "en-US" || opts.locale === "ja" || opts.locale === "pl" ? opts.locale : "zh-CN";
editor.setEditable(mode === "editor");
// Configuring a reused WebView is not a document edit. TipTap otherwise emits
// an update for its old (often empty) body before native pushes the share seed.
editor.setEditable(mode === "editor", false);
setToolbarVisible(mode === "editor");
document.documentElement.dataset.theme = opts.theme || "light";
document.body.classList.toggle("viewer-mode", mode === "viewer");
@@ -4,6 +4,112 @@ import WebKit
/// Ensures the TipTap editor HTML is actually shipped in the app (not the plain-text fallback).
final class EditorBundleTests: XCTestCase {
@MainActor
func testSharedFallbackSurvivesEditorConfigurationAndReuse() async throws {
let url = try XCTUnwrap(TipTapWebView.Coordinator.packagedEditorHTMLURL())
let loaded = expectation(description: "editor bridge ready")
final class Loader: NSObject, WKScriptMessageHandler {
let loaded: XCTestExpectation
var changes: [[String: Any]] = []
init(_ loaded: XCTestExpectation) { self.loaded = loaded }
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any] else { return }
if body["type"] as? String == "ready" { loaded.fulfill() }
if body["type"] as? String == "change" { changes.append(body) }
}
}
let loader = Loader(loaded)
let config = WKWebViewConfiguration()
config.userContentController.add(loader, name: "edgeever")
defer { config.userContentController.removeScriptMessageHandler(forName: "edgeever") }
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: config)
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
await fulfillment(of: [loaded], timeout: 30)
for path in ["first", "second"] {
// This used to emit the previous body (empty on first use) to native,
// replacing the share seed even though no user edit had occurred.
_ = try await webView.evaluateJavaScript("window.EdgeEverEditor.configure({mode:'editor',locale:'en-US',theme:'light'}); true")
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertTrue(loader.changes.isEmpty, "configuration must not overwrite the native share seed")
let source = try XCTUnwrap(URL(string: "https://x.com/example/status/\(path)"))
let draft = WebClipper.fallback(source)
let decision = TipTapContentSource.resolve(
mode: .editor, documentJSON: MemoEditViewModel.emptyDocJSON, markdown: draft.contentMarkdown
)
XCTAssertFalse(decision.useJSON)
let acknowledged = try await webView.evaluateJavaScript(
TipTapResourceLoader.jsCall(fn: "setMarkdown", arg: decision.payload)
)
XCTAssertEqual(acknowledged as? Bool, true)
let snapshot = try await webView.evaluateJavaScript("({markdown:window.EdgeEverEditor.getMarkdown(),json:window.EdgeEverEditor.getDocument(),href:document.querySelector('.ProseMirror a')?.getAttribute('href')})") as? [String: String]
XCTAssertTrue(snapshot?["markdown"]?.contains(source.absoluteString) == true)
XCTAssertTrue(snapshot?["json"]?.contains(source.absoluteString) == true)
XCTAssertEqual(snapshot?["href"], source.absoluteString)
}
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertTrue(loader.changes.isEmpty, "native content pushes must not act as user edits")
}
@MainActor
func testContentPushRequiresBridgeAcknowledgement() async throws {
let webView = WKWebView()
let script = TipTapResourceLoader.jsCall(fn: "setMarkdown", arg: "Source: https://example.com")
let missing = try await webView.evaluateJavaScript(script)
XCTAssertEqual(missing as? Bool, false)
_ = try await webView.evaluateJavaScript("window.EdgeEverEditor = {setMarkdown(){throw new Error('load failed')}}; true")
let failed = try await webView.evaluateJavaScript(script)
XCTAssertEqual(failed as? Bool, false)
_ = try await webView.evaluateJavaScript("window.EdgeEverEditor.setMarkdown = value => {window.receivedMarkdown = value}; true")
let succeeded = try await webView.evaluateJavaScript(script)
XCTAssertEqual(succeeded as? Bool, true)
let received = try await webView.evaluateJavaScript("window.receivedMarkdown")
XCTAssertEqual(received as? String, "Source: https://example.com")
}
@MainActor
func testFailedContentPushCannotReplaceShareWithEmptySnapshot() async throws {
let runtime = SharedTipTapRuntime.editor
let host = TipTapHostView()
runtime.attach(to: host)
defer {
runtime.webView.evaluateJavaScript("if(window.savedEditorAPI){window.EdgeEverEditor=window.savedEditorAPI;delete window.savedEditorAPI;window.EdgeEverEditor.configure({mode:'editor',locale:'en-US',theme:'light'})}", completionHandler: nil)
runtime.detach(from: host)
}
for _ in 0..<100 {
if (try? await runtime.webView.evaluateJavaScript("!!window.EdgeEverEditor")) as? Bool == true { break }
try await Task.sleep(nanoseconds: 100_000_000)
}
_ = try await runtime.webView.evaluateJavaScript("""
window.savedEditorAPI = window.EdgeEverEditor;
window.EdgeEverEditor = {
configure(){},
setMarkdown(){throw new Error('content push failed')},
getMarkdown(){return ''},
getDocument(){return '{"type":"doc","content":[{"type":"paragraph"}]}'},
focusEnd(){}
}; true
""")
var readyCount = 0
var changeCount = 0
runtime.bind(TipTapSession(
mode: .editor,
documentJSON: MemoEditViewModel.emptyDocJSON,
markdown: "Source: https://example.com/shared",
baseURL: nil, token: nil, locale: "en-US", theme: "light", placeholder: "",
onChange: { _, _ in changeCount += 1 },
onBodyReady: { readyCount += 1 }
))
try await Task.sleep(nanoseconds: 100_000_000)
_ = try await runtime.webView.evaluateJavaScript("window.webkit.messageHandlers.edgeever.postMessage({type:'change',contentMarkdown:'',contentJson:'{\"type\":\"doc\",\"content\":[]}'}); true")
try await Task.sleep(nanoseconds: 100_000_000)
let snapshot = await runtime.snapshotContent()
XCTAssertNil(snapshot, "failed initialization must leave the native share body intact")
XCTAssertEqual(readyCount, 0)
XCTAssertEqual(changeCount, 0)
}
@MainActor
func testNativeImageBatchCreatesAnEditableGallery() async throws {
let url = try XCTUnwrap(TipTapWebView.Coordinator.packagedEditorHTMLURL())
@@ -2,6 +2,21 @@ import XCTest
@testable import EdgeEver
final class WebClipperTests: XCTestCase {
func testFetchFailuresAlwaysKeepSourceLink() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [ClipFailureProtocol.self]
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
for path in ["network-error", "forbidden", "empty-body"] {
let source = try XCTUnwrap(URL(string: "https://example.com/\(path)"))
let draft = await WebClipper.build(source, session: session)
XCTAssertTrue(draft.contentMarkdown.contains("[\(source.absoluteString)](\(source.absoluteString))"))
XCTAssertTrue(draft.contentMarkdown.contains(WebClipLabels.chinese.unavailableBody))
XCTAssertEqual(draft.tagsText, "web-clip")
}
}
@MainActor
func testSharedImagesResolveOnlyFilesInsideHandoffDirectory() throws {
let directory = FileManager.default.temporaryDirectory
@@ -148,3 +163,25 @@ final class WebClipperTests: XCTestCase {
XCTAssertTrue(draft.contentMarkdown.contains("本文を取り込めませんでした"))
}
}
private final class ClipFailureProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let url = request.url else { return }
if url.lastPathComponent == "network-error" {
client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
return
}
let response = HTTPURLResponse(
url: url,
statusCode: url.lastPathComponent == "forbidden" ? 403 : 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/html"]
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("<html><title>Shared post</title></html>".utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}