dsh-background-by-model 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # Changelog
2
2
 
3
- > The full release history lives here; the two most recent releases (v0.7.1 / v0.7.0) are also listed in [README.md](./README.md#recent-optimizations).
3
+ > The full release history lives here; the two most recent releases (v0.7.2 / v0.7.1) are also listed in [README.md](./README.md#recent-optimizations).
4
+
5
+ ## v0.7.2
6
+
7
+ - **The filmstrip IS the control: drag to reorder, double-click to show** — the order used to be reachable only through a pair of ↑ / ↓ arrows and a "make first" button in the row below, which is a second and worse way to say "put this one there" when the five thumbnails already show the order they are in. Grab any tile and drop it where it belongs: the drop point is a 2px bar drawn INSIDE the tile it precedes, because a bar that took up room would push around the very tiles the pointer is being measured against. Which picture is on screen is the strip's OTHER gesture, and it changes no order and writes nothing at all — the image being shown is runtime state, not a setting, so it is not saved and the next boot starts at the rule's first image again. A double-click on a tile of a rule that is not painting says why (reusing "only the active rule can step the background") instead of leaving a double-click that looks broken, and both gestures are named in the tile's tooltip.
8
+ - **The order and "which one is on screen" no longer interfere with each other (this one is a fix)** — both old actions (`moveRuleImage`, `setCurrentImage`) ended by moving the image index onto the image they had just moved, so tidying the list swapped the wallpaper as well: with the rotation off the index is sticky, which is exactly the state this was met in ("even with the rotation off, the background can change when I adjust the order, and it can happen by dragging or by the buttons"). `moveRuleImageTo(id, slot, to)` replaces both of them: it remembers the SLOT being painted before the splice and puts the index back on it afterwards. A reorder is not a change of subject — the three buttons left with the two uncalled actions behind them, and `check:repaint`'s list follows.
9
+ - **The tile that is on screen lost its ring and kept its dot** — `.is-sel` already rings the tile that is open for editing, so a second ring put two "selected" tiles in a row of near-identical thumbnails, which is the one question a filmstrip must not leave open. Being on screen is the dot, and nothing else.
10
+ - **Every image of a batch upload gets its own color** — a theme color belongs to an IMAGE, so an upload carrying several pictures has several palettes to fill, and the extraction was addressed by POSITION: `addRuleImages` appended the whole batch and then asked for a color for `rule.images[rule.images.length - 1]` alone. That is the right answer for the one-file upload this action grew up with, and silently the wrong one for every batch — the pictures before the last kept no color of their own and stayed on the system theme, with nothing in the interface to say why. It is now addressed by the slots the batch itself just appended, so which pictures get themed no longer depends on where they landed in the rule, nor on which one the card happens to select.
11
+ - **Copy trimmed, and the framing button moved** — the "Rules" heading and the priority sentence under it, and the sentence explaining the automatic extraction, are gone (the panel's title introduces the rules and the `fallback` badge on the first card states the priority; the switch and its own label stay). The labels lost their nouns: `Image 1 / 5` for `当前第 1 / 5`, and `Add` / `Replace` / `Remove` for `添加图片` / `替换这张` / `移除图片`. `Edit position` (`编辑位置`) moved out of the image-action row to the end of the layout row — what it opens edits how the picture fills the area, which is what those five modes are about — and it stays last rather than between two chips because the modes are mutually exclusive and it is not a sixth one. It still appears only in **fit**, because dragging the framing is what fit means.
12
+ - **Gates** — `check:repaint` gained the batch section (the batch form must walk every slot it was handed, an upload must record the slots of its own batch and theme all of them, and the positional `maybeAutoExtract(id, rule.images[…])` must not come back), and `moveRuleImageTo` joins the actions required to sample the winner and close with `repaintIfMoved` while the two deleted actions leave it. `check:ui` covers the new strings and the new class (`.dab-strip-bar`, plus the `is-dragging` state), and requires the three keys nothing renders any more to leave both dictionaries in the same commit.
4
13
 
5
14
  ## v0.7.1
6
15
 
package/CHANGELOG.zh.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # 更新日志
2
2
 
3
- > 这里保留完整的历史记录;最近两个版本(v0.7.1 / v0.7.0)同时列在 [README.zh.md](./README.zh.md#近期优化) 里。
3
+ > 这里保留完整的历史记录;最近两个版本(v0.7.2 / v0.7.1)同时列在 [README.zh.md](./README.zh.md#近期优化) 里。
4
+
5
+ ## v0.7.2
6
+
7
+ - **胶片条自己就是控件:拖拽排顺序,双击换画面** —— 顺序以前只能靠下面那排 ↑ / ↓ 和一个「设为第一张」按钮改,而五个缩略图本来就把顺序摆在那里,那对箭头是同一件事的第二种、更差的说法。现在抓住任意一张拖到新位置:落点画成一根 2px 竖条,画在目标格子**内部**,因为一块占位的竖条会把指针正在测量的那些格子推走。而「哪张在屏上」是胶片条的**另一个**手势 —— 双击它。它不改顺序,也**不写任何配置**:在屏哪张是运行时状态而不是设置,所以它不会被保存,下次启动照旧从规则的第一张开始。在非生效规则的格子上双击会说明原因(复用现成的那句「只有正在生效的规则能换画面上的图」),而不是留下一个看起来坏了的双击;两个手势都写在格子的悬停提示里。
8
+ - **顺序和在屏的是哪张,从此互不干涉(这一条是修复)** —— 旧的两个动作(`moveRuleImage`、`setCurrentImage`)都以「把图片下标挪到刚被移动的那张图上」结尾,于是整理列表会把壁纸一起换掉:轮换关着的时候下标是粘住的,而这正是最容易撞见它的状态(「我调整顺序时,即便关了轮换,当前背景也可能会变,拖拽或点按钮调整顺序都有可能发生」)。新的 `moveRuleImageTo(id, slot, to)` 取代了它们两个:摘出之前先记下**正在画的那个槽位**,插回去之后把下标放回它身上。重排不是换主题 —— 三个按钮连同它们背后两个不再被调用的动作一起删掉了,`check:repaint` 的清单同步。
9
+ - **在屏那张不再有边框,只留圆点** —— `.is-sel` 已经给「正在编辑的那张」画了圈,再给「正在显示的那张」画一个,就成了一排几乎一样的缩略图里同时有两个「选中」,而这是胶片条唯一不能留给人猜的问题。现在在屏 = 右上角那个圆点,仅此而已。
10
+ - **一次上传的多张图,每一张都会各自取色** —— 主题色属于**图片**,所以一次带多张的上传有好几个调色板要填,而取色是**按下标**寻址的:`addRuleImages` 先把整批追加完,然后只对 `rule.images[rule.images.length - 1]` 取色。这对它长大时面对的单张上传是对的,对批量则静默地错 —— 最后一张之前的那些图没有自己的颜色、留在系统主题上,而界面上什么都不会说。现在按**这一批自己刚追加的那些槽位**寻址,所以「哪些图会被取色」不再取决于它们落在规则的哪个位置,也不取决于卡片当时选中了哪一张。
11
+ - **卡片文案精简,「编辑位置」换了位置** —— 「规则列表」标题和它下面那句优先级说明、以及自动取色旁边那句解释都删了(面板标题已经介绍了规则,第一张卡上的 `兜底` 徽章说明了优先级;开关和它自己的标签留着)。标签去掉多余的名词:`当前第 1 / 5` → `第 1 / 5`,`添加图片` / `替换这张` / `移除图片` → `添加` / `替换` / `移除`,英文同步。`编辑位置` 从「图片动作」那一行移到**布局模式那一行的末尾**:它打开的东西编辑的是图片如何填满这块区域,正是那一行五个模式在说的事;它留在末尾而不是插在两个模式之间,因为那五个是互斥的,它不是第六个。它仍然只在**适应**下出现 —— 拖拽构图就是适应在做的事。
12
+ - **门禁** —— `check:repaint` 新增一节钉住批量取色:批量形式必须遍历它拿到的每一个槽位、上传必须记录自己这批的槽位并给它们全部取色、按下标寻址的 `maybeAutoExtract(id, rule.images[…])` 不许回来;`moveRuleImageTo` 进入「每个写入路径都必须先采样 winner、再以 `repaintIfMoved` 收尾」的清单,两个被删掉的动作则从中移出。`check:ui` 覆盖新文案与新类(`.dab-strip-bar`,以及 `is-dragging` 状态),并且要求不再有调用者的那三个字典键在同一个提交里离开两份字典。
4
13
 
5
14
  ## v0.7.1
6
15
 
package/README.md CHANGED
@@ -253,7 +253,15 @@ The other half of the same hole: while it owns a color the plugin **forces** `bo
253
253
 
254
254
  ## Recent Optimizations
255
255
 
256
- > Only the two most recent releases are listed here; older ones (v0.6.0 and earlier) live in [CHANGELOG.md](./CHANGELOG.md).
256
+ > Only the two most recent releases are listed here; older ones (v0.7.0 and earlier) live in [CHANGELOG.md](./CHANGELOG.md).
257
+
258
+ ### v0.7.2
259
+
260
+ - **The filmstrip IS the control: drag to reorder, double-click to show** — the order used to be reachable only through a pair of ↑ / ↓ arrows and a "make first" button in the row below, when the five thumbnails already show the order they are in. Grab any tile and drop it where it belongs (the drop point is a 2px bar), and give "which picture is on screen" to the strip's other gesture: a double-click, which changes no order and **writes nothing at all** — the image being shown is runtime state, not a setting, so the next boot starts at the rule's first image again.
261
+ - **The order and "which one is on screen" no longer interfere (a fix)** — both old actions ended by moving the image index onto the image they had just moved, so tidying the list swapped the wallpaper with it (most visible with the rotation off, where the index is sticky: "even with the rotation off, the background can change when I adjust the order"). `moveRuleImageTo` replaces both: it remembers the SLOT being painted before the splice and puts the index back on it afterwards. The three buttons left with the two uncalled actions behind them.
262
+ - **The tile on screen keeps its dot and loses its ring** — the tile open for editing already has `.is-sel`'s ring, and a second one put two "selected" tiles in a row of near-identical thumbnails; being on screen is the dot, and nothing else.
263
+ - **Every image of a batch upload gets its own color** — the extraction was addressed by POSITION (it only asked about `rule.images[length - 1]`), which is right for a one-file upload and silently wrong for a batch: the pictures before the last kept no color of their own and stayed on the system theme. It is now addressed by the slots the batch itself just appended, so which pictures get themed depends on neither where they landed nor which one the card selects.
264
+ - **Copy trimmed** — the "Rules" heading, its priority sentence and the automatic-extraction sentence are gone (the panel's title and the `fallback` badge already say both), `Add images` / `Replace this image` / `Remove image` lose the noun they were repeating, and `Edit position` moved to the end of the layout row (it edits how the picture fills the area, and still appears in **fit** only).
257
265
 
258
266
  ### v0.7.1
259
267
 
@@ -266,21 +274,6 @@ The other half of the same hole: while it owns a color the plugin **forces** `bo
266
274
  - **The config shape moved to 4, and an older host still cannot eat a profile** — `images[].color` is unknown to a schema-3 sanitizer, which rebuilds each image entry from the keys it knows: the color would survive in memory until the next reload and then be gone, with nothing in the log to explain it. The version check is a plain `>=`, so a host announcing less holds every write and says so in the panel until DSH is restarted.
267
275
  - **The gates cover it** — `check:node` now pins the lift in both directions (an absent key inherits the rule's color, an explicit `null` does not), per-image independence through the file, holidays forcing both colors, and that a `null` is written as an explicit key rather than as an absent one (writing it as absent would silently re-inherit the rule's color on every load). `check:repaint` additionally fails if a rotation step ever stops re-emitting the palette.
268
276
 
269
- ### v0.7.0
270
-
271
- - **Multi-image rotation, per rule** — a rule now owns an ordered list of images and can cycle them on its own rhythm: 10 s / 30 s / 1 min / 5 min / 30 min / 1 h or a custom dwell, **in order** or **shuffle** (which never lands on the image already on screen, because that is indistinguishable from a missed tick). Off by default, because rotating spends bandwidth, memory and battery and no default should decide that quietly.
272
- - **A second trigger: a model switch** — with *also step on a model switch* on, stepping has nothing to do with time at all: every model change that lands on the rule shows its next image. A rule can then read as "a different picture every time" with no timer scheduled.
273
- - **One timer, aimed at the active rule** — it is armed by a signature of exactly the inputs the schedule depends on (rule, interval, order, image count, visibility), so a slider drag re-runs the whole apply dozens of times a second without ever restarting the dwell. A hidden tab stops the rotation instead of painting three times into the void.
274
- - **What follows the image is nothing** — theme color, layout mode, opacity and blur stay the rule's; only the picture changes, through the cross-fade a model switch already used. A rule can ask for a slower blend than a switch (or 0 for a hard cut).
275
- - **Storyboard, not a form** — the rule card's single thumbnail became a filmstrip: add several images at once (multi-select, drop, or one URL per line), click one to edit it, reorder it, promote it to first, replace it in place, remove it. The big preview above the strip shows the selected image, and the one on screen carries a live dot.
276
- - **An empty rule is a state, not a dead end** — the first cut refused to remove a rule's last picture and cleared its bytes "instead", which left the entry itself behind: a blank tile at position 1 that could never be deleted, so the next upload landed at position 2 and the rule kept painting nothing while the preview showed an empty frame. Removing the last image now empties the list, a brand-new rule starts empty (no slot is allocated until the first picture), the picture you add becomes the one you are looking at, and a hand-edited list whose entries are all unusable lands in the same state instead of being conjured back into a broken slot.
277
- - **Empty is not off: the theme color keeps working** — the first cut read "has an image" as "is usable", so a rule whose pictures had all been removed **stopped matching and stopped being the fallback**: its own theme color, the wheel, the swatches and the clear-to-system-theme button became decoration (the symptom is "the theme color does nothing") and the models it used to own were handed to the next rule. A rule now counts while it has **an image, or a theme color of its own** (`ruleCanPaint`) — no wallpaper, just the interface painted from that color. The card's copy names which of the three real states it is in (no image but a color / neither / an image whose bytes have not arrived) instead of covering all three with one sentence.
278
- - **Editing a rule that is not the active one takes effect at once** — `color`, `enabled` and `match` all feed which rule wins, but the write path only re-ran the resolution when the edited rule was already the winner: picking the first color for an empty rule, or typing a match string into a rule that had not won yet, changed nothing on screen until some unrelated event re-ran it.
279
- - **Only the images that are needed are loaded** — boot hydrates each rule's FIRST image (exactly what it hydrated per rule before multi-image), the rest arrive when a card is expanded or when the rotation is about to need one. Twenty pictures across five rules therefore do not mean twenty full-size data URLs before the first frame.
280
- - **A stale host can no longer eat a profile** — the node half publishes the config shape it writes, and a client that finds a host announcing an older version (`SCHEMA_VERSION`, where 3 is the first that means "an empty image list is legal") **holds every config write** and says so in the panel. Without it, updating the package and merely refreshing the page would let the previous sanitizer drop every rule on the next slider drag.
281
- - **Old configs still load, and old exports still import** — a rule's single `slot`/`bgState` pair is lifted into `images: [{ slot, bgState }]` on read; theme files (version 4 now, version 3 and earlier still accepted) carry, and restore, every image of every rule rather than just the first.
282
- - **A fourth static gate: `pnpm check:rotation`** — the rotation's decisions are pure (`nextIndex` / `isRotating`) and are now pinned down: no walking off the end of a list, shuffle never repeating the current image (checked across the whole support of the roll, not one sample), and "rotating" never being claimed for a rule with nothing to rotate. `check:node` covers the new shape through the real RPC handler: legacy lifting, per-image framing, dedupe, clamping, nested-drift warnings and per-image slot independence.
283
-
284
277
  ## Star History
285
278
 
286
279
  [![Star History Chart](https://api.star-history.com/chart?repos=HarmlessFunny/dsh-background-by-model&type=timeline&legend=bottom-right)](https://www.star-history.com/?repos=HarmlessFunny%2Fdsh-background-by-model&type=timeline&legend=bottom-right)
package/README.zh.md CHANGED
@@ -253,7 +253,15 @@ pnpm run typecheck
253
253
 
254
254
  ## 近期优化
255
255
 
256
- > 这里只列最近两个版本;更早的版本(v0.6.0 及以前)见 [CHANGELOG.zh.md](./CHANGELOG.zh.md)。
256
+ > 这里只列最近两个版本;更早的版本(v0.7.0 及以前)见 [CHANGELOG.zh.md](./CHANGELOG.zh.md)。
257
+
258
+ ### v0.7.2
259
+
260
+ - **胶片条自己就是控件:拖拽排顺序,双击换画面** — 顺序以前只能靠下面那排 ↑ / ↓ 和「设为第一张」改,而五个缩略图本来就把顺序摆在那里。现在抓住任意一张拖到新位置即可,落点是一根 2px 竖条;而「哪张在屏上」交给了双击 —— 它不改顺序,也**不写任何配置**:在屏哪张是运行时状态而不是设置,所以下次启动照旧从第一张开始。
261
+ - **顺序和在屏的是哪张,从此互不干涉(修复)** — 旧的两个动作都以「把下标挪到刚被移动的那张图上」结尾,于是整理列表会把壁纸一起换掉(轮换关着、下标粘住时最容易撞见:「我调整顺序时,即便关了轮换,当前背景也可能会变」)。新的 `moveRuleImageTo` 取代了它们:摘出之前先记下**正在画的槽位**,插回去后把下标放回它身上;三个按钮和背后两个不再被调用的动作一起删掉。
262
+ - **在屏那张只留圆点,不再有边框** — 「正在编辑」已经有 `.is-sel` 的圈了,再给「正在显示」画一个,一排几乎一样的缩略图里就同时有两个「选中」;这是在屏状态唯一不能留给人猜的地方。
263
+ - **一次上传的多张图,每张都会各自取色** — 取色原本是**按下标**寻址的(只问 `rule.images[length - 1]`),对单张上传正确、对批量静默地错:最后一张之前的图没有自己的颜色、留在系统主题上。现在按**这一批自己刚追加的槽位**寻址,与它们落在哪个位置、卡片选中了哪张都无关。
264
+ - **卡片文案精简** — 「规则列表」标题、优先级那句、自动取色旁边那句解释都删了(面板标题与 `兜底` 徽章已经说过);`添加图片` / `替换这张` / `移除图片` 收成 `添加` / `替换` / `移除`;`编辑位置` 移到**布局模式那一行的末尾**(它编辑的是图片如何填满这块区域,仍只在「适应」下出现)。
257
265
 
258
266
  ### v0.7.1
259
267
 
@@ -266,21 +274,6 @@ pnpm run typecheck
266
274
  - **配置格式升到 4,旧宿主依旧吃不掉配置** — `images[].color` 对 schema 3 的 sanitizer 是未知字段,而它会**按自己认识的键重建**每个图片条目:颜色会一直在内存里活到下一次重新加载,然后悄无声息地消失,日志里还什么都不会说。版本判断是简单的 `>=`,所以报出更低版本的宿主会让所有写入被扣住、面板里说明,直到重启 DSH。
267
275
  - **门禁覆盖到它** — `check:node` 现在双向钉住抬升(缺键继承规则色、显式 `null` 不继承)、逐图颜色经过文件往返后各自独立、节日两处颜色都被强制,以及 `null` 是**写出键**而不是省略键(写成省略就是每次加载都悄悄把规则色继承回来)。`check:repaint` 还会在轮换步骤不再重发调色板时报错。
268
276
 
269
- ### v0.7.0
270
-
271
- - **每条规则多图轮换** — 规则现在持有一组有序图片,可以按自己的节奏循环:10 秒 / 30 秒 / 1 分钟 / 5 分钟 / 30 分钟 / 1 小时,或自定义停留时长;顺序可选**按顺序**或**随机**(随机绝不会抽到当前正在显示的那张,因为那看起来就跟这一次没生效完全一样)。默认关闭 —— 轮换真的要花流量、内存和电,不该由默认值替你决定。
272
- - **第二种触发:切换模型** — 打开「切换模型时也换一张」之后,换图就与时间无关了:每次切换模型落到这条规则上,就显示它的下一张。这样一条规则可以是「每次都不一样」,而完全不需要挂定时器。
273
- - **只有一个定时器,且始终对准生效规则** — 它的重排条件是「排期真正依赖的那些输入」(规则、间隔、顺序、图片数量、标签页可见性)组成的签名,所以拖动滑块导致每秒重跑几十次 apply 也不会把停留计时反复清零;标签页隐藏时轮换直接停下,而不是对着空屏画三次。
274
- - **换的只有图本身** — 主题色、布局模式、透明度、模糊度都留在规则上;只有画面在变,用的就是切换模型时那套交叉淡入。还能单独设淡入时长(0 就是硬切)。
275
- - **是分镜条,不是表单** — 规则卡里那张单图缩略图变成了胶片条:可一次加多张(多选、拖放、一行一个网址)、点选要编辑的那张、排序、设为第一张、原地替换、删除。上方大预览显示选中的那张,正在显示的那张带一个跳动的圆点。
276
- - **空规则是一个状态,不是死路** — 这个功能的第一版拒绝删除最后一张,转而「清空它的字节」,结果把条目本身留了下来:胶片条第 1 位一块永远删不掉的空白图,于是下一次上传只能排到第 2 位,规则继续什么都不画、预览一直停在空帧。现在删除最后一张就是把列表清空;新建规则从空列表开始(第一张图到达时才分配槽位);你加进去的那张就是你正在看的那张;手改导致「每一项都不可用」的列表也落到这个状态,而不是被硬塞回一个坏槽位。
277
- - **空规则不等于失效:主题色继续生效** — 第一版把「有没有图」当成了「这条规则是否可用」,于是把图片删光的规则**既不再匹配、也不再当兜底**:它自己那份主题色、色轮、灵感色板、「跟随系统主题」全部变成摆设(现象就是「主题色无效」),而它原本负责的模型被悄悄交给下一条规则。现在只要是**有图、或者有自己的主题色**(`ruleCanPaint`)就仍然生效 —— 不铺壁纸,只给界面配色。面板上的说明也按三种真实状态分开说(无图有颜色 / 无图无颜色 / 有图但还没取到内容),不再拿同一句话覆盖三种情况。
278
- - **改一条没在生效的规则也会立刻生效** — `color` / `enabled` / `match` 都影响「哪条规则赢」,但以前只有编辑当前生效规则时才会重新解析:给空规则选上第一个颜色、或往一条还没赢的规则里输入匹配串,界面上都不会有反应,要等某个无关事件顺手重跑一次解析。
279
- - **只加载用得到的图** — 启动时只灌每条规则的**第一张**(跟多图出现之前每条规则灌一张完全一致),其余的在展开卡片时、或轮换即将用到时再取。于是「五条规则二十张图」不等于首帧之前先传二十张全尺寸 data URL。
280
- - **旧宿主再也不可能吃掉一份配置** — node 半边现在会公布自己写入的配置格式,客户端发现宿主报出的版本低于自己(`SCHEMA_VERSION`,2 → 3 起表示「空图片列表合法」)时会**扣住所有配置写入**并在面板里说明。没有这道闸门,更新包之后只刷新一次页面,上一个版本的 sanitizer 就会在下一次拖动滑块时把全部规则丢掉。
281
- - **老配置照常读,老导出照常导入** — 规则里那对单图字段(`slot` / `bgState`)会在读取时被提升成 `images: [{ slot, bgState }]`;主题文件升到版本 4(版本 3 及更早仍可导入),导出与还原的是每条规则的全部图片,而不只是第一张。
282
- - **第四道静态门禁:`pnpm check:rotation`** — 轮换的判定是纯函数(`nextIndex` / `isRotating`),现在被钉住了:不会走越界、随机不会重复当前这张(在整个随机取值范围内穷举,不是抽一次样本)、也不会把只有一张图的规则报成「正在轮换」。`check:node` 则通过真实 RPC 处理器覆盖新形状:旧配置提升、逐图取景、去重、钳位、嵌套字段漂移告警,以及各图槽位互不干扰。
283
-
284
277
  ## Star History
285
278
 
286
279
  [![Star History Chart](https://api.star-history.com/chart?repos=HarmlessFunny/dsh-background-by-model&type=timeline&legend=bottom-right)](https://www.star-history.com/?repos=HarmlessFunny%2Fdsh-background-by-model&type=timeline&legend=bottom-right)
package/lib/client.js CHANGED
@@ -44,8 +44,6 @@ window.__ModuleLoader__.load({
44
44
  statusNoteNoSession: "会话服务已就绪,但还没有当前会话 id",
45
45
  statusNoteNoProjection: "拿不到本会话的模型选择投影(binding / projections 不可用)",
46
46
  statusNoteEmptySelection: "本会话的模型选择投影还没有值(切一次模型即可写入)",
47
- rulesTitle: "规则列表",
48
- rulesHint: "从上到下即为优先级。规则 1 同时兼任兜底:所有匹配串都没命中时使用它",
49
47
  hostStaleHint: "检测到 DSH 主进程仍在运行旧版插件:新版配置格式它还读不懂,为避免丢规则,本次只改界面不写入配置。重启 DSH 后多图与轮换即可正常保存",
50
48
  tryoutTitle: "匹配试跑",
51
49
  tryoutPlaceholder: "输入模型名,如 deepseek-flash",
@@ -55,7 +53,6 @@ window.__ModuleLoader__.load({
55
53
  tryoutNone: "没有可用规则(规则需要启用,并且有图片或主题色)",
56
54
  tryoutEmpty: "输入模型名即可试跑",
57
55
  autoExtract: "选图后自动取主题色",
58
- autoExtractHint: "只在图片还没有自己的颜色时自动取色,绝不覆盖你手动选的颜色",
59
56
  ruleAdd: "新增规则",
60
57
  ruleFallbackBadge: "兜底",
61
58
  ruleMatchPlaceholder: "如 flash / glm(忽略大小写,留空则不参与匹配)",
@@ -71,7 +68,7 @@ window.__ModuleLoader__.load({
71
68
  ruleUrlApplying: "加载中…",
72
69
  ruleUrlBadHttp: "仅支持 http/https 图片网址",
73
70
  ruleUrlFail: "获取图片失败",
74
- ruleImageRemove: "移除图片",
71
+ ruleImageRemove: "移除",
75
72
  ruleEmptyHint: "这条规则还没有图片,也没有自己的主题色:没有可显示的内容,匹配和兜底都会跳过它",
76
73
  ruleColorOnlyHint: "这条规则还没有图片:不会铺壁纸,界面只用这个主题色",
77
74
  ruleImagePendingHint: "这张图片还没有取到内容,稍后会自己出现(也可以直接替换它)",
@@ -102,13 +99,12 @@ window.__ModuleLoader__.load({
102
99
  rotOnSwitch: "切换模型时也换一张",
103
100
  rotNext: "下一张",
104
101
  rotNextInactive: "只有正在生效的规则能换画面上的图",
105
- rotPos: "当前第",
102
+ rotPos: "第",
106
103
  rotShowing: "正在显示",
107
- rotAddImage: "添加图片",
108
- rotReplaceImage: "替换这张",
109
- rotImageEarlier: "前移一张",
110
- rotImageLater: "后移一张",
111
- rotMakeFirst: "设为第一张",
104
+ rotAddImage: "添加",
105
+ rotReplaceImage: "替换",
106
+ rotDragHint: "拖动排序",
107
+ rotDblHint: "双击切换",
112
108
  rotEvery10s: "10 秒",
113
109
  rotEvery30s: "30 秒",
114
110
  rotEvery1m: "1 分钟",
@@ -174,8 +170,6 @@ window.__ModuleLoader__.load({
174
170
  statusNoteNoSession: "The sessions service is up but reports no current session yet",
175
171
  statusNoteNoProjection: "This session's model-selection projection is unreachable (no binding / projections)",
176
172
  statusNoteEmptySelection: "The model-selection projection exists but carries no value yet (selecting a model once writes it)",
177
- rulesTitle: "Rules",
178
- rulesHint: "Top to bottom is the priority. Rule 1 doubles as the fallback — it is used when no match string hits",
179
173
  hostStaleHint: "The DSH host process is still running the older plugin, which cannot read the new config shape. To avoid dropping rules, this session changes the interface but holds every config write — restart DSH and multi-image plus rotation will save normally",
180
174
  tryoutTitle: "Match test",
181
175
  tryoutPlaceholder: "Type a model name, e.g. deepseek-flash",
@@ -185,7 +179,6 @@ window.__ModuleLoader__.load({
185
179
  tryoutNone: "No usable rule (a rule needs to be enabled and carry an image or a theme color)",
186
180
  tryoutEmpty: "Type a model name to test the list",
187
181
  autoExtract: "Extract the theme color when an image is chosen",
188
- autoExtractHint: "Only fills an image that has no color of its own yet — a color you picked is never overwritten",
189
182
  ruleAdd: "Add rule",
190
183
  ruleFallbackBadge: "fallback",
191
184
  ruleMatchPlaceholder: "e.g. flash / glm (case-insensitive, empty never matches)",
@@ -201,7 +194,7 @@ window.__ModuleLoader__.load({
201
194
  ruleUrlApplying: "Loading…",
202
195
  ruleUrlBadHttp: "Only http/https image URLs are supported",
203
196
  ruleUrlFail: "Could not fetch the image",
204
- ruleImageRemove: "Remove image",
197
+ ruleImageRemove: "Remove",
205
198
  ruleEmptyHint: "This rule has no image and no theme color of its own: it has nothing to show, so matching and the fallback both skip it",
206
199
  ruleColorOnlyHint: "No image in this rule yet: no wallpaper is shown, and the interface is painted from this theme color alone",
207
200
  ruleImagePendingHint: "This image has no content yet — it appears once it loads; you can also replace it",
@@ -234,11 +227,10 @@ window.__ModuleLoader__.load({
234
227
  rotNextInactive: "Only the active rule can step the background",
235
228
  rotPos: "Image",
236
229
  rotShowing: "On screen",
237
- rotAddImage: "Add images",
238
- rotReplaceImage: "Replace this image",
239
- rotImageEarlier: "Move earlier",
240
- rotImageLater: "Move later",
241
- rotMakeFirst: "Make first",
230
+ rotAddImage: "Add",
231
+ rotReplaceImage: "Replace",
232
+ rotDragHint: "drag to reorder",
233
+ rotDblHint: "double-click to show",
242
234
  rotEvery10s: "10 s",
243
235
  rotEvery30s: "30 s",
244
236
  rotEvery1m: "1 min",
@@ -1956,16 +1948,28 @@ window.__ModuleLoader__.load({
1956
1948
  .dab-rule-section-title{font-size:12px;font-weight:600;margin-bottom:9px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}
1957
1949
 
1958
1950
  /* ── image strip (multi-image rules) ─────────────────────────────────────── */
1959
- /* A horizontally scrolling row of thumbnails: the edited one is ringed, the one
1960
- the wallpaper is actually painting carries a live dot. Drops land here, which
1961
- is why the border reacts to a drag rather than to hover. */
1951
+ /* A horizontally scrolling row of thumbnails: the one open for editing is
1952
+ ringed, and the one the wallpaper is actually painting carries a live dot —
1953
+ but deliberately NO ring of its own. Two tiles ringed at once read as "two
1954
+ things are selected", which is the one question a row of near-identical
1955
+ thumbnails must not leave open: being on screen is the dot, and nothing
1956
+ else. FILES dropped here are added, which is why the container's border
1957
+ reacts to a drag; a TILE dragged across the row reorders it instead, and that
1958
+ is what the is-dragging / dab-strip-bar pair below is for. */
1962
1959
  .dab-strip{display:flex;gap:7px;margin-top:9px;padding:7px;border-radius:12px;border:1.5px dashed var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);overflow-x:auto;scrollbar-width:thin}
1963
1960
  .dab-strip.is-over{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent)}
1964
- .dab-strip-item{position:relative;flex:none;width:62px;height:44px;padding:0;border-radius:8px;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);cursor:pointer;transition:border-color .2s,box-shadow .2s,transform .18s}
1961
+ .dab-strip-item{position:relative;flex:none;width:62px;height:44px;padding:0;border-radius:8px;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);cursor:grab;user-select:none;transition:border-color .2s,box-shadow .2s,transform .18s}
1965
1962
  .dab-strip-item img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
1966
1963
  .dab-strip-item:hover{transform:translateY(-1px);border-color:var(--dsw-alias-brand-primary)}
1967
1964
  .dab-strip-item.is-sel{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary) 40%,transparent)}
1968
- .dab-strip-item.is-live{box-shadow:0 0 0 2px var(--dsw-alias-brand-primary)}
1965
+ /* The tile being dragged out of the row, and the 2px bar that says where it will
1966
+ land. The bar is drawn INSIDE the tile it precedes (at its left edge) or inside
1967
+ the last tile (at its right edge, for a drop past the end): a bar as its own flex
1968
+ item would push the tiles sideways, and a target that slides away as the pointer
1969
+ approaches it is how a drop ends up one slot off. */
1970
+ .dab-strip-item.is-dragging{cursor:grabbing;opacity:.45;transform:none;border-style:dashed}
1971
+ .dab-strip-bar{position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--dsw-alias-brand-primary);z-index:2}
1972
+ .dab-strip-bar.is-end{left:auto;right:0}
1969
1973
  .dab-strip-wait{position:absolute;inset:0;background:repeating-linear-gradient(45deg,transparent 0 4px,color-mix(in srgb,var(--dsw-alias-label-tertiary) 14%,transparent) 4px 8px)}
1970
1974
  .dab-strip-num{position:absolute;left:3px;bottom:2px;font-family:var(--dab-mono);font-size:9.5px;line-height:1;padding:1px 4px;border-radius:5px;background:rgba(0,0,0,.45);color:#fff}
1971
1975
  .dab-strip-live{position:absolute;right:3px;top:3px;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px rgba(0,0,0,.35);animation:dab-pulse 1.8s ease-in-out infinite}
@@ -4772,15 +4776,6 @@ window.__ModuleLoader__.load({
4772
4776
  className: "dab-rise",
4773
4777
  style: { "--d": 2 },
4774
4778
  children: [
4775
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4776
- className: "dab-swatch-title",
4777
- children: t("rulesTitle")
4778
- }),
4779
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4780
- className: "dab-hint",
4781
- style: { marginBottom: 11 },
4782
- children: t("rulesHint")
4783
- }),
4784
4779
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4785
4780
  className: "dab-chip-row",
4786
4781
  style: {
@@ -4800,11 +4795,6 @@ window.__ModuleLoader__.load({
4800
4795
  children: t("autoExtract")
4801
4796
  })]
4802
4797
  }),
4803
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4804
- className: "dab-hint",
4805
- style: { marginBottom: 11 },
4806
- children: t("autoExtractHint")
4807
- }),
4808
4798
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4809
4799
  className: "dab-rules",
4810
4800
  children: rules.map((rule, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RuleCard, {
@@ -4834,6 +4824,12 @@ window.__ModuleLoader__.load({
4834
4824
  const { t } = p;
4835
4825
  const [open, setOpen] = (0, react.useState)(active);
4836
4826
  const [dragOver, setDragOver] = (0, react.useState)(false);
4827
+ const pendingDrag = (0, react.useRef)(null);
4828
+ /** Insertion slot of the drag in flight (index of the tile it goes BEFORE). */
4829
+ const dragAt = (0, react.useRef)(null);
4830
+ const [drop, setDrop] = (0, react.useState)(null);
4831
+ /** Set while a drag is real, so the click that ends it is not also a selection. */
4832
+ const draggedRef = (0, react.useRef)(false);
4837
4833
  const [urlOpen, setUrlOpen] = (0, react.useState)(false);
4838
4834
  const [urlVal, setUrlVal] = (0, react.useState)("");
4839
4835
  const [urlBusy, setUrlBusy] = (0, react.useState)(false);
@@ -4961,8 +4957,61 @@ window.__ModuleLoader__.load({
4961
4957
  const rotatable = rule.images.length >= 2;
4962
4958
  const preset = ROTATE_PRESETS.find((x) => x.ms === rule.rotate.intervalMs);
4963
4959
  const selSlot = image?.slot ?? null;
4964
- const moveSel = (dir) => {
4965
- if (selSlot !== null) p.moveRuleImage(rule.id, selSlot, dir);
4960
+ /** How far the pointer must travel before a click turns into a drag. */
4961
+ const DRAG_SLOP = 5;
4962
+ const tileDown = (i, slot) => (e) => {
4963
+ if (e.button !== 0 || e.pointerType === "touch") return;
4964
+ e.currentTarget.setPointerCapture(e.pointerId);
4965
+ pendingDrag.current = {
4966
+ x: e.clientX,
4967
+ y: e.clientY,
4968
+ from: i,
4969
+ slot,
4970
+ id: e.pointerId
4971
+ };
4972
+ draggedRef.current = false;
4973
+ };
4974
+ const tileMove = (e) => {
4975
+ const pen = pendingDrag.current;
4976
+ if (pen === null || pen.id !== e.pointerId) return;
4977
+ const cur = dragAt.current;
4978
+ if (cur === null) {
4979
+ if (Math.abs(e.clientX - pen.x) + Math.abs(e.clientY - pen.y) < DRAG_SLOP) return;
4980
+ draggedRef.current = true;
4981
+ }
4982
+ const strip = e.currentTarget.parentElement;
4983
+ const list = strip === null ? [] : [...strip.querySelectorAll(".dab-strip-item")];
4984
+ let at = list.length;
4985
+ for (let i = 0; i < list.length; i++) {
4986
+ const box = list[i].getBoundingClientRect();
4987
+ if (e.clientX < box.left + box.width / 2) {
4988
+ at = i;
4989
+ break;
4990
+ }
4991
+ }
4992
+ if (cur !== null && cur.at === at) return;
4993
+ dragAt.current = {
4994
+ from: pen.from,
4995
+ at
4996
+ };
4997
+ setDrop({
4998
+ from: pen.from,
4999
+ at
5000
+ });
5001
+ };
5002
+ /** End a drag: commit the new order, or just forget it on a cancel. */
5003
+ const tileUp = (commit) => (e) => {
5004
+ const pen = pendingDrag.current;
5005
+ pendingDrag.current = null;
5006
+ if (pen !== null && e.currentTarget.hasPointerCapture(pen.id)) e.currentTarget.releasePointerCapture(pen.id);
5007
+ const end = dragAt.current;
5008
+ dragAt.current = null;
5009
+ setDrop(null);
5010
+ if (!commit || pen === null || end === null) return;
5011
+ const to = end.at > pen.from ? end.at - 1 : end.at;
5012
+ if (to === pen.from) return;
5013
+ p.moveRuleImageTo(rule.id, pen.slot, to);
5014
+ setSel(to);
4966
5015
  };
4967
5016
  const removeSel = () => {
4968
5017
  if (selSlot === null) return;
@@ -5089,9 +5138,26 @@ window.__ModuleLoader__.load({
5089
5138
  const thumb = p.imageOf(img.slot);
5090
5139
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5091
5140
  type: "button",
5092
- className: `dab-strip-item${i === selIdx ? " is-sel" : ""}${liveIndex === i ? " is-live" : ""}`,
5093
- title: `${i + 1} / ${rule.images.length}`,
5094
- onClick: () => setSel(i),
5141
+ className: `dab-strip-item${i === selIdx ? " is-sel" : ""}${drop !== null && drop.from === i ? " is-dragging" : ""}`,
5142
+ title: `${i + 1} / ${rule.images.length} · ${t("rotDragHint")} · ${t("rotDblHint")}`,
5143
+ onPointerDown: tileDown(i, img.slot),
5144
+ onPointerMove: tileMove,
5145
+ onPointerUp: tileUp(true),
5146
+ onPointerCancel: tileUp(false),
5147
+ onClick: () => {
5148
+ if (draggedRef.current) {
5149
+ draggedRef.current = false;
5150
+ return;
5151
+ }
5152
+ setSel(i);
5153
+ },
5154
+ onDoubleClick: () => {
5155
+ if (!active) {
5156
+ notify(t("rotNextInactive"));
5157
+ return;
5158
+ }
5159
+ p.showRuleImage(rule.id, img.slot);
5160
+ },
5095
5161
  children: [
5096
5162
  thumb !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
5097
5163
  src: thumb,
@@ -5102,6 +5168,8 @@ window.__ModuleLoader__.load({
5102
5168
  className: "dab-strip-num",
5103
5169
  children: i + 1
5104
5170
  }),
5171
+ drop !== null && drop.at === i ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-strip-bar" }) : null,
5172
+ drop !== null && drop.at === rule.images.length && i === rule.images.length - 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "dab-strip-bar is-end" }) : null,
5105
5173
  img.color !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5106
5174
  className: "dab-strip-dot",
5107
5175
  title: toHex(hslToRgb(img.color[0], img.color[1], img.color[2])).toUpperCase(),
@@ -5162,38 +5230,6 @@ window.__ModuleLoader__.load({
5162
5230
  },
5163
5231
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(UploadIcon, { size: 13 }), t("rotReplaceImage")]
5164
5232
  }) : null,
5165
- url !== null && rule.bgMode === "fit" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5166
- type: "button",
5167
- className: "dab-btn",
5168
- onClick: () => setEditorOpen(true),
5169
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(EditIcon, { size: 13 }), t("ruleFramingEdit")]
5170
- }) : null,
5171
- rule.images.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5172
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5173
- type: "button",
5174
- className: "dab-icon-btn",
5175
- disabled: selIdx === 0,
5176
- title: t("rotImageEarlier"),
5177
- onClick: () => moveSel(-1),
5178
- children: "↑"
5179
- }),
5180
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5181
- type: "button",
5182
- className: "dab-icon-btn",
5183
- disabled: selIdx === rule.images.length - 1,
5184
- title: t("rotImageLater"),
5185
- onClick: () => moveSel(1),
5186
- children: "↓"
5187
- }),
5188
- selIdx !== 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5189
- type: "button",
5190
- className: "dab-btn dab-btn-ghost",
5191
- onClick: () => {
5192
- if (selSlot !== null) p.setCurrentImage(rule.id, selSlot);
5193
- },
5194
- children: t("rotMakeFirst")
5195
- }) : null
5196
- ] }) : null,
5197
5233
  selSlot !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5198
5234
  type: "button",
5199
5235
  className: "dab-btn dab-btn-ghost dab-btn-danger",
@@ -5401,14 +5437,19 @@ window.__ModuleLoader__.load({
5401
5437
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5402
5438
  className: "dab-rule-section-title",
5403
5439
  children: t("ruleLayout")
5404
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5440
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5405
5441
  className: "dab-chip-row",
5406
- children: BG_MODES.map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5442
+ children: [BG_MODES.map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5407
5443
  type: "button",
5408
5444
  className: `dab-chip${rule.bgMode === m.mode ? " is-active" : ""}`,
5409
5445
  onClick: () => p.setRule(rule.id, { bgMode: m.mode }),
5410
5446
  children: t(m.key)
5411
- }, m.mode))
5447
+ }, m.mode)), url !== null && rule.bgMode === "fit" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
5448
+ type: "button",
5449
+ className: "dab-btn",
5450
+ onClick: () => setEditorOpen(true),
5451
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(EditIcon, { size: 13 }), t("ruleFramingEdit")]
5452
+ }) : null]
5412
5453
  })]
5413
5454
  })
5414
5455
  ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
@@ -6314,6 +6355,23 @@ window.__ModuleLoader__.load({
6314
6355
  saveConfig();
6315
6356
  repaintIfMoved(id, winner);
6316
6357
  };
6358
+ /**
6359
+ * The batch form, for one upload that carried several pictures.
6360
+ *
6361
+ * EVERY picture that arrived is themed from its own bytes, the first ones
6362
+ * included. The single-slot reading of an upload ("the last image added is the
6363
+ * one being looked at") is true of the CARD's selection and false of the
6364
+ * palette: since a theme color belongs to an image, a batch that themed only
6365
+ * its last file left the rest of a fresh rotation on the system theme, with
6366
+ * nothing in the interface to say why.
6367
+ *
6368
+ * Sequential on purpose: a batch is a handful of full-size photos, and ten
6369
+ * parallel decodes buy nothing the user can see, while landing the colors in
6370
+ * list order does (the card fills in from its first picture).
6371
+ */
6372
+ const maybeAutoExtractEach = async (id, slots) => {
6373
+ for (const slot of slots) await maybeAutoExtract(id, slot);
6374
+ };
6317
6375
  const activeLang = () => {
6318
6376
  try {
6319
6377
  const snapshot = ctx.locale.getSnapshot();
@@ -6390,6 +6448,8 @@ window.__ModuleLoader__.load({
6390
6448
  const rule = ruleById(id);
6391
6449
  if (rule === null || dataUrls.length === 0) return;
6392
6450
  const winner = winnerId();
6451
+ /** Slots of THIS batch, in list order — the extraction below is per slot. */
6452
+ const added = [];
6393
6453
  for (const dataUrl of dataUrls) {
6394
6454
  const slot = nextSlot();
6395
6455
  const image = {
@@ -6398,13 +6458,14 @@ window.__ModuleLoader__.load({
6398
6458
  color: null
6399
6459
  };
6400
6460
  rule.images.push(image);
6461
+ added.push(slot);
6401
6462
  setImage(slot, dataUrl);
6402
6463
  writeImage(slot, dataUrl);
6403
6464
  }
6404
6465
  rulesRev++;
6405
6466
  persistConfig();
6406
6467
  repaintIfMoved(id, winner);
6407
- maybeAutoExtract(id, rule.images[rule.images.length - 1]?.slot);
6468
+ maybeAutoExtractEach(id, added);
6408
6469
  },
6409
6470
  removeRuleImage: (id, slot) => {
6410
6471
  const rule = ruleById(id);
@@ -6422,34 +6483,21 @@ window.__ModuleLoader__.load({
6422
6483
  }
6423
6484
  repaintIfMoved(id, winner);
6424
6485
  },
6425
- moveRuleImage: (id, slot, dir) => {
6426
- const rule = ruleById(id);
6427
- if (rule === null) return;
6428
- const idx = rule.images.findIndex((i) => i.slot === slot);
6429
- const to = idx + dir;
6430
- if (idx < 0 || to < 0 || to >= rule.images.length) return;
6431
- const winner = winnerId();
6432
- const [image] = rule.images.splice(idx, 1);
6433
- rule.images.splice(to, 0, image);
6434
- rulesRev++;
6435
- if (id === activeRuleId) {
6436
- setRotIndex(Math.max(0, to));
6437
- scheduleRotation(true);
6438
- }
6439
- persistConfig();
6440
- repaintIfMoved(id, winner);
6441
- },
6442
- setCurrentImage: (id, slot) => {
6486
+ moveRuleImageTo: (id, slot, to) => {
6443
6487
  const rule = ruleById(id);
6444
6488
  if (rule === null) return;
6445
6489
  const idx = rule.images.findIndex((i) => i.slot === slot);
6446
6490
  if (idx < 0) return;
6491
+ const at = Math.max(0, Math.min(rule.images.length - 1, Math.trunc(to)));
6492
+ if (at === idx) return;
6447
6493
  const winner = winnerId();
6494
+ const painted = id === activeRuleId ? rule.images[imageIndexOf(rule)]?.slot ?? null : null;
6448
6495
  const [image] = rule.images.splice(idx, 1);
6449
- rule.images.unshift(image);
6496
+ rule.images.splice(at, 0, image);
6450
6497
  rulesRev++;
6451
6498
  if (id === activeRuleId) {
6452
- setRotIndex(0);
6499
+ const keep = painted === null ? -1 : rule.images.findIndex((i) => i.slot === painted);
6500
+ setRotIndex(keep < 0 ? Math.min(at, rule.images.length - 1) : keep);
6453
6501
  scheduleRotation(true);
6454
6502
  }
6455
6503
  persistConfig();
@@ -6484,6 +6532,15 @@ window.__ModuleLoader__.load({
6484
6532
  paintImage(rule, next);
6485
6533
  scheduleRotation(true);
6486
6534
  },
6535
+ showRuleImage: (id, slot) => {
6536
+ const rule = ruleById(id);
6537
+ if (rule === null || id !== activeRuleId) return;
6538
+ const idx = rule.images.findIndex((i) => i.slot === slot);
6539
+ if (idx < 0) return;
6540
+ setRotIndex(idx);
6541
+ paintImage(rule, idx);
6542
+ scheduleRotation(true);
6543
+ },
6487
6544
  loadRuleImages: async (id) => {
6488
6545
  const rule = ruleById(id);
6489
6546
  if (rule === null) return;