optimize: community polish — crossfade, position lock, effort sync
- 双图层交叉淡化人像过渡:备用层加载完成才淡入,无白闪;拖动 120ms 快速过渡、落定 340ms 慢速过渡,prefers-reduced-motion 直切 - 滑块位置固定:原生模型座位按钮级 180px 固定足迹(选择器限定 button,避免误伤 label/effort 子元素)+ 滑块 flex 固定尺寸 + 交互期 rAF 漂移补偿 - 原生推理等级与滑块实时同步:拖动预览乐观回写共享 directory store,松手提交、失败/Esc/取消自动回滚 - 滑动更丝滑:移除图片序列模式下对隐藏 video 的每帧 seek,24 张锚点预加载 + decoding=async,交叉淡化走合成器动画 - 滑块自定义光标:liang-cursor.cur(热点居中 16,16),加载失败回退 ew-resize - 版本升至 0.2.0,随包新增 CHANGELOG.md 优化说明
This commit is contained in:
72
CHANGELOG.md
Normal file
72
CHANGELOG.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 滑动变祖 · 社区优化版 0.2.0
|
||||||
|
|
||||||
|
基于上游 [kingOfSoySauce/dsh-liang-skin](https://github.com/kingOfSoySauce/dsh-liang-skin) v0.1.5 优化,
|
||||||
|
已针对 DeepSeek Harness `0.1.0-rc.6` 验证。本包为**本地优化构建**,未推送上游仓库。
|
||||||
|
|
||||||
|
## 优化清单
|
||||||
|
|
||||||
|
1. **图像过渡更自然(交叉淡化)**
|
||||||
|
- 原版:切换推理等级时直接硬换人像 `src`,跳变生硬。
|
||||||
|
- 现版:双图层交叉淡化(备用层先加载、加载完成才淡入,不出现白闪),并叠加轻微
|
||||||
|
缩放(1.018→1)缓动。拖动中快速过渡(120ms,跟手),松手落定时慢速过渡
|
||||||
|
(340ms ease-out);`prefers-reduced-motion` 用户自动降级为直切。
|
||||||
|
|
||||||
|
2. **滑块位置固定,切换等级不再移位**
|
||||||
|
- 根源:原生「模型 · 等级」座位宽度随等级文本变化,在右锚定 flex 行里把滑块挤动。
|
||||||
|
- 修复:① 原生座位按钮固定 180px 足迹(模型名省略号吸收变化);② 滑块自身
|
||||||
|
`flex: 0 0 124px` 固定尺寸;③ 交互/提交期间 rAF 逐帧检测漂移并用 `translateX`
|
||||||
|
补偿,把滑块钉在屏幕上,结束后 200ms 平滑回位。
|
||||||
|
- 注意:固定宽度的选择器必须限定为 `button[class*="_7KE1Ra_trigger"]`——宿主的
|
||||||
|
`_7KE1Ra_triggerLabel`/`_7KE1Ra_triggerEffort` 子元素类名包含同样子串,宽选择器
|
||||||
|
会误伤并把等级文本挤出按钮。
|
||||||
|
|
||||||
|
3. **原生推理等级与滑块实时同步(新增)**
|
||||||
|
- 拖动预览期间把最近档位乐观回写共享 directory store(`SnapshotStore.update`),
|
||||||
|
原生座位实时跟随滑块档位变化;松手才经官方 `ModelDirectory.select` 提交,
|
||||||
|
失败 / Esc / 取消时自动回滚到交互前快照。
|
||||||
|
|
||||||
|
4. **滑动更丝滑**
|
||||||
|
- 移除图片序列模式下每帧对隐藏 `<video>` 的无谓 seek(原版最大卡顿源),
|
||||||
|
仅视频回退模式保留 seek;24 张人像锚点全量预加载 + `decoding="async"`;
|
||||||
|
交叉淡化走 opacity/transform 合成器动画(`will-change`),不触发重排。
|
||||||
|
|
||||||
|
5. **滑块自定义光标**
|
||||||
|
- 使用 `.cur` 标准光标(type=cursor、热点 16,16 居中),挂载于滑块交互区,
|
||||||
|
加载失败自动回退 `ew-resize`。源素材为 32×32 的 `.ico`,已在打包时一并附带。
|
||||||
|
|
||||||
|
6. **修复:模型名与推理等级拆开显示(回归修复)**
|
||||||
|
- 问题:固定原生座位宽度时子串选择器误伤子元素,等级文本被挤出按钮、孤立显示。
|
||||||
|
- 修复:选择器限定按钮本身,「DeepSeek-V4-Pro · 高」恢复一体显示。
|
||||||
|
|
||||||
|
## 与上游 v0.1.5 的差异
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/client/index.tsx` | 双图层 crossfade 呈现器、seek 条件化、预览乐观同步 + 回滚、位置锁定 |
|
||||||
|
| `src/client/skin.css` | crossfade 过渡、滑块固定尺寸、按钮级固定宽度、自定义光标 |
|
||||||
|
| `src/index.js` | 静态路由新增 `liang-cursor.cur` / `liang-cursor.ico` |
|
||||||
|
| `assets/liang-cursor.cur` / `.ico` | 新增光标资产 |
|
||||||
|
| `lib/client.js` / `.map` | 重新构建的客户端产物 |
|
||||||
|
|
||||||
|
## 安装(tarball)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh plugin --profile web add ./dsh-client-liang-intensity-skin-0.2.0.tgz
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后重启 DSH Web 生效;入口仍为「设置 → 通用设置 → 外观皮肤 → 滑动变祖」,
|
||||||
|
「滑动变祖绑定思考等级」开关控制滑块是否联动推理等级。
|
||||||
|
|
||||||
|
## 卸载 / 回退
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dsh plugin --profile web remove dsh-client-liang-intensity-skin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建与测试
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm test # 8/8 通过(vitest,沙箱环境需 --pool=threads)
|
||||||
|
npm run build # esbuild 产出 lib/client.js
|
||||||
|
```
|
||||||
BIN
assets/liang-cursor.cur
Normal file
BIN
assets/liang-cursor.cur
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
assets/liang-cursor.ico
Normal file
BIN
assets/liang-cursor.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
1521
lib/client.js
1521
lib/client.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-client-liang-intensity-skin",
|
"name": "dsh-client-liang-intensity-skin",
|
||||||
"version": "0.1.5",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "滑动变祖:an opt-in adaptive reasoning slider skin for DeepSeek Harness",
|
"description": "滑动变祖(社区优化版):an opt-in adaptive reasoning slider skin for DeepSeek Harness",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/kingOfSoySauce/dsh-liang-skin.git"
|
"url": "git+https://github.com/kingOfSoySauce/dsh-liang-skin.git"
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"assets/liang-cursor.cur",
|
||||||
|
"assets/liang-cursor.ico",
|
||||||
"assets/liang-evolution.mp4",
|
"assets/liang-evolution.mp4",
|
||||||
"assets/liang-evolution.webm",
|
"assets/liang-evolution.webm",
|
||||||
"assets/liang-poster.png",
|
"assets/liang-poster.png",
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"assets/portrait-source-v2/level-28.png",
|
"assets/portrait-source-v2/level-28.png",
|
||||||
"assets/portrait-source-v2/level-29.png",
|
"assets/portrait-source-v2/level-29.png",
|
||||||
"assets/portrait-source-v2/stage-30.png",
|
"assets/portrait-source-v2/stage-30.png",
|
||||||
|
"CHANGELOG.md",
|
||||||
"cordis.patch.yml",
|
"cordis.patch.yml",
|
||||||
"lib/client.js",
|
"lib/client.js",
|
||||||
"lib/client.js.map",
|
"lib/client.js.map",
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const PORTRAIT_ANCHORS = [
|
|||||||
{ level: 30, file: "stage-30.png" },
|
{ level: 30, file: "stage-30.png" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
|
||||||
|
|
||||||
interface SkinSettings {
|
interface SkinSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
bindEffort: boolean;
|
bindEffort: boolean;
|
||||||
@@ -141,13 +143,16 @@ class SkinPresenter {
|
|||||||
private readonly root: HTMLDivElement;
|
private readonly root: HTMLDivElement;
|
||||||
private readonly video: HTMLVideoElement;
|
private readonly video: HTMLVideoElement;
|
||||||
private readonly poster: HTMLImageElement;
|
private readonly poster: HTMLImageElement;
|
||||||
private readonly portrait: HTMLImageElement;
|
private readonly frames: HTMLImageElement[];
|
||||||
private readonly preloads: HTMLImageElement[];
|
private readonly preloads: HTMLImageElement[];
|
||||||
private enabled = true;
|
private enabled = true;
|
||||||
// Default to the max frame so the first paint after load is the dark shell;
|
// Default to the max frame so the first paint after load is the dark shell;
|
||||||
// starting at 0 flashed the light palette before the directory resolved.
|
// starting at 0 flashed the light palette before the directory resolved.
|
||||||
private frame = PREVIEW_MAX_FRAME;
|
private frame = PREVIEW_MAX_FRAME;
|
||||||
private pendingFrame = PREVIEW_MAX_FRAME;
|
private pendingFrame = PREVIEW_MAX_FRAME;
|
||||||
|
private pendingMode: "drag" | "settle" = "settle";
|
||||||
|
private activeFrame = 0;
|
||||||
|
private pendingSource: string | null = null;
|
||||||
private raf = 0;
|
private raf = 0;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
private unsubscribe: () => void;
|
private unsubscribe: () => void;
|
||||||
@@ -159,6 +164,7 @@ class SkinPresenter {
|
|||||||
this.root.className = "liang-skin-backdrop";
|
this.root.className = "liang-skin-backdrop";
|
||||||
this.root.dataset.plugin = PACKAGE_ID;
|
this.root.dataset.plugin = PACKAGE_ID;
|
||||||
this.root.dataset.media = "sequence";
|
this.root.dataset.media = "sequence";
|
||||||
|
this.root.dataset.crossfade = "slow";
|
||||||
this.root.setAttribute("aria-hidden", "true");
|
this.root.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
this.video = document.createElement("video");
|
this.video = document.createElement("video");
|
||||||
@@ -167,7 +173,7 @@ class SkinPresenter {
|
|||||||
this.video.preload = "auto";
|
this.video.preload = "auto";
|
||||||
// A video poster stays painted while a paused video is scrubbed in some
|
// A video poster stays painted while a paused video is scrubbed in some
|
||||||
// browsers, which would make every effort show the same portrait. Keep the
|
// browsers, which would make every effort show the same portrait. Keep the
|
||||||
// separate image element as the true load-error fallback instead.
|
// separate image elements as the true load-error fallback instead.
|
||||||
const webm = document.createElement("source");
|
const webm = document.createElement("source");
|
||||||
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
|
webm.src = `${ASSET_PREFIX}/liang-evolution.webm`;
|
||||||
webm.type = "video/webm";
|
webm.type = "video/webm";
|
||||||
@@ -181,19 +187,29 @@ class SkinPresenter {
|
|||||||
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
|
this.poster.src = `${ASSET_PREFIX}/liang-poster.png`;
|
||||||
this.poster.alt = "";
|
this.poster.alt = "";
|
||||||
|
|
||||||
this.portrait = document.createElement("img");
|
// Two stacked crossfade layers: the standby layer loads the next portrait
|
||||||
this.portrait.className = "liang-skin-sequence-frame";
|
// while the active one stays painted, then an opacity/scale transition
|
||||||
this.portrait.alt = "";
|
// hands over — no hard src swap, no white flash.
|
||||||
this.portrait.draggable = false;
|
this.frames = [];
|
||||||
this.portrait.addEventListener("error", this.handleSequenceError);
|
for (let index = 0; index < 2; index += 1) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.className = "liang-skin-sequence-frame";
|
||||||
|
image.alt = "";
|
||||||
|
image.draggable = false;
|
||||||
|
image.decoding = "async";
|
||||||
|
image.addEventListener("error", this.handleSequenceError);
|
||||||
|
image.addEventListener("load", () => this.handleFrameLoaded(image));
|
||||||
|
this.frames.push(image);
|
||||||
|
}
|
||||||
|
|
||||||
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
image.decoding = "async";
|
||||||
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
||||||
return image;
|
return image;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.root.append(this.video, this.portrait, this.poster);
|
this.root.append(this.video, this.frames[0], this.frames[1], this.poster);
|
||||||
document.body.prepend(this.root);
|
document.body.prepend(this.root);
|
||||||
|
|
||||||
this.video.addEventListener("loadedmetadata", this.handleMetadata);
|
this.video.addEventListener("loadedmetadata", this.handleMetadata);
|
||||||
@@ -246,9 +262,10 @@ class SkinPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFrame(frame: number) {
|
setFrame(frame: number, mode: "drag" | "settle" = "drag") {
|
||||||
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
|
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
|
||||||
this.frame = this.pendingFrame;
|
this.frame = this.pendingFrame;
|
||||||
|
this.pendingMode = mode;
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
if (this.raf !== 0) return;
|
if (this.raf !== 0) return;
|
||||||
this.raf = requestAnimationFrame(() => {
|
this.raf = requestAnimationFrame(() => {
|
||||||
@@ -278,8 +295,11 @@ class SkinPresenter {
|
|||||||
body.style.setProperty("--liang-accent-hover", palette.accentHover);
|
body.style.setProperty("--liang-accent-hover", palette.accentHover);
|
||||||
body.style.setProperty("--liang-hover", palette.hover);
|
body.style.setProperty("--liang-hover", palette.hover);
|
||||||
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
|
body.style.setProperty("--liang-portrait-opacity", palette.portraitOpacity);
|
||||||
|
this.root.dataset.crossfade = this.pendingMode === "settle" ? "slow" : "fast";
|
||||||
this.updatePortrait(palette.level);
|
this.updatePortrait(palette.level);
|
||||||
this.seek(this.frame);
|
// Seeking a hidden <video> is pure cost (and visibly janky) during drags
|
||||||
|
// in the default image-sequence mode; only the video fallback needs it.
|
||||||
|
if (this.root.dataset.media === "video") this.seek(this.frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncNativeTheme(theme: NativeThemeId = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
|
syncNativeTheme(theme: NativeThemeId = paletteForFrame(this.frame).stage === 5 ? "dark" : "light") {
|
||||||
@@ -288,15 +308,47 @@ class SkinPresenter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updatePortrait(level: number) {
|
private updatePortrait(level: number) {
|
||||||
|
if (this.root.dataset.media !== "sequence") return;
|
||||||
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
|
const { lowerIndex, upperIndex, mix } = portraitBlendForLevel(
|
||||||
level,
|
level,
|
||||||
PORTRAIT_ANCHORS.map((anchor) => anchor.level),
|
ANCHOR_LEVELS,
|
||||||
);
|
);
|
||||||
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
||||||
const upper = PORTRAIT_ANCHORS[upperIndex];
|
const upper = PORTRAIT_ANCHORS[upperIndex];
|
||||||
const selected = mix >= 0.5 ? upper : lower;
|
const selected = mix >= 0.5 ? upper : lower;
|
||||||
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
|
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
|
||||||
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
|
const active = this.frames[this.activeFrame];
|
||||||
|
if (active.getAttribute("src") === source) {
|
||||||
|
// Already showing this anchor; drop any stale queued handover.
|
||||||
|
this.pendingSource = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.pendingSource === source) return;
|
||||||
|
const standbyIndex = 1 - this.activeFrame;
|
||||||
|
const standby = this.frames[standbyIndex];
|
||||||
|
this.pendingSource = source;
|
||||||
|
if (standby.getAttribute("src") !== source) standby.src = source;
|
||||||
|
// Cache hit (preloaded): hand over immediately — the CSS transition still
|
||||||
|
// eases the swap, so a fast drag stays glued to the pointer.
|
||||||
|
if (standby.complete && standby.naturalWidth > 0) {
|
||||||
|
this.activate(standbyIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly handleFrameLoaded = (image: HTMLImageElement) => {
|
||||||
|
if (
|
||||||
|
this.pendingSource === null
|
||||||
|
|| image.getAttribute("src") !== this.pendingSource
|
||||||
|
) return;
|
||||||
|
this.activate(this.frames.indexOf(image));
|
||||||
|
};
|
||||||
|
|
||||||
|
private activate(index: number) {
|
||||||
|
if (index < 0 || index === this.activeFrame) return;
|
||||||
|
this.frames[this.activeFrame].classList.remove("is-active");
|
||||||
|
this.activeFrame = index;
|
||||||
|
this.frames[index].classList.add("is-active");
|
||||||
|
this.pendingSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private seek(frame: number) {
|
private seek(frame: number) {
|
||||||
@@ -324,7 +376,10 @@ class SkinPresenter {
|
|||||||
this.video.pause();
|
this.video.pause();
|
||||||
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
|
this.video.removeEventListener("loadedmetadata", this.handleMetadata);
|
||||||
this.video.removeEventListener("error", this.handleVideoError);
|
this.video.removeEventListener("error", this.handleVideoError);
|
||||||
this.portrait.removeEventListener("error", this.handleSequenceError);
|
for (const image of this.frames) {
|
||||||
|
image.removeEventListener("error", this.handleSequenceError);
|
||||||
|
image.src = "";
|
||||||
|
}
|
||||||
this.poster.removeEventListener("error", this.handlePosterError);
|
this.poster.removeEventListener("error", this.handlePosterError);
|
||||||
for (const image of this.preloads) image.src = "";
|
for (const image of this.preloads) image.src = "";
|
||||||
this.root.remove();
|
this.root.remove();
|
||||||
@@ -376,6 +431,13 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
|
|||||||
const [interacting, setInteracting] = useState(false);
|
const [interacting, setInteracting] = useState(false);
|
||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const dragStartFrame = useRef(frame);
|
const dragStartFrame = useRef(frame);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const interactionArmed = useRef(false);
|
||||||
|
const dragStartCommittedId = useRef<string | undefined>(undefined);
|
||||||
|
const dragStartCurrent = useRef<ModelSelection | null>(null);
|
||||||
|
const previewSyncedId = useRef<string | undefined>(undefined);
|
||||||
|
const lockRaf = useRef(0);
|
||||||
|
const lockBaseLeft = useRef<number | null>(null);
|
||||||
const enabled = skin.enabled;
|
const enabled = skin.enabled;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -385,9 +447,44 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bindEffort || dragging.current || pending) return;
|
if (!bindEffort || dragging.current || pending) return;
|
||||||
setFrame(committedFrame);
|
setFrame(committedFrame);
|
||||||
presenter.setFrame(committedFrame);
|
presenter.setFrame(committedFrame, "settle");
|
||||||
}, [bindEffort, committedFrame, pending, presenter]);
|
}, [bindEffort, committedFrame, pending, presenter]);
|
||||||
|
|
||||||
|
// While the user interacts (or a commit is in flight), the native model
|
||||||
|
// seat to the right may change its label width and reflow this flex row.
|
||||||
|
// Pin the control's on-screen position each frame and compensate any drift
|
||||||
|
// with a transform, then glide back smoothly when the interaction ends.
|
||||||
|
const locked = interacting || pending;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locked) return;
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (root === null) return;
|
||||||
|
root.style.transition = "";
|
||||||
|
root.style.willChange = "transform";
|
||||||
|
lockBaseLeft.current = root.getBoundingClientRect().left;
|
||||||
|
const step = () => {
|
||||||
|
lockRaf.current = requestAnimationFrame(step);
|
||||||
|
const base = lockBaseLeft.current;
|
||||||
|
if (base === null) return;
|
||||||
|
const drift = base - root.getBoundingClientRect().left;
|
||||||
|
if (Math.abs(drift) > 0.5) {
|
||||||
|
root.style.transform = `translateX(${drift.toFixed(2)}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
lockRaf.current = requestAnimationFrame(step);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(lockRaf.current);
|
||||||
|
lockRaf.current = 0;
|
||||||
|
root.style.transition = "transform 200ms ease-out";
|
||||||
|
root.style.transform = "";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
root.style.transition = "";
|
||||||
|
root.style.willChange = "";
|
||||||
|
}, 220);
|
||||||
|
lockBaseLeft.current = null;
|
||||||
|
};
|
||||||
|
}, [locked]);
|
||||||
|
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
if (bindEffort && (reasoning === null || efforts.length < 2)) return null;
|
if (bindEffort && (reasoning === null || efforts.length < 2)) return null;
|
||||||
|
|
||||||
@@ -396,21 +493,62 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
|
|||||||
const progressRatio = frame / PREVIEW_MAX_FRAME;
|
const progressRatio = frame / PREVIEW_MAX_FRAME;
|
||||||
const tooltipLabel = indicatorLabel(frame, bindEffort ? efforts : []);
|
const tooltipLabel = indicatorLabel(frame, bindEffort ? efforts : []);
|
||||||
|
|
||||||
|
const beginInteraction = () => {
|
||||||
|
if (interactionArmed.current) return;
|
||||||
|
interactionArmed.current = true;
|
||||||
|
dragging.current = true;
|
||||||
|
dragStartFrame.current = frame;
|
||||||
|
const snapshot = directory.getSnapshot();
|
||||||
|
dragStartCurrent.current = snapshot.current;
|
||||||
|
const snapshotReasoning = modelReasoning(snapshot);
|
||||||
|
dragStartCommittedId.current = snapshotReasoning?.selection.reasoningEffort
|
||||||
|
?? snapshotReasoning?.defaultEffort;
|
||||||
|
previewSyncedId.current = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roll the shared directory store back to the value captured before this
|
||||||
|
// interaction (undoes any optimistic preview echo).
|
||||||
|
const restorePreviewSync = () => {
|
||||||
|
const original = dragStartCurrent.current;
|
||||||
|
if (original === null || previewSyncedId.current === undefined) return;
|
||||||
|
directory.store.update((draft) => {
|
||||||
|
draft.current = original;
|
||||||
|
});
|
||||||
|
previewSyncedId.current = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelInteraction = () => {
|
||||||
|
setInteracting(false);
|
||||||
|
interactionArmed.current = false;
|
||||||
|
dragging.current = false;
|
||||||
|
setFrame(dragStartFrame.current);
|
||||||
|
presenter.setFrame(dragStartFrame.current, "settle");
|
||||||
|
restorePreviewSync();
|
||||||
|
};
|
||||||
|
|
||||||
const commit = async (rawFrame: number) => {
|
const commit = async (rawFrame: number) => {
|
||||||
|
interactionArmed.current = false;
|
||||||
|
dragging.current = false;
|
||||||
if (!bindEffort) {
|
if (!bindEffort) {
|
||||||
dragging.current = false;
|
|
||||||
setFrame(rawFrame);
|
setFrame(rawFrame);
|
||||||
presenter.setFrame(rawFrame);
|
presenter.setFrame(rawFrame, "settle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetIndex = nearestEffortIndex(rawFrame, efforts);
|
const targetIndex = nearestEffortIndex(rawFrame, efforts);
|
||||||
const target = efforts[targetIndex];
|
const target = efforts[targetIndex];
|
||||||
if (target === undefined) return;
|
if (target === undefined) {
|
||||||
|
restorePreviewSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const targetFrame = frameForEffort(targetIndex, efforts.length);
|
const targetFrame = frameForEffort(targetIndex, efforts.length);
|
||||||
dragging.current = false;
|
|
||||||
setFrame(targetFrame);
|
setFrame(targetFrame);
|
||||||
presenter.setFrame(targetFrame);
|
presenter.setFrame(targetFrame, "settle");
|
||||||
if (targetIndex === committedIndex || pending) return;
|
if (target.id === dragStartCommittedId.current) {
|
||||||
|
// No real change: just undo any optimistic preview echo.
|
||||||
|
restorePreviewSync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
const accepted = await select({
|
const accepted = await select({
|
||||||
@@ -421,13 +559,25 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
|
|||||||
setPending(false);
|
setPending(false);
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
setFrame(committedFrame);
|
restorePreviewSync();
|
||||||
presenter.setFrame(committedFrame);
|
const rollbackIndex = selectedEffortIndex(
|
||||||
|
efforts,
|
||||||
|
dragStartCommittedId.current,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const rollbackFrame = rollbackIndex < 0
|
||||||
|
? PREVIEW_MAX_FRAME
|
||||||
|
: frameForEffort(rollbackIndex, efforts.length);
|
||||||
|
setFrame(rollbackFrame);
|
||||||
|
presenter.setFrame(rollbackFrame, "settle");
|
||||||
|
} else {
|
||||||
|
previewSyncedId.current = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
className="liang-effort-control"
|
className="liang-effort-control"
|
||||||
data-plugin={PACKAGE_ID}
|
data-plugin={PACKAGE_ID}
|
||||||
data-state={failed ? "error" : pending ? "pending" : "ready"}
|
data-state={failed ? "error" : pending ? "pending" : "ready"}
|
||||||
@@ -461,25 +611,38 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
|
|||||||
aria-label={bindEffort ? "思考等级" : "皮肤进度"}
|
aria-label={bindEffort ? "思考等级" : "皮肤进度"}
|
||||||
aria-valuetext={bindEffort ? previewEffort?.name ?? "" : tooltipLabel}
|
aria-valuetext={bindEffort ? previewEffort?.name ?? "" : tooltipLabel}
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
dragging.current = true;
|
beginInteraction();
|
||||||
dragStartFrame.current = frame;
|
|
||||||
setInteracting(true);
|
setInteracting(true);
|
||||||
}}
|
}}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
dragging.current = true;
|
dragging.current = true;
|
||||||
const next = Number(event.currentTarget.value);
|
const next = Number(event.currentTarget.value);
|
||||||
setFrame(next);
|
setFrame(next);
|
||||||
presenter.setFrame(next);
|
presenter.setFrame(next, "drag");
|
||||||
|
if (!bindEffort || reasoning === null) return;
|
||||||
|
// Optimistically echo the nearest preview effort into the shared
|
||||||
|
// directory store so the native model seat updates in lockstep
|
||||||
|
// while dragging; commit/rollback settles the final value.
|
||||||
|
const index = nearestEffortIndex(next, efforts);
|
||||||
|
const effort = efforts[index];
|
||||||
|
if (effort === undefined || effort.id === previewSyncedId.current) return;
|
||||||
|
previewSyncedId.current = effort.id;
|
||||||
|
directory.store.update((draft) => {
|
||||||
|
const current = draft.current;
|
||||||
|
if (
|
||||||
|
current === null
|
||||||
|
|| current.provider !== reasoning.selection.provider
|
||||||
|
|| current.model !== reasoning.selection.model
|
||||||
|
) return;
|
||||||
|
current.reasoningEffort = effort.id;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onPointerUp={(event) => {
|
onPointerUp={(event) => {
|
||||||
setInteracting(false);
|
setInteracting(false);
|
||||||
void commit(Number(event.currentTarget.value));
|
void commit(Number(event.currentTarget.value));
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => {
|
onPointerCancel={() => {
|
||||||
setInteracting(false);
|
cancelInteraction();
|
||||||
dragging.current = false;
|
|
||||||
setFrame(dragStartFrame.current);
|
|
||||||
presenter.setFrame(dragStartFrame.current);
|
|
||||||
}}
|
}}
|
||||||
onKeyUp={(event) => {
|
onKeyUp={(event) => {
|
||||||
setInteracting(false);
|
setInteracting(false);
|
||||||
@@ -491,11 +654,9 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }: Slider
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
setInteracting(true);
|
setInteracting(true);
|
||||||
|
beginInteraction();
|
||||||
if (event.key === "Escape" && !pending) {
|
if (event.key === "Escape" && !pending) {
|
||||||
setInteracting(false);
|
cancelInteraction();
|
||||||
dragging.current = false;
|
|
||||||
setFrame(dragStartFrame.current);
|
|
||||||
presenter.setFrame(dragStartFrame.current);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,24 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
|||||||
|
|
||||||
.liang-skin-backdrop[data-media="sequence"] .liang-skin-sequence-frame {
|
.liang-skin-backdrop[data-media="sequence"] .liang-skin-sequence-frame {
|
||||||
display: block;
|
display: block;
|
||||||
|
/* Crossfade layers: inactive sits transparent and slightly enlarged, the
|
||||||
|
active one eases back to a calm 1:1. Dragging swaps fast so the preview
|
||||||
|
stays glued to the pointer; settling swaps slow for a gentle landing. */
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) scale(1.018);
|
||||||
|
transition:
|
||||||
|
opacity 120ms ease-out,
|
||||||
|
transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liang-skin-backdrop[data-media="sequence"] .liang-skin-sequence-frame.is-active {
|
||||||
opacity: var(--liang-portrait-opacity, 0.82);
|
opacity: var(--liang-portrait-opacity, 0.82);
|
||||||
|
transform: translateY(-50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liang-skin-backdrop[data-crossfade="slow"] .liang-skin-sequence-frame {
|
||||||
|
transition-duration: 340ms, 520ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop[data-media="video"] .liang-skin-sequence-frame,
|
.liang-skin-backdrop[data-media="video"] .liang-skin-sequence-frame,
|
||||||
@@ -337,7 +354,14 @@ body[data-liang-skin="on"]:is(
|
|||||||
--liang-control-rail: var(--dsw-alias-border-l3, rgb(0 0 0 / 16%));
|
--liang-control-rail: var(--dsw-alias-border-l3, rgb(0 0 0 / 16%));
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
/* Fixed footprint: the native model seat to the right changes its label
|
||||||
|
width when the reasoning effort changes, which reflows the trailing flex
|
||||||
|
row. Pin this control's own size so it never contributes to that shift
|
||||||
|
(absolute position is additionally locked during interaction in JS). */
|
||||||
|
flex: 0 0 124px;
|
||||||
width: 124px;
|
width: 124px;
|
||||||
|
min-width: 124px;
|
||||||
|
max-width: 124px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0 3px;
|
margin: 0 3px;
|
||||||
@@ -349,6 +373,19 @@ body[data-liang-skin="on"] .liang-effort-control {
|
|||||||
--liang-control-rail: color-mix(in srgb, var(--liang-secondary) 28%, transparent);
|
--liang-control-rail: color-mix(in srgb, var(--liang-secondary) 28%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The native model seat (composer input.model) widens with the effort label
|
||||||
|
text; give it a stable footprint under the Liang skin so switching effort
|
||||||
|
never reflows the slider out of position. The label ellipsizes instead.
|
||||||
|
Button-scoped on purpose: the inner label/effort spans also contain this
|
||||||
|
class substring and must never be sized (sizing them pushed the effort text
|
||||||
|
out of the button, splitting the model+effort pair apart). */
|
||||||
|
body[data-liang-skin="on"] button[class*="_7KE1Ra_trigger"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 180px;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.liang-effort-control__ticks {
|
.liang-effort-control__ticks {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -415,7 +452,9 @@ body[data-liang-skin="on"] .liang-effort-control {
|
|||||||
width: calc(100% - 7px);
|
width: calc(100% - 7px);
|
||||||
height: 28px;
|
height: 28px;
|
||||||
margin: 0 3.5px;
|
margin: 0 3.5px;
|
||||||
cursor: ew-resize;
|
/* Branded pointer (centered hotspot baked into the .cur); fall back to the
|
||||||
|
native resize cursor if the asset fails to load. */
|
||||||
|
cursor: url("/plugins/dsh-client-liang-intensity-skin/assets/liang-cursor.cur") 16 16, ew-resize;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
@@ -620,7 +659,10 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
|||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.liang-effort-control {
|
.liang-effort-control {
|
||||||
|
flex-basis: 92px;
|
||||||
width: 92px;
|
width: 92px;
|
||||||
|
min-width: 92px;
|
||||||
|
max-width: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.liang-skin-backdrop video,
|
.liang-skin-backdrop video,
|
||||||
@@ -636,6 +678,7 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
|||||||
.liang-skin-backdrop,
|
.liang-skin-backdrop,
|
||||||
.liang-skin-backdrop video,
|
.liang-skin-backdrop video,
|
||||||
.liang-skin-backdrop img,
|
.liang-skin-backdrop img,
|
||||||
|
.liang-skin-backdrop .liang-skin-sequence-frame,
|
||||||
body[data-liang-skin="on"] button,
|
body[data-liang-skin="on"] button,
|
||||||
body[data-liang-skin="on"] input,
|
body[data-liang-skin="on"] input,
|
||||||
body[data-liang-skin="on"] textarea {
|
body[data-liang-skin="on"] textarea {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const SETTINGS_NAMESPACE = "liang-intensity-skin";
|
|||||||
export const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets/`;
|
export const ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets/`;
|
||||||
|
|
||||||
const ASSET_SPECS = [
|
const ASSET_SPECS = [
|
||||||
|
["liang-cursor.cur", "image/x-icon"],
|
||||||
|
["liang-cursor.ico", "image/x-icon"],
|
||||||
["liang-evolution.webm", "video/webm"],
|
["liang-evolution.webm", "video/webm"],
|
||||||
["liang-evolution.mp4", "video/mp4"],
|
["liang-evolution.mp4", "video/mp4"],
|
||||||
["liang-poster.png", "image/png"],
|
["liang-poster.png", "image/png"],
|
||||||
|
|||||||
Reference in New Issue
Block a user