chore: update EdgeEver from v2.15.0

EdgeEver-Upstream-Commit: e052ac3b54bdcba4b2c452e6b06c091b1e2c70f4
This commit is contained in:
edgeever-updater[bot] committed 2026-10-30 08:39:50 +00:00
1 parent d04350db18
commit d78620caaa
31 files changed
+1603 -1242

No files matched your search

+2 -2
View File
@@ -324,11 +324,12 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -338,6 +339,5 @@ EdgeEver はセルフホスト型ソフトウェアです。公式デモを除
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+2 -2
View File
@@ -325,11 +325,12 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -339,6 +340,5 @@ EdgeEver is self-hosted software. Except for official demo instances, project ma
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+2 -2
View File
@@ -325,11 +325,12 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -339,6 +340,5 @@ EdgeEver jest oprogramowaniem do samodzielnego hostingu. Poza oficjalnymi instan
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+2 -2
View File
@@ -329,11 +329,12 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -343,6 +344,5 @@ EdgeEver 是自托管软件。除官方演示实例外,项目维护者不托
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+2 -2
View File
@@ -329,11 +329,12 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不
<a href="https://github.com/dkoryto"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/2572180?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@dkoryto" /></a>
<a href="https://github.com/sivdead"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/27731043?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@sivdead" /></a>
<a href="https://github.com/thorufur"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/55167?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@thorufur" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/yulei666"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/7358439?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@yulei666" /></a>
<a href="https://github.com/arsaboo"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/18319734?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@arsaboo" /></a>
<a href="https://github.com/alyyasser"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/16476111?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@alyyasser" /></a>
<a href="https://github.com/MadlyFX"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/4896705?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@MadlyFX" /></a>
<a href="https://github.com/claude"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81847?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@claude" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
<a href="https://github.com/CottonChou"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/17818375?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@CottonChou" /></a>
<a href="https://github.com/Z-DDV"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/122068983?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Z-DDV" /></a>
<a href="https://github.com/0xfelixli"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/81979996?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@0xfelixli" /></a>
@@ -343,6 +344,5 @@ EdgeEver 是自行託管軟體。除官方展示實例外,專案維護者不
<a href="https://github.com/mookim-eth"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/97934198?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@mookim-eth" /></a>
<a href="https://github.com/nzhdotme"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/9588882?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@nzhdotme" /></a>
<a href="https://github.com/Shunamxiao"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/92215296?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Shunamxiao" /></a>
<a href="https://github.com/Crowszzz"><img src="https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/75199133?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d" width="48" height="48" alt="@Crowszzz" /></a>
</p>
<!-- readme-contributors:end -->
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/desktop",
"version": "2.14.0",
"version": "2.15.0",
"desktopName": "EdgeEver",
"private": true,
"description": "Native EdgeEver desktop client",
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+535 -535
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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")}
+1 -1
View File
@@ -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 = ({
</ClipboardCopyNotice>
)}
{/* Overlay the article/outline; horizontal space belongs to the document. */}
{!effectiveReadOnly && !aiAssistantOpen ? (
<IconTooltip
side="left"
+1 -1
View File
@@ -190,7 +190,7 @@ export const SettingsPane = ({
const activeTab = selectedTab ?? "general";
const activeMobileTab = selectedTab;
const mobileTabItems = tabItems.filter((item) => 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) {
@@ -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")');
});
});
@@ -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<number | null>(null);
const dialogContentRef = useRef<HTMLDivElement>(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 = ({
</Button>
</div>
<div className="flex items-center justify-between gap-2 px-0.5 text-xs">
<span className="flex min-w-0 items-center gap-1.5 text-slate-700 dark:text-slate-300">
<FileCode2 className="h-4 w-4 shrink-0 text-slate-500" />
<span className="font-medium">{t("sharing.markdownRawLabel")}</span>
<span className="truncate text-[11px] text-slate-400">
· {share.passwordProtected
? t("sharing.markdownPasswordProtectedHint")
: t("sharing.markdownHint")}
</span>
</span>
<Button
type="button"
variant={copyState === "copied" && copyTarget === "markdown" ? "solid" : copyState === "error" && copyTarget === "markdown" ? "danger" : "outline"}
size="sm"
className={`h-7 px-2.5 text-xs shrink-0 ${copyState === "copied" && copyTarget === "markdown" ? SHARE_DIALOG_PRIMARY_BUTTON_CLASS_NAME : ""}`}
disabled={Boolean(share.passwordProtected)}
onClick={() => void copyValue("markdown", markdownUrl)}
>
{copyState === "copied" && copyTarget === "markdown" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{t(
copyState === "copied" && copyTarget === "markdown"
? "sharing.copied"
: copyState === "error" && copyTarget === "markdown"
? "sharing.copyFailed"
: "sharing.copyMarkdownLink",
)}
</Button>
</div>
<ShareSlugControls
slug={share.slug ?? null}
busy={isWorking}
@@ -189,10 +219,22 @@ export const ShareMemoDialog = ({
<Trash2 className="h-4 w-4" />
{t("sharing.revoke")}
</Button>
<Button variant="outline" className="ml-auto" onClick={() => window.open(shareUrl, "_blank", "noopener,noreferrer")}>
<ExternalLink className="h-4 w-4" />
{t("sharing.open")}
</Button>
<div className="ml-auto flex items-center gap-2">
{!share.passwordProtected ? (
<Button
type="button"
variant="outline"
onClick={() => window.open(markdownUrl, "_blank", "noopener,noreferrer")}
>
<FileCode2 className="h-4 w-4" />
{t("sharing.openMarkdown")}
</Button>
) : null}
<Button variant="outline" onClick={() => window.open(shareUrl, "_blank", "noopener,noreferrer")}>
<ExternalLink className="h-4 w-4" />
{t("sharing.open")}
</Button>
</div>
</div>
</div>
) : (
@@ -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);
});
}
});
@@ -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 <Card className="shadow-none"><CardHeader className={SETTINGS_CARD_HEADER_CLASSNAME}><CardTitle className={SETTINGS_CARD_TITLE_CLASSNAME}><HardDriveDownload className={SETTINGS_CARD_ICON_CLASSNAME} />{t("desktopBackup.title")}</CardTitle></CardHeader><CardContent className="p-4 pt-0 sm:px-5"><p className="text-sm text-destructive" role="alert">{describeDesktopBackupError(t, status.error)}</p></CardContent></Card>;
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 = () => {
</div>
<div className="grid gap-1.5 rounded-md bg-muted/40 p-3 text-xs" aria-live="polite" data-backup-status>
<p className="font-medium text-slate-800">{t("desktopBackup.lastSuccess", { time: date(status.lastSuccessAt) })}</p>
{lastBackup ? <p className="text-slate-700">{t("desktopBackup.lastBackupSize", { size: formatBytes(lastBackup.size) })}</p> : null}
{status.task ? <p className="flex items-center gap-2"><Loader2 className="h-3.5 w-3.5 animate-spin" />{t(`desktopBackup.phases.${status.task.phase}`)}{status.task.total > 0 ? ` ${status.task.completed} / ${status.task.total}` : ""}</p> : status.lastCheck ? <p className="text-slate-700">{t(`desktopBackup.checkResults.${status.lastCheck.result}`)}</p> : null}
<p className="text-muted-foreground">{t("desktopBackup.lastCheck", { time: status.lastCheck ? date(status.lastCheck.at) : t("desktopBackup.notChecked") })}</p>
<p className="text-muted-foreground">{status.config.enabled ? t("desktopBackup.nextDue", { time: status.nextDueAt ? date(status.nextDueAt) : t("desktopBackup.whenReady") }) : t("desktopBackup.autoOff")}</p>
@@ -126,7 +129,7 @@ export const DesktopScheduledBackupCard = () => {
<section className="grid gap-2.5 border-t pt-3" aria-label={t("desktopBackup.history", { count: status.history.length })}>
<h3 className="text-xs font-medium text-slate-700">{t("desktopBackup.history", { count: status.history.length })}</h3>
{status.history.length ? <ul className="divide-y divide-slate-100">{[...status.history].reverse().map((item) => <li key={item.name} className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-2.5 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1 basis-60 space-y-0.5"><p className="break-all font-mono text-xs text-slate-800">{item.name}</p><p className="text-xs text-muted-foreground">{date(item.completedAt)} · {(item.size / 1048576).toFixed(1)} MiB · {t(item.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}</p></div>
<div className="min-w-0 flex-1 basis-60 space-y-0.5"><p className="break-all font-mono text-xs text-slate-800">{item.name}</p><p className="text-xs text-muted-foreground">{date(item.completedAt)} · <span className="whitespace-nowrap tabular-nums">{formatBytes(item.size)}</span> · {t(item.encrypted ? "desktopBackup.encrypted" : "desktopBackup.plain")}</p></div>
<div className="flex shrink-0 gap-1"><Button size="sm" variant="outline" className="h-7 text-xs font-normal" disabled={restoreDisabled} onClick={() => restore(item.name)}>{t("desktopBackup.restore")}</Button><Button size="sm" variant="ghost" className="h-7 text-xs font-normal text-destructive hover:text-destructive" disabled={busy || saving} onClick={() => { setDeleteError(null); setDeleting(item.name); }}>{t("common.delete")}</Button></div>
</li>)}</ul> : <p className="text-xs text-muted-foreground">{t("desktopBackup.emptyHistory")}</p>}
</section>
@@ -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<ShortcutAction | null>(null);
const [captureMessage, setCaptureMessage] = useState("");
const [globalShortcut, setGlobalShortcut] = useState<DesktopGlobalShortcutState | null>(null);
const [globalShortcut, setGlobalShortcut] = useState<DesktopGlobalShortcutState | null>(devPreviewGlobalShortcutState);
const [recordingTarget, setRecordingTarget] = useState<GlobalShortcutTarget | null>(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 (
<div className="grid gap-4">
{window.edgeeverDesktop?.isAvailable ? (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
{globalRows.map((row, index) => {
const recording = recordingTarget === row.target;
return (
<div key={row.target} className={cn(index > 0 && "border-t border-slate-100")}>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div>
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{row.label}</div>
<p className="mt-1 text-xs text-slate-500">{row.description}</p>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" className="h-8 min-w-24 px-3 font-mono text-xs font-normal" disabled={!globalShortcut || globalBusy || Boolean(recordingAction) || (Boolean(recordingTarget) && !recording)} onClick={() => { void beginRecording(row.target); }}>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
{isGlobalShortcutAvailable ? (
<div className="border-b border-slate-100 bg-slate-50/70 px-4 py-2.5">
<span className="text-xs font-medium text-slate-700">{t("shortcuts.appSectionTitle")}</span>
</div>
) : null}
<div className="grid grid-cols-1 gap-px bg-slate-100 sm:grid-cols-2">
{shortcutActionOptions.map((item, index) => {
const recording = recordingAction === item.value;
const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1;
return (
<div
key={item.value}
className={cn(
"flex h-12 items-center justify-between gap-3 bg-card px-4",
lastAlone && "sm:col-span-2",
)}
>
<div className={cn("min-w-0 truncate", SETTINGS_ITEM_TITLE_CLASSNAME)}>{item.label}</div>
<Button
ref={recording ? captureButtonRef : null}
type="button"
variant={recording ? "solid" : "outline"}
className={cn("h-8 min-w-24 shrink-0 px-3 font-mono text-xs font-normal", !recording && "bg-card")}
onClick={() => { void beginRecording(item.value); }}
disabled={Boolean(recordingTarget) || globalBusy}
>
{recording ? t("shortcuts.recording") : formatShortcutBinding(shortcutSettings[item.value])}
</Button>
</div>
);
})}
</div>
{captureMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-700">
{captureMessage}
</div>
) : null}
{isGlobalShortcutAvailable ? (
<div>
<div className="border-b border-t border-slate-100 bg-slate-50/70 px-4 py-2.5">
<span className="text-xs font-medium text-slate-700">{t("shortcuts.globalSectionTitle")}</span>
</div>
<div className="grid grid-cols-1 gap-px bg-slate-100 sm:grid-cols-2">
{globalRows.map((row) => {
const recording = recordingTarget === row.target;
return (
<div
key={row.target}
className="flex h-12 items-center justify-between gap-3 bg-card px-4"
>
<div className={cn("min-w-0 truncate", SETTINGS_ITEM_TITLE_CLASSNAME)}>{row.label}</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
type="button"
variant={recording ? "solid" : "outline"}
className={cn(
"h-8 min-w-24 shrink-0 px-3 font-mono text-xs font-normal",
!recording && "bg-card",
)}
disabled={!globalShortcut || globalBusy || Boolean(recordingAction) || (Boolean(recordingTarget) && !recording)}
onClick={() => { void beginRecording(row.target); }}
>
{recording ? t("shortcuts.recording") : row.binding ? formatGlobalBinding(row.binding) : t("shortcuts.global.disabled")}
</Button>
{row.binding ? (
<Button type="button" variant="outline" className="h-8 px-3 text-xs font-normal" disabled={globalBusy || Boolean(recordingTarget) || Boolean(recordingAction)} onClick={() => disableGlobalShortcut(row.target)}>
{t("shortcuts.global.disable")}
</Button>
<ButtonTooltip title={t("shortcuts.global.disable")}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="h-8 w-8 text-slate-400 hover:bg-slate-100 hover:text-rose-600"
aria-label={t("shortcuts.global.disable")}
disabled={globalBusy || Boolean(recordingTarget) || Boolean(recordingAction)}
onClick={() => disableGlobalShortcut(row.target)}
>
<X className="h-3.5 w-3.5" />
</Button>
</ButtonTooltip>
) : null}
</div>
</div>
{row.binding && !row.registered && !globalMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700">{t("shortcuts.global.unavailable")}</div>
) : null}
</div>
);
})}
{globalMessage ? <div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700">{globalMessage}</div> : null}
);
})}
</div>
{globalMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-700">
{globalMessage}
</div>
) : hasUnregisteredGlobal ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-700">
{t("shortcuts.global.unavailable")}
</div>
) : null}
</div>
) : null}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-card">
<div className="grid grid-cols-1 gap-px bg-slate-100 sm:grid-cols-2">
{shortcutActionOptions.map((item, index) => {
const recording = recordingAction === item.value;
const lastAlone = index === shortcutActionOptions.length - 1 && shortcutActionOptions.length % 2 === 1;
return (
<div
key={item.value}
className={cn(
"flex h-12 items-center justify-between gap-3 bg-card px-4",
lastAlone && "sm:col-span-2",
)}
>
<div className={cn("min-w-0 truncate", SETTINGS_ITEM_TITLE_CLASSNAME)}>{item.label}</div>
<Button
ref={recording ? captureButtonRef : null}
type="button"
variant={recording ? "solid" : "outline"}
className={cn("h-8 min-w-24 shrink-0 px-3 font-mono text-xs font-normal", !recording && "bg-card")}
onClick={() => { void beginRecording(item.value); }}
disabled={Boolean(recordingTarget) || globalBusy}
>
{recording ? t("shortcuts.recording") : formatShortcutBinding(shortcutSettings[item.value])}
</Button>
</div>
);
})}
</div>
{captureMessage ? (
<div className="border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs font-medium text-amber-700">
{captureMessage}
</div>
) : null}
<div className="flex justify-end border-t border-slate-100 px-4 py-3">
<Button type="button" variant="outline" className="h-9 px-3 text-xs font-normal" onClick={handleResetShortcuts}>
<RotateCcw className="h-4 w-4" />
{t("shortcuts.reset")}
</Button>
</div>
<div className="flex justify-end border-t border-slate-100 px-4 py-3">
<Button type="button" variant="outline" className="h-9 px-3 text-xs font-normal" onClick={handleResetShortcuts}>
<RotateCcw className="h-4 w-4" />
{t("shortcuts.reset")}
</Button>
</div>
</div>
);
@@ -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,
+4 -6
View File
@@ -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({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "edgeever",
"version": "2.14.0",
"version": "2.15.0",
"packageManager": "bun@1.3.14",
"private": true,
"type": "module",
+10
View File
@@ -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",
+10
View File
@@ -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: "アクセスパスワード",
+10
View File
@@ -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",
+10
View File
@@ -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: "Пароль доступа",
+10
View File
@@ -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: "访问密码",
@@ -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());
});
});
+27 -8
View File
@@ -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;
},
});
+16 -16
View File
@@ -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ボタン周辺の余白を減らし、ノートの編集スペースを広げます。",
"ショートカット設定のレイアウトを統一し、見出しと操作を分かりやすくします。",
"デスクトップの最新バックアップのサイズを表示し、履歴のサイズ表記を改善します。"
]
}
}
+15 -5
View File
@@ -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");