diff --git a/apps/ios/EdgeEver/Editor/SystemImagePicker.swift b/apps/ios/EdgeEver/Editor/SystemImagePicker.swift index 97a0b867..f22caf2a 100644 --- a/apps/ios/EdgeEver/Editor/SystemImagePicker.swift +++ b/apps/ios/EdgeEver/Editor/SystemImagePicker.swift @@ -10,6 +10,7 @@ enum ImagePickerResult { case cancelled case failed(String) case picked(Data, filename: String) + case pickedImages([(data: Data, filename: String)]) } enum CameraCaptureNextStep: Equatable { @@ -45,7 +46,8 @@ struct SystemImagePicker: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration(photoLibrary: .shared()) config.filter = .images - config.selectionLimit = 1 + config.selectionLimit = 20 + config.selection = .ordered config.preferredAssetRepresentationMode = .current let picker = PHPickerViewController(configuration: config) picker.delegate = context.coordinator @@ -68,18 +70,22 @@ struct SystemImagePicker: UIViewControllerRepresentable { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard !settled else { return } - guard let provider = results.first?.itemProvider else { + guard !results.isEmpty else { settled = true DispatchQueue.main.async { self.onFinish(.cancelled) } return } Task { do { - let (data, name) = try await Self.loadImage(from: provider) + var images: [(data: Data, filename: String)] = [] + for result in results { + let (data, name) = try await Self.loadImage(from: result.itemProvider) + images.append((data: data, filename: name)) + } await MainActor.run { guard !self.settled else { return } self.settled = true - self.onFinish(.picked(data, filename: name)) + self.onFinish(.pickedImages(images)) } } catch { await MainActor.run { diff --git a/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift b/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift index cef9f0ee..0380198c 100644 --- a/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift +++ b/apps/ios/EdgeEver/Editor/TipTapWarmPool.swift @@ -383,6 +383,24 @@ final class SharedTipTapRuntime: NSObject, WKScriptMessageHandler, WKNavigationD return true } + /// Group only successfully inserted images from this picker batch. + func groupImages(sources: [String]) async -> Bool { + guard ready, session?.mode == .editor, + let json = try? JSONEncoder().encode(sources) else { return false } + let encoded = json.base64EncodedString() + let js = """ + (function(){ + const bytes = Uint8Array.from(atob('\(encoded)'), c => c.charCodeAt(0)); + return window.EdgeEverEditor.groupImages(JSON.parse(new TextDecoder().decode(bytes))); + })(); + """ + return await withCheckedContinuation { continuation in + webView.evaluateJavaScript(js) { result, _ in + continuation.resume(returning: (result as? Bool) ?? false) + } + } + } + /// 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 } diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index ed24111f..09cb680c 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -39,6 +39,7 @@ struct MemoEditView: View { @State private var showTagPicker = false @State private var showImageSourcePicker = false @State private var imagePickerRoute: ImagePickerRoute? + @State private var isImportingImageBatch = false @State private var showCameraAccessAlert = false @State private var cameraAccessCanOpenSettings = false @State private var cameraAccessMessage = "" @@ -72,7 +73,7 @@ struct MemoEditView: View { private var isDirty: Bool { get { viewModel.isDirty } nonmutating set { viewModel.isDirty = newValue } } private var isSaving: Bool { get { viewModel.isSaving } nonmutating set { viewModel.isSaving = newValue } } private var isCreating: Bool { get { viewModel.isCreating } nonmutating set { viewModel.isCreating = newValue } } - private var isUploading: Bool { get { viewModel.isUploading } nonmutating set { viewModel.isUploading = newValue } } + private var isUploading: Bool { get { viewModel.isUploading || isImportingImageBatch } nonmutating set { viewModel.isUploading = newValue } } private var editorReady: Bool { get { viewModel.editorReady } nonmutating set { viewModel.editorReady = newValue } } private var suppressPersistence: Bool { get { viewModel.suppressPersistence } nonmutating set { viewModel.suppressPersistence = newValue } } private var contentHydrated: Bool { get { viewModel.contentHydrated } nonmutating set { viewModel.contentHydrated = newValue } } @@ -677,6 +678,8 @@ struct MemoEditView: View { showUploadError = true case .picked(let data, let filename): Task { _ = await insertImageData(data, filename: filename) } + case .pickedImages(let images): + Task { await insertImageBatch(images) } } } @@ -1190,7 +1193,27 @@ struct MemoEditView: View { } /// Upload bytes from the system PHPicker and insert into TipTap. - private func insertImageData(_ data: Data, filename: String) async -> Bool { + private func insertImageBatch(_ images: [(data: Data, filename: String)]) async { + guard !isUploading, !images.isEmpty else { return } + isImportingImageBatch = true + var sources: [String] = [] + for image in images { + let succeeded = await insertImageData(image.data, filename: image.filename) { sources.append($0) } + if !succeeded { break } + } + if sources.count > 1 { + _ = await SharedTipTapRuntime.editor.groupImages(sources: sources) + await pullEditorSnapshotIfPossible() + } + isImportingImageBatch = false + if !sources.isEmpty { + editGeneration &+= 1 + isDirty = true + await drainPendingSave() + } + } + + private func insertImageData(_ data: Data, filename: String, onInserted: ((String) -> Void)? = nil) async -> Bool { let succeeded = await viewModel.performUpload { NSLog("MemoEditView insertImageData: start bytes=%d name=%@", data.count, filename) let compress = env.preferences.useCompression @@ -1242,6 +1265,7 @@ struct MemoEditView: View { ) ) } + onInserted?(imageSrc) // Snapshot TipTap JSON (order is authoritative). Only inject if the resource // is truly missing — never append a second image node at document end. await pullEditorSnapshotIfPossible() diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/index.html b/apps/ios/EdgeEver/Resources/EditorBundle/index.html index 87c07e65..24029d68 100644 --- a/apps/ios/EdgeEver/Resources/EditorBundle/index.html +++ b/apps/ios/EdgeEver/Resources/EditorBundle/index.html @@ -7,38 +7,32 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
'+(n?a:bh(a,!0))+`
`:""+(n?a:bh(a,!0))+`
`}blockquote({tokens:e}){return`@@ -215,19 +215,19 @@ ${this.parser.parse(e)}`}tablerow({text:e}){return`
${bh(e,!0)}`}br(e){return"An error occurred:
"+bh(n.message+"",!0)+"";return r?Promise.resolve(i):i}if(r)return Promise.reject(n);throw n}}},F1=new uit;function oi(t,e){return F1.parse(t,e)}oi.options=oi.setOptions=function(t){return F1.setOptions(t),oi.defaults=F1.defaults,mxe(oi.defaults),oi};oi.getDefaults=jX;oi.defaults=Ty;oi.use=function(...t){return F1.use(...t),oi.defaults=F1.defaults,mxe(oi.defaults),oi};oi.walkTokens=function(t,e){return F1.walkTokens(t,e)};oi.parseInline=F1.parseInline;oi.Parser=Lu;oi.parser=Lu.parse;oi.Renderer=p9;oi.TextRenderer=rK;oi.Lexer=Mu;oi.lexer=Mu.lex;oi.Tokenizer=f9;oi.Hooks=m5;oi.parse=oi;oi.options;oi.setOptions;oi.use;oi.walkTokens;oi.parseInline;Lu.parse;Mu.lex;const hit=/\n[^\S\n]*(?:\n[^\S\n]*)+$/;function dit(t){return t.flatMap((e,r)=>{var n;if(e.type==="space"||((n=t[r+1])===null||n===void 0?void 0:n.type)==="space")return[e];const i=(e.raw||"").match(hit);return i?[{...e,raw:(e.raw||"").slice(0,-i[0].length)},{type:"space",raw:i[0]}]:[e]})}function fit(t,e){const r=e.split(` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`
${bh(e,!0)}`}br(e){return"An error occurred:
"+bh(n.message+"",!0)+"";return r?Promise.resolve(i):i}if(r)return Promise.reject(n);throw n}}},F1=new fit;function oi(t,e){return F1.parse(t,e)}oi.options=oi.setOptions=function(t){return F1.setOptions(t),oi.defaults=F1.defaults,yxe(oi.defaults),oi};oi.getDefaults=KX;oi.defaults=Ty;oi.use=function(...t){return F1.use(...t),oi.defaults=F1.defaults,yxe(oi.defaults),oi};oi.walkTokens=function(t,e){return F1.walkTokens(t,e)};oi.parseInline=F1.parseInline;oi.Parser=Lu;oi.parser=Lu.parse;oi.Renderer=g9;oi.TextRenderer=iK;oi.Lexer=Mu;oi.lexer=Mu.lex;oi.Tokenizer=p9;oi.Hooks=m5;oi.parse=oi;oi.options;oi.setOptions;oi.use;oi.walkTokens;oi.parseInline;Lu.parse;Mu.lex;const pit=/\n[^\S\n]*(?:\n[^\S\n]*)+$/;function git(t){return t.flatMap((e,r)=>{var n;if(e.type==="space"||((n=t[r+1])===null||n===void 0?void 0:n.type)==="space")return[e];const i=(e.raw||"").match(pit);return i?[{...e,raw:(e.raw||"").slice(0,-i[0].length)},{type:"space",raw:i[0]}]:[e]})}function mit(t,e){const r=e.split(` `).flatMap(n=>[n,""]).map(n=>`${t}${n}`).join(` -`);return r.slice(0,r.length-1)}function Cse(t,e){const r=[];return Array.from(t.entries()).forEach(([n,i])=>{if(!e){r.push(n);return}(e.marks||[]).find(a=>a.type===n&&wT(a.attrs,i.attrs))||r.push(n)}),r}function pit(t,e){const r=[];return Array.from(e.entries()).forEach(([n,i])=>{const a=t.get(n);(!a||!wT(a.attrs,i.attrs))&&r.push({type:n,mark:i})}),r}function git(t,e,r,n){const i=!r,a=r&&(!r.marks||r.marks.length===0),s=r&&r.marks&&!n(e,new Map(r.marks.map(l=>[l.type,l]))),o=[];return(i||a||s)&&(r&&r.marks?Array.from(t.entries()).reverse().forEach(([l,u])=>{r.marks.find(h=>h.type===l&&wT(h.attrs,u.attrs))||o.push(l)}):(i||a)&&o.push(...Array.from(t.keys()).reverse())),o}function mit(t,e){let r="";return Array.from(t.keys()).reverse().forEach(n=>{const i=e(n,t.get(n));i&&(r=i+r)}),t.clear(),r}function yit(t,e,r){let n="";return Array.from(t.entries()).forEach(([i,a])=>{const s=r(i,a);s&&(n+=s),e.set(i,a)}),n}function pP(t){const e=(t.raw||t.text||"").match(/^(\s*)[-+*]\s+\[([ xX])\]\s+/);return e?{isTask:!0,checked:e[2].toLowerCase()==="x",indentLevel:e[1].length}:{isTask:!1,indentLevel:0}}function H7(t,e){return typeof t!="string"?"json":e}const vit=new Set(["a","abbr","address","area","article","aside","audio","b","base","bdi","bdo","blockquote","body","br","button","canvas","caption","cite","code","col","colgroup","data","datalist","dd","del","details","dfn","dialog","div","dl","dt","em","embed","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","iframe","img","input","ins","kbd","label","legend","li","link","main","map","mark","menu","meta","meter","nav","noscript","object","ol","optgroup","option","output","p","param","picture","pre","progress","q","rp","rt","ruby","s","samp","script","search","section","select","slot","small","source","span","strong","style","sub","summary","sup","svg","circle","clippath","defs","ellipse","foreignobject","g","image","line","lineargradient","mask","path","polygon","polyline","radialgradient","rect","stop","switch","symbol","textpath","tspan","use","table","tbody","td","template","textarea","tfoot","th","thead","time","title","tr","track","u","ul","var","video","wbr"]),bit=/<\/?([a-zA-Z][\w-]*)/g;function xit(t){const e=[];let r;for(;(r=bit.exec(t))!==null;)e.push(r[1].toLowerCase());return e}function wit(t){const e=t.toLowerCase();return e.includes("-")?!1:!vit.has(e)}function Tit(t,e){return xit(t).some(r=>wit(r)?!e.has(r):!1)}var Sse=class{constructor(e){var r,n,i,a,s;this.activeParseLexer=null,this.extensionRanks=new Map,this.baseExtensions=[],this.extensions=[],this.codeTypes=new Set,this.schemaParseDomTagsCache=null,this.inlineNodeTypesCache=null,this.lastParseResult=null,this.markedInstance=(r=e==null?void 0:e.marked)!==null&&r!==void 0?r:oi,this.indentStyle=(n=e==null||(i=e.indentation)===null||i===void 0?void 0:i.style)!==null&&n!==void 0?n:"space",this.indentSize=(a=e==null||(s=e.indentation)===null||s===void 0?void 0:s.size)!==null&&a!==void 0?a:2,this.baseExtensions=(e==null?void 0:e.extensions)||[],e!=null&&e.markedOptions&&typeof this.markedInstance.setOptions=="function"&&this.markedInstance.setOptions(e.markedOptions),this.registry=new Map,this.nodeTypeRegistry=new Map,e!=null&&e.extensions&&(this.baseExtensions=e.extensions,nb(s$(e.extensions)).forEach(o=>this.registerExtension(o)))}get instance(){return this.markedInstance}get indentCharacter(){return this.indentStyle==="space"?" ":" "}get indentString(){return this.indentCharacter.repeat(this.indentSize)}hasMarked(){return!!this.markedInstance}registerExtension(e){var r,n;this.extensions.push(e);const i=qn(dr(e,"code")),a=e.name;i&&this.codeTypes.add(a),this.extensionRanks.has(a)||this.extensionRanks.set(a,this.extensionRanks.size);const s=dr(e,"markdownTokenName")||a,o=dr(e,"parseMarkdown"),l=dr(e,"renderMarkdown"),u=dr(e,"markdownTokenizer"),h=(r=dr(e,"markdownOptions"))!==null&&r!==void 0?r:null,d={tokenName:s,nodeName:a,parseMarkdown:o,renderMarkdown:l,isIndenting:(n=h==null?void 0:h.indentsContent)!==null&&n!==void 0?n:!1,htmlReopen:h==null?void 0:h.htmlReopen,tokenizer:u};if(s&&o){const f=this.registry.get(s)||[];f.push(d),this.registry.set(s,f)}if(l){const f=this.nodeTypeRegistry.get(a)||[];f.push(d),this.nodeTypeRegistry.set(a,f)}u&&this.hasMarked()&&this.registerTokenizer(u)}createLexer(){return new this.markedInstance.Lexer(this.markedInstance.defaults)}createTokenizerHelpers(e){return{inlineTokens:r=>e.inlineTokens(r),blockTokens:r=>e.blockTokens(r)}}tokenizeInline(e){var r;return((r=this.activeParseLexer)!==null&&r!==void 0?r:this.createLexer()).inlineTokens(e)}registerTokenizer(e){if(!this.hasMarked())return;const{name:r,start:n,level:i="inline",tokenize:a}=e,s=this.createTokenizerHelpers.bind(this),o=this.createLexer.bind(this);let l;n?l=typeof n=="function"?n:h=>h.indexOf(n):l=h=>{const d=a(h,[],this.createTokenizerHelpers(this.createLexer()));return d&&d.raw?h.indexOf(d.raw):-1};const u={name:r,level:i,start:l,tokenizer(h,d){const f=this.lexer?s(this.lexer):s(o()),p=a(h,d,f);if(p&&p.type)return{...p,type:p.type||r,raw:p.raw||"",tokens:p.tokens||[]}},childTokens:[]};this.markedInstance.use({extensions:[u]})}getHandlersForToken(e){try{return this.registry.get(e)||[]}catch{return[]}}getHandlerForToken(e){const r=this.getHandlersForToken(e);if(r.length>0)return r[0];const n=this.getHandlersForNodeType(e);return n.length>0?n[0]:void 0}getHandlersForNodeType(e){try{return this.nodeTypeRegistry.get(e)||[]}catch{return[]}}serialize(e){if(!e)return"";const r=this.renderNodes(e,e);return this.isEmptyOutput(r)?"":r}isEmptyOutput(e){return!e||e.trim()===""?!0:e.replace(/ /g,"").replace(/\u00A0/g,"").trim()===""}parse(e){if(!this.hasMarked())throw new Error("No marked instance available for parsing");const r=this.activeParseLexer,n=this.createLexer();this.activeParseLexer=n;try{const i=n.lex(e);return{type:"doc",content:this.parseTokens(i,!0)}}finally{this.activeParseLexer=r}}parseTokens(e,r=!1){const n=r?dit(e):e,i=n.reduce((o,l,u)=>(l.type!=="space"&&o.push(u),o),[]);let a=-1,s=0;return n.flatMap((o,l)=>{for(;s