fix: restore skin controls and desktop portrait compatibility
Respect host root layout, draw the scrim once, discover appearance controls without build hashes, and recover failed portraits independently. Add unit and real-browser regression coverage. Refs #8, #12, #15, #10, #13. Native Desktop acceptance remains pending; no release.
This commit is contained in:
550
lib/client.js
550
lib/client.js
@@ -29,7 +29,7 @@ var import_react = require("react");
|
||||
// src/client/skin.css
|
||||
var skin_default = `.liang-skin-backdrop {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
@@ -48,7 +48,7 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
||||
right: 0;
|
||||
display: block;
|
||||
width: min(42vw, 720px);
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 42%;
|
||||
opacity: var(--liang-portrait-opacity, 0.45);
|
||||
@@ -75,11 +75,16 @@ body[data-liang-skin="on"] .liang-skin-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: "";
|
||||
background: linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%);
|
||||
background:
|
||||
linear-gradient(90deg, var(--liang-page, transparent) 0 28%, transparent 57% 100%),
|
||||
linear-gradient(var(--liang-bg-base, transparent), var(--liang-bg-base, transparent));
|
||||
}
|
||||
|
||||
body[data-liang-skin="on"] {
|
||||
--dsw-alias-bg-base: var(--liang-bg-base) !important;
|
||||
/* Keep the backdrop above the canvas but below content and native portals.
|
||||
The host owns root positioning, dimensions, and its own stacking order. */
|
||||
isolation: isolate;
|
||||
--dsw-alias-bg-base: transparent !important;
|
||||
--dsw-alias-bg-layer-1: var(--liang-layer-1) !important;
|
||||
--dsw-alias-bg-layer-2: var(--liang-layer-2) !important;
|
||||
--dsw-alias-bg-layer-3: var(--liang-layer-3) !important;
|
||||
@@ -239,11 +244,16 @@ body[data-liang-skin="on"]:is(
|
||||
}
|
||||
|
||||
body[data-liang-skin="on"] > #root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body[data-liang-skin="on"]:is(
|
||||
[data-dsh-desktop-mode="compatibility"],
|
||||
[data-dsh-desktop-mode="extended"]
|
||||
) .liang-skin-backdrop {
|
||||
top: var(--dsh-desktop-frame-height, 0px);
|
||||
}
|
||||
|
||||
/* Windows paints the sidebar fill across the host frame, covering the portrait.
|
||||
Match macOS's transparent frame while keeping the sidebar, caption, and
|
||||
conversation surfaces painted by their own host rules. */
|
||||
@@ -480,7 +490,7 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
[class*="_8HJdBW_cubeRow"]:has(.liang-appearance-choice) {
|
||||
[class*="_cubeRow"]:has(.liang-appearance-choice) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
@@ -626,6 +636,324 @@ body[data-liang-skin="on"] .liang-effort-control__range::-webkit-slider-thumb {
|
||||
}
|
||||
`;
|
||||
|
||||
// src/client/appearance.ts
|
||||
var NATIVE_APPEARANCE_ROW = '[class*="_cubeRow"]';
|
||||
var LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
|
||||
var LIANG_BINDING_CONTROL = "liang-appearance-binding";
|
||||
var LIANG_BINDING_INPUT = "liang-appearance-binding__input";
|
||||
function hasClassSuffix(node, suffix) {
|
||||
return [...node.classList].some((name) => name.endsWith(suffix));
|
||||
}
|
||||
function findAppearanceRow() {
|
||||
for (const row of document.querySelectorAll(NATIVE_APPEARANCE_ROW)) {
|
||||
if (!hasClassSuffix(row, "_cubeRow")) continue;
|
||||
const nativeButtons = [...row.children].filter(
|
||||
(node) => node instanceof HTMLButtonElement && hasClassSuffix(node, "_themeCube")
|
||||
);
|
||||
if (nativeButtons.length < 3) continue;
|
||||
let group = row.parentElement;
|
||||
while (group !== null && !hasClassSuffix(group, "_group")) {
|
||||
group = group.parentElement;
|
||||
}
|
||||
group ??= row.parentElement;
|
||||
if (group !== null) return { row, group, nativeButtons };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function installLiangAppearanceButton(scope, presenter, packageId) {
|
||||
let pending = false;
|
||||
let disposed = false;
|
||||
let mounted = null;
|
||||
const nativeClickHandlers = /* @__PURE__ */ new Map();
|
||||
const unmount = () => {
|
||||
for (const [button, handler] of nativeClickHandlers) {
|
||||
button.removeEventListener("click", handler, { capture: true });
|
||||
}
|
||||
nativeClickHandlers.clear();
|
||||
if (mounted === null) return;
|
||||
mounted.button.removeEventListener("click", mounted.handleCustomClick);
|
||||
mounted.bindingInput.removeEventListener("change", mounted.handleBindingChange);
|
||||
mounted.button.remove();
|
||||
mounted.binding.remove();
|
||||
mounted = null;
|
||||
};
|
||||
const choose = async (enabled) => {
|
||||
if (disposed || pending || scope.getSnapshot().enabled === enabled) return;
|
||||
pending = true;
|
||||
sync();
|
||||
try {
|
||||
await presenter.choose(enabled);
|
||||
} catch {
|
||||
} finally {
|
||||
pending = false;
|
||||
if (!disposed) sync();
|
||||
}
|
||||
};
|
||||
const createMount = (row, group) => {
|
||||
const english = document.documentElement.lang.toLowerCase().startsWith("en");
|
||||
const button = document.createElement("button");
|
||||
button.className = LIANG_APPEARANCE_BUTTON;
|
||||
button.type = "button";
|
||||
button.dataset.plugin = packageId;
|
||||
button.setAttribute("aria-label", "\u6ED1\u52A8\u53D8\u7956");
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "liang-appearance-choice__icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = "\u25C8";
|
||||
const label = document.createElement("span");
|
||||
label.className = "liang-appearance-choice__label";
|
||||
label.textContent = english ? "Slider" : "\u6ED1\u52A8\u53D8\u7956";
|
||||
button.append(icon, label);
|
||||
const handleCustomClick = () => {
|
||||
void choose(true);
|
||||
};
|
||||
button.addEventListener("click", handleCustomClick);
|
||||
const binding = document.createElement("label");
|
||||
binding.className = LIANG_BINDING_CONTROL;
|
||||
binding.dataset.plugin = packageId;
|
||||
const bindingCopy = document.createElement("span");
|
||||
bindingCopy.className = "liang-appearance-binding__copy";
|
||||
const bindingLabel = document.createElement("span");
|
||||
bindingLabel.className = "liang-appearance-binding__label";
|
||||
const bindingText = english ? "Bind slider to reasoning level" : "\u6ED1\u52A8\u53D8\u7956\u7ED1\u5B9A\u601D\u8003\u7B49\u7EA7";
|
||||
bindingLabel.textContent = bindingText;
|
||||
const bindingDescription = document.createElement("span");
|
||||
bindingDescription.className = "liang-appearance-binding__description";
|
||||
bindingDescription.id = "liang-appearance-binding-description";
|
||||
bindingDescription.textContent = english ? "When off, the slider does not change the reasoning level." : "\u5173\u95ED\u4E4B\u540E\u6ED1\u5757\u4E0D\u8054\u52A8\u601D\u8003\u7B49\u7EA7";
|
||||
bindingCopy.append(bindingLabel, bindingDescription);
|
||||
const bindingInput = document.createElement("input");
|
||||
bindingInput.className = LIANG_BINDING_INPUT;
|
||||
bindingInput.type = "checkbox";
|
||||
bindingInput.setAttribute("role", "switch");
|
||||
bindingInput.setAttribute("aria-label", bindingText);
|
||||
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
|
||||
const updateBinding = async () => {
|
||||
if (disposed || pending) return;
|
||||
try {
|
||||
await scope.setBindEffort(bindingInput.checked);
|
||||
} catch {
|
||||
} finally {
|
||||
if (!disposed) sync();
|
||||
}
|
||||
};
|
||||
const handleBindingChange = () => {
|
||||
void updateBinding();
|
||||
};
|
||||
bindingInput.addEventListener("change", handleBindingChange);
|
||||
binding.append(bindingCopy, bindingInput);
|
||||
return { row, group, button, binding, bindingInput, handleCustomClick, handleBindingChange };
|
||||
};
|
||||
const sync = () => {
|
||||
if (disposed) return;
|
||||
const target = findAppearanceRow();
|
||||
if (target === null) {
|
||||
unmount();
|
||||
return;
|
||||
}
|
||||
if (mounted !== null && (mounted.row !== target.row || mounted.group !== target.group)) {
|
||||
unmount();
|
||||
}
|
||||
mounted ??= createMount(target.row, target.group);
|
||||
const { row, group, button, binding, bindingInput } = mounted;
|
||||
if (button.parentElement !== row) row.append(button);
|
||||
const snapshot = scope.getSnapshot();
|
||||
button.disabled = pending;
|
||||
button.setAttribute("aria-pressed", String(snapshot.enabled));
|
||||
if (snapshot.enabled) {
|
||||
if (binding.parentElement !== group) group.append(binding);
|
||||
bindingInput.checked = snapshot.bindEffort;
|
||||
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
|
||||
bindingInput.disabled = pending;
|
||||
} else {
|
||||
binding.remove();
|
||||
}
|
||||
for (const [nativeButton, handler] of nativeClickHandlers) {
|
||||
if (target.nativeButtons.includes(nativeButton)) continue;
|
||||
nativeButton.removeEventListener("click", handler, { capture: true });
|
||||
nativeClickHandlers.delete(nativeButton);
|
||||
}
|
||||
for (const nativeButton of target.nativeButtons) {
|
||||
if (nativeClickHandlers.has(nativeButton)) continue;
|
||||
const handleNativeClick = () => {
|
||||
void choose(false);
|
||||
};
|
||||
nativeClickHandlers.set(nativeButton, handleNativeClick);
|
||||
nativeButton.addEventListener("click", handleNativeClick, { capture: true });
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
const unsubscribe = scope.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
unmount();
|
||||
};
|
||||
}
|
||||
|
||||
// src/client/portrait-loader.ts
|
||||
var RETRY_DELAYS = [500, 1500, 3e3];
|
||||
function retryUrl(source, retry) {
|
||||
if (retry === 0) return source;
|
||||
const hashIndex = source.indexOf("#");
|
||||
const hash = hashIndex < 0 ? "" : source.slice(hashIndex);
|
||||
const base = hashIndex < 0 ? source : source.slice(0, hashIndex);
|
||||
const queryIndex = base.indexOf("?");
|
||||
const path = queryIndex < 0 ? base : base.slice(0, queryIndex);
|
||||
const query = new URLSearchParams(queryIndex < 0 ? "" : base.slice(queryIndex + 1));
|
||||
query.set("retry", String(retry));
|
||||
return `${path}?${query}${hash}`;
|
||||
}
|
||||
function createPortraitLoader(options) {
|
||||
const sources = options.sources.map((source) => ({ ...source }));
|
||||
const assets = /* @__PURE__ */ new Map();
|
||||
for (const { url } of sources) {
|
||||
if (!assets.has(url)) {
|
||||
assets.set(url, {
|
||||
url,
|
||||
status: "idle",
|
||||
loadedUrl: null,
|
||||
retries: 0,
|
||||
image: null,
|
||||
timer: null,
|
||||
detach: null
|
||||
});
|
||||
}
|
||||
}
|
||||
const createImage = options.createImage ?? (() => new Image());
|
||||
let onChange = options.onChange;
|
||||
let active = false;
|
||||
let disposed = false;
|
||||
let generation = 0;
|
||||
let targetLevel = sources[0]?.level ?? 0;
|
||||
let displayedIndex = null;
|
||||
let lastState = null;
|
||||
const isCurrent = (run) => active && !disposed && generation === run;
|
||||
const nearestIndex = (available) => {
|
||||
let nearest = null;
|
||||
for (let index = 0; index < sources.length; index += 1) {
|
||||
if (!available(index)) continue;
|
||||
const distance = Math.abs(sources[index].level - targetLevel);
|
||||
const previous = nearest === null ? Infinity : Math.abs(sources[nearest].level - targetLevel);
|
||||
if (nearest === null || distance < previous || distance === previous && sources[index].level > sources[nearest].level) {
|
||||
nearest = index;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
};
|
||||
const isReady = (index) => assets.get(sources[index].url)?.status === "ready";
|
||||
const emit = (state) => {
|
||||
if (lastState?.index === state.index && lastState.url === state.url && lastState.status === state.status) return;
|
||||
lastState = state;
|
||||
onChange?.(state);
|
||||
};
|
||||
const reconcile = () => {
|
||||
if (!active || disposed) return;
|
||||
const target = nearestIndex(() => true);
|
||||
if (target !== null && isReady(target)) displayedIndex = target;
|
||||
else if (displayedIndex === null) displayedIndex = nearestIndex(isReady);
|
||||
if (displayedIndex !== null) {
|
||||
const asset = assets.get(sources[displayedIndex].url);
|
||||
emit({ index: displayedIndex, url: asset.loadedUrl, image: asset.image, status: "ready" });
|
||||
} else {
|
||||
const exhausted = [...assets.values()].every((asset) => asset.status === "failed");
|
||||
emit({ index: null, url: null, image: null, status: exhausted ? "failed" : "loading" });
|
||||
}
|
||||
};
|
||||
const request = (asset, run) => {
|
||||
if (!isCurrent(run)) return;
|
||||
asset.status = "loading";
|
||||
const image = createImage();
|
||||
asset.image = image;
|
||||
image.loading = "eager";
|
||||
image.decoding = "async";
|
||||
const url = retryUrl(asset.url, asset.retries);
|
||||
let settled = false;
|
||||
let decoding = false;
|
||||
const current = () => !settled && isCurrent(run) && asset.image === image;
|
||||
const detach = () => {
|
||||
image.removeEventListener("load", handleLoad);
|
||||
image.removeEventListener("error", handleError);
|
||||
asset.detach = null;
|
||||
};
|
||||
const handleError = () => {
|
||||
if (!current()) return;
|
||||
settled = true;
|
||||
detach();
|
||||
image.removeAttribute("src");
|
||||
asset.image = null;
|
||||
if (asset.retries < RETRY_DELAYS.length) {
|
||||
const delay = RETRY_DELAYS[asset.retries];
|
||||
asset.retries += 1;
|
||||
asset.timer = setTimeout(() => {
|
||||
if (!isCurrent(run)) return;
|
||||
asset.timer = null;
|
||||
request(asset, run);
|
||||
}, delay);
|
||||
} else {
|
||||
asset.status = "failed";
|
||||
reconcile();
|
||||
}
|
||||
};
|
||||
const handleLoad = () => {
|
||||
if (!current() || decoding) return;
|
||||
decoding = true;
|
||||
void image.decode().then(() => {
|
||||
if (!current()) return;
|
||||
settled = true;
|
||||
detach();
|
||||
asset.loadedUrl = url;
|
||||
asset.status = "ready";
|
||||
reconcile();
|
||||
}, handleError);
|
||||
};
|
||||
asset.detach = detach;
|
||||
image.addEventListener("load", handleLoad);
|
||||
image.addEventListener("error", handleError);
|
||||
image.src = url;
|
||||
};
|
||||
const stop = () => {
|
||||
active = false;
|
||||
generation += 1;
|
||||
for (const asset of assets.values()) {
|
||||
if (asset.timer !== null) clearTimeout(asset.timer);
|
||||
asset.timer = null;
|
||||
asset.detach?.();
|
||||
asset.image?.removeAttribute("src");
|
||||
asset.image = null;
|
||||
asset.status = "idle";
|
||||
asset.loadedUrl = null;
|
||||
asset.retries = 0;
|
||||
}
|
||||
displayedIndex = null;
|
||||
lastState = null;
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (disposed || active) return;
|
||||
active = true;
|
||||
const run = ++generation;
|
||||
reconcile();
|
||||
for (const asset of assets.values()) request(asset, run);
|
||||
},
|
||||
select(level) {
|
||||
if (disposed || !Number.isFinite(level)) return;
|
||||
targetLevel = level;
|
||||
reconcile();
|
||||
},
|
||||
stop,
|
||||
dispose() {
|
||||
stop();
|
||||
disposed = true;
|
||||
onChange = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// src/client/logic.ts
|
||||
var PREVIEW_MAX_FRAME = 240;
|
||||
var MAX_LEVEL = 30;
|
||||
@@ -665,19 +993,6 @@ function selectedEffortIndex(efforts, selectedId, defaultId) {
|
||||
const id = selectedId ?? defaultId;
|
||||
return id === void 0 ? -1 : efforts.findIndex((effort) => effort.id === id);
|
||||
}
|
||||
function portraitBlendForLevel(level, anchors) {
|
||||
if (anchors.length === 0) return { lowerIndex: -1, upperIndex: -1, mix: 0 };
|
||||
const safeLevel = Number.isFinite(level) ? Math.min(anchors[anchors.length - 1], Math.max(anchors[0], level)) : anchors[0];
|
||||
let upperIndex = anchors.findIndex((anchor) => anchor >= safeLevel);
|
||||
if (upperIndex < 0) upperIndex = anchors.length - 1;
|
||||
const lowerIndex = Math.max(0, upperIndex - (anchors[upperIndex] > safeLevel ? 1 : 0));
|
||||
const span = anchors[upperIndex] - anchors[lowerIndex];
|
||||
return {
|
||||
lowerIndex,
|
||||
upperIndex,
|
||||
mix: span === 0 ? 0 : (safeLevel - anchors[lowerIndex]) / span
|
||||
};
|
||||
}
|
||||
function liangRankForFrame(rawFrame) {
|
||||
const level = clampFrame(rawFrame) / PREVIEW_MAX_FRAME * MAX_LEVEL;
|
||||
const index = level >= MAX_LEVEL ? LIANG_RANKS.length - 1 : Math.floor(level / 6);
|
||||
@@ -750,7 +1065,6 @@ var import_jsx_runtime = require("react/jsx-runtime");
|
||||
var PACKAGE_ID = "dsh-client-liang-intensity-skin";
|
||||
var LOCALE_NAMESPACE = "liang.skin";
|
||||
var ASSET_PREFIX = `/plugins/${PACKAGE_ID}/assets`;
|
||||
var FIRST_PORTRAIT_FILE = "portrait-source-v2/stage-00.webp";
|
||||
var BIND_EFFORT_KEY = "dsh-liang-intensity-skin.bind-effort";
|
||||
var PORTRAIT_ANCHORS = [
|
||||
{ level: 0, file: "stage-00.webp" },
|
||||
@@ -778,7 +1092,6 @@ var PORTRAIT_ANCHORS = [
|
||||
{ level: 29, file: "level-29.webp" },
|
||||
{ level: 30, file: "stage-30.webp" }
|
||||
];
|
||||
var ANCHOR_LEVELS = PORTRAIT_ANCHORS.map((anchor) => anchor.level);
|
||||
var cssVariables = [
|
||||
"--liang-strength",
|
||||
"--liang-page",
|
||||
@@ -800,9 +1113,7 @@ var SkinPresenter = class {
|
||||
scope;
|
||||
theme;
|
||||
root;
|
||||
portrait;
|
||||
preloads;
|
||||
portraitReady = false;
|
||||
portraits;
|
||||
enabled = true;
|
||||
// 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.
|
||||
@@ -817,46 +1128,31 @@ var SkinPresenter = class {
|
||||
this.root = document.createElement("div");
|
||||
this.root.className = "liang-skin-backdrop";
|
||||
this.root.dataset.plugin = PACKAGE_ID;
|
||||
this.root.dataset.media = "sequence";
|
||||
this.root.dataset.media = "color";
|
||||
this.root.setAttribute("aria-hidden", "true");
|
||||
this.portrait = document.createElement("img");
|
||||
this.portrait.className = "liang-skin-sequence-frame";
|
||||
this.portrait.alt = "";
|
||||
this.portrait.draggable = false;
|
||||
this.portrait.decoding = "async";
|
||||
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`;
|
||||
this.portrait.addEventListener("error", this.handleSequenceError);
|
||||
this.preloads = PORTRAIT_ANCHORS.map(({ file }) => {
|
||||
const image = new Image();
|
||||
image.loading = "eager";
|
||||
image.decoding = "async";
|
||||
image.src = `${ASSET_PREFIX}/portrait-source-v2/${file}`;
|
||||
return image;
|
||||
});
|
||||
void Promise.all(this.preloads.map((image) => image.decode())).then(
|
||||
() => {
|
||||
if (this.disposed) return;
|
||||
this.portraitReady = true;
|
||||
this.portraits = createPortraitLoader({
|
||||
sources: PORTRAIT_ANCHORS.map(({ level, file }) => ({
|
||||
level,
|
||||
url: `${ASSET_PREFIX}/portrait-source-v2/${file}`
|
||||
})),
|
||||
onChange: ({ image }) => {
|
||||
if (this.disposed || !this.enabled) return;
|
||||
if (image === null) {
|
||||
this.root.dataset.media = "color";
|
||||
this.root.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const portrait = image;
|
||||
portrait.className = "liang-skin-sequence-frame";
|
||||
portrait.alt = "";
|
||||
portrait.draggable = false;
|
||||
if (this.root.firstChild !== portrait) this.root.replaceChildren(portrait);
|
||||
this.root.dataset.media = "sequence";
|
||||
this.updatePortrait(paletteForFrame(this.frame).level);
|
||||
},
|
||||
() => {
|
||||
}
|
||||
);
|
||||
this.root.append(this.portrait);
|
||||
document.body.prepend(this.root);
|
||||
});
|
||||
this.unsubscribe = scope.subscribe(() => this.syncSettings());
|
||||
this.syncSettings();
|
||||
}
|
||||
handleSequenceError = () => {
|
||||
if (this.portrait.src.endsWith(FIRST_PORTRAIT_FILE)) {
|
||||
this.root.dataset.media = "color";
|
||||
return;
|
||||
}
|
||||
this.portraitReady = false;
|
||||
this.portrait.src = `${ASSET_PREFIX}/${FIRST_PORTRAIT_FILE}`;
|
||||
this.root.dataset.media = "sequence";
|
||||
};
|
||||
syncSettings() {
|
||||
this.setEnabled(this.scope.getSnapshot().enabled);
|
||||
}
|
||||
@@ -867,19 +1163,27 @@ var SkinPresenter = class {
|
||||
return this.frame;
|
||||
}
|
||||
setEnabled(enabled) {
|
||||
if (this.disposed) return;
|
||||
this.enabled = enabled;
|
||||
if (enabled) {
|
||||
if (!this.root.isConnected) document.body.prepend(this.root);
|
||||
document.body.dataset.liangSkin = "on";
|
||||
this.applyFrame();
|
||||
this.portraits.start();
|
||||
} else {
|
||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
this.root.remove();
|
||||
this.portraits.stop();
|
||||
this.root.replaceChildren();
|
||||
this.root.dataset.media = "color";
|
||||
delete document.body.dataset.liangSkin;
|
||||
delete document.body.dataset.liangStage;
|
||||
for (const name of cssVariables) document.body.style.removeProperty(name);
|
||||
}
|
||||
}
|
||||
setFrame(frame) {
|
||||
if (this.disposed) return;
|
||||
this.pendingFrame = Math.min(PREVIEW_MAX_FRAME, Math.max(0, Math.round(frame)));
|
||||
this.frame = this.pendingFrame;
|
||||
if (!this.enabled) return;
|
||||
@@ -891,6 +1195,7 @@ var SkinPresenter = class {
|
||||
});
|
||||
}
|
||||
applyFrame() {
|
||||
if (this.disposed || !this.enabled) return;
|
||||
const palette = paletteForFrame(this.frame);
|
||||
const body = document.body;
|
||||
this.syncNativeTheme(palette.stage === 5 ? "dark" : "light");
|
||||
@@ -917,16 +1222,7 @@ var SkinPresenter = class {
|
||||
this.theme.setTheme(theme);
|
||||
}
|
||||
updatePortrait(level) {
|
||||
if (!this.portraitReady) return;
|
||||
const { lowerIndex, upperIndex, mix: mix2 } = portraitBlendForLevel(
|
||||
level,
|
||||
ANCHOR_LEVELS
|
||||
);
|
||||
const lower = PORTRAIT_ANCHORS[lowerIndex];
|
||||
const upper = PORTRAIT_ANCHORS[upperIndex];
|
||||
const selected = mix2 >= 0.5 ? upper : lower;
|
||||
const source = `${ASSET_PREFIX}/portrait-source-v2/${selected.file}`;
|
||||
if (this.portrait.getAttribute("src") !== source) this.portrait.src = source;
|
||||
this.portraits.select(level);
|
||||
}
|
||||
async choose(enabled) {
|
||||
this.setEnabled(enabled);
|
||||
@@ -942,8 +1238,7 @@ var SkinPresenter = class {
|
||||
this.disposed = true;
|
||||
this.unsubscribe();
|
||||
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
||||
this.portrait.removeEventListener("error", this.handleSequenceError);
|
||||
for (const image of this.preloads) image.src = "";
|
||||
this.portraits.dispose();
|
||||
this.root.remove();
|
||||
document.body.removeAttribute("data-liang-skin");
|
||||
delete document.body.dataset.liangStage;
|
||||
@@ -1110,117 +1405,6 @@ function LiangEffortSlider({ directory, load, select, presenter, scope }) {
|
||||
}
|
||||
);
|
||||
}
|
||||
var NATIVE_APPEARANCE_GROUP = '[class*="_8HJdBW_group"]';
|
||||
var NATIVE_APPEARANCE_ROW = '[class*="_8HJdBW_cubeRow"]';
|
||||
var LIANG_APPEARANCE_BUTTON = "liang-appearance-choice";
|
||||
var LIANG_BINDING_CONTROL = "liang-appearance-binding";
|
||||
var LIANG_BINDING_INPUT = "liang-appearance-binding__input";
|
||||
function installLiangAppearanceButton(scope, presenter) {
|
||||
let pending = false;
|
||||
const hookedNativeButtons = /* @__PURE__ */ new Set();
|
||||
const nativeClickHandlers = /* @__PURE__ */ new Map();
|
||||
const sync = () => {
|
||||
const group = [...document.querySelectorAll(NATIVE_APPEARANCE_GROUP)].find((node) => node.querySelector('[class*="_8HJdBW_themeCube"]'));
|
||||
const row = group?.querySelector(NATIVE_APPEARANCE_ROW);
|
||||
if (row === void 0 || row === null) return;
|
||||
let customButton = row.querySelector(`.${LIANG_APPEARANCE_BUTTON}`);
|
||||
if (customButton === null) {
|
||||
customButton = document.createElement("button");
|
||||
customButton.className = LIANG_APPEARANCE_BUTTON;
|
||||
customButton.type = "button";
|
||||
customButton.dataset.plugin = PACKAGE_ID;
|
||||
customButton.setAttribute("aria-label", "\u6ED1\u52A8\u53D8\u7956");
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "liang-appearance-choice__icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = "\u25C8";
|
||||
const label = document.createElement("span");
|
||||
label.className = "liang-appearance-choice__label";
|
||||
label.textContent = document.documentElement.lang.toLowerCase().startsWith("en") ? "Slider" : "\u6ED1\u52A8\u53D8\u7956";
|
||||
customButton.append(icon, label);
|
||||
customButton.addEventListener("click", () => {
|
||||
if (pending || scope.getSnapshot().enabled) return;
|
||||
pending = true;
|
||||
sync();
|
||||
void presenter.choose(true).finally(() => {
|
||||
pending = false;
|
||||
sync();
|
||||
});
|
||||
});
|
||||
}
|
||||
if (customButton.parentElement !== row) row.append(customButton);
|
||||
const snapshot = scope.getSnapshot();
|
||||
customButton.disabled = pending;
|
||||
customButton.setAttribute("aria-pressed", String(snapshot.enabled));
|
||||
let bindingControl = group.querySelector(`.${LIANG_BINDING_CONTROL}`);
|
||||
if (!snapshot.enabled) {
|
||||
bindingControl?.remove();
|
||||
bindingControl = null;
|
||||
} else if (bindingControl === null) {
|
||||
bindingControl = document.createElement("label");
|
||||
bindingControl.className = LIANG_BINDING_CONTROL;
|
||||
bindingControl.dataset.plugin = PACKAGE_ID;
|
||||
const bindingCopy = document.createElement("span");
|
||||
bindingCopy.className = "liang-appearance-binding__copy";
|
||||
const bindingLabel = document.createElement("span");
|
||||
bindingLabel.className = "liang-appearance-binding__label";
|
||||
const bindingText = document.documentElement.lang.toLowerCase().startsWith("en") ? "Bind slider to reasoning level" : "\u6ED1\u52A8\u53D8\u7956\u7ED1\u5B9A\u601D\u8003\u7B49\u7EA7";
|
||||
bindingLabel.textContent = bindingText;
|
||||
const bindingDescription = document.createElement("span");
|
||||
bindingDescription.className = "liang-appearance-binding__description";
|
||||
bindingDescription.id = "liang-appearance-binding-description";
|
||||
bindingDescription.textContent = document.documentElement.lang.toLowerCase().startsWith("en") ? "When off, the slider does not change the reasoning level." : "\u5173\u95ED\u4E4B\u540E\u6ED1\u5757\u4E0D\u8054\u52A8\u601D\u8003\u7B49\u7EA7";
|
||||
bindingCopy.append(bindingLabel, bindingDescription);
|
||||
const bindingInput = document.createElement("input");
|
||||
bindingInput.className = LIANG_BINDING_INPUT;
|
||||
bindingInput.type = "checkbox";
|
||||
bindingInput.setAttribute("role", "switch");
|
||||
bindingInput.setAttribute("aria-label", bindingText);
|
||||
bindingInput.setAttribute("aria-describedby", bindingDescription.id);
|
||||
bindingInput.addEventListener("change", () => {
|
||||
void scope.setBindEffort(bindingInput.checked).catch(() => sync());
|
||||
});
|
||||
bindingControl.append(bindingCopy, bindingInput);
|
||||
group.append(bindingControl);
|
||||
}
|
||||
if (bindingControl !== null) {
|
||||
const bindingInput = bindingControl.querySelector(`.${LIANG_BINDING_INPUT}`);
|
||||
if (bindingInput !== null) {
|
||||
bindingInput.checked = snapshot.bindEffort;
|
||||
bindingInput.setAttribute("aria-checked", String(snapshot.bindEffort));
|
||||
bindingInput.disabled = pending;
|
||||
}
|
||||
}
|
||||
for (const nativeButton of row.querySelectorAll('[class*="_8HJdBW_themeCube"]')) {
|
||||
if (hookedNativeButtons.has(nativeButton)) continue;
|
||||
hookedNativeButtons.add(nativeButton);
|
||||
const handleNativeClick = () => {
|
||||
if (pending || !scope.getSnapshot().enabled) return;
|
||||
pending = true;
|
||||
sync();
|
||||
void presenter.choose(false).finally(() => {
|
||||
pending = false;
|
||||
sync();
|
||||
});
|
||||
};
|
||||
nativeClickHandlers.set(nativeButton, handleNativeClick);
|
||||
nativeButton.addEventListener("click", handleNativeClick, { capture: true });
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
const unsubscribe = scope.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
for (const [nativeButton, handleNativeClick] of nativeClickHandlers) {
|
||||
nativeButton.removeEventListener("click", handleNativeClick, { capture: true });
|
||||
}
|
||||
document.querySelectorAll(`.${LIANG_APPEARANCE_BUTTON}`).forEach((button) => button.remove());
|
||||
document.querySelectorAll(`.${LIANG_BINDING_CONTROL}`).forEach((control) => control.remove());
|
||||
};
|
||||
}
|
||||
var inject = [
|
||||
"slots",
|
||||
"sessions",
|
||||
@@ -1284,7 +1468,7 @@ function apply(ctx) {
|
||||
const presenter = new SkinPresenter(scope, theme);
|
||||
ctx.effect(() => () => presenter.dispose(), "liang-intensity-skin: backdrop presenter");
|
||||
ctx.effect(
|
||||
() => installLiangAppearanceButton(scope, presenter),
|
||||
() => installLiangAppearanceButton(scope, presenter, PACKAGE_ID),
|
||||
"liang-intensity-skin: native appearance extension"
|
||||
);
|
||||
ctx.effect(
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user