fix(ios): preserve shared clip body during editor initialization (#601)
This commit is contained in:
1 parent
df22f55d1c
commit
dca7757caa
9 files changed
+183
-28
No files matched your search
@@ -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\"}]}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
Reference in new issue
Block a user