From be5b1eaeba61f461d17e4d43cf192aaae11ba118 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 24 Sep 2026 13:09:38 +0800 Subject: [PATCH] fix(editor): preserve empty links and paste resources in Markdown mode --- .../Resources/EditorBundle/index.html | 836 +++++++++--------- apps/ios/EditorBundle/index.html | 836 +++++++++--------- apps/ios/EditorSource/src/main.ts | 2 - apps/web/src/components/EditorPane.tsx | 103 ++- .../editor/MarkdownSourceEditor.tsx | 52 +- .../src/lib/markdown-resource-paste.test.mjs | 11 + apps/web/src/lib/markdown-resource-paste.ts | 9 + apps/web/src/styles/globals.css | 33 + packages/shared/src/content.test.mjs | 17 + packages/shared/src/content.ts | 3 + packages/shared/src/document-extensions.ts | 8 +- packages/shared/src/editor-link.ts | 13 + packages/shared/src/empty-external-link.ts | 54 ++ .../src/mobile-content-compatibility.test.mjs | 12 + .../src/mobile-content-compatibility.ts | 2 + 15 files changed, 1118 insertions(+), 873 deletions(-) create mode 100644 apps/web/src/lib/markdown-resource-paste.test.mjs create mode 100644 apps/web/src/lib/markdown-resource-paste.ts create mode 100644 packages/shared/src/empty-external-link.ts diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/index.html b/apps/ios/EdgeEver/Resources/EditorBundle/index.html index 5338da09..a8905476 100644 --- a/apps/ios/EdgeEver/Resources/EditorBundle/index.html +++ b/apps/ios/EdgeEver/Resources/EditorBundle/index.html @@ -7,14 +7,14 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> EdgeEver Editor - +`},"getStyles"),RT,nu=(RT=class{constructor(){this.d=""}moveTo(e,r){return this.d+=`M ${e} ${r} `,this}lineTo(e,r){return this.d+=`L ${e} ${r} `,this}horizontalTo(e){return this.d+=`H ${e} `,this}verticalTo(e){return this.d+=`V ${e} `,this}arcTo(e,r,n,i,s,a,o){return this.d+=`A ${e} ${r} ${n} ${i?1:0} ${s?1:0} ${a} ${o} `,this}build(){return this.d.trim()}},w(RT,"PathBuilder"),RT),_T,Nxr=(_T=class{constructor(e,r=aie()){this.textCache=new Map,this.svg=e,this.config=r}measureText(e){if(this.textCache.has(e))return this.textCache.get(e);const r=this.svg.append("text").attr("font-family",this.config.fontFamily).attr("font-size",this.config.fontSize).text(e),n=r.node().getBBox(),i={width:n.width,height:n.height};return r.remove(),this.textCache.set(e,i),i}renderTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,s=n.height+this.config.padding*2,a=e.append("g").attr("class","railroad-terminal");return a.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",s).attr("rx",10).attr("ry",10),a.append("text").attr("x",i/2).attr("y",s/2).text(r),{element:a.node(),dimensions:{width:i,height:s,up:s/2,down:s/2}}}renderNonTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,s=n.height+this.config.padding*2,a=e.append("g").attr("class","railroad-nonterminal");return a.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",s),a.append("text").attr("x",i/2).attr("y",s/2).text(r),{element:a.node(),dimensions:{width:i,height:s,up:s/2,down:s/2}}}renderSequence(e,r){const n=r.map(u=>this.renderExpression(e,u));let i=0,s=0,a=0;for(const u of n)i+=u.dimensions.width,s=Math.max(s,u.dimensions.up),a=Math.max(a,u.dimensions.down);i+=(n.length-1)*this.config.horizontalSeparation;const o=e.append("g").attr("class","railroad-sequence");let l=0;for(let u=0;uthis.renderExpression(e,f));let i=0,s=0;for(const f of n)i=Math.max(i,f.dimensions.width),s+=f.dimensions.height;s+=(n.length-1)*this.config.verticalSeparation;const a=this.config.arcRadius,o=a*4,l=i+o,u=e.append("g").attr("class","railroad-choice");let h=0;const d=s/2;for(const f of n){const p=h,g=p+f.dimensions.up,m=a*2+(i-f.dimensions.width)/2;u.node().appendChild(f.element).setAttribute("transform",`translate(${m}, ${p})`);const v=new nu,x=g>d;g===d?v.moveTo(0,d).lineTo(m,g):v.moveTo(0,d).arcTo(a,a,0,!1,x,a,d+(x?a:-a)).lineTo(a,g-(x?a:-a)).arcTo(a,a,0,!1,!x,a*2,g).lineTo(m,g),u.append("path").attr("class","railroad-line").attr("d",v.build());const b=new nu,T=m+f.dimensions.width,C=l-a*2;g===d?b.moveTo(T,g).lineTo(l,d):b.moveTo(T,g).lineTo(C,g).arcTo(a,a,0,!1,!x,l-a,g+(x?-a:a)).lineTo(l-a,d+(x?a:-a)).arcTo(a,a,0,!1,x,l,d),u.append("path").attr("class","railroad-line").attr("d",b.build()),h+=f.dimensions.height+this.config.verticalSeparation}return{element:u.node(),dimensions:{width:l,height:s,up:d,down:s-d}}}renderOptional(e,r){const n=this.renderExpression(e,r),i=this.config.arcRadius,s=i*2,a=n.dimensions.width+i*4,o=n.dimensions.height+s,l=e.append("g").attr("class","railroad-optional"),u=i*2,h=s;l.node().appendChild(n.element).setAttribute("transform",`translate(${u}, ${h})`);const f=h+n.dimensions.up,p=new nu().moveTo(0,f).lineTo(i*2,f);l.append("path").attr("class","railroad-line").attr("d",p.build());const g=new nu().moveTo(u+n.dimensions.width,f).lineTo(a,f);l.append("path").attr("class","railroad-line").attr("d",g.build());const m=new nu().moveTo(0,f).arcTo(i,i,0,!1,!1,i,f-i).lineTo(i,i).arcTo(i,i,0,!1,!0,i*2,0).lineTo(a-i*2,0).arcTo(i,i,0,!1,!0,a-i,i).lineTo(a-i,f-i).arcTo(i,i,0,!1,!1,a,f);return l.append("path").attr("class","railroad-line").attr("d",m.build()),{element:l.node(),dimensions:{width:a,height:o,up:f,down:o-f}}}renderRepetition(e,r,n){const i=this.renderExpression(e,r),s=this.config.arcRadius,a=s*2,o=i.dimensions.width+s*4,l=n===0,u=i.dimensions.height+a+(l?a:0),h=e.append("g").attr("class","railroad-repetition"),d=s*2,f=l?a:0;h.node().appendChild(i.element).setAttribute("transform",`translate(${d}, ${f})`);const g=f+i.dimensions.up;h.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(0,g).lineTo(s*2,g).build()),h.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(d+i.dimensions.width,g).lineTo(o,g).build());const m=f+i.dimensions.height+s,y=new nu().moveTo(d+i.dimensions.width,g).arcTo(s,s,0,!1,!0,d+i.dimensions.width+s,g+s).lineTo(d+i.dimensions.width+s,m).arcTo(s,s,0,!1,!0,d+i.dimensions.width,m+s).lineTo(s*2,m+s).arcTo(s,s,0,!1,!0,s,m).lineTo(s,g+s).arcTo(s,s,0,!1,!0,s*2,g);if(h.append("path").attr("class","railroad-line").attr("d",y.build()),l){const v=new nu().moveTo(0,g).arcTo(s,s,0,!1,!1,s,g-s).lineTo(s,s).arcTo(s,s,0,!1,!0,s*2,0).lineTo(o-s*2,0).arcTo(s,s,0,!1,!0,o-s,s).lineTo(o-s,g-s).arcTo(s,s,0,!1,!1,o,g);h.append("path").attr("class","railroad-line").attr("d",v.build())}return{element:h.node(),dimensions:{width:o,height:u,up:g,down:u-g}}}renderSpecial(e,r){const n=this.measureText("? "+r+" ?"),i=n.width+this.config.padding*2,s=n.height+this.config.padding*2,a=e.append("g").attr("class","railroad-special");return a.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",s),a.append("text").attr("x",i/2).attr("y",s/2).text("? "+r+" ?"),{element:a.node(),dimensions:{width:i,height:s,up:s/2,down:s/2}}}renderExpression(e,r){switch(r.type){case"terminal":return this.renderTerminal(e,r.value);case"nonterminal":return this.renderNonTerminal(e,r.name);case"sequence":return this.renderSequence(e,r.elements);case"choice":return this.renderChoice(e,r.alternatives);case"optional":return this.renderOptional(e,r.element);case"repetition":return this.renderRepetition(e,r.element,r.min);case"special":return this.renderSpecial(e,r.text);default:throw new Error(`Unknown node type: ${r.type}`)}}renderRule(e,r){const n=this.svg.append("g").attr("class","railroad-rule").attr("transform",`translate(0, ${r})`),i=e.name+" =",s=this.measureText(i).width+20,a=s+20,o=n.append("g"),l=this.renderExpression(o,e.definition),u=Math.max(20,l.dimensions.up),h=u-l.dimensions.up;return o.attr("transform",`translate(${a}, ${h})`),n.append("g").attr("class","railroad-rule-name-group").append("text").attr("class","railroad-rule-name").attr("x",0).attr("y",u).text(i),n.append("g").attr("class","railroad-start").append("circle").attr("cx",s).attr("cy",u).attr("r",this.config.markerRadius),n.append("g").attr("class","railroad-end").append("circle").attr("cx",a+l.dimensions.width+10).attr("cy",u).attr("r",this.config.markerRadius),n.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(s+this.config.markerRadius,u).lineTo(a,u).build()),n.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(a+l.dimensions.width,u).lineTo(a+l.dimensions.width+10-this.config.markerRadius,u).build()),{height:Math.max(40,h+l.dimensions.height+this.config.padding*2),width:a+l.dimensions.width+10+this.config.markerRadius}}renderDiagram(e){let r=this.config.padding,n=0;for(const i of e){const s=this.renderRule(i,r);r+=s.height+this.config.verticalSeparation,n=Math.max(n,s.width)}return{width:n+this.config.padding*2,height:r+this.config.padding}}},w(_T,"RailroadRenderer"),_T),Vye=w((t,e,r)=>{Fs(t,e.height,e.width,r),t.attr("viewBox",`0 0 ${e.width} ${e.height}`)},"configureRailroadSvgSize"),Dxr=w((t,e,r)=>{de.debug(`[Railroad] Rendering diagram +`+t);try{const n=Xo(e);n.attr("class","railroad-diagram");const i=vr().railroad,s=(i==null?void 0:i.useMaxWidth)??!0,a=Qi.getRules();if(de.debug(`[Railroad] Rendering ${a.length} rules`),a.length===0){de.warn("[Railroad] No rules to render"),Vye(n,{height:100,width:200},s);return}const l=new Nxr(n,aie()).renderDiagram(a);Vye(n,l,s),de.debug("[Railroad] Render complete")}catch(n){throw de.error("[Railroad] Render error:",n),n}},"draw"),BP={draw:Dxr},Oxr=nne().Railroad.parser.LangiumParser,q2=w(t=>{switch(t.$type){case"RailroadTerminalExpr":return{type:"terminal",value:t.value};case"RailroadNonTerminalExpr":return{type:"nonterminal",name:t.name};case"RailroadSpecialExpr":return{type:"special",text:t.text};case"RailroadSequenceExpr":{const e=t.elements.map(q2);return e.length===1?e[0]:{type:"sequence",elements:e}}case"RailroadChoiceExpr":{const e=t.alternatives.map(q2);return e.length===1?e[0]:{type:"choice",alternatives:e}}case"RailroadOptionalExpr":return{type:"optional",element:q2(t.element)};case"RailroadOneOrMoreExpr":return{type:"repetition",element:q2(t.element),min:1,max:1/0};case"RailroadZeroOrMoreExpr":return{type:"repetition",element:q2(t.element),min:0,max:1/0};default:throw new Error(`Unsupported railroad expression: ${t.$type}`)}},"transformExpression"),Pxr=w(t=>({name:t.name,definition:q2(t.definition)}),"transformRule"),$xr=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(Pxr(e)))},"populateDb"),Bxr={parse:w(t=>{Qi.clear(),de.debug("[Railroad Parser] Starting Langium parse");const e=Oxr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[Railroad Parser] Parsed rules:",r.rules.length),$xr(r),de.debug("[Railroad Parser] Parse complete")},"parse"),parser:{yy:Qi}},Fxr={parser:Bxr,db:Qi,renderer:BP,styles:$P};const zxr=Object.freeze(Object.defineProperty({__proto__:null,diagram:Fxr},Symbol.toStringTag,{value:"Module"}));var Vxr=ine().RailroadEbnf.parser.LangiumParser,XR=w(t=>{const e=t.alternatives.map(Hxr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformChoice"),Hxr=w(t=>{const e=t.elements.map(Wxr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),hje=w(t=>{switch(t.$type){case"EbnfTerminal":return{type:"terminal",value:t.value};case"EbnfNonTerminal":return{type:"nonterminal",name:t.name};case"EbnfSpecial":return{type:"special",text:t.text};case"EbnfGroup":return XR(t.element);case"EbnfOptional":return{type:"optional",element:XR(t.element)};case"EbnfRepetition":return{type:"repetition",element:XR(t.element),min:0,max:1/0};default:throw new Error(`Unsupported EBNF primary node: ${t.$type}`)}},"transformPrimary"),Gxr=w((t,e)=>{switch(e.$type){case"EbnfOptionalPostfix":return{type:"optional",element:t};case"EbnfZeroOrMorePostfix":return{type:"repetition",element:t,min:0,max:1/0};case"EbnfOneOrMorePostfix":return{type:"repetition",element:t,min:1,max:1/0};case"EbnfExceptionPostfix":return{type:"sequence",elements:[t,{type:"terminal",value:"-"},hje(e.except)]};default:throw new Error(`Unsupported EBNF postfix node: ${e.$type}`)}},"transformPostfix"),Wxr=w(t=>t.postfixes.reduce((e,r)=>Gxr(e,r),hje(t.base)),"transformTerm"),qxr=w(t=>({name:t.name,definition:XR(t.definition)}),"transformRule"),Uxr=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(qxr(e)))},"populateDb"),Yxr={parse:w(t=>{Qi.clear(),de.debug("[EBNF Parser] Starting Langium parse");const e=Vxr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[EBNF Parser] Parsed rules:",r.rules.length),Uxr(r),de.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:Qi}},jxr={parser:Yxr,db:Qi,renderer:BP,styles:$P};const Xxr=Object.freeze(Object.defineProperty({__proto__:null,diagram:jxr},Symbol.toStringTag,{value:"Module"}));var Kxr=sne().RailroadAbnf.parser.LangiumParser,jj=w(t=>{const e=t.alternatives.map(Zxr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformAlternation"),Zxr=w(t=>{const e=t.elements.map(Jxr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformConcatenation"),Qxr=w(t=>{if(t.includes("*")){const[r,n]=t.split("*"),i=r?parseInt(r,10):0,s=n?parseInt(n,10):1/0;return{min:i,max:s}}const e=parseInt(t,10);return{min:e,max:e}},"parseRepeat"),Jxr=w(t=>{const e=e4r(t.primary);if(!t.repeat)return e;const{min:r,max:n}=Qxr(t.repeat);return r===0&&n===1?{type:"optional",element:e}:{type:"repetition",element:e,min:r,max:n}},"transformElement"),e4r=w(t=>{switch(t.$type){case"AbnfStringLiteral":return{type:"terminal",value:t.value};case"AbnfNumVal":return{type:"terminal",value:t.value};case"AbnfRuleName":return{type:"nonterminal",name:t.name};case"AbnfGroup":return jj(t.element);case"AbnfOptionalGroup":return{type:"optional",element:jj(t.element)};default:throw new Error(`Unsupported ABNF primary node: ${t.$type}`)}},"transformPrimary"),t4r=w(t=>({name:t.name,definition:jj(t.definition)}),"transformRule"),r4r=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(t4r(e)))},"populateDb"),n4r={parse:w(t=>{Qi.clear(),de.debug("[ABNF Parser] Starting Langium parse");const e=Kxr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[ABNF Parser] Parsed rules:",r.rules.length),r4r(r),de.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:Qi}},i4r={parser:n4r,db:Qi,renderer:BP,styles:$P};const s4r=Object.freeze(Object.defineProperty({__proto__:null,diagram:i4r},Symbol.toStringTag,{value:"Module"}));var a4r=ane().RailroadPeg.parser.LangiumParser,dje=w(t=>{const e=t.alternatives.map(o4r);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformOrderedChoice"),o4r=w(t=>{const e=t.elements.map(l4r);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),l4r=w(t=>{const e=c4r(t.suffix);return t.operator?{type:"special",text:t.operator==="&"?`&${Hye(e)}`:`!${Hye(e)}`}:e},"transformPrefix"),Hye=w(t=>{switch(t.type){case"terminal":return`"${t.value}"`;case"nonterminal":return t.name;case"special":return t.text;default:return"(...)"}},"nodeToLabel"),c4r=w(t=>{const e=u4r(t.primary);if(!t.operator)return e;switch(t.operator){case"?":return{type:"optional",element:e};case"*":return{type:"repetition",element:e,min:0,max:1/0};case"+":return{type:"repetition",element:e,min:1,max:1/0};default:throw new Error(`Unsupported PEG suffix operator: ${t.operator}`)}},"transformSuffix"),u4r=w(t=>{switch(t.$type){case"PegLiteral":return{type:"terminal",value:t.value};case"PegIdentifier":return{type:"nonterminal",name:t.name};case"PegGroup":return dje(t.element);case"PegAny":return{type:"special",text:t.dot};default:throw new Error(`Unsupported PEG primary node: ${t.$type}`)}},"transformPrimary"),h4r=w(t=>({name:t.name,definition:dje(t.definition)}),"transformRule"),d4r=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(h4r(e)))},"populateDb"),f4r={parse:w(t=>{Qi.clear(),de.debug("[PEG Parser] Starting Langium parse");const e=a4r.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[PEG Parser] Parsed rules:",r.rules.length),d4r(r),de.debug("[PEG Parser] Parse complete")},"parse"),parser:{yy:Qi}},p4r={parser:f4r,db:Qi,renderer:BP,styles:$P};const g4r=Object.freeze(Object.defineProperty({__proto__:null,diagram:p4r},Symbol.toStringTag,{value:"Module"}));var m4r=1;function oie(){if(!(typeof globalThis>"u"))return globalThis}w(oie,"getCaptureGlobal");function fje(){var t;return!!((t=oie())!=null&&t.mermaidCaptureSizes)}w(fje,"shouldCaptureSizes");function pje(){return typeof location>"u"?"browser-dev":`${location.pathname}${location.search}`}w(pje,"capturedFromLocation");function gje(t,e){const r=oie();if(!r)return;const n=e.node(),i=(n&&"ownerSVGElement"in n?n.ownerSVGElement:null)??n,s=(i==null?void 0:i.id)??"(unknown)";r.mermaidCapturedSizes??(r.mermaidCapturedSizes=[]);const a={svgId:s,sizes:t};r.mermaidCapturedSizes.push(a),r.mermaidLastCapturedSizes=a}w(gje,"emitCapturedSizes");function mje(t,e){const r=[];for(const n of e.nodes)n.isGroup||r.push({id:n.id,width:n.width??0,height:n.height??0});r.length!==0&&gje({metadata:{captureVersion:m4r,capturedAt:new Date().toISOString(),capturedFrom:pje()},nodes:r},t)}w(mje,"captureNodeSizes");const y4r=Object.freeze(Object.defineProperty({__proto__:null,captureNodeSizes:mje,shouldCaptureSizes:fje},Symbol.toStringTag,{value:"Module"})),v4r=Object.freeze(Object.defineProperty({__proto__:null,InfoModule:_Ge,createInfoServices:LGe},Symbol.toStringTag,{value:"Module"})),b4r=Object.freeze(Object.defineProperty({__proto__:null,PacketModule:IGe,createPacketServices:NGe},Symbol.toStringTag,{value:"Module"})),x4r=Object.freeze(Object.defineProperty({__proto__:null,PieModule:DGe,createPieServices:OGe},Symbol.toStringTag,{value:"Module"})),w4r=Object.freeze(Object.defineProperty({__proto__:null,TreeViewModule:PGe,createTreeViewServices:$Ge},Symbol.toStringTag,{value:"Module"})),T4r=Object.freeze(Object.defineProperty({__proto__:null,ArchitectureModule:BGe,createArchitectureServices:FGe},Symbol.toStringTag,{value:"Module"})),C4r=Object.freeze(Object.defineProperty({__proto__:null,GitGraphModule:MGe,createGitGraphServices:RGe},Symbol.toStringTag,{value:"Module"})),k4r=Object.freeze(Object.defineProperty({__proto__:null,EventModelingModule:VGe,createEventModelingServices:HGe},Symbol.toStringTag,{value:"Module"})),S4r=Object.freeze(Object.defineProperty({__proto__:null,RadarModule:gGe,createRadarServices:mGe},Symbol.toStringTag,{value:"Module"})),E4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadModule:yGe,createRailroadServices:nne},Symbol.toStringTag,{value:"Module"})),A4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadEbnfModule:vGe,createRailroadEbnfServices:ine},Symbol.toStringTag,{value:"Module"})),M4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadAbnfModule:bGe,createRailroadAbnfServices:sne},Symbol.toStringTag,{value:"Module"})),R4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadPegModule:xGe,createRailroadPegServices:ane},Symbol.toStringTag,{value:"Module"})),_4r=Object.freeze(Object.defineProperty({__proto__:null,TreemapModule:TGe,createTreemapServices:CGe},Symbol.toStringTag,{value:"Module"})),L4r=Object.freeze(Object.defineProperty({__proto__:null,WardleyModule:kGe,createWardleyServices:SGe},Symbol.toStringTag,{value:"Module"})),I4r=Object.freeze(Object.defineProperty({__proto__:null,CynefinModule:EGe,createCynefinServices:AGe},Symbol.toStringTag,{value:"Module"})); diff --git a/apps/ios/EditorBundle/index.html b/apps/ios/EditorBundle/index.html index 5338da09..a8905476 100644 --- a/apps/ios/EditorBundle/index.html +++ b/apps/ios/EditorBundle/index.html @@ -7,14 +7,14 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> EdgeEver Editor - +`},"getStyles"),RT,nu=(RT=class{constructor(){this.d=""}moveTo(e,r){return this.d+=`M ${e} ${r} `,this}lineTo(e,r){return this.d+=`L ${e} ${r} `,this}horizontalTo(e){return this.d+=`H ${e} `,this}verticalTo(e){return this.d+=`V ${e} `,this}arcTo(e,r,n,i,s,a,o){return this.d+=`A ${e} ${r} ${n} ${i?1:0} ${s?1:0} ${a} ${o} `,this}build(){return this.d.trim()}},w(RT,"PathBuilder"),RT),_T,Nxr=(_T=class{constructor(e,r=aie()){this.textCache=new Map,this.svg=e,this.config=r}measureText(e){if(this.textCache.has(e))return this.textCache.get(e);const r=this.svg.append("text").attr("font-family",this.config.fontFamily).attr("font-size",this.config.fontSize).text(e),n=r.node().getBBox(),i={width:n.width,height:n.height};return r.remove(),this.textCache.set(e,i),i}renderTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,s=n.height+this.config.padding*2,a=e.append("g").attr("class","railroad-terminal");return a.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",s).attr("rx",10).attr("ry",10),a.append("text").attr("x",i/2).attr("y",s/2).text(r),{element:a.node(),dimensions:{width:i,height:s,up:s/2,down:s/2}}}renderNonTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,s=n.height+this.config.padding*2,a=e.append("g").attr("class","railroad-nonterminal");return a.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",s),a.append("text").attr("x",i/2).attr("y",s/2).text(r),{element:a.node(),dimensions:{width:i,height:s,up:s/2,down:s/2}}}renderSequence(e,r){const n=r.map(u=>this.renderExpression(e,u));let i=0,s=0,a=0;for(const u of n)i+=u.dimensions.width,s=Math.max(s,u.dimensions.up),a=Math.max(a,u.dimensions.down);i+=(n.length-1)*this.config.horizontalSeparation;const o=e.append("g").attr("class","railroad-sequence");let l=0;for(let u=0;uthis.renderExpression(e,f));let i=0,s=0;for(const f of n)i=Math.max(i,f.dimensions.width),s+=f.dimensions.height;s+=(n.length-1)*this.config.verticalSeparation;const a=this.config.arcRadius,o=a*4,l=i+o,u=e.append("g").attr("class","railroad-choice");let h=0;const d=s/2;for(const f of n){const p=h,g=p+f.dimensions.up,m=a*2+(i-f.dimensions.width)/2;u.node().appendChild(f.element).setAttribute("transform",`translate(${m}, ${p})`);const v=new nu,x=g>d;g===d?v.moveTo(0,d).lineTo(m,g):v.moveTo(0,d).arcTo(a,a,0,!1,x,a,d+(x?a:-a)).lineTo(a,g-(x?a:-a)).arcTo(a,a,0,!1,!x,a*2,g).lineTo(m,g),u.append("path").attr("class","railroad-line").attr("d",v.build());const b=new nu,T=m+f.dimensions.width,C=l-a*2;g===d?b.moveTo(T,g).lineTo(l,d):b.moveTo(T,g).lineTo(C,g).arcTo(a,a,0,!1,!x,l-a,g+(x?-a:a)).lineTo(l-a,d+(x?a:-a)).arcTo(a,a,0,!1,x,l,d),u.append("path").attr("class","railroad-line").attr("d",b.build()),h+=f.dimensions.height+this.config.verticalSeparation}return{element:u.node(),dimensions:{width:l,height:s,up:d,down:s-d}}}renderOptional(e,r){const n=this.renderExpression(e,r),i=this.config.arcRadius,s=i*2,a=n.dimensions.width+i*4,o=n.dimensions.height+s,l=e.append("g").attr("class","railroad-optional"),u=i*2,h=s;l.node().appendChild(n.element).setAttribute("transform",`translate(${u}, ${h})`);const f=h+n.dimensions.up,p=new nu().moveTo(0,f).lineTo(i*2,f);l.append("path").attr("class","railroad-line").attr("d",p.build());const g=new nu().moveTo(u+n.dimensions.width,f).lineTo(a,f);l.append("path").attr("class","railroad-line").attr("d",g.build());const m=new nu().moveTo(0,f).arcTo(i,i,0,!1,!1,i,f-i).lineTo(i,i).arcTo(i,i,0,!1,!0,i*2,0).lineTo(a-i*2,0).arcTo(i,i,0,!1,!0,a-i,i).lineTo(a-i,f-i).arcTo(i,i,0,!1,!1,a,f);return l.append("path").attr("class","railroad-line").attr("d",m.build()),{element:l.node(),dimensions:{width:a,height:o,up:f,down:o-f}}}renderRepetition(e,r,n){const i=this.renderExpression(e,r),s=this.config.arcRadius,a=s*2,o=i.dimensions.width+s*4,l=n===0,u=i.dimensions.height+a+(l?a:0),h=e.append("g").attr("class","railroad-repetition"),d=s*2,f=l?a:0;h.node().appendChild(i.element).setAttribute("transform",`translate(${d}, ${f})`);const g=f+i.dimensions.up;h.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(0,g).lineTo(s*2,g).build()),h.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(d+i.dimensions.width,g).lineTo(o,g).build());const m=f+i.dimensions.height+s,y=new nu().moveTo(d+i.dimensions.width,g).arcTo(s,s,0,!1,!0,d+i.dimensions.width+s,g+s).lineTo(d+i.dimensions.width+s,m).arcTo(s,s,0,!1,!0,d+i.dimensions.width,m+s).lineTo(s*2,m+s).arcTo(s,s,0,!1,!0,s,m).lineTo(s,g+s).arcTo(s,s,0,!1,!0,s*2,g);if(h.append("path").attr("class","railroad-line").attr("d",y.build()),l){const v=new nu().moveTo(0,g).arcTo(s,s,0,!1,!1,s,g-s).lineTo(s,s).arcTo(s,s,0,!1,!0,s*2,0).lineTo(o-s*2,0).arcTo(s,s,0,!1,!0,o-s,s).lineTo(o-s,g-s).arcTo(s,s,0,!1,!1,o,g);h.append("path").attr("class","railroad-line").attr("d",v.build())}return{element:h.node(),dimensions:{width:o,height:u,up:g,down:u-g}}}renderSpecial(e,r){const n=this.measureText("? "+r+" ?"),i=n.width+this.config.padding*2,s=n.height+this.config.padding*2,a=e.append("g").attr("class","railroad-special");return a.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",s),a.append("text").attr("x",i/2).attr("y",s/2).text("? "+r+" ?"),{element:a.node(),dimensions:{width:i,height:s,up:s/2,down:s/2}}}renderExpression(e,r){switch(r.type){case"terminal":return this.renderTerminal(e,r.value);case"nonterminal":return this.renderNonTerminal(e,r.name);case"sequence":return this.renderSequence(e,r.elements);case"choice":return this.renderChoice(e,r.alternatives);case"optional":return this.renderOptional(e,r.element);case"repetition":return this.renderRepetition(e,r.element,r.min);case"special":return this.renderSpecial(e,r.text);default:throw new Error(`Unknown node type: ${r.type}`)}}renderRule(e,r){const n=this.svg.append("g").attr("class","railroad-rule").attr("transform",`translate(0, ${r})`),i=e.name+" =",s=this.measureText(i).width+20,a=s+20,o=n.append("g"),l=this.renderExpression(o,e.definition),u=Math.max(20,l.dimensions.up),h=u-l.dimensions.up;return o.attr("transform",`translate(${a}, ${h})`),n.append("g").attr("class","railroad-rule-name-group").append("text").attr("class","railroad-rule-name").attr("x",0).attr("y",u).text(i),n.append("g").attr("class","railroad-start").append("circle").attr("cx",s).attr("cy",u).attr("r",this.config.markerRadius),n.append("g").attr("class","railroad-end").append("circle").attr("cx",a+l.dimensions.width+10).attr("cy",u).attr("r",this.config.markerRadius),n.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(s+this.config.markerRadius,u).lineTo(a,u).build()),n.append("path").attr("class","railroad-line").attr("d",new nu().moveTo(a+l.dimensions.width,u).lineTo(a+l.dimensions.width+10-this.config.markerRadius,u).build()),{height:Math.max(40,h+l.dimensions.height+this.config.padding*2),width:a+l.dimensions.width+10+this.config.markerRadius}}renderDiagram(e){let r=this.config.padding,n=0;for(const i of e){const s=this.renderRule(i,r);r+=s.height+this.config.verticalSeparation,n=Math.max(n,s.width)}return{width:n+this.config.padding*2,height:r+this.config.padding}}},w(_T,"RailroadRenderer"),_T),Vye=w((t,e,r)=>{Fs(t,e.height,e.width,r),t.attr("viewBox",`0 0 ${e.width} ${e.height}`)},"configureRailroadSvgSize"),Dxr=w((t,e,r)=>{de.debug(`[Railroad] Rendering diagram +`+t);try{const n=Xo(e);n.attr("class","railroad-diagram");const i=vr().railroad,s=(i==null?void 0:i.useMaxWidth)??!0,a=Qi.getRules();if(de.debug(`[Railroad] Rendering ${a.length} rules`),a.length===0){de.warn("[Railroad] No rules to render"),Vye(n,{height:100,width:200},s);return}const l=new Nxr(n,aie()).renderDiagram(a);Vye(n,l,s),de.debug("[Railroad] Render complete")}catch(n){throw de.error("[Railroad] Render error:",n),n}},"draw"),BP={draw:Dxr},Oxr=nne().Railroad.parser.LangiumParser,q2=w(t=>{switch(t.$type){case"RailroadTerminalExpr":return{type:"terminal",value:t.value};case"RailroadNonTerminalExpr":return{type:"nonterminal",name:t.name};case"RailroadSpecialExpr":return{type:"special",text:t.text};case"RailroadSequenceExpr":{const e=t.elements.map(q2);return e.length===1?e[0]:{type:"sequence",elements:e}}case"RailroadChoiceExpr":{const e=t.alternatives.map(q2);return e.length===1?e[0]:{type:"choice",alternatives:e}}case"RailroadOptionalExpr":return{type:"optional",element:q2(t.element)};case"RailroadOneOrMoreExpr":return{type:"repetition",element:q2(t.element),min:1,max:1/0};case"RailroadZeroOrMoreExpr":return{type:"repetition",element:q2(t.element),min:0,max:1/0};default:throw new Error(`Unsupported railroad expression: ${t.$type}`)}},"transformExpression"),Pxr=w(t=>({name:t.name,definition:q2(t.definition)}),"transformRule"),$xr=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(Pxr(e)))},"populateDb"),Bxr={parse:w(t=>{Qi.clear(),de.debug("[Railroad Parser] Starting Langium parse");const e=Oxr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[Railroad Parser] Parsed rules:",r.rules.length),$xr(r),de.debug("[Railroad Parser] Parse complete")},"parse"),parser:{yy:Qi}},Fxr={parser:Bxr,db:Qi,renderer:BP,styles:$P};const zxr=Object.freeze(Object.defineProperty({__proto__:null,diagram:Fxr},Symbol.toStringTag,{value:"Module"}));var Vxr=ine().RailroadEbnf.parser.LangiumParser,XR=w(t=>{const e=t.alternatives.map(Hxr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformChoice"),Hxr=w(t=>{const e=t.elements.map(Wxr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),hje=w(t=>{switch(t.$type){case"EbnfTerminal":return{type:"terminal",value:t.value};case"EbnfNonTerminal":return{type:"nonterminal",name:t.name};case"EbnfSpecial":return{type:"special",text:t.text};case"EbnfGroup":return XR(t.element);case"EbnfOptional":return{type:"optional",element:XR(t.element)};case"EbnfRepetition":return{type:"repetition",element:XR(t.element),min:0,max:1/0};default:throw new Error(`Unsupported EBNF primary node: ${t.$type}`)}},"transformPrimary"),Gxr=w((t,e)=>{switch(e.$type){case"EbnfOptionalPostfix":return{type:"optional",element:t};case"EbnfZeroOrMorePostfix":return{type:"repetition",element:t,min:0,max:1/0};case"EbnfOneOrMorePostfix":return{type:"repetition",element:t,min:1,max:1/0};case"EbnfExceptionPostfix":return{type:"sequence",elements:[t,{type:"terminal",value:"-"},hje(e.except)]};default:throw new Error(`Unsupported EBNF postfix node: ${e.$type}`)}},"transformPostfix"),Wxr=w(t=>t.postfixes.reduce((e,r)=>Gxr(e,r),hje(t.base)),"transformTerm"),qxr=w(t=>({name:t.name,definition:XR(t.definition)}),"transformRule"),Uxr=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(qxr(e)))},"populateDb"),Yxr={parse:w(t=>{Qi.clear(),de.debug("[EBNF Parser] Starting Langium parse");const e=Vxr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[EBNF Parser] Parsed rules:",r.rules.length),Uxr(r),de.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:Qi}},jxr={parser:Yxr,db:Qi,renderer:BP,styles:$P};const Xxr=Object.freeze(Object.defineProperty({__proto__:null,diagram:jxr},Symbol.toStringTag,{value:"Module"}));var Kxr=sne().RailroadAbnf.parser.LangiumParser,jj=w(t=>{const e=t.alternatives.map(Zxr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformAlternation"),Zxr=w(t=>{const e=t.elements.map(Jxr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformConcatenation"),Qxr=w(t=>{if(t.includes("*")){const[r,n]=t.split("*"),i=r?parseInt(r,10):0,s=n?parseInt(n,10):1/0;return{min:i,max:s}}const e=parseInt(t,10);return{min:e,max:e}},"parseRepeat"),Jxr=w(t=>{const e=e4r(t.primary);if(!t.repeat)return e;const{min:r,max:n}=Qxr(t.repeat);return r===0&&n===1?{type:"optional",element:e}:{type:"repetition",element:e,min:r,max:n}},"transformElement"),e4r=w(t=>{switch(t.$type){case"AbnfStringLiteral":return{type:"terminal",value:t.value};case"AbnfNumVal":return{type:"terminal",value:t.value};case"AbnfRuleName":return{type:"nonterminal",name:t.name};case"AbnfGroup":return jj(t.element);case"AbnfOptionalGroup":return{type:"optional",element:jj(t.element)};default:throw new Error(`Unsupported ABNF primary node: ${t.$type}`)}},"transformPrimary"),t4r=w(t=>({name:t.name,definition:jj(t.definition)}),"transformRule"),r4r=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(t4r(e)))},"populateDb"),n4r={parse:w(t=>{Qi.clear(),de.debug("[ABNF Parser] Starting Langium parse");const e=Kxr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[ABNF Parser] Parsed rules:",r.rules.length),r4r(r),de.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:Qi}},i4r={parser:n4r,db:Qi,renderer:BP,styles:$P};const s4r=Object.freeze(Object.defineProperty({__proto__:null,diagram:i4r},Symbol.toStringTag,{value:"Module"}));var a4r=ane().RailroadPeg.parser.LangiumParser,dje=w(t=>{const e=t.alternatives.map(o4r);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformOrderedChoice"),o4r=w(t=>{const e=t.elements.map(l4r);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),l4r=w(t=>{const e=c4r(t.suffix);return t.operator?{type:"special",text:t.operator==="&"?`&${Hye(e)}`:`!${Hye(e)}`}:e},"transformPrefix"),Hye=w(t=>{switch(t.type){case"terminal":return`"${t.value}"`;case"nonterminal":return t.name;case"special":return t.text;default:return"(...)"}},"nodeToLabel"),c4r=w(t=>{const e=u4r(t.primary);if(!t.operator)return e;switch(t.operator){case"?":return{type:"optional",element:e};case"*":return{type:"repetition",element:e,min:0,max:1/0};case"+":return{type:"repetition",element:e,min:1,max:1/0};default:throw new Error(`Unsupported PEG suffix operator: ${t.operator}`)}},"transformSuffix"),u4r=w(t=>{switch(t.$type){case"PegLiteral":return{type:"terminal",value:t.value};case"PegIdentifier":return{type:"nonterminal",name:t.name};case"PegGroup":return dje(t.element);case"PegAny":return{type:"special",text:t.dot};default:throw new Error(`Unsupported PEG primary node: ${t.$type}`)}},"transformPrimary"),h4r=w(t=>({name:t.name,definition:dje(t.definition)}),"transformRule"),d4r=w(t=>{Fl(t,Qi),t.title&&Qi.setTitle(t.title),t.rules.map(e=>Qi.addRule(h4r(e)))},"populateDb"),f4r={parse:w(t=>{Qi.clear(),de.debug("[PEG Parser] Starting Langium parse");const e=a4r.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new j7(e);const r=e.value;de.debug("[PEG Parser] Parsed rules:",r.rules.length),d4r(r),de.debug("[PEG Parser] Parse complete")},"parse"),parser:{yy:Qi}},p4r={parser:f4r,db:Qi,renderer:BP,styles:$P};const g4r=Object.freeze(Object.defineProperty({__proto__:null,diagram:p4r},Symbol.toStringTag,{value:"Module"}));var m4r=1;function oie(){if(!(typeof globalThis>"u"))return globalThis}w(oie,"getCaptureGlobal");function fje(){var t;return!!((t=oie())!=null&&t.mermaidCaptureSizes)}w(fje,"shouldCaptureSizes");function pje(){return typeof location>"u"?"browser-dev":`${location.pathname}${location.search}`}w(pje,"capturedFromLocation");function gje(t,e){const r=oie();if(!r)return;const n=e.node(),i=(n&&"ownerSVGElement"in n?n.ownerSVGElement:null)??n,s=(i==null?void 0:i.id)??"(unknown)";r.mermaidCapturedSizes??(r.mermaidCapturedSizes=[]);const a={svgId:s,sizes:t};r.mermaidCapturedSizes.push(a),r.mermaidLastCapturedSizes=a}w(gje,"emitCapturedSizes");function mje(t,e){const r=[];for(const n of e.nodes)n.isGroup||r.push({id:n.id,width:n.width??0,height:n.height??0});r.length!==0&&gje({metadata:{captureVersion:m4r,capturedAt:new Date().toISOString(),capturedFrom:pje()},nodes:r},t)}w(mje,"captureNodeSizes");const y4r=Object.freeze(Object.defineProperty({__proto__:null,captureNodeSizes:mje,shouldCaptureSizes:fje},Symbol.toStringTag,{value:"Module"})),v4r=Object.freeze(Object.defineProperty({__proto__:null,InfoModule:_Ge,createInfoServices:LGe},Symbol.toStringTag,{value:"Module"})),b4r=Object.freeze(Object.defineProperty({__proto__:null,PacketModule:IGe,createPacketServices:NGe},Symbol.toStringTag,{value:"Module"})),x4r=Object.freeze(Object.defineProperty({__proto__:null,PieModule:DGe,createPieServices:OGe},Symbol.toStringTag,{value:"Module"})),w4r=Object.freeze(Object.defineProperty({__proto__:null,TreeViewModule:PGe,createTreeViewServices:$Ge},Symbol.toStringTag,{value:"Module"})),T4r=Object.freeze(Object.defineProperty({__proto__:null,ArchitectureModule:BGe,createArchitectureServices:FGe},Symbol.toStringTag,{value:"Module"})),C4r=Object.freeze(Object.defineProperty({__proto__:null,GitGraphModule:MGe,createGitGraphServices:RGe},Symbol.toStringTag,{value:"Module"})),k4r=Object.freeze(Object.defineProperty({__proto__:null,EventModelingModule:VGe,createEventModelingServices:HGe},Symbol.toStringTag,{value:"Module"})),S4r=Object.freeze(Object.defineProperty({__proto__:null,RadarModule:gGe,createRadarServices:mGe},Symbol.toStringTag,{value:"Module"})),E4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadModule:yGe,createRailroadServices:nne},Symbol.toStringTag,{value:"Module"})),A4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadEbnfModule:vGe,createRailroadEbnfServices:ine},Symbol.toStringTag,{value:"Module"})),M4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadAbnfModule:bGe,createRailroadAbnfServices:sne},Symbol.toStringTag,{value:"Module"})),R4r=Object.freeze(Object.defineProperty({__proto__:null,RailroadPegModule:xGe,createRailroadPegServices:ane},Symbol.toStringTag,{value:"Module"})),_4r=Object.freeze(Object.defineProperty({__proto__:null,TreemapModule:TGe,createTreemapServices:CGe},Symbol.toStringTag,{value:"Module"})),L4r=Object.freeze(Object.defineProperty({__proto__:null,WardleyModule:kGe,createWardleyServices:SGe},Symbol.toStringTag,{value:"Module"})),I4r=Object.freeze(Object.defineProperty({__proto__:null,CynefinModule:EGe,createCynefinServices:AGe},Symbol.toStringTag,{value:"Module"})); diff --git a/apps/ios/EditorSource/src/main.ts b/apps/ios/EditorSource/src/main.ts index f310ec09..bc2664af 100644 --- a/apps/ios/EditorSource/src/main.ts +++ b/apps/ios/EditorSource/src/main.ts @@ -5,7 +5,6 @@ import { Editor } from "@tiptap/core"; import Image from "@tiptap/extension-image"; import Placeholder from "@tiptap/extension-placeholder"; import CodeBlock from "@tiptap/extension-code-block"; -import { EdgeEverLink } from "@edgeever/shared/editor-link"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { NodeSelection } from "@tiptap/pm/state"; import mermaid from "mermaid"; @@ -759,7 +758,6 @@ function buildExtensions(placeholder: string) { table: { table: { resizable: false } }, markdown: true, }), - EdgeEverLink, NativeAttachmentMetadata, CodeBlock.configure({ languageClassPrefix: "language-", diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 54d3e152..2ef65d0a 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -192,6 +192,7 @@ import { } from "@/lib/editor-external-link"; import { insertAiDraftAtTextCursor } from "@/lib/ai-draft-insertion"; import { createFileBatchQueue, processFileUploadBatch } from "@/lib/file-batch"; +import { resourceToMarkdown } from "@/lib/markdown-resource-paste"; import { MEMO_ID_REMAPPED_EVENT, MEMO_SYNC_ACKNOWLEDGED_EVENT } from "@/lib/sync-events"; import { useStandaloneMobileEditor } from "@/hooks/useStandaloneMobileEditor"; import { statusSettleMotion } from "@/lib/motion"; @@ -886,6 +887,39 @@ const RichEditorPane = ({ } }, [focusMobileInputTarget, isMobileViewport, memo?.id, mobileDefaultEditMemoId, onMobileDefaultEditConsumed, readOnly]); + const uploadEditorResource = useCallback(async (targetMemoId: string, uploadFile: File, isImage: boolean) => { + try { + const resource = (await repository.uploadMemoResource(targetMemoId, uploadFile)).resource; + return { ...resource, url: toDesktopResourceUrl(resource.url) }; + } catch (error) { + if (!isDesktopResourceRuntime()) throw error; + const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null; mimeType?: string | null; byteSize?: number }> })); + const existing = findMatchingMemoResource( + listed.resources.filter((item) => item.memoId === targetMemoId), + uploadFile.name, + isImage ? "image" : "attachment", + ); + if (existing) { + return { + kind: isImage ? "image" as const : "attachment" as const, + filename: existing.filename || uploadFile.name, + mimeType: existing.mimeType || uploadFile.type || null, + byteSize: existing.byteSize ?? uploadFile.size, + url: toDesktopResourceUrl(existing.url), + }; + } + const staged = await stageDesktopResource(targetMemoId, uploadFile); + if (!staged) throw error; + return { + kind: isImage ? "image" as const : "attachment" as const, + filename: uploadFile.name, + mimeType: uploadFile.type || null, + byteSize: uploadFile.size, + url: `edgeever-staged://${staged.id}`, + }; + } + }, [repository]); + const insertResourceFiles = useCallback((files: File[]) => { const currentMemo = memoRef.current; const currentEditor = editorRef.current; @@ -941,44 +975,7 @@ const RichEditorPane = ({ setImageUploadState("uploading"); if (placeholder) updateImageUploadPlaceholder(editorRef.current, placeholder, t("editor.uploadState.uploading")); - let resource: { - kind: "image" | "attachment"; - filename: string | null; - mimeType: string | null; - byteSize: number; - url: string; - }; - try { - const uploadedResource = (await repository.uploadMemoResource(targetMemoId, uploadFile)).resource; - resource = { ...uploadedResource, url: toDesktopResourceUrl(uploadedResource.url) }; - } catch (error) { - if (!isDesktopResourceRuntime()) throw error; - const listed = await repository.listResources().catch(() => ({ resources: [] as Array<{ memoId?: string; url: string; filename?: string | null; kind?: string | null; mimeType?: string | null; byteSize?: number }> })); - const existing = findMatchingMemoResource( - listed.resources.filter((item) => item.memoId === targetMemoId), - uploadFile.name, - isImage ? "image" : "attachment", - ); - if (existing) { - resource = { - kind: isImage ? "image" : "attachment", - filename: existing.filename || uploadFile.name, - mimeType: existing.mimeType || uploadFile.type || null, - byteSize: existing.byteSize ?? uploadFile.size, - url: toDesktopResourceUrl(existing.url), - }; - } else { - const staged = await stageDesktopResource(targetMemoId, uploadFile); - if (!staged) throw error; - resource = { - kind: isImage ? "image" : "attachment", - filename: uploadFile.name, - mimeType: uploadFile.type || null, - byteSize: uploadFile.size, - url: `edgeever-staged://${staged.id}`, - }; - } - } + const resource = await uploadEditorResource(targetMemoId, uploadFile, isImage); if (resource.kind === "image") { imageReadiness.push(waitForImageSourceReady(resource.url)); } @@ -1082,7 +1079,7 @@ const RichEditorPane = ({ }); }); return true; - }, [queryClient, repository, resourceInsertionLimit, t]); + }, [queryClient, resourceInsertionLimit, t, uploadEditorResource]); const pluginEmbedExtension = useMemo(() => createPluginEmbedExtension(pluginHost), [pluginHost]); const inlineFieldExtension = useMemo(() => createInlineFieldExtension(i18n.language), [i18n.language]); @@ -1282,6 +1279,32 @@ const RichEditorPane = ({ }); const useMarkdownSourceEditor = !useMobilePlainTextEditor && isMarkdownMode; + const uploadMarkdownPasteFiles = useCallback(async (files: File[]) => { + const targetMemoId = memoRef.current?.id; + if (!targetMemoId || effectiveReadOnly) return ""; + setImageUploadState("uploading"); + const results = await processFileUploadBatch(files, async (file) => { + const shouldCompress = SUPPORTED_PASTE_IMAGE_TYPES.has(file.type) && imageCompressionEnabledRef.current; + if (!shouldCompress) return file; + setImageUploadState("compressing"); + return (await compressImageForUpload(file)).file; + }, async (uploadFile, file) => { + if (memoRef.current?.id !== targetMemoId) throw new Error("The active note changed during upload"); + setImageUploadState("uploading"); + return uploadEditorResource(targetMemoId, uploadFile, SUPPORTED_PASTE_IMAGE_TYPES.has(file.type)); + }); + if (results.some((result) => result.status === "fulfilled")) { + void queryClient.invalidateQueries({ queryKey: ["resources"] }); + } + const failed = results.some((result) => result.status === "rejected"); + setImageUploadState(failed ? "error" : "idle"); + if (failed) window.setTimeout(() => setImageUploadState("idle"), 2200); + return results + .filter((result) => result.status === "fulfilled") + .map((result) => resourceToMarkdown(result.value, result.file.name)) + .join("\n\n"); + }, [effectiveReadOnly, queryClient, uploadEditorResource]); + useEffect(() => { const syncPreference = (event?: Event) => { const detail = event && event.type === AI_SPACE_SHORTCUT_CHANGED_EVENT @@ -4085,8 +4108,10 @@ const RichEditorPane = ({ }> HTMLElement | null; @@ -38,8 +41,10 @@ export interface MarkdownSourceEditorRef { } export interface MarkdownSourceEditorProps { + memoId: string; value: string; onChange: (value: string) => void; + onPasteFiles?: (files: File[]) => Promise; themeName: MarkdownThemeName; readOnly?: boolean; placeholder?: string; @@ -101,8 +106,10 @@ const baseEditorTheme = EditorView.theme({ export const MarkdownSourceEditor = forwardRef( ( { + memoId, value, onChange, + onPasteFiles, themeName, readOnly = false, placeholder, @@ -114,6 +121,9 @@ export const MarkdownSourceEditor = forwardRef { const cmRef = useRef(null); + const memoIdRef = useRef(memoId); + memoIdRef.current = memoId; + const pendingPastesRef = useRef(new Set()); useImperativeHandle( ref, @@ -181,9 +191,48 @@ export const MarkdownSourceEditor = forwardRef { + if (!update.docChanged) return; + for (const pending of pendingPastesRef.current) { + pending.from = update.changes.mapPos(pending.from, -1); + pending.to = update.changes.mapPos(pending.to, 1); + } + }), ]; }, []); + const handlePasteCapture = useCallback((event: ReactClipboardEvent) => { + if (readOnly || !onPasteFiles) return; + const files = getResourceFilesFromDataTransfer(event.clipboardData); + if (!files.length) return; + const view = cmRef.current?.view; + if (!view) return; + event.preventDefault(); + event.stopPropagation(); + const selection = view.state.selection.main; + const pending: PendingPaste = { + from: selection.from, + to: selection.to, + originalText: view.state.doc.sliceString(selection.from, selection.to), + }; + const targetMemoId = memoId; + pendingPastesRef.current.add(pending); + void onPasteFiles(files).then((markdown) => { + pendingPastesRef.current.delete(pending); + if (!markdown || cmRef.current?.view !== view || memoIdRef.current !== targetMemoId) return; + const unchanged = view.state.doc.sliceString(pending.from, pending.to) === pending.originalText; + const from = unchanged ? pending.from : pending.to; + const shouldMoveCursor = view.hasFocus && view.state.selection.main.from === pending.to + && view.state.selection.main.to === pending.to; + view.dispatch({ + changes: { from, to: pending.to, insert: markdown }, + ...(shouldMoveCursor ? { selection: { anchor: from + markdown.length }, scrollIntoView: true } : {}), + }); + }).catch(() => { + pendingPastesRef.current.delete(pending); + }); + }, [memoId, onPasteFiles, readOnly]); + const handleKeyDown = useCallback( (event: React.KeyboardEvent | KeyboardEvent) => { // Link shortcut: Cmd+K / Ctrl+K @@ -248,6 +297,7 @@ export const MarkdownSourceEditor = forwardRef { + test("formats uploaded images and attachments without breaking Markdown syntax", () => { + expect(resourceToMarkdown({ kind: "image", filename: "a[b].png", url: "/api/v1/resources/res_1/blob" }, "fallback.png")) + .toBe("![a\\[b\\].png](/api/v1/resources/res_1/blob)"); + expect(resourceToMarkdown({ kind: "attachment", filename: null, url: "https://example.com/a(b).pdf" }, "report.pdf")) + .toBe("[report.pdf](https://example.com/a%28b%29.pdf)"); + }); +}); diff --git a/apps/web/src/lib/markdown-resource-paste.ts b/apps/web/src/lib/markdown-resource-paste.ts new file mode 100644 index 00000000..df85f723 --- /dev/null +++ b/apps/web/src/lib/markdown-resource-paste.ts @@ -0,0 +1,9 @@ +const escapeLabel = (value: string) => value + .replaceAll("\\", "\\\\") + .replace(/[\[\]\r\n]/g, (character) => character === "\r" || character === "\n" ? " " : `\\${character}`); + +export const resourceToMarkdown = (resource: { kind: "image" | "attachment"; url: string; filename: string | null }, fallbackName: string) => { + const label = escapeLabel(resource.filename || fallbackName || "file"); + const url = resource.url.replaceAll("(", "%28").replaceAll(")", "%29"); + return `${resource.kind === "image" ? "!" : ""}[${label}](${url})`; +}; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index bad43463..718ccbf8 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1189,6 +1189,39 @@ textarea { border-radius: 2px; } +.ProseMirror a.edgeever-empty-external-link, +.markdown-content a.edgeever-empty-external-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5em; + min-height: 1.5em; + padding: 0 0.25em; + border: 1px solid var(--brand-green-border); + border-radius: 0.35em; + background: var(--brand-green-soft); + color: var(--brand-green-text); + font-size: 0.9em; + font-weight: 700; + line-height: 1; + text-decoration: none; + vertical-align: baseline; +} + +.ProseMirror a.edgeever-empty-external-link:hover, +.markdown-content a.edgeever-empty-external-link:hover { + border-color: var(--brand-green); + background: var(--brand-green-muted); + color: var(--brand-green-text); + text-decoration: none; +} + +.ProseMirror a.edgeever-empty-external-link:focus-visible, +.markdown-content a.edgeever-empty-external-link:focus-visible { + outline: 2px solid var(--brand-green); + outline-offset: 2px; +} + .ProseMirror a.edgeever-note-link, .ProseMirror a[href^="#memo="] { display: inline-flex; diff --git a/packages/shared/src/content.test.mjs b/packages/shared/src/content.test.mjs index 2263be41..1c9636ce 100644 --- a/packages/shared/src/content.test.mjs +++ b/packages/shared/src/content.test.mjs @@ -156,6 +156,23 @@ describe("file attachment Markdown compatibility", () => { expect(doc.content[0]?.content?.[0]?.type).toBe("text"); }); + test("preserves an empty external link through Markdown and JSON", () => { + const markdown = "Before [](https://example.com/path) and [named](https://example.com)."; + const doc = markdownToDoc(markdown); + expect(doc.content[0]?.content?.[1]).toMatchObject({ + type: "edgeeverEmptyExternalLink", + attrs: { href: "https://example.com/path" }, + }); + expect(docToMarkdown(doc)).toBe(markdown); + expect(markdownToDoc("`[](https://example.com)`").content[0]?.content?.[0]?.type).toBe("text"); + }); + + test("recovers an empty link from older content JSON", () => { + const markdown = "Read [](https://example.com) here"; + const oldDoc = markdownToDoc("Read here"); + expect(resolveMemoContentDoc(oldDoc, markdown)).toEqual(markdownToDoc(markdown)); + }); + test("keeps video preview expanded unless compact display mode is stored", () => { expect(resolveFileDisplayMode(undefined)).toBe("inline"); expect(resolveFileDisplayMode("inline")).toBe("inline"); diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index 4e8f8a6f..a83d28c4 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -14,6 +14,7 @@ import { import { projectNativeUnknownContentForMarkdown } from "./mobile-content-compatibility"; import { PluginEmbed, PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed"; import { ImageGallery, IMAGE_GALLERY_NODE_TYPE, normalizeImageGalleries } from "./image-gallery"; +import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link"; export { PluginEmbed, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, normalizePluginEmbedAttributes } from "./plugin-embed"; export type { PluginEmbedAttributes } from "./plugin-embed"; @@ -169,6 +170,7 @@ export const resolveMemoContentDoc = ( docContainsNodeType(currentDoc, INLINE_MATH_NODE_TYPE) || docContainsNodeType(currentDoc, PDF_ATTACHMENT_NODE_TYPE) || docContainsNodeType(currentDoc, FILE_ATTACHMENT_NODE_TYPE) + || docContainsNodeType(currentDoc, EMPTY_EXTERNAL_LINK_NODE_TYPE) || docContainsNodeType(currentDoc, "details") ) { return currentDoc; @@ -185,6 +187,7 @@ export const resolveMemoContentDoc = ( || docContainsNodeType(markdownDoc, MERGE_DIVIDER_NODE_TYPE) || docContainsNodeType(markdownDoc, BLOCK_MATH_NODE_TYPE) || docContainsNodeType(markdownDoc, INLINE_MATH_NODE_TYPE) + || docContainsNodeType(markdownDoc, EMPTY_EXTERNAL_LINK_NODE_TYPE) || docContainsNodeType(markdownDoc, "details") || !docToText(currentDoc) ? markdownDoc diff --git a/packages/shared/src/document-extensions.ts b/packages/shared/src/document-extensions.ts index 54ae7898..829e910a 100644 --- a/packages/shared/src/document-extensions.ts +++ b/packages/shared/src/document-extensions.ts @@ -10,6 +10,8 @@ import { ImageGallery } from "./image-gallery"; import { MergeDivider } from "./merge-divider"; import { PdfAttachment } from "./pdf-attachment"; import { PluginEmbed } from "./plugin-embed"; +import { EmptyExternalLink } from "./empty-external-link"; +import { EdgeEverLink } from "./editor-link"; export type CreateEdgeEverDocumentExtensionsOptions = { mathematics: AnyExtension[]; @@ -36,7 +38,11 @@ const withOptional = (value: AnyExtension | false | undefined, fallback: AnyExte export const createEdgeEverDocumentExtensions = ( options: CreateEdgeEverDocumentExtensionsOptions, ): AnyExtension[] => [ - options.starterKit === undefined ? StarterKit : StarterKit.configure(options.starterKit), + options.markdown + ? StarterKit.configure({ ...options.starterKit, link: false }) + : options.starterKit === undefined ? StarterKit : StarterKit.configure(options.starterKit), + ...(options.markdown ? [EdgeEverLink] : []), + EmptyExternalLink, TaskList, TaskItem.configure({ nested: true }), options.table === undefined ? TableKit : TableKit.configure(options.table), diff --git a/packages/shared/src/editor-link.ts b/packages/shared/src/editor-link.ts index 25f85d78..496af51b 100644 --- a/packages/shared/src/editor-link.ts +++ b/packages/shared/src/editor-link.ts @@ -1,4 +1,5 @@ import Link from "@tiptap/extension-link"; +import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link"; /** * Keep text entered at the end of a link outside that link while preserving @@ -6,4 +7,16 @@ import Link from "@tiptap/extension-link"; */ export const EdgeEverLink = Link.extend({ inclusive: false, + parseMarkdown(token, helpers) { + if (!token.tokens?.length && token.text === "" && typeof token.href === "string" && token.href) { + return { + type: EMPTY_EXTERNAL_LINK_NODE_TYPE, + attrs: { href: token.href, title: token.title || null }, + }; + } + return helpers.applyMark("link", helpers.parseInline(token.tokens || []), { + href: token.href, + title: token.title || null, + }); + }, }); diff --git a/packages/shared/src/empty-external-link.ts b/packages/shared/src/empty-external-link.ts new file mode 100644 index 00000000..ccf70b05 --- /dev/null +++ b/packages/shared/src/empty-external-link.ts @@ -0,0 +1,54 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +export const EMPTY_EXTERNAL_LINK_NODE_TYPE = "edgeeverEmptyExternalLink" as const; + +const safeHref = (value: unknown) => { + if (typeof value !== "string") return ""; + const href = value.trim(); + return /^(?:https?:\/\/|\/|#)/i.test(href) ? href : ""; +}; + +/** A visible, round-trippable target for Markdown links with no label. */ +export const EmptyExternalLink = Node.create({ + name: EMPTY_EXTERNAL_LINK_NODE_TYPE, + group: "inline", + inline: true, + atom: true, + selectable: true, + + addAttributes() { + return { + href: { default: "" }, + title: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "a[data-edgeever-empty-link]" }]; + }, + + renderHTML({ HTMLAttributes }) { + const href = safeHref(HTMLAttributes.href); + return [ + "a", + mergeAttributes(HTMLAttributes, { + href: href || undefined, + target: "_blank", + rel: "noopener noreferrer", + "data-edgeever-empty-link": "true", + "aria-label": href ? `Open link: ${href}` : "Open link", + class: "edgeever-empty-external-link", + }), + "↗", + ]; + }, + + renderMarkdown(node) { + const href = safeHref(node.attrs?.href); + if (!href) return ""; + const title = typeof node.attrs?.title === "string" && node.attrs.title + ? ` "${node.attrs.title.replaceAll('"', '\\"')}"` + : ""; + return `[](${href}${title})`; + }, +}); diff --git a/packages/shared/src/mobile-content-compatibility.test.mjs b/packages/shared/src/mobile-content-compatibility.test.mjs index 728fc870..0a5938b5 100644 --- a/packages/shared/src/mobile-content-compatibility.test.mjs +++ b/packages/shared/src/mobile-content-compatibility.test.mjs @@ -86,6 +86,18 @@ describe("native editor unknown-content compatibility", () => { .toEqual(galleryDocument); }); + test("keeps empty links visible and serializable in native editors", () => { + const document = { + type: "doc", + content: [{ + type: "paragraph", + content: [{ type: "edgeeverEmptyExternalLink", attrs: { href: "https://example.com", title: null } }], + }], + }; + expect(prepareNativeEditorContent(document)).toEqual(document); + expect(docToMarkdown(document)).toBe("[](https://example.com)"); + }); + test("keeps Markdown generation alive by projecting unknown content to text", () => { const markdown = docToMarkdown(futureDocument); expect(markdown).toContain("保留主题块正文"); diff --git a/packages/shared/src/mobile-content-compatibility.ts b/packages/shared/src/mobile-content-compatibility.ts index 3317d516..24b8b157 100644 --- a/packages/shared/src/mobile-content-compatibility.ts +++ b/packages/shared/src/mobile-content-compatibility.ts @@ -3,6 +3,7 @@ import type { TiptapDoc, TiptapMark, TiptapNode, TiptapTextNode } from "./conten import { FILE_ATTACHMENT_NODE_TYPE } from "./file-attachment"; import { PDF_ATTACHMENT_NODE_TYPE } from "./pdf-attachment"; import { PLUGIN_EMBED_NODE_TYPE } from "./plugin-embed"; +import { EMPTY_EXTERNAL_LINK_NODE_TYPE } from "./empty-external-link"; export const UNSUPPORTED_BLOCK_NODE_TYPE = "edgeeverUnsupportedBlock" as const; export const UNSUPPORTED_INLINE_NODE_TYPE = "edgeeverUnsupportedInline" as const; @@ -38,6 +39,7 @@ export const NATIVE_EDITOR_NODE_TYPES = new Set([ "tableHeader", "tableCell", "edgeeverMergeDivider", + EMPTY_EXTERNAL_LINK_NODE_TYPE, "inlineMath", "blockMath", ...FALLBACK_NODE_TYPES,