chore: update EdgeEver from v2.15.0
EdgeEver-Upstream-Commit: e052ac3b54bdcba4b2c452e6b06c091b1e2c70f4
This commit is contained in:
1 parent
d04350db18
commit
d78620caaa
31 files changed
+1603
-1242
No files matched your search
+2
-2
@@ -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 -->
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@edgeever/desktop",
|
||||
"version": "2.14.0",
|
||||
"version": "2.15.0",
|
||||
"desktopName": "EdgeEver",
|
||||
"private": true,
|
||||
"description": "Native EdgeEver desktop client",
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
+535
-535
File diff suppressed because it is too large.
Load diff
Vendored
+535
-535
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "edgeever",
|
||||
"version": "2.14.0",
|
||||
"version": "2.15.0",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "アクセスパスワード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Пароль доступа",
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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ボタン周辺の余白を減らし、ノートの編集スペースを広げます。",
|
||||
"ショートカット設定のレイアウトを統一し、見出しと操作を分かりやすくします。",
|
||||
"デスクトップの最新バックアップのサイズを表示し、履歴のサイズ表記を改善します。"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
// 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("");
|
||||
await page.getByRole("button", { name: "Redo", exact: true }).click();
|
||||
await expect(gallery).toHaveCount(0);
|
||||
await expect(page.locator("#markdown")).toHaveText("");
|
||||
// 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");
|
||||
|
||||
Reference in new issue
Block a user