diff --git a/README.ja.md b/README.ja.md index 34847f08..865b5a78 100644 --- a/README.ja.md +++ b/README.ja.md @@ -324,11 +324,12 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除 @dkoryto @sivdead @thorufur - @claude @yulei666 @arsaboo @alyyasser @MadlyFX + @claude + @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -338,6 +339,5 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除 @mookim-eth @nzhdotme @Shunamxiao - @Crowszzz

diff --git a/README.md b/README.md index 47896a66..df9d1aec 100644 --- a/README.md +++ b/README.md @@ -325,11 +325,12 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma @dkoryto @sivdead @thorufur - @claude @yulei666 @arsaboo @alyyasser @MadlyFX + @claude + @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -339,6 +340,5 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma @mookim-eth @nzhdotme @Shunamxiao - @Crowszzz

diff --git a/README.pl-PL.md b/README.pl-PL.md index cd352188..95f57c83 100644 --- a/README.pl-PL.md +++ b/README.pl-PL.md @@ -325,11 +325,12 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan @dkoryto @sivdead @thorufur - @claude @yulei666 @arsaboo @alyyasser @MadlyFX + @claude + @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -339,6 +340,5 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan @mookim-eth @nzhdotme @Shunamxiao - @Crowszzz

diff --git a/README.zh-CN.md b/README.zh-CN.md index e4ba4faf..3d0ca15a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -329,11 +329,12 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托 @dkoryto @sivdead @thorufur - @claude @yulei666 @arsaboo @alyyasser @MadlyFX + @claude + @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -343,6 +344,5 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托 @mookim-eth @nzhdotme @Shunamxiao - @Crowszzz

diff --git a/README.zh-TW.md b/README.zh-TW.md index 1e0717a6..4a21c157 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -329,11 +329,12 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不 @dkoryto @sivdead @thorufur - @claude @yulei666 @arsaboo @alyyasser @MadlyFX + @claude + @Crowszzz @CottonChou @Z-DDV @0xfelixli @@ -343,6 +344,5 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不 @mookim-eth @nzhdotme @Shunamxiao - @Crowszzz

diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 881163f9..a21f4984 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@edgeever/desktop", - "version": "2.14.0", + "version": "2.15.0", "desktopName": "EdgeEver", "private": true, "description": "Native EdgeEver desktop client", diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index 2c88998e..8640af9f 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -8,5 +8,5 @@ // CI_BUILD_NUMBER into the archive when present. // Last completed ASC store build: 48 (2026-09-14, Xcode Cloud, v1.70.0). // Build 49 is reserved for the 1.74.0 App Store submission. -MARKETING_VERSION = 2.14.0 +MARKETING_VERSION = 2.15.0 CURRENT_PROJECT_VERSION = 49 diff --git a/apps/ios/EdgeEver.xcodeproj/project.pbxproj b/apps/ios/EdgeEver.xcodeproj/project.pbxproj index 060b1933..28d9e4ef 100644 --- a/apps/ios/EdgeEver.xcodeproj/project.pbxproj +++ b/apps/ios/EdgeEver.xcodeproj/project.pbxproj @@ -812,7 +812,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 2.14.0; + MARKETING_VERSION = 2.15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -902,7 +902,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 2.14.0; + MARKETING_VERSION = 2.15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/apps/ios/EdgeEver/Resources/EditorBundle/index.html b/apps/ios/EdgeEver/Resources/EditorBundle/index.html index 50d3a2df..2ebfe8bf 100644 --- a/apps/ios/EdgeEver/Resources/EditorBundle/index.html +++ b/apps/ios/EdgeEver/Resources/EditorBundle/index.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> EdgeEver Editor - +`},"getStyles"),c7,Pc=(c7=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,a,s,o){return this.d+=`A ${e} ${r} ${n} ${i?1:0} ${a?1:0} ${s} ${o} `,this}build(){return this.d.trim()}},x(c7,"PathBuilder"),c7),u7,B6r=(u7=class{constructor(e,r=eie()){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,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-terminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a).attr("rx",10).attr("ry",10),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderNonTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-nonterminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderSequence(e,r){const n=r.map(u=>this.renderExpression(e,u));let i=0,a=0,s=0;for(const u of n)i+=u.dimensions.width,a=Math.max(a,u.dimensions.up),s=Math.max(s,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,a=0;for(const f of n)i=Math.max(i,f.dimensions.width),a+=f.dimensions.height;a+=(n.length-1)*this.config.verticalSeparation;const s=this.config.arcRadius,o=s*4,l=i+o,u=e.append("g").attr("class","railroad-choice");let d=0;const h=a/2;for(const f of n){const p=d,g=p+f.dimensions.up,m=s*2+(i-f.dimensions.width)/2;u.node().appendChild(f.element).setAttribute("transform",`translate(${m}, ${p})`);const y=new Pc,v=g>h;g===h?y.moveTo(0,h).lineTo(m,g):y.moveTo(0,h).arcTo(s,s,0,!1,v,s,h+(v?s:-s)).lineTo(s,g-(v?s:-s)).arcTo(s,s,0,!1,!v,s*2,g).lineTo(m,g),u.append("path").attr("class","railroad-line").attr("d",y.build());const b=new Pc,w=m+f.dimensions.width,T=l-s*2;g===h?b.moveTo(w,g).lineTo(l,h):b.moveTo(w,g).lineTo(T,g).arcTo(s,s,0,!1,!v,l-s,g+(v?-s:s)).lineTo(l-s,h+(v?s:-s)).arcTo(s,s,0,!1,v,l,h),u.append("path").attr("class","railroad-line").attr("d",b.build()),d+=f.dimensions.height+this.config.verticalSeparation}return{element:u.node(),dimensions:{width:l,height:a,up:h,down:a-h}}}renderOptional(e,r){const n=this.renderExpression(e,r),i=this.config.arcRadius,a=i*2,s=n.dimensions.width+i*4,o=n.dimensions.height+a,l=e.append("g").attr("class","railroad-optional"),u=i*2,d=a;l.node().appendChild(n.element).setAttribute("transform",`translate(${u}, ${d})`);const f=d+n.dimensions.up,p=new Pc().moveTo(0,f).lineTo(i*2,f);l.append("path").attr("class","railroad-line").attr("d",p.build());const g=new Pc().moveTo(u+n.dimensions.width,f).lineTo(s,f);l.append("path").attr("class","railroad-line").attr("d",g.build());const m=new Pc().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(s-i*2,0).arcTo(i,i,0,!1,!0,s-i,i).lineTo(s-i,f-i).arcTo(i,i,0,!1,!1,s,f);return l.append("path").attr("class","railroad-line").attr("d",m.build()),{element:l.node(),dimensions:{width:s,height:o,up:f,down:o-f}}}renderRepetition(e,r,n){const i=this.renderExpression(e,r),a=this.config.arcRadius,s=a*2,o=i.dimensions.width+a*4,l=n===0,u=i.dimensions.height+s+(l?s:0),d=e.append("g").attr("class","railroad-repetition"),h=a*2,f=l?s:0;d.node().appendChild(i.element).setAttribute("transform",`translate(${h}, ${f})`);const g=f+i.dimensions.up;d.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(0,g).lineTo(a*2,g).build()),d.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(h+i.dimensions.width,g).lineTo(o,g).build());const m=f+i.dimensions.height+a,U=new Pc().moveTo(h+i.dimensions.width,g).arcTo(a,a,0,!1,!0,h+i.dimensions.width+a,g+a).lineTo(h+i.dimensions.width+a,m).arcTo(a,a,0,!1,!0,h+i.dimensions.width,m+a).lineTo(a*2,m+a).arcTo(a,a,0,!1,!0,a,m).lineTo(a,g+a).arcTo(a,a,0,!1,!0,a*2,g);if(d.append("path").attr("class","railroad-line").attr("d",U.build()),l){const y=new Pc().moveTo(0,g).arcTo(a,a,0,!1,!1,a,g-a).lineTo(a,a).arcTo(a,a,0,!1,!0,a*2,0).lineTo(o-a*2,0).arcTo(a,a,0,!1,!0,o-a,a).lineTo(o-a,g-a).arcTo(a,a,0,!1,!1,o,g);d.append("path").attr("class","railroad-line").attr("d",y.build())}return{element:d.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,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-special");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text("? "+r+" ?"),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/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+" =",a=this.measureText(i).width+20,s=a+20,o=n.append("g"),l=this.renderExpression(o,e.definition),u=Math.max(20,l.dimensions.up),d=u-l.dimensions.up;return o.attr("transform",`translate(${s}, ${d})`),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",a).attr("cy",u).attr("r",this.config.markerRadius),n.append("g").attr("class","railroad-end").append("circle").attr("cx",s+l.dimensions.width+10).attr("cy",u).attr("r",this.config.markerRadius),n.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(a+this.config.markerRadius,u).lineTo(s,u).build()),n.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(s+l.dimensions.width,u).lineTo(s+l.dimensions.width+10-this.config.markerRadius,u).build()),{height:Math.max(40,d+l.dimensions.height+this.config.padding*2),width:s+l.dimensions.width+10+this.config.markerRadius}}renderDiagram(e){let r=this.config.padding,n=0;for(const i of e){const a=this.renderRule(i,r);r+=a.height+this.config.verticalSeparation,n=Math.max(n,a.width)}return{width:n+this.config.padding*2,height:r+this.config.padding}}},x(u7,"RailroadRenderer"),u7),FUe=x((t,e,r)=>{xa(t,e.height,e.width,r),t.attr("viewBox",`0 0 ${e.width} ${e.height}`)},"configureRailroadSvgSize"),$6r=x((t,e,r)=>{ce.debug(`[Railroad] Rendering diagram +`+t);try{const n=No(e);n.attr("class","railroad-diagram");const i=cr().railroad,a=(i==null?void 0:i.useMaxWidth)??!0,s=Pi.getRules();if(ce.debug(`[Railroad] Rendering ${s.length} rules`),s.length===0){ce.warn("[Railroad] No rules to render"),FUe(n,{height:100,width:200},a);return}const l=new B6r(n,eie()).renderDiagram(s);FUe(n,l,a),ce.debug("[Railroad] Render complete")}catch(n){throw ce.error("[Railroad] Render error:",n),n}},"draw"),P_={draw:$6r},F6r=Zre().Railroad.parser.LangiumParser,Cb=x(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(Cb);return e.length===1?e[0]:{type:"sequence",elements:e}}case"RailroadChoiceExpr":{const e=t.alternatives.map(Cb);return e.length===1?e[0]:{type:"choice",alternatives:e}}case"RailroadOptionalExpr":return{type:"optional",element:Cb(t.element)};case"RailroadOneOrMoreExpr":return{type:"repetition",element:Cb(t.element),min:1,max:1/0};case"RailroadZeroOrMoreExpr":return{type:"repetition",element:Cb(t.element),min:0,max:1/0};default:throw new Error(`Unsupported railroad expression: ${t.$type}`)}},"transformExpression"),z6r=x(t=>({name:t.name,definition:Cb(t.definition)}),"transformRule"),H6r=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(z6r(e)))},"populateDb"),V6r={parse:x(t=>{Pi.clear(),ce.debug("[Railroad Parser] Starting Langium parse");const e=F6r.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[Railroad Parser] Parsed rules:",r.rules.length),H6r(r),ce.debug("[Railroad Parser] Parse complete")},"parse"),parser:{yy:Pi}},G6r={parser:V6r,db:Pi,renderer:P_,styles:__};const W6r=Object.freeze(Object.defineProperty({__proto__:null,diagram:G6r},Symbol.toStringTag,{value:"Module"}));var q6r=Jre().RailroadEbnf.parser.LangiumParser,GA=x(t=>{const e=t.alternatives.map(j6r);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformChoice"),j6r=x(t=>{const e=t.elements.map(X6r);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),iYe=x(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 GA(t.element);case"EbnfOptional":return{type:"optional",element:GA(t.element)};case"EbnfRepetition":return{type:"repetition",element:GA(t.element),min:0,max:1/0};default:throw new Error(`Unsupported EBNF primary node: ${t.$type}`)}},"transformPrimary"),Y6r=x((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:"-"},iYe(e.except)]};default:throw new Error(`Unsupported EBNF postfix node: ${e.$type}`)}},"transformPostfix"),X6r=x(t=>t.postfixes.reduce((e,r)=>Y6r(e,r),iYe(t.base)),"transformTerm"),K6r=x(t=>({name:t.name,definition:GA(t.definition)}),"transformRule"),Z6r=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(K6r(e)))},"populateDb"),J6r={parse:x(t=>{Pi.clear(),ce.debug("[EBNF Parser] Starting Langium parse");const e=q6r.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[EBNF Parser] Parsed rules:",r.rules.length),Z6r(r),ce.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:Pi}},Q6r={parser:J6r,db:Pi,renderer:P_,styles:__};const evr=Object.freeze(Object.defineProperty({__proto__:null,diagram:Q6r},Symbol.toStringTag,{value:"Module"}));var tvr=Qre().RailroadAbnf.parser.LangiumParser,YY=x(t=>{const e=t.alternatives.map(rvr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformAlternation"),rvr=x(t=>{const e=t.elements.map(ivr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformConcatenation"),nvr=x(t=>{if(t.includes("*")){const[r,n]=t.split("*"),i=r?parseInt(r,10):0,a=n?parseInt(n,10):1/0;return{min:i,max:a}}const e=parseInt(t,10);return{min:e,max:e}},"parseRepeat"),ivr=x(t=>{const e=avr(t.primary);if(!t.repeat)return e;const{min:r,max:n}=nvr(t.repeat);return r===0&&n===1?{type:"optional",element:e}:{type:"repetition",element:e,min:r,max:n}},"transformElement"),avr=x(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 YY(t.element);case"AbnfOptionalGroup":return{type:"optional",element:YY(t.element)};default:throw new Error(`Unsupported ABNF primary node: ${t.$type}`)}},"transformPrimary"),svr=x(t=>({name:t.name,definition:YY(t.definition)}),"transformRule"),ovr=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(svr(e)))},"populateDb"),lvr={parse:x(t=>{Pi.clear(),ce.debug("[ABNF Parser] Starting Langium parse");const e=tvr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[ABNF Parser] Parsed rules:",r.rules.length),ovr(r),ce.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:Pi}},cvr={parser:lvr,db:Pi,renderer:P_,styles:__};const uvr=Object.freeze(Object.defineProperty({__proto__:null,diagram:cvr},Symbol.toStringTag,{value:"Module"}));var dvr=ene().RailroadPeg.parser.LangiumParser,aYe=x(t=>{const e=t.alternatives.map(hvr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformOrderedChoice"),hvr=x(t=>{const e=t.elements.map(fvr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),fvr=x(t=>{const e=pvr(t.suffix);return t.operator?{type:"special",text:t.operator==="&"?`&${zUe(e)}`:`!${zUe(e)}`}:e},"transformPrefix"),zUe=x(t=>{switch(t.type){case"terminal":return`"${t.value}"`;case"nonterminal":return t.name;case"special":return t.text;default:return"(...)"}},"nodeToLabel"),pvr=x(t=>{const e=gvr(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"),gvr=x(t=>{switch(t.$type){case"PegLiteral":return{type:"terminal",value:t.value};case"PegIdentifier":return{type:"nonterminal",name:t.name};case"PegGroup":return aYe(t.element);case"PegAny":return{type:"special",text:t.dot};default:throw new Error(`Unsupported PEG primary node: ${t.$type}`)}},"transformPrimary"),mvr=x(t=>({name:t.name,definition:aYe(t.definition)}),"transformRule"),Uvr=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(mvr(e)))},"populateDb"),yvr={parse:x(t=>{Pi.clear(),ce.debug("[PEG Parser] Starting Langium parse");const e=dvr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[PEG Parser] Parsed rules:",r.rules.length),Uvr(r),ce.debug("[PEG Parser] Parse complete")},"parse"),parser:{yy:Pi}},bvr={parser:yvr,db:Pi,renderer:P_,styles:__};const vvr=Object.freeze(Object.defineProperty({__proto__:null,diagram:bvr},Symbol.toStringTag,{value:"Module"}));var xvr=1;function tie(){if(!(typeof globalThis>"u"))return globalThis}x(tie,"getCaptureGlobal");function sYe(){var t;return!!((t=tie())!=null&&t.mermaidCaptureSizes)}x(sYe,"shouldCaptureSizes");function oYe(){return typeof location>"u"?"browser-dev":`${location.pathname}${location.search}`}x(oYe,"capturedFromLocation");function lYe(t,e){const r=tie();if(!r)return;const n=e.node(),i=(n&&"ownerSVGElement"in n?n.ownerSVGElement:null)??n,a=(i==null?void 0:i.id)??"(unknown)";r.mermaidCapturedSizes??(r.mermaidCapturedSizes=[]);const s={svgId:a,sizes:t};r.mermaidCapturedSizes.push(s),r.mermaidLastCapturedSizes=s}x(lYe,"emitCapturedSizes");function cYe(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&&lYe({metadata:{captureVersion:xvr,capturedAt:new Date().toISOString(),capturedFrom:oYe()},nodes:r},t)}x(cYe,"captureNodeSizes");const wvr=Object.freeze(Object.defineProperty({__proto__:null,captureNodeSizes:cYe,shouldCaptureSizes:sYe},Symbol.toStringTag,{value:"Module"})),Tvr=Object.freeze(Object.defineProperty({__proto__:null,InfoModule:TVe,createInfoServices:CVe},Symbol.toStringTag,{value:"Module"})),Cvr=Object.freeze(Object.defineProperty({__proto__:null,PacketModule:SVe,createPacketServices:EVe},Symbol.toStringTag,{value:"Module"})),Svr=Object.freeze(Object.defineProperty({__proto__:null,PieModule:kVe,createPieServices:AVe},Symbol.toStringTag,{value:"Module"})),Evr=Object.freeze(Object.defineProperty({__proto__:null,TreeViewModule:MVe,createTreeViewServices:RVe},Symbol.toStringTag,{value:"Module"})),kvr=Object.freeze(Object.defineProperty({__proto__:null,ArchitectureModule:IVe,createArchitectureServices:LVe},Symbol.toStringTag,{value:"Module"})),Avr=Object.freeze(Object.defineProperty({__proto__:null,GitGraphModule:xVe,createGitGraphServices:wVe},Symbol.toStringTag,{value:"Module"})),Mvr=Object.freeze(Object.defineProperty({__proto__:null,EventModelingModule:OVe,createEventModelingServices:DVe},Symbol.toStringTag,{value:"Module"})),Rvr=Object.freeze(Object.defineProperty({__proto__:null,RadarModule:lVe,createRadarServices:cVe},Symbol.toStringTag,{value:"Module"})),Ivr=Object.freeze(Object.defineProperty({__proto__:null,RailroadModule:uVe,createRailroadServices:Zre},Symbol.toStringTag,{value:"Module"})),Lvr=Object.freeze(Object.defineProperty({__proto__:null,RailroadEbnfModule:dVe,createRailroadEbnfServices:Jre},Symbol.toStringTag,{value:"Module"})),Nvr=Object.freeze(Object.defineProperty({__proto__:null,RailroadAbnfModule:hVe,createRailroadAbnfServices:Qre},Symbol.toStringTag,{value:"Module"})),Ovr=Object.freeze(Object.defineProperty({__proto__:null,RailroadPegModule:fVe,createRailroadPegServices:ene},Symbol.toStringTag,{value:"Module"})),Dvr=Object.freeze(Object.defineProperty({__proto__:null,TreemapModule:gVe,createTreemapServices:mVe},Symbol.toStringTag,{value:"Module"})),_vr=Object.freeze(Object.defineProperty({__proto__:null,WardleyModule:UVe,createWardleyServices:yVe},Symbol.toStringTag,{value:"Module"})),Pvr=Object.freeze(Object.defineProperty({__proto__:null,CynefinModule:bVe,createCynefinServices:vVe},Symbol.toStringTag,{value:"Module"})); diff --git a/apps/ios/EditorBundle/index.html b/apps/ios/EditorBundle/index.html index 50d3a2df..2ebfe8bf 100644 --- a/apps/ios/EditorBundle/index.html +++ b/apps/ios/EditorBundle/index.html @@ -7,7 +7,7 @@ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> EdgeEver Editor - +`},"getStyles"),c7,Pc=(c7=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,a,s,o){return this.d+=`A ${e} ${r} ${n} ${i?1:0} ${a?1:0} ${s} ${o} `,this}build(){return this.d.trim()}},x(c7,"PathBuilder"),c7),u7,B6r=(u7=class{constructor(e,r=eie()){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,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-terminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a).attr("rx",10).attr("ry",10),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderNonTerminal(e,r){const n=this.measureText(r),i=n.width+this.config.padding*2,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-nonterminal");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text(r),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/2}}}renderSequence(e,r){const n=r.map(u=>this.renderExpression(e,u));let i=0,a=0,s=0;for(const u of n)i+=u.dimensions.width,a=Math.max(a,u.dimensions.up),s=Math.max(s,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,a=0;for(const f of n)i=Math.max(i,f.dimensions.width),a+=f.dimensions.height;a+=(n.length-1)*this.config.verticalSeparation;const s=this.config.arcRadius,o=s*4,l=i+o,u=e.append("g").attr("class","railroad-choice");let d=0;const h=a/2;for(const f of n){const p=d,g=p+f.dimensions.up,m=s*2+(i-f.dimensions.width)/2;u.node().appendChild(f.element).setAttribute("transform",`translate(${m}, ${p})`);const y=new Pc,v=g>h;g===h?y.moveTo(0,h).lineTo(m,g):y.moveTo(0,h).arcTo(s,s,0,!1,v,s,h+(v?s:-s)).lineTo(s,g-(v?s:-s)).arcTo(s,s,0,!1,!v,s*2,g).lineTo(m,g),u.append("path").attr("class","railroad-line").attr("d",y.build());const b=new Pc,w=m+f.dimensions.width,T=l-s*2;g===h?b.moveTo(w,g).lineTo(l,h):b.moveTo(w,g).lineTo(T,g).arcTo(s,s,0,!1,!v,l-s,g+(v?-s:s)).lineTo(l-s,h+(v?s:-s)).arcTo(s,s,0,!1,v,l,h),u.append("path").attr("class","railroad-line").attr("d",b.build()),d+=f.dimensions.height+this.config.verticalSeparation}return{element:u.node(),dimensions:{width:l,height:a,up:h,down:a-h}}}renderOptional(e,r){const n=this.renderExpression(e,r),i=this.config.arcRadius,a=i*2,s=n.dimensions.width+i*4,o=n.dimensions.height+a,l=e.append("g").attr("class","railroad-optional"),u=i*2,d=a;l.node().appendChild(n.element).setAttribute("transform",`translate(${u}, ${d})`);const f=d+n.dimensions.up,p=new Pc().moveTo(0,f).lineTo(i*2,f);l.append("path").attr("class","railroad-line").attr("d",p.build());const g=new Pc().moveTo(u+n.dimensions.width,f).lineTo(s,f);l.append("path").attr("class","railroad-line").attr("d",g.build());const m=new Pc().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(s-i*2,0).arcTo(i,i,0,!1,!0,s-i,i).lineTo(s-i,f-i).arcTo(i,i,0,!1,!1,s,f);return l.append("path").attr("class","railroad-line").attr("d",m.build()),{element:l.node(),dimensions:{width:s,height:o,up:f,down:o-f}}}renderRepetition(e,r,n){const i=this.renderExpression(e,r),a=this.config.arcRadius,s=a*2,o=i.dimensions.width+a*4,l=n===0,u=i.dimensions.height+s+(l?s:0),d=e.append("g").attr("class","railroad-repetition"),h=a*2,f=l?s:0;d.node().appendChild(i.element).setAttribute("transform",`translate(${h}, ${f})`);const g=f+i.dimensions.up;d.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(0,g).lineTo(a*2,g).build()),d.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(h+i.dimensions.width,g).lineTo(o,g).build());const m=f+i.dimensions.height+a,U=new Pc().moveTo(h+i.dimensions.width,g).arcTo(a,a,0,!1,!0,h+i.dimensions.width+a,g+a).lineTo(h+i.dimensions.width+a,m).arcTo(a,a,0,!1,!0,h+i.dimensions.width,m+a).lineTo(a*2,m+a).arcTo(a,a,0,!1,!0,a,m).lineTo(a,g+a).arcTo(a,a,0,!1,!0,a*2,g);if(d.append("path").attr("class","railroad-line").attr("d",U.build()),l){const y=new Pc().moveTo(0,g).arcTo(a,a,0,!1,!1,a,g-a).lineTo(a,a).arcTo(a,a,0,!1,!0,a*2,0).lineTo(o-a*2,0).arcTo(a,a,0,!1,!0,o-a,a).lineTo(o-a,g-a).arcTo(a,a,0,!1,!1,o,g);d.append("path").attr("class","railroad-line").attr("d",y.build())}return{element:d.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,a=n.height+this.config.padding*2,s=e.append("g").attr("class","railroad-special");return s.append("rect").attr("x",0).attr("y",0).attr("width",i).attr("height",a),s.append("text").attr("x",i/2).attr("y",a/2).text("? "+r+" ?"),{element:s.node(),dimensions:{width:i,height:a,up:a/2,down:a/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+" =",a=this.measureText(i).width+20,s=a+20,o=n.append("g"),l=this.renderExpression(o,e.definition),u=Math.max(20,l.dimensions.up),d=u-l.dimensions.up;return o.attr("transform",`translate(${s}, ${d})`),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",a).attr("cy",u).attr("r",this.config.markerRadius),n.append("g").attr("class","railroad-end").append("circle").attr("cx",s+l.dimensions.width+10).attr("cy",u).attr("r",this.config.markerRadius),n.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(a+this.config.markerRadius,u).lineTo(s,u).build()),n.append("path").attr("class","railroad-line").attr("d",new Pc().moveTo(s+l.dimensions.width,u).lineTo(s+l.dimensions.width+10-this.config.markerRadius,u).build()),{height:Math.max(40,d+l.dimensions.height+this.config.padding*2),width:s+l.dimensions.width+10+this.config.markerRadius}}renderDiagram(e){let r=this.config.padding,n=0;for(const i of e){const a=this.renderRule(i,r);r+=a.height+this.config.verticalSeparation,n=Math.max(n,a.width)}return{width:n+this.config.padding*2,height:r+this.config.padding}}},x(u7,"RailroadRenderer"),u7),FUe=x((t,e,r)=>{xa(t,e.height,e.width,r),t.attr("viewBox",`0 0 ${e.width} ${e.height}`)},"configureRailroadSvgSize"),$6r=x((t,e,r)=>{ce.debug(`[Railroad] Rendering diagram +`+t);try{const n=No(e);n.attr("class","railroad-diagram");const i=cr().railroad,a=(i==null?void 0:i.useMaxWidth)??!0,s=Pi.getRules();if(ce.debug(`[Railroad] Rendering ${s.length} rules`),s.length===0){ce.warn("[Railroad] No rules to render"),FUe(n,{height:100,width:200},a);return}const l=new B6r(n,eie()).renderDiagram(s);FUe(n,l,a),ce.debug("[Railroad] Render complete")}catch(n){throw ce.error("[Railroad] Render error:",n),n}},"draw"),P_={draw:$6r},F6r=Zre().Railroad.parser.LangiumParser,Cb=x(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(Cb);return e.length===1?e[0]:{type:"sequence",elements:e}}case"RailroadChoiceExpr":{const e=t.alternatives.map(Cb);return e.length===1?e[0]:{type:"choice",alternatives:e}}case"RailroadOptionalExpr":return{type:"optional",element:Cb(t.element)};case"RailroadOneOrMoreExpr":return{type:"repetition",element:Cb(t.element),min:1,max:1/0};case"RailroadZeroOrMoreExpr":return{type:"repetition",element:Cb(t.element),min:0,max:1/0};default:throw new Error(`Unsupported railroad expression: ${t.$type}`)}},"transformExpression"),z6r=x(t=>({name:t.name,definition:Cb(t.definition)}),"transformRule"),H6r=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(z6r(e)))},"populateDb"),V6r={parse:x(t=>{Pi.clear(),ce.debug("[Railroad Parser] Starting Langium parse");const e=F6r.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[Railroad Parser] Parsed rules:",r.rules.length),H6r(r),ce.debug("[Railroad Parser] Parse complete")},"parse"),parser:{yy:Pi}},G6r={parser:V6r,db:Pi,renderer:P_,styles:__};const W6r=Object.freeze(Object.defineProperty({__proto__:null,diagram:G6r},Symbol.toStringTag,{value:"Module"}));var q6r=Jre().RailroadEbnf.parser.LangiumParser,GA=x(t=>{const e=t.alternatives.map(j6r);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformChoice"),j6r=x(t=>{const e=t.elements.map(X6r);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),iYe=x(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 GA(t.element);case"EbnfOptional":return{type:"optional",element:GA(t.element)};case"EbnfRepetition":return{type:"repetition",element:GA(t.element),min:0,max:1/0};default:throw new Error(`Unsupported EBNF primary node: ${t.$type}`)}},"transformPrimary"),Y6r=x((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:"-"},iYe(e.except)]};default:throw new Error(`Unsupported EBNF postfix node: ${e.$type}`)}},"transformPostfix"),X6r=x(t=>t.postfixes.reduce((e,r)=>Y6r(e,r),iYe(t.base)),"transformTerm"),K6r=x(t=>({name:t.name,definition:GA(t.definition)}),"transformRule"),Z6r=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(K6r(e)))},"populateDb"),J6r={parse:x(t=>{Pi.clear(),ce.debug("[EBNF Parser] Starting Langium parse");const e=q6r.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[EBNF Parser] Parsed rules:",r.rules.length),Z6r(r),ce.debug("[EBNF Parser] Parse complete")},"parse"),parser:{yy:Pi}},Q6r={parser:J6r,db:Pi,renderer:P_,styles:__};const evr=Object.freeze(Object.defineProperty({__proto__:null,diagram:Q6r},Symbol.toStringTag,{value:"Module"}));var tvr=Qre().RailroadAbnf.parser.LangiumParser,YY=x(t=>{const e=t.alternatives.map(rvr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformAlternation"),rvr=x(t=>{const e=t.elements.map(ivr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformConcatenation"),nvr=x(t=>{if(t.includes("*")){const[r,n]=t.split("*"),i=r?parseInt(r,10):0,a=n?parseInt(n,10):1/0;return{min:i,max:a}}const e=parseInt(t,10);return{min:e,max:e}},"parseRepeat"),ivr=x(t=>{const e=avr(t.primary);if(!t.repeat)return e;const{min:r,max:n}=nvr(t.repeat);return r===0&&n===1?{type:"optional",element:e}:{type:"repetition",element:e,min:r,max:n}},"transformElement"),avr=x(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 YY(t.element);case"AbnfOptionalGroup":return{type:"optional",element:YY(t.element)};default:throw new Error(`Unsupported ABNF primary node: ${t.$type}`)}},"transformPrimary"),svr=x(t=>({name:t.name,definition:YY(t.definition)}),"transformRule"),ovr=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(svr(e)))},"populateDb"),lvr={parse:x(t=>{Pi.clear(),ce.debug("[ABNF Parser] Starting Langium parse");const e=tvr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[ABNF Parser] Parsed rules:",r.rules.length),ovr(r),ce.debug("[ABNF Parser] Parse complete")},"parse"),parser:{yy:Pi}},cvr={parser:lvr,db:Pi,renderer:P_,styles:__};const uvr=Object.freeze(Object.defineProperty({__proto__:null,diagram:cvr},Symbol.toStringTag,{value:"Module"}));var dvr=ene().RailroadPeg.parser.LangiumParser,aYe=x(t=>{const e=t.alternatives.map(hvr);return e.length===1?e[0]:{type:"choice",alternatives:e}},"transformOrderedChoice"),hvr=x(t=>{const e=t.elements.map(fvr);return e.length===1?e[0]:{type:"sequence",elements:e}},"transformSequence"),fvr=x(t=>{const e=pvr(t.suffix);return t.operator?{type:"special",text:t.operator==="&"?`&${zUe(e)}`:`!${zUe(e)}`}:e},"transformPrefix"),zUe=x(t=>{switch(t.type){case"terminal":return`"${t.value}"`;case"nonterminal":return t.name;case"special":return t.text;default:return"(...)"}},"nodeToLabel"),pvr=x(t=>{const e=gvr(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"),gvr=x(t=>{switch(t.$type){case"PegLiteral":return{type:"terminal",value:t.value};case"PegIdentifier":return{type:"nonterminal",name:t.name};case"PegGroup":return aYe(t.element);case"PegAny":return{type:"special",text:t.dot};default:throw new Error(`Unsupported PEG primary node: ${t.$type}`)}},"transformPrimary"),mvr=x(t=>({name:t.name,definition:aYe(t.definition)}),"transformRule"),Uvr=x(t=>{Tl(t,Pi),t.title&&Pi.setTitle(t.title),t.rules.map(e=>Pi.addRule(mvr(e)))},"populateDb"),yvr={parse:x(t=>{Pi.clear(),ce.debug("[PEG Parser] Starting Langium parse");const e=dvr.parse(t);if(e.lexerErrors.length>0||e.parserErrors.length>0)throw new VC(e);const r=e.value;ce.debug("[PEG Parser] Parsed rules:",r.rules.length),Uvr(r),ce.debug("[PEG Parser] Parse complete")},"parse"),parser:{yy:Pi}},bvr={parser:yvr,db:Pi,renderer:P_,styles:__};const vvr=Object.freeze(Object.defineProperty({__proto__:null,diagram:bvr},Symbol.toStringTag,{value:"Module"}));var xvr=1;function tie(){if(!(typeof globalThis>"u"))return globalThis}x(tie,"getCaptureGlobal");function sYe(){var t;return!!((t=tie())!=null&&t.mermaidCaptureSizes)}x(sYe,"shouldCaptureSizes");function oYe(){return typeof location>"u"?"browser-dev":`${location.pathname}${location.search}`}x(oYe,"capturedFromLocation");function lYe(t,e){const r=tie();if(!r)return;const n=e.node(),i=(n&&"ownerSVGElement"in n?n.ownerSVGElement:null)??n,a=(i==null?void 0:i.id)??"(unknown)";r.mermaidCapturedSizes??(r.mermaidCapturedSizes=[]);const s={svgId:a,sizes:t};r.mermaidCapturedSizes.push(s),r.mermaidLastCapturedSizes=s}x(lYe,"emitCapturedSizes");function cYe(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&&lYe({metadata:{captureVersion:xvr,capturedAt:new Date().toISOString(),capturedFrom:oYe()},nodes:r},t)}x(cYe,"captureNodeSizes");const wvr=Object.freeze(Object.defineProperty({__proto__:null,captureNodeSizes:cYe,shouldCaptureSizes:sYe},Symbol.toStringTag,{value:"Module"})),Tvr=Object.freeze(Object.defineProperty({__proto__:null,InfoModule:TVe,createInfoServices:CVe},Symbol.toStringTag,{value:"Module"})),Cvr=Object.freeze(Object.defineProperty({__proto__:null,PacketModule:SVe,createPacketServices:EVe},Symbol.toStringTag,{value:"Module"})),Svr=Object.freeze(Object.defineProperty({__proto__:null,PieModule:kVe,createPieServices:AVe},Symbol.toStringTag,{value:"Module"})),Evr=Object.freeze(Object.defineProperty({__proto__:null,TreeViewModule:MVe,createTreeViewServices:RVe},Symbol.toStringTag,{value:"Module"})),kvr=Object.freeze(Object.defineProperty({__proto__:null,ArchitectureModule:IVe,createArchitectureServices:LVe},Symbol.toStringTag,{value:"Module"})),Avr=Object.freeze(Object.defineProperty({__proto__:null,GitGraphModule:xVe,createGitGraphServices:wVe},Symbol.toStringTag,{value:"Module"})),Mvr=Object.freeze(Object.defineProperty({__proto__:null,EventModelingModule:OVe,createEventModelingServices:DVe},Symbol.toStringTag,{value:"Module"})),Rvr=Object.freeze(Object.defineProperty({__proto__:null,RadarModule:lVe,createRadarServices:cVe},Symbol.toStringTag,{value:"Module"})),Ivr=Object.freeze(Object.defineProperty({__proto__:null,RailroadModule:uVe,createRailroadServices:Zre},Symbol.toStringTag,{value:"Module"})),Lvr=Object.freeze(Object.defineProperty({__proto__:null,RailroadEbnfModule:dVe,createRailroadEbnfServices:Jre},Symbol.toStringTag,{value:"Module"})),Nvr=Object.freeze(Object.defineProperty({__proto__:null,RailroadAbnfModule:hVe,createRailroadAbnfServices:Qre},Symbol.toStringTag,{value:"Module"})),Ovr=Object.freeze(Object.defineProperty({__proto__:null,RailroadPegModule:fVe,createRailroadPegServices:ene},Symbol.toStringTag,{value:"Module"})),Dvr=Object.freeze(Object.defineProperty({__proto__:null,TreemapModule:gVe,createTreemapServices:mVe},Symbol.toStringTag,{value:"Module"})),_vr=Object.freeze(Object.defineProperty({__proto__:null,WardleyModule:UVe,createWardleyServices:yVe},Symbol.toStringTag,{value:"Module"})),Pvr=Object.freeze(Object.defineProperty({__proto__:null,CynefinModule:bVe,createCynefinServices:vVe},Symbol.toStringTag,{value:"Module"})); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 8ad8f8b0..7b067305 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "EdgeEver", "slug": "edgeever", "scheme": "edgeever", - "version": "2.14.0", + "version": "2.15.0", "runtimeVersion": { "policy": "appVersion" }, @@ -32,7 +32,7 @@ }, "android": { "package": "org.edgeever.mobile", - "versionCode": 251, + "versionCode": 252, "adaptiveIcon": { "backgroundColor": "#16A06E", "backgroundImage": "./assets/icon.png", diff --git a/apps/web/src/components/EditorOutline.tsx b/apps/web/src/components/EditorOutline.tsx index f1ebbe9a..54a920d2 100644 --- a/apps/web/src/components/EditorOutline.tsx +++ b/apps/web/src/components/EditorOutline.tsx @@ -320,7 +320,7 @@ export const EditorOutline = ({ editor, memoId, editingMemoIdRef, scrollContaine "select-none overflow-x-hidden", collapsed ? "pointer-events-auto absolute inset-0 overflow-hidden" - : "sticky top-6 h-fit max-h-[calc(100vh-8rem)] shrink-0 overflow-y-auto py-2" + : "sticky top-6 h-fit max-h-[calc(100vh-8rem)] shrink-0 overflow-y-auto pt-2 pb-16" )} style={!collapsed ? { width: EDITOR_OUTLINE_WIDTH } : undefined} aria-label={t("editor.outline")} diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index a31d5ff5..02b56e7b 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -3929,7 +3929,6 @@ const RichEditorPane = ({ && !phonePreviewOpen; const outlineReservesSpace = showDocumentOutline && !editorOutlineCollapsed; const compactEditorReadingGutter = shouldCompactEditorReadingGutter({ - aiAssistantOpen, desktopColumn: isDesktopColumn, columnWidth: Math.max(0, editorColumnWidth - editorScrollbarGutter * 2), articleMaxWidth: Number.parseInt(EDITOR_CONTENT_MAX_WIDTH, 10), @@ -4738,6 +4737,7 @@ const RichEditorPane = ({ )} + {/* Overlay the article/outline; horizontal space belongs to the document. */} {!effectiveReadOnly && !aiAssistantOpen ? ( item.key !== "shortcuts"); + const mobileTabItems = tabItems.filter((item) => item.key !== "shortcuts" || import.meta.env.DEV); const handleBack = () => { if (window.matchMedia("(max-width: 1023px)").matches && activeMobileTab !== null) { diff --git a/apps/web/src/components/dialogs/ShareMemoDialog.test.mjs b/apps/web/src/components/dialogs/ShareMemoDialog.test.mjs new file mode 100644 index 00000000..b5b1f4fb --- /dev/null +++ b/apps/web/src/components/dialogs/ShareMemoDialog.test.mjs @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { getPublicShareUrl } from "./ShareMemoDialog.tsx"; + +const source = readFileSync(new URL("./ShareMemoDialog.tsx", import.meta.url), "utf8"); + +describe("ShareMemoDialog raw markdown link support", () => { + test("generates public share URLs and .md alternates", () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + try { + delete globalThis.window; + const url = getPublicShareUrl("my-custom-note"); + expect(url).toBe("/share/my-custom-note"); + expect(`${url}.md`).toBe("/share/my-custom-note.md"); + + globalThis.window = { location: { origin: "https://notes.example.com" } }; + expect(getPublicShareUrl("我的笔记")).toBe(`https://notes.example.com/share/${encodeURIComponent("我的笔记")}`); + } finally { + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow); + else delete globalThis.window; + } + }); + + test("renders compact raw markdown strip with copy button and i18n labels", () => { + expect(source).toContain('t("sharing.markdownRawLabel")'); + expect(source).toContain('"sharing.copyMarkdownLink"'); + expect(source).toContain('disabled={Boolean(share.passwordProtected)}'); + expect(source).toContain('onClick={() => void copyValue("markdown", markdownUrl)}'); + }); + + test("renders open markdown button in actions footer when not password protected", () => { + expect(source).toContain('t("sharing.openMarkdown")'); + expect(source).toContain('window.open(markdownUrl, "_blank", "noopener,noreferrer")'); + expect(source).toContain('!share.passwordProtected'); + }); + + test("switches compact hint text when password protection is active", () => { + expect(source).toContain('share.passwordProtected\n ? t("sharing.markdownPasswordProtectedHint")\n : t("sharing.markdownHint")'); + }); +}); diff --git a/apps/web/src/components/dialogs/ShareMemoDialog.tsx b/apps/web/src/components/dialogs/ShareMemoDialog.tsx index f3b36c7d..c35e68d6 100644 --- a/apps/web/src/components/dialogs/ShareMemoDialog.tsx +++ b/apps/web/src/components/dialogs/ShareMemoDialog.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Copy, ExternalLink, Globe, LoaderCircle, Share2, Trash2 } from "lucide-react"; +import { Check, Copy, ExternalLink, FileCode2, Globe, LoaderCircle, Share2, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; @@ -16,7 +16,7 @@ import { copyTextToClipboard } from "@/lib/clipboard"; import { MemoNotebookSites } from "../MemoNotebookSites"; export const getPublicShareUrl = (token: string) => { - const baseUrl = getConfiguredDesktopApiBaseUrl() || window.location.origin; + const baseUrl = getConfiguredDesktopApiBaseUrl() || (typeof window !== "undefined" ? window.location.origin : ""); return `${baseUrl.replace(/\/$/, "")}/share/${encodeURIComponent(token)}`; }; @@ -35,7 +35,7 @@ export const ShareMemoDialog = ({ const location = useLocation(); const queryClient = useQueryClient(); const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle"); - const [copyTarget, setCopyTarget] = useState<"link" | "password" | "both">("link"); + const [copyTarget, setCopyTarget] = useState<"link" | "password" | "both" | "markdown">("link"); const copyResetTimerRef = useRef(null); const dialogContentRef = useRef(null); const queryKey = memoShareQueryKey(memoId); @@ -91,11 +91,12 @@ export const ShareMemoDialog = ({ const revealedPassword = share?.passwordProtected ? share.password ?? "" : ""; const shareUrl = share ? getPublicShareUrl(memoSharePathSegment(share)) : ""; + const markdownUrl = shareUrl ? `${shareUrl}.md` : ""; const isWorking = shareQuery.isLoading || createMutation.isPending || passwordMutation.isPending || slugMutation.isPending || revokeMutation.isPending; const slugTaken = slugMutation.error instanceof ApiRequestError && slugMutation.error.code === "share_slug_taken"; const error = shareQuery.error || createMutation.error || passwordMutation.error || (slugTaken ? null : slugMutation.error) || revokeMutation.error; - const markCopied = (target: "link" | "password" | "both", copied: boolean) => { + const markCopied = (target: "link" | "password" | "both" | "markdown", copied: boolean) => { setCopyTarget(target); setCopyState(copied ? "copied" : "error"); if (copyResetTimerRef.current !== null) window.clearTimeout(copyResetTimerRef.current); @@ -105,7 +106,7 @@ export const ShareMemoDialog = ({ }, 3000); }; - const copyValue = async (target: "link" | "password" | "both", value: string) => { + const copyValue = async (target: "link" | "password" | "both" | "markdown", value: string) => { markCopied(target, await copyTextToClipboard(value)); }; @@ -164,6 +165,35 @@ export const ShareMemoDialog = ({ +
+ + + {t("sharing.markdownRawLabel")} + + · {share.passwordProtected + ? t("sharing.markdownPasswordProtectedHint") + : t("sharing.markdownHint")} + + + +
+ {t("sharing.revoke")} - +
+ {!share.passwordProtected ? ( + + ) : null} + +
) : ( diff --git a/apps/web/src/components/editor/image-drag.test.mjs b/apps/web/src/components/editor/image-drag.test.mjs index d2275669..a4dc868f 100644 --- a/apps/web/src/components/editor/image-drag.test.mjs +++ b/apps/web/src/components/editor/image-drag.test.mjs @@ -4,7 +4,7 @@ import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; import { EditorState } from "@tiptap/pm/state"; import { history, undo, redo } from "@tiptap/pm/history"; -import { ImageGallery } from "@edgeever/shared"; +import { ImageGallery, createImageGalleryCleanupPlugin } from "@edgeever/shared"; import { createImageDragPlugin, imageDragKey, moveImage } from "./image-drag"; const schema = getSchema([StarterKit, Image.extend({ addAttributes() { @@ -52,6 +52,22 @@ describe("existing image dragging (#708)", () => { expect(next.doc.childCount).toBe(1); expect(sources(next.doc)).toEqual(["B", "A", "C"]); }); + test("moving one image out of a two-image gallery leaves the other standalone (#728)", () => { + // The editor runs the gallery cleanup plugin alongside dragging. + const before = EditorState.create({ + doc: schema.nodes.doc.create(null, [gallery("A", "B"), paragraph("text")]), + plugins: [history(), createImageDragPlugin(), createImageGalleryCleanupPlugin()], + }); + let next = move(before, 2, before.doc.content.size); + const moved = next.doc.toJSON(); + expect(moved).toEqual(schema.nodes.doc.create(null, [image("A"), paragraph("text"), image("B")]).toJSON()); + expect(next.doc.firstChild).toBe(before.doc.firstChild.firstChild); + expect(next.selection.node.attrs.src).toBe("B"); + expect(undo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true); + expect(next.doc.toJSON()).toEqual(before.doc.toJSON()); + expect(redo(next, (tr) => { next = next.applyTransaction(tr).state; })).toBe(true); + expect(next.doc.toJSON()).toEqual(moved); + }); test("one undo restores the original document; redo repeats the move", () => { const before = state(gallery("A"), paragraph("text"), gallery("B", "C")); let next = move(before, 1, 11); @@ -95,3 +111,25 @@ describe("existing image dragging (#708)", () => { expect(imageDragKey.getState(next)).toBeNull(); }); }); + +describe("dragging out of a two-image gallery (#728)", () => { + for (const from of [1, 2]) { + test(`moving image ${from} preserves attributes, selection and one-step undo`, () => { + const before = EditorState.create({ + doc: schema.nodes.doc.create(null, [gallery("A", "B"), paragraph("text")]), + plugins: [history(), createImageDragPlugin(), createImageGalleryCleanupPlugin()], + }); + const moved = before.doc.nodeAt(from); + const survivor = before.doc.firstChild.child(from === 1 ? 1 : 0); + let next = move(before, from, before.doc.content.size); + expect(next.doc.firstChild).toBe(survivor); + expect(next.doc.lastChild).toBe(moved); + expect(next.selection.node).toBe(moved); + const after = next.doc; + expect(undo(next, tr => { next = next.applyTransaction(tr).state; })).toBe(true); + expect(next.doc.eq(before.doc)).toBe(true); + expect(redo(next, tr => { next = next.applyTransaction(tr).state; })).toBe(true); + expect(next.doc.eq(after)).toBe(true); + }); + } +}); diff --git a/apps/web/src/components/settings/DesktopScheduledBackupCard.tsx b/apps/web/src/components/settings/DesktopScheduledBackupCard.tsx index ded94331..0e8fce42 100644 --- a/apps/web/src/components/settings/DesktopScheduledBackupCard.tsx +++ b/apps/web/src/components/settings/DesktopScheduledBackupCard.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs"; import { describeDesktopBackupError, useDesktopBackup } from "@/lib/desktop-backup"; +import { formatBytes } from "@/lib/format-bytes"; import { DesktopBackupImport } from "./DesktopBackupImport"; import { DesktopBackupRunDialog } from "./DesktopBackupRunDialog"; import { SETTINGS_CARD_DESCRIPTION_CLASSNAME, SETTINGS_CARD_HEADER_CLASSNAME, SETTINGS_CARD_ICON_CLASSNAME, SETTINGS_CARD_TITLE_CLASSNAME } from "./settings-ui"; @@ -37,6 +38,7 @@ export const DesktopScheduledBackupCard = () => { if (!bridge?.backup || !status || !config) return null; if (status.blocked) return {t("desktopBackup.title")}

{describeDesktopBackupError(t, status.error)}

; const busy = Boolean(status.task); + const lastBackup = status.history.find((item) => item.completedAt === status.lastSuccessAt); const pending = status.pendingPublication; const dirty = JSON.stringify(config) !== configKey || Boolean(password); const passwordNeeded = config.encrypted && status.secureStorageAvailable && (!status.passwordSet || changePassword); @@ -71,6 +73,7 @@ export const DesktopScheduledBackupCard = () => {

{t("desktopBackup.lastSuccess", { time: date(status.lastSuccessAt) })}

+ {lastBackup ?

{t("desktopBackup.lastBackupSize", { size: formatBytes(lastBackup.size) })}

: null} {status.task ?

{t(`desktopBackup.phases.${status.task.phase}`)}{status.task.total > 0 ? ` ${status.task.completed} / ${status.task.total}` : ""}

: status.lastCheck ?

{t(`desktopBackup.checkResults.${status.lastCheck.result}`)}

: null}

{t("desktopBackup.lastCheck", { time: status.lastCheck ? date(status.lastCheck.at) : t("desktopBackup.notChecked") })}

{status.config.enabled ? t("desktopBackup.nextDue", { time: status.nextDueAt ? date(status.nextDueAt) : t("desktopBackup.whenReady") }) : t("desktopBackup.autoOff")}

@@ -126,7 +129,7 @@ export const DesktopScheduledBackupCard = () => {

{t("desktopBackup.history", { count: status.history.length })}

{status.history.length ?
    {[...status.history].reverse().map((item) =>
  • -

    {item.name}

    {date(item.completedAt)} · {(item.size / 1048576).toFixed(1)} MiB · {t(item.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}

    +

    {item.name}

    {date(item.completedAt)} · {formatBytes(item.size)} · {t(item.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}

  • )}
:

{t("desktopBackup.emptyHistory")}

}
diff --git a/apps/web/src/components/settings/ShortcutSettingsItem.tsx b/apps/web/src/components/settings/ShortcutSettingsItem.tsx index 6eab3f30..170e9ad3 100644 --- a/apps/web/src/components/settings/ShortcutSettingsItem.tsx +++ b/apps/web/src/components/settings/ShortcutSettingsItem.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { RotateCcw } from "lucide-react"; +import { RotateCcw, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { ShortcutAction, ShortcutBinding, ShortcutSettings } from "@/lib/app-helpers"; import { @@ -12,6 +12,7 @@ import { shortcutBindingsEqual, } from "@/lib/app-helpers"; import { Button } from "@/components/ui/button"; +import { ButtonTooltip } from "@/components/ui/button-tooltip"; import { cn } from "@/lib/utils"; import { SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui"; @@ -67,11 +68,17 @@ const globalBindingMatchesLocal = (global: DesktopGlobalShortcutBinding, local: && global.shift === local.shift; }; +const devPreviewGlobalShortcutState = (): DesktopGlobalShortcutState | null => ( + import.meta.env.DEV && typeof window !== "undefined" && !window.edgeeverDesktop?.globalShortcut + ? { binding: null, registered: false, screenshot: null, screenshotRegistered: false } + : null +); + export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChange }: ShortcutSettingsItemProps) => { const { t } = useTranslation(); const [recordingAction, setRecordingAction] = useState(null); const [captureMessage, setCaptureMessage] = useState(""); - const [globalShortcut, setGlobalShortcut] = useState(null); + const [globalShortcut, setGlobalShortcut] = useState(devPreviewGlobalShortcutState); const [recordingTarget, setRecordingTarget] = useState(null); const [globalBusy, setGlobalBusy] = useState(false); const [globalMessage, setGlobalMessage] = useState(""); @@ -80,11 +87,13 @@ export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChang useEffect(() => { let active = true; - void window.edgeeverDesktop?.globalShortcut().then((state) => { - if (active) setGlobalShortcut(state); - }).catch(() => { - if (active) setGlobalMessage(t("shortcuts.global.saveFailed")); - }); + if (window.edgeeverDesktop?.globalShortcut) { + void window.edgeeverDesktop.globalShortcut().then((state) => { + if (active) setGlobalShortcut(state); + }).catch(() => { + if (active) setGlobalMessage(t("shortcuts.global.saveFailed")); + }); + } return () => { active = false; }; }, [t]); @@ -92,7 +101,9 @@ export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChang const beginRecording = async (action: ShortcutAction | GlobalShortcutTarget) => { try { - await window.edgeeverDesktop?.captureGlobalShortcut(true); + if (window.edgeeverDesktop?.captureGlobalShortcut) { + await window.edgeeverDesktop.captureGlobalShortcut(true); + } if (action === "window" || action === "screenshot") { setRecordingTarget(action); setGlobalMessage(""); @@ -142,12 +153,23 @@ export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChang finishRecording(); setGlobalBusy(true); void (async () => { - await window.edgeeverDesktop?.captureGlobalShortcut(false); + if (window.edgeeverDesktop?.captureGlobalShortcut) { + await window.edgeeverDesktop.captureGlobalShortcut(false); + } const setter = target === "screenshot" ? window.edgeeverDesktop?.setScreenshotShortcut : window.edgeeverDesktop?.setGlobalShortcut; - const state = await setter?.(binding); - if (state) { - setGlobalShortcut(state); - setGlobalMessage(state.error ? t(`shortcuts.global.${state.error}`) : ""); + if (setter) { + const state = await setter(binding); + if (state) { + setGlobalShortcut(state); + setGlobalMessage(state.error ? t(`shortcuts.global.${state.error}`) : ""); + } + } else if (import.meta.env.DEV) { + setGlobalShortcut((prev) => ({ + binding: target === "window" ? binding : (prev?.binding ?? null), + registered: true, + screenshot: target === "screenshot" ? binding : (prev?.screenshot ?? null), + screenshotRegistered: true, + })); } })().catch(() => setGlobalMessage(t("shortcuts.global.saveFailed"))).finally(() => setGlobalBusy(false)); }; @@ -219,104 +241,152 @@ export const ShortcutSettingsItem = ({ shortcutSettings, onShortcutSettingsChang const disableGlobalShortcut = (target: GlobalShortcutTarget) => { setGlobalBusy(true); const setter = target === "screenshot" ? window.edgeeverDesktop?.setScreenshotShortcut : window.edgeeverDesktop?.setGlobalShortcut; - void setter?.(null).then((state) => { - if (state) { - setGlobalShortcut(state); - setGlobalMessage(state.error ? t(`shortcuts.global.${state.error}`) : ""); - } - }).catch(() => setGlobalMessage(t("shortcuts.global.saveFailed"))).finally(() => setGlobalBusy(false)); + if (setter) { + void setter(null).then((state) => { + if (state) { + setGlobalShortcut(state); + setGlobalMessage(state.error ? t(`shortcuts.global.${state.error}`) : ""); + } + }).catch(() => setGlobalMessage(t("shortcuts.global.saveFailed"))).finally(() => setGlobalBusy(false)); + } else if (import.meta.env.DEV) { + setGlobalShortcut((prev) => ({ + binding: target === "window" ? null : (prev?.binding ?? null), + registered: target === "window" ? false : Boolean(prev?.registered), + screenshot: target === "screenshot" ? null : (prev?.screenshot ?? null), + screenshotRegistered: target === "screenshot" ? false : Boolean(prev?.screenshotRegistered), + })); + setGlobalBusy(false); + } }; - const globalRows: Array<{ target: GlobalShortcutTarget; label: string; description: string; binding: DesktopGlobalShortcutBinding | null | undefined; registered: boolean }> = [ + const globalRows: Array<{ + target: GlobalShortcutTarget; + label: string; + binding: DesktopGlobalShortcutBinding | null | undefined; + registered: boolean; + }> = [ { target: "window", label: t("shortcuts.global.label"), - description: t("shortcuts.global.description"), binding: globalShortcut?.binding, registered: Boolean(globalShortcut?.registered), }, { target: "screenshot", label: t("shortcuts.screenshot.label"), - description: t("shortcuts.screenshot.description"), binding: globalShortcut?.screenshot, registered: Boolean(globalShortcut?.screenshotRegistered), }, ]; + const hasUnregisteredGlobal = globalRows.some((row) => row.binding && !row.registered); + const isGlobalShortcutAvailable = Boolean(window.edgeeverDesktop?.isAvailable || import.meta.env.DEV); + return ( -
- {window.edgeeverDesktop?.isAvailable ? ( -
- {globalRows.map((row, index) => { - const recording = recordingTarget === row.target; - return ( -
0 && "border-t border-slate-100")}> -
-
-
{row.label}
-

{row.description}

-
-
- +
+ ); + })} +
+ {captureMessage ? ( +
+ {captureMessage} +
+ ) : null} + + {isGlobalShortcutAvailable ? ( +
+
+ {t("shortcuts.globalSectionTitle")} +
+
+ {globalRows.map((row) => { + const recording = recordingTarget === row.target; + return ( +
+
{row.label}
+
+ {row.binding ? ( - + + + ) : null}
- {row.binding && !row.registered && !globalMessage ? ( -
{t("shortcuts.global.unavailable")}
- ) : null} -
- ); - })} - {globalMessage ?
{globalMessage}
: null} + ); + })} +
+ {globalMessage ? ( +
+ {globalMessage} +
+ ) : hasUnregisteredGlobal ? ( +
+ {t("shortcuts.global.unavailable")} +
+ ) : null}
) : null} -
-
- {shortcutActionOptions.map((item, index) => { - const recording = recordingAction === item.value; - const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1; - return ( -
-
{item.label}
- -
- ); - })} -
- {captureMessage ? ( -
- {captureMessage} -
- ) : null} -
- -
+
+
); diff --git a/apps/web/src/lib/editor-reading-gutter.test.mjs b/apps/web/src/lib/editor-reading-gutter.test.mjs index f56a1ee5..4404cf34 100644 --- a/apps/web/src/lib/editor-reading-gutter.test.mjs +++ b/apps/web/src/lib/editor-reading-gutter.test.mjs @@ -12,28 +12,25 @@ const comfortableWithOutline = standardReading + outlineBesideArticle + EDITOR_DESKTOP_READING_GUTTER_PX * 2; describe("editor reading gutter", () => { - test("keeps the 6rem gutters when the assistant is closed", () => { + test("returns narrow-window space to the article even with the assistant closed", () => { expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: false, desktopColumn: true, columnWidth: 920, articleMaxWidth: standardReading, reservedBesideArticle: outlineBesideArticle, - })).toBe(false); + })).toBe(true); }); - test("compacts once an open assistant leaves no room for the article, outline, and 6rem gutters", () => { + test("compacts when the column cannot fit the article, outline, and 6rem gutters", () => { expect(comfortableWithOutline).toBe(1404); expect(EDITOR_PANE_TIGHT_PX).toBe(720); expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: 920, articleMaxWidth: standardReading, reservedBesideArticle: outlineBesideArticle, })).toBe(true); expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: comfortableWithOutline, articleMaxWidth: standardReading, @@ -43,14 +40,12 @@ describe("editor reading gutter", () => { test("keeps the wide reading measure when that column still fits", () => { expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: 1600, articleMaxWidth: 1040, reservedBesideArticle: outlineBesideArticle, })).toBe(false); expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: 1563, articleMaxWidth: 1040, @@ -60,14 +55,12 @@ describe("editor reading gutter", () => { test("leaves the narrower breakpoints and an unmeasured column alone", () => { expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: false, columnWidth: 800, articleMaxWidth: standardReading, reservedBesideArticle: outlineBesideArticle, })).toBe(false); expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: 0, articleMaxWidth: standardReading, @@ -77,7 +70,6 @@ describe("editor reading gutter", () => { test("uses the 1400px focus row when deciding", () => { expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: 1800, articleMaxWidth: 960, @@ -85,7 +77,6 @@ describe("editor reading gutter", () => { focusRow: true, })).toBe(true); expect(shouldCompactEditorReadingGutter({ - aiAssistantOpen: true, desktopColumn: true, columnWidth: 1800, articleMaxWidth: 960, diff --git a/apps/web/src/lib/editor-reading-gutter.ts b/apps/web/src/lib/editor-reading-gutter.ts index 87ec0aaf..6c41e421 100644 --- a/apps/web/src/lib/editor-reading-gutter.ts +++ b/apps/web/src/lib/editor-reading-gutter.ts @@ -28,26 +28,24 @@ export const editorColumnFitsDesktopReadingGutters = ({ columnWidth >= articleMaxWidth + reservedBesideArticle + EDITOR_DESKTOP_READING_GUTTER_PX * 2; /** - * While the companion sidebar is open, yield the 6rem reading gutters as soon - * as the column can no longer hold the chosen article width, the outline, and - * those gutters. The shared `lg:` gutter stays 6rem for every other width. + * Yield the 6rem gutters whenever the editor column cannot fit the article + * and outline at their preferred widths. The floating AI launcher overlays + * either surface; it must not require a dedicated strip beside the outline. */ export const shouldCompactEditorReadingGutter = ({ - aiAssistantOpen, desktopColumn, columnWidth, articleMaxWidth, reservedBesideArticle = 0, focusRow = false, }: { - aiAssistantOpen: boolean; desktopColumn: boolean; columnWidth: number; articleMaxWidth: number; reservedBesideArticle?: number; focusRow?: boolean; }) => { - if (!aiAssistantOpen || !desktopColumn || columnWidth <= 0) return false; + if (!desktopColumn || columnWidth <= 0) return false; if (!Number.isFinite(articleMaxWidth) || articleMaxWidth <= 0) return false; const available = focusRow ? Math.min(columnWidth, EDITOR_FOCUS_ROW_MAX_PX) : columnWidth; return !editorColumnFitsDesktopReadingGutters({ diff --git a/package.json b/package.json index 7630d643..d21081f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "edgeever", - "version": "2.14.0", + "version": "2.15.0", "packageManager": "bun@1.3.14", "private": true, "type": "module", diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 3c5c256e..068d3fd6 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -2041,6 +2041,7 @@ export const enUS = { openFolder: "Open folder", never: "Not yet", lastSuccess: "Last successful backup: {{time}}", + lastBackupSize: "Latest backup size: {{size}}", nextDue: "Next automatic check: {{time}}", whenReady: "When the app is ready", stagingWarning: "Private temporary files could not be removed. Check the app data folder permissions and restart.", @@ -2186,6 +2187,10 @@ export const enUS = { confirmImport: "Import", }, shortcuts: { + globalSectionTitle: "Global shortcuts", + globalSectionTip: "Works from other apps; saved only on this device", + appSectionTitle: "Shortcuts", + appSectionTip: "Active when the window is in focus", global: { label: "Show or hide desktop window", description: "Works from other apps. This shortcut is saved only on this device.", @@ -2949,6 +2954,11 @@ export const enUS = { copied: "Copied", copyFailed: "Copy failed", open: "Open shared note", + openMarkdown: "View .md", + markdownRawLabel: "Raw Markdown", + copyMarkdownLink: "Copy .md link", + markdownHint: "Ideal for AI reference or importing into other apps", + markdownPasswordProtectedHint: "Unavailable with password", revoke: "Revoke sharing", error: "Sharing could not be updated. Please try again.", passwordToggle: "Access password", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 249f1ae7..fef29d56 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2030,6 +2030,7 @@ export const ja = { openFolder: "フォルダーを開く", never: "未完了", lastSuccess: "前回のバックアップ成功:{{time}}", + lastBackupSize: "最新のバックアップサイズ:{{size}}", nextDue: "次回の自動確認:{{time}}", whenReady: "アプリの準備ができ次第", stagingWarning: "非公開の一時ファイルを削除できません。データフォルダーの権限を確認し、再起動してください。", @@ -2175,6 +2176,10 @@ export const ja = { confirmImport: "取り込む", }, shortcuts: { + globalSectionTitle: "グローバルショートカット", + globalSectionTip: "他のアプリからでも使用可能(この端末にのみ保存)", + appSectionTitle: "ショートカット", + appSectionTip: "ウィンドウがアクティブなときに有効", global: { label: "デスクトップウィンドウの表示・非表示", description: "他のアプリからも使用できます。このショートカットはこの端末にのみ保存されます。", @@ -2938,6 +2943,11 @@ export const ja = { copied: "コピーしました", copyFailed: "コピーに失敗", open: "共有ノートを開く", + openMarkdown: ".md を表示", + markdownRawLabel: "Raw Markdown", + copyMarkdownLink: ".md リンクをコピー", + markdownHint: "AI での参照や他アプリへの取り込み向け", + markdownPasswordProtectedHint: "パスワード設定時は利用不可", revoke: "共有を取り消す", error: "共有を更新できませんでした。再試行してください。", passwordToggle: "アクセスパスワード", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 00e6a7eb..1cfb91c9 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -2030,6 +2030,7 @@ export const pl = { openFolder: "Otwórz folder", never: "Jeszcze nie", lastSuccess: "Ostatnia udana kopia: {{time}}", + lastBackupSize: "Rozmiar ostatniej kopii: {{size}}", nextDue: "Następne automatyczne sprawdzenie: {{time}}", whenReady: "Gdy aplikacja będzie gotowa", stagingWarning: "Nie usunięto prywatnych plików tymczasowych. Sprawdź uprawnienia folderu danych i uruchom aplikację ponownie.", @@ -2175,6 +2176,10 @@ export const pl = { confirmImport: "Importuj", }, shortcuts: { + globalSectionTitle: "Skróty globalne", + globalSectionTip: "Działają w innych aplikacjach; zapisane tylko na tym urządzeniu", + appSectionTitle: "Skróty", + appSectionTip: "Aktywne, gdy okno jest aktywne", global: { label: "Pokaż lub ukryj okno aplikacji", description: "Działa także w innych aplikacjach. Skrót jest zapisany tylko na tym urządzeniu.", @@ -2938,6 +2943,11 @@ export const pl = { copied: "Skopiowano", copyFailed: "Nie udało się skopiować", open: "Otwórz udostępnioną notatkę", + openMarkdown: "Zobacz .md", + markdownRawLabel: "Surowy Markdown", + copyMarkdownLink: "Kopiuj link .md", + markdownHint: "Do kontekstu AI lub importu do innych aplikacji", + markdownPasswordProtectedHint: "Niedostępne z hasłem", revoke: "Wyłącz udostępnianie", error: "Nie udało się zaktualizować udostępniania. Spróbuj ponownie.", passwordToggle: "Hasło dostępu", diff --git a/packages/shared/src/i18n/ru.ts b/packages/shared/src/i18n/ru.ts index 8b80f6b1..c988067e 100644 --- a/packages/shared/src/i18n/ru.ts +++ b/packages/shared/src/i18n/ru.ts @@ -2041,6 +2041,7 @@ export const ru = { openFolder: "Открыть папку", never: "Ещё не создана", lastSuccess: "Последняя успешная копия: {{time}}", + lastBackupSize: "Размер последней копии: {{size}}", nextDue: "Следующая автоматическая проверка: {{time}}", whenReady: "Когда приложение будет готово", stagingWarning: "Не удалось удалить временные файлы. Проверьте права доступа к папке данных приложения и перезапустите его.", @@ -2186,6 +2187,10 @@ export const ru = { confirmImport: "Импорт", }, shortcuts: { + globalSectionTitle: "Глобальные горячие клавиши", + globalSectionTip: "Работают в других приложениях; сохраняются только на этом устройстве", + appSectionTitle: "Горячие клавиши", + appSectionTip: "Действуют, когда окно в фокусе", global: { label: "Показать или скрыть окно", description: "Работает из других приложений. Сочетание хранится только на этом устройстве.", @@ -2949,6 +2954,11 @@ export const ru = { copied: "Скопировано", copyFailed: "Не удалось скопировать", open: "Открыть общую заметку", + openMarkdown: "Просмотр .md", + markdownRawLabel: "Исходный Markdown", + copyMarkdownLink: "Копировать ссылку .md", + markdownHint: "Для ИИ-контекста и импорта в другие приложения", + markdownPasswordProtectedHint: "Недоступно с паролем", revoke: "Отозвать доступ", error: "Не удалось обновить доступ. Попробуйте снова.", passwordToggle: "Пароль доступа", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 728e6caa..66a2c9d9 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2041,6 +2041,7 @@ export const zhCN = { openFolder: "打开文件夹", never: "尚未完成", lastSuccess: "上次成功备份:{{time}}", + lastBackupSize: "最近备份大小:{{size}}", nextDue: "下次自动检查:{{time}}", whenReady: "应用就绪后", stagingWarning: "私有临时文件清理失败。请检查应用数据目录权限并重启。", @@ -2186,6 +2187,10 @@ export const zhCN = { confirmImport: "确认导入", }, shortcuts: { + globalSectionTitle: "全局快捷键", + globalSectionTip: "在其他应用中也可使用,仅保存在当前设备", + appSectionTitle: "快捷键", + appSectionTip: "仅在窗口处于焦点时生效", global: { label: "显示或隐藏桌面窗口", description: "在其他应用中也可使用。此快捷键只保存在当前设备。", @@ -2947,6 +2952,11 @@ export const zhCN = { copied: "已复制", copyFailed: "复制失败", open: "打开分享页", + openMarkdown: "查看 .md", + markdownRawLabel: "原始 Markdown", + copyMarkdownLink: "复制 .md 链接", + markdownHint: "适合 AI 对话引用或跨软件导入", + markdownPasswordProtectedHint: "密码保护下不可用", revoke: "取消分享", error: "分享状态更新失败,请稍后重试。", passwordToggle: "访问密码", diff --git a/packages/shared/src/image-gallery-deletion.test.mjs b/packages/shared/src/image-gallery-deletion.test.mjs index 8a8ce207..090a3dd5 100644 --- a/packages/shared/src/image-gallery-deletion.test.mjs +++ b/packages/shared/src/image-gallery-deletion.test.mjs @@ -4,7 +4,7 @@ import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; import { EditorState, NodeSelection, TextSelection } from "@tiptap/pm/state"; import { closeHistory, history, undo, redo } from "@tiptap/pm/history"; -import { ImageGallery, createImageGalleryCleanupPlugin, TRANSIENT_IMAGE_UPLOAD_PREFIX } from "./image-gallery"; +import { ImageGallery, createImageGalleryCleanupPlugin, normalizeImageGalleries, TRANSIENT_IMAGE_UPLOAD_PREFIX } from "./image-gallery"; import { docToMarkdown, markdownToDoc } from "./content"; const schema = getSchema([StarterKit, Image, ImageGallery]); @@ -29,9 +29,10 @@ describe("live gallery deletion (#707)", () => { test("deleting both images leaves editable empty content and empty Markdown", () => { const e = editor([gallery("/one.jpg", "/two.jpg")]); deleteImage(e, 1); - expect(e.state.doc.firstChild.firstChild.attrs.src).toBe("/two.jpg"); - expect(e.state.doc.firstChild.attrs).toMatchObject({ layout: "3", fit: "height" }); - deleteImage(e, 1); + // The remaining image becomes standalone (#728). + expect(e.state.doc.firstChild.type.name).toBe("image"); + expect(e.state.doc.firstChild.attrs.src).toBe("/two.jpg"); + deleteImage(e, 0); expect(e.state.doc.toJSON()).toEqual({ type: "doc", content: [{ type: "paragraph" }] }); expect(e.state.selection).toBeInstanceOf(TextSelection); expect(docToMarkdown(e.state.doc.toJSON())).toBe(""); @@ -41,15 +42,15 @@ describe("live gallery deletion (#707)", () => { }); test("last-image deletion and cleanup undo and redo as one edit", () => { - const e = editor([gallery("/one.jpg", "/two.jpg")]); - deleteImage(e, 1); - e.dispatch(closeHistory(e.state.tr)); + const e = editor([paragraph("before"), gallery("/one.jpg", "/two.jpg")]); const before = e.state.doc.toJSON(); - deleteImage(e, 1); + const start = e.state.doc.firstChild.nodeSize + 1; + e.dispatch(e.state.tr.delete(start, start + 2)); + expect(e.state.doc.childCount).toBe(1); expect(undo(e.state, e.dispatch)).toBe(true); expect(e.state.doc.toJSON()).toEqual(before); expect(redo(e.state, e.dispatch)).toBe(true); - expect(docToMarkdown(e.state.doc.toJSON())).toBe(""); + expect(docToMarkdown(e.state.doc.toJSON())).toBe("before"); }); test("keyboard node-selection deletion preserves neighboring prose", () => { @@ -90,9 +91,108 @@ describe("live gallery deletion (#707)", () => { expect(e.state.doc.firstChild.firstChild.attrs.src).toBe(pending); }); + test("a gallery left with one image unwraps it with its attributes (#728)", () => { + const e = editor([paragraph("before"), gallery("/one.jpg", "/two.jpg"), paragraph("after")]); + const remaining = e.state.doc.child(1).lastChild; + deleteImage(e, e.state.doc.firstChild.nodeSize + 1); + expect(e.state.doc.childCount).toBe(3); + expect(e.state.doc.child(1)).toBe(remaining); + expect(e.state.doc.toJSON()).toEqual(normalizeImageGalleries(e.state.doc.toJSON())); + }); + + test("deletion and unwrapping undo and redo as one edit (#728)", () => { + const e = editor([gallery("/one.jpg", "/two.jpg")]); + e.dispatch(closeHistory(e.state.tr)); + const before = e.state.doc.toJSON(); + deleteImage(e, 2); + const after = e.state.doc.toJSON(); + expect(after).toEqual({ type: "doc", content: [image("/one.jpg").toJSON()] }); + expect(undo(e.state, e.dispatch)).toBe(true); + expect(e.state.doc.toJSON()).toEqual(before); + expect(redo(e.state, e.dispatch)).toBe(true); + expect(e.state.doc.toJSON()).toEqual(after); + }); + + test("keeps a selection on the remaining image when it is unwrapped (#728)", () => { + const e = editor([gallery("/one.jpg", "/two.jpg")]); + e.dispatch(e.state.tr.setSelection(NodeSelection.create(e.state.doc, 1))); + deleteImage(e, 2); + expect(e.state.selection).toBeInstanceOf(NodeSelection); + expect(e.state.selection.node.attrs.src).toBe("/one.jpg"); + }); + + test("unwraps a nested gallery inside its container (#728)", () => { + const e = editor([schema.nodes.blockquote.create(null, gallery("/one.jpg", "/two.jpg"))]); + deleteImage(e, 2); + expect(e.state.doc.firstChild.type.name).toBe("blockquote"); + expect(e.state.doc.firstChild.firstChild.type.name).toBe("image"); + expect(e.state.doc.firstChild.firstChild.attrs.src).toBe("/two.jpg"); + }); + + test("a pending upload left alone stays in its gallery until it finishes", () => { + const pending = `${TRANSIENT_IMAGE_UPLOAD_PREFIX}pending`; + const e = editor([gallery("/one.jpg", pending)]); + deleteImage(e, 1); + expect(e.state.doc.firstChild.type.name).toBe("edgeeverImageGallery"); + e.dispatch(e.state.tr.setNodeMarkup(1, undefined, { ...e.state.doc.firstChild.firstChild.attrs, src: "/uploaded.jpg" })); + expect(e.state.doc.firstChild.type.name).toBe("image"); + expect(e.state.doc.firstChild.attrs.src).toBe("/uploaded.jpg"); + }); + + test("galleries with two or more images are left unchanged", () => { + const e = editor([gallery("/one.jpg", "/two.jpg", "/three.jpg")]); + deleteImage(e, 1); + expect(e.state.doc.firstChild.type.name).toBe("edgeeverImageGallery"); + expect(e.state.doc.firstChild.childCount).toBe(2); + expect(e.state.doc.firstChild.attrs).toMatchObject({ layout: "3", fit: "height" }); + }); + test("standalone image deletion keeps its normal behavior", () => { const e = editor([image("/one.jpg")]); deleteImage(e, 0); expect(e.state.doc.toJSON()).toEqual({ type: "doc", content: [{ type: "paragraph" }] }); }); }); + +describe("single-image gallery cleanup (#728)", () => { + for (const [fit, removed] of [["width", 1], ["width", 2], ["height", 1], ["height", 2]]) { + test(`${fit}: deleting image ${removed} unwraps the survivor and undo restores the gallery`, () => { + const node = gallery("/one.jpg", "/two.jpg"); + const e = editor([node.type.create({ ...node.attrs, fit }, node.content)]); + const before = e.state.doc; + const survivor = before.firstChild.child(removed === 1 ? 1 : 0); + deleteImage(e, removed); + expect(e.state.doc.firstChild).toBe(survivor); + const after = e.state.doc; + expect(undo(e.state, e.dispatch)).toBe(true); + expect(e.state.doc.eq(before)).toBe(true); + expect(redo(e.state, e.dispatch)).toBe(true); + expect(e.state.doc.eq(after)).toBe(true); + }); + } + + test("three images becoming two retain their gallery settings", () => { + const e = editor([gallery("A", "B", "C")]); + deleteImage(e, 2); + expect(e.state.doc.firstChild.type.name).toBe("edgeeverImageGallery"); + expect(e.state.doc.firstChild.childCount).toBe(2); + expect(e.state.doc.firstChild.attrs).toMatchObject({ layout: "3", fit: "height" }); + }); + + test("lifting a nested image preserves its selection and surrounding text", () => { + const e = editor([schema.nodes.blockquote.create(null, gallery("A", "B")), paragraph("outside")]); + e.dispatch(e.state.tr.setSelection(NodeSelection.create(e.state.doc, 2))); + deleteImage(e, 3); + expect(e.state.doc.firstChild.firstChild.attrs.src).toBe("A"); + expect(e.state.selection).toBeInstanceOf(NodeSelection); + expect(e.state.selection.node.attrs.src).toBe("A"); + expect(e.state.doc.lastChild.textContent).toBe("outside"); + }); + + test("multiple galleries unwrap in one edit without shifting other content", () => { + const e = editor([gallery("A", "B"), paragraph("keep"), gallery("C", "D")]); + const second = e.state.doc.child(0).nodeSize + e.state.doc.child(1).nodeSize; + e.dispatch(e.state.tr.delete(second + 2, second + 3).delete(2, 3)); + expect(e.state.doc.toJSON()).toEqual(schema.nodes.doc.create(null, [image("A"), paragraph("keep"), image("C")]).toJSON()); + }); +}); diff --git a/packages/shared/src/image-gallery.ts b/packages/shared/src/image-gallery.ts index 666ac97c..c0ce79fe 100644 --- a/packages/shared/src/image-gallery.ts +++ b/packages/shared/src/image-gallery.ts @@ -1,5 +1,7 @@ import { mergeAttributes, Node } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Slice } from "@tiptap/pm/model"; +import { ReplaceAroundStep } from "@tiptap/pm/transform"; import { openBlockSource } from "./markdown-block-start"; import type { TiptapDoc, TiptapNode, TiptapTextNode } from "./content"; import { parseImageAlignment } from "./image-alignment"; @@ -159,30 +161,47 @@ const hasUsableImageSource = (node: TiptapNode | TiptapTextNode) => typeof node.attrs?.src === "string" && node.attrs.src.trim().length > 0; -/** Keep node-view, keyboard and range deletions in the same undoable edit. */ +/** + * Repair galleries emptied or left with one image by a move or deletion, in + * the same undoable edit as the node-view, keyboard or range change. + */ export const createImageGalleryCleanupPlugin = () => new Plugin({ key: new PluginKey("edgeever-image-gallery-cleanup"), appendTransaction(transactions, _oldState, state) { if (!transactions.some((transaction) => transaction.docChanged)) return null; - const emptyGalleries: Array<{ from: number; to: number }> = []; + const repairs: Array<{ from: number; to: number; unwrap: boolean }> = []; state.doc.descendants((node, position) => { if (node.type.name !== IMAGE_GALLERY_NODE_TYPE) return; // image+ makes ProseMirror insert an image with src=null when its last // child is deleted. Remove that shell from the live document as well as // saved snapshots. Pending uploads have a nonempty src and stay intact. - if (!node.content.content.some((image) => + const images = node.content.content.filter((image) => typeof image.attrs.src === "string" && image.attrs.src.trim().length > 0 - )) { - emptyGalleries.push({ from: position, to: position + node.nodeSize }); + ); + if (!images.length) { + repairs.push({ from: position, to: position + node.nodeSize, unwrap: false }); + } else if (node.childCount === 1 && !isTransientImageUploadSource(images[0]!.attrs.src)) { + // A gallery left with one image after a move or deletion would crop it + // to the gallery cell. Unwrap it as normalizeImageGalleries() does for + // snapshots, so the editor shows the standalone image that is saved. + repairs.push({ from: position, to: position + node.nodeSize, unwrap: true }); } return false; }); - if (!emptyGalleries.length) return null; + if (!repairs.length) return null; const transaction = state.tr; - for (const { from, to } of emptyGalleries.reverse()) transaction.delete(from, to); - return transaction; + for (const { from, to, unwrap } of repairs.reverse()) { + if (!unwrap) { + transaction.delete(from, to); + continue; + } + // Remove only the wrapper, as lift() would if the gallery were not + // isolating, so the image and a selection on it keep their identity. + transaction.maybeStep(new ReplaceAroundStep(from, to, from + 1, to - 1, Slice.empty, 0, true)); + } + return transaction.docChanged ? transaction : null; }, }); diff --git a/release-summary.json b/release-summary.json index 382b5057..ec50bfc9 100644 --- a/release-summary.json +++ b/release-summary.json @@ -1,26 +1,26 @@ { - "version": "2.14.0", + "version": "2.15.0", "changes": { "en-US": [ - "Move existing images within notes and reorder gallery images by dragging, with gallery controls shown on interaction. (#708)", - "Fix excessive blank lines when copying notes to other apps.", - "New desktop backup configurations retain one copy by default, with choices of 1, 3 or 5 copies; existing retention settings are preserved.", - "Improve clipping of dynamically loaded Instagram posts and carousel images.", - "Add step-by-step Instagram and GitHub repository metadata clipping examples to the best practices guide." + "Access and copy the raw Markdown link directly from the note sharing dialog.", + "Preserve the full remaining image when moving or deleting an image leaves a gallery with one image. (#728)", + "Use more of the available writing space around the floating AI launcher.", + "Make shortcut settings easier to scan with a consistent layout and clearer controls.", + "Show the latest desktop backup size and improve backup history size formatting." ], "zh-CN": [ - "支持拖动笔记中已有图片及调整图库图片顺序,图库控件在交互时显示。(#708)", - "修复复制笔记到其他应用时出现过多空行的问题。", - "桌面端新备份配置默认保留 1 份,可选 1、3 或 5 份;已有配置继续使用原保留设置。", - "改善动态加载的 Instagram 帖子与轮播图片的剪藏效果。", - "最佳实践指南新增 Instagram 与 GitHub 仓库元数据剪藏示例。" + "在笔记分享弹窗中直接查看并复制 Markdown 原文链接。", + "图库在移动或删除图片后仅剩一张时,完整显示剩余图片,避免上下裁切。(#728)", + "减少浮动 AI 入口周围的留白,提供更宽敞的笔记编辑空间。", + "统一快捷键设置布局并简化分组标题,让设置与操作更清晰。", + "显示桌面端最近一次备份的大小,并改善备份历史中的大小显示。" ], "ja": [ - "ノート内の既存画像の移動とギャラリー画像の並べ替えをドラッグで行え、操作時にギャラリーのコントロールを表示します。", - "ノートをほかのアプリへコピーした際に空行が増えすぎる問題を修正します。", - "デスクトップの新規バックアップ設定では既定で1世代を保持し、1・3・5世代から選択できます。既存の保持設定は維持されます。", - "動的に読み込まれるInstagramの投稿とカルーセル画像のクリッピングを改善します。", - "活用ガイドにInstagramとGitHubリポジトリのメタデータをクリッピングする手順例を追加します。" + "ノートの共有ダイアログからMarkdown原文のリンクを直接確認・コピーできます。", + "ギャラリー内の画像を移動・削除して1枚だけになった際、残った画像が上下に切り取られず全体を表示するよう修正します。", + "フローティングAIボタン周辺の余白を減らし、ノートの編集スペースを広げます。", + "ショートカット設定のレイアウトを統一し、見出しと操作を分かりやすくします。", + "デスクトップの最新バックアップのサイズを表示し、履歴のサイズ表記を改善します。" ] } } diff --git a/tests/e2e/image-gallery-deletion.spec.ts b/tests/e2e/image-gallery-deletion.spec.ts index a4403664..35dd2fae 100644 --- a/tests/e2e/image-gallery-deletion.spec.ts +++ b/tests/e2e/image-gallery-deletion.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { fileURLToPath } from "node:url"; -test("deleting the last gallery image removes its node view and Markdown (#707)", async ({ page }) => { +test("deleting gallery images unwraps the last one and then removes it (#707, #728)", async ({ page }) => { const fixturePath = fileURLToPath(new URL("./fixtures/image-gallery-deletion.html", import.meta.url)); await page.goto(`/@fs${fixturePath}`); const images = page.getByRole("button", { name: "Delete image", exact: true }); @@ -9,18 +9,28 @@ test("deleting the last gallery image removes its node view and Markdown (#707)" await expect(images).toHaveCount(2); await images.first().click(); await expect(images).toHaveCount(1); + // The remaining image is unwrapped instead of staying in a one-image gallery (#728). + await expect(gallery).toHaveCount(0); + await expect(page.locator("#markdown")).toHaveText("![](/two.jpg)"); // Separate deliberate deletion actions into individual history events. await page.waitForTimeout(550); await images.click(); - await expect(gallery).toHaveCount(0); + await expect(images).toHaveCount(0); await expect(page.locator("#markdown")).toHaveText(""); await expect(page.locator("#document")).toHaveText('{"type":"doc","content":[{"type":"paragraph"}]}'); await page.getByRole("button", { name: "Undo", exact: true }).click(); await expect(images).toHaveCount(1); - await expect(gallery).toHaveAttribute("data-image-gallery-layout", "2"); - await expect(page.locator("#markdown")).toContainText("![](/two.jpg)"); - await page.getByRole("button", { name: "Redo", exact: true }).click(); await expect(gallery).toHaveCount(0); + await expect(page.locator("#markdown")).toHaveText("![](/two.jpg)"); + // One more undo restores the gallery together with the deleted image. + await page.getByRole("button", { name: "Undo", exact: true }).click(); + await expect(images).toHaveCount(2); + await expect(gallery).toHaveAttribute("data-image-gallery-layout", "2"); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + await expect(images).toHaveCount(1); + await expect(gallery).toHaveCount(0); + await page.getByRole("button", { name: "Redo", exact: true }).click(); + await expect(images).toHaveCount(0); await expect(page.locator("#markdown")).toHaveText(""); await page.locator(".tiptap").fill("still editable"); await expect(page.locator("#markdown")).toHaveText("still editable");