初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
import { apiJson } from "./core.js";
|
||||
import { animationPreviewFrameAtElapsed } from "./animation-ui-model.js";
|
||||
|
||||
export const ANIMATION_PREVIEW_PAGE_SIZE = 500;
|
||||
export const ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT = 2;
|
||||
export const ANIMATION_PREVIEW_MIN_MAX_CONCURRENT = 1;
|
||||
export const ANIMATION_PREVIEW_MAX_MAX_CONCURRENT = 50;
|
||||
export const ANIMATION_PREVIEW_PRELOAD_COUNT = 3;
|
||||
|
||||
export function normalizeAnimationPreviewMaxConcurrent(value) {
|
||||
if (!Number.isInteger(value)
|
||||
|| value < ANIMATION_PREVIEW_MIN_MAX_CONCURRENT
|
||||
|| value > ANIMATION_PREVIEW_MAX_MAX_CONCURRENT) {
|
||||
throw new Error(
|
||||
`动态缩略图并发数量必须是 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stalePreviewError() {
|
||||
const error = new Error("动图已在预览加载期间发生变化");
|
||||
error.code = "ANIMATION_PREVIEW_STALE";
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function loadCompleteAnimationPreview(
|
||||
animation,
|
||||
{ fetchJson = apiJson, signal, pageSize = ANIMATION_PREVIEW_PAGE_SIZE } = {},
|
||||
) {
|
||||
const expectedRevision = String(animation?.revision || "");
|
||||
const expectedCount = Number(animation?.frame_count || 0);
|
||||
if (!animation?.id || !expectedRevision) throw new Error("动图预览缺少标识或修订值");
|
||||
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > ANIMATION_PREVIEW_PAGE_SIZE) {
|
||||
throw new Error("动图预览分页大小无效");
|
||||
}
|
||||
if (expectedCount === 0) return [];
|
||||
|
||||
const frames = [];
|
||||
let offset = 0;
|
||||
let total = expectedCount;
|
||||
while (offset < total) {
|
||||
const query = new URLSearchParams({
|
||||
frame_offset: String(offset),
|
||||
frame_limit: String(pageSize),
|
||||
});
|
||||
const page = await fetchJson(`/api/animations/${animation.id}?${query}`, { signal });
|
||||
if (String(page?.revision || "") !== expectedRevision) throw stalePreviewError();
|
||||
if (!Array.isArray(page.frames)) throw new Error("动图预览帧列表无效");
|
||||
total = Number(page.frame_total ?? page.frame_count ?? page.frames.length);
|
||||
if (!Number.isInteger(total) || total < 0 || total !== expectedCount) throw stalePreviewError();
|
||||
if (!page.frames.length && offset < total) throw new Error("动图预览分页提前结束");
|
||||
frames.push(...page.frames);
|
||||
offset += page.frames.length;
|
||||
}
|
||||
if (frames.length !== expectedCount) throw stalePreviewError();
|
||||
return frames;
|
||||
}
|
||||
|
||||
class AnimationPreviewEngine {
|
||||
constructor() {
|
||||
this.records = new Set();
|
||||
this.initialQueue = [];
|
||||
this.activeInitialLoads = 0;
|
||||
this.queue = [];
|
||||
this.activeLoads = 0;
|
||||
this.maxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT;
|
||||
this.timer = null;
|
||||
this.cache = new Map();
|
||||
this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
this.observer = typeof IntersectionObserver === "function"
|
||||
? new IntersectionObserver((entries) => this.handleIntersections(entries), { rootMargin: "0px" })
|
||||
: null;
|
||||
this.viewportSyncPending = false;
|
||||
this.handleFallbackViewport = () => {
|
||||
if (this.observer || this.viewportSyncPending) return;
|
||||
this.viewportSyncPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
this.viewportSyncPending = false;
|
||||
this.syncAll();
|
||||
});
|
||||
};
|
||||
this.handleVisibility = () => this.syncAll();
|
||||
this.handleMotionChange = () => this.syncAll();
|
||||
document.addEventListener("visibilitychange", this.handleVisibility);
|
||||
this.reducedMotion.addEventListener?.("change", this.handleMotionChange);
|
||||
window.addEventListener("scroll", this.handleFallbackViewport, true);
|
||||
window.addEventListener("resize", this.handleFallbackViewport);
|
||||
}
|
||||
|
||||
cacheKey(animation) {
|
||||
return `${animation.id}:${animation.revision}`;
|
||||
}
|
||||
|
||||
setMaxConcurrent(value) {
|
||||
const normalized = normalizeAnimationPreviewMaxConcurrent(value);
|
||||
if (normalized === this.maxConcurrent) return normalized;
|
||||
this.maxConcurrent = normalized;
|
||||
this.syncAll();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
createGroup() {
|
||||
const engine = this;
|
||||
const records = new Set();
|
||||
return {
|
||||
active: false,
|
||||
register(options) {
|
||||
const record = engine.register(this, options);
|
||||
records.add(record);
|
||||
return () => {
|
||||
records.delete(record);
|
||||
engine.disposeRecord(record);
|
||||
engine.syncAll();
|
||||
};
|
||||
},
|
||||
setActive(active) {
|
||||
this.active = Boolean(active);
|
||||
engine.syncAll();
|
||||
},
|
||||
clear() {
|
||||
const keys = new Set();
|
||||
for (const record of records) {
|
||||
keys.add(engine.cacheKey(record.animation));
|
||||
engine.disposeRecord(record);
|
||||
}
|
||||
records.clear();
|
||||
for (const key of keys) engine.cache.delete(key);
|
||||
engine.scheduleTick();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
register(group, {
|
||||
animation,
|
||||
image,
|
||||
initialUrl,
|
||||
target = image,
|
||||
onFrame = null,
|
||||
onLoad = null,
|
||||
onInitialError = null,
|
||||
}) {
|
||||
const record = {
|
||||
group,
|
||||
animation,
|
||||
image,
|
||||
initialUrl,
|
||||
target,
|
||||
onFrame,
|
||||
onLoad,
|
||||
onInitialError,
|
||||
visible: false,
|
||||
disposed: false,
|
||||
initialQueued: false,
|
||||
initialLoading: false,
|
||||
initialReady: false,
|
||||
initialFailed: false,
|
||||
queued: false,
|
||||
loading: false,
|
||||
loadController: null,
|
||||
schedule: null,
|
||||
startedAt: null,
|
||||
currentIndex: -1,
|
||||
preloads: new Map(),
|
||||
lastSuccessfulUrl: "",
|
||||
restoring: false,
|
||||
};
|
||||
record.handleLoad = () => {
|
||||
if (record.initialLoading) this.finishInitialLoad(record, true);
|
||||
record.lastSuccessfulUrl = image.currentSrc || image.src;
|
||||
record.restoring = false;
|
||||
onLoad?.();
|
||||
};
|
||||
record.handleError = () => {
|
||||
if (record.initialLoading) {
|
||||
this.finishInitialLoad(record, false);
|
||||
record.restoring = false;
|
||||
onInitialError?.();
|
||||
return;
|
||||
}
|
||||
const failedUrl = image.currentSrc || image.src;
|
||||
if (record.lastSuccessfulUrl && failedUrl !== record.lastSuccessfulUrl && !record.restoring) {
|
||||
record.restoring = true;
|
||||
image.src = record.lastSuccessfulUrl;
|
||||
return;
|
||||
}
|
||||
record.restoring = false;
|
||||
onInitialError?.();
|
||||
};
|
||||
image.addEventListener("load", record.handleLoad);
|
||||
image.addEventListener("error", record.handleError);
|
||||
this.records.add(record);
|
||||
this.observer?.observe(target);
|
||||
this.syncAll();
|
||||
return record;
|
||||
}
|
||||
|
||||
disposeRecord(record) {
|
||||
if (record.disposed) return;
|
||||
record.disposed = true;
|
||||
record.initialQueued = false;
|
||||
if (record.initialLoading) {
|
||||
record.initialLoading = false;
|
||||
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
||||
record.image.removeAttribute("src");
|
||||
}
|
||||
record.loadController?.abort();
|
||||
record.preloads.clear();
|
||||
record.image.removeEventListener("load", record.handleLoad);
|
||||
record.image.removeEventListener("error", record.handleError);
|
||||
this.observer?.unobserve(record.target);
|
||||
this.records.delete(record);
|
||||
this.pumpInitialQueue();
|
||||
}
|
||||
|
||||
canLoadInitial(record) {
|
||||
return !record.disposed
|
||||
&& record.group.active
|
||||
&& this.isRecordVisible(record)
|
||||
&& document.visibilityState === "visible"
|
||||
&& Boolean(record.initialUrl);
|
||||
}
|
||||
|
||||
isRecordVisible(record) {
|
||||
if (this.observer) return record.visible;
|
||||
const rect = record.target.getBoundingClientRect();
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& rect.right > 0
|
||||
&& rect.bottom > 0
|
||||
&& rect.left < window.innerWidth
|
||||
&& rect.top < window.innerHeight;
|
||||
}
|
||||
|
||||
syncInitial(record) {
|
||||
if (!this.canLoadInitial(record)
|
||||
|| record.initialReady
|
||||
|| record.initialFailed
|
||||
|| record.initialLoading
|
||||
|| record.initialQueued) return;
|
||||
record.initialQueued = true;
|
||||
this.initialQueue.push(record);
|
||||
}
|
||||
|
||||
pumpInitialQueue() {
|
||||
while (this.activeInitialLoads < this.maxConcurrent
|
||||
&& this.initialQueue.length) {
|
||||
const record = this.initialQueue.shift();
|
||||
record.initialQueued = false;
|
||||
if (!this.canLoadInitial(record) || record.initialReady || record.initialFailed) continue;
|
||||
record.initialLoading = true;
|
||||
this.activeInitialLoads += 1;
|
||||
record.image.src = record.initialUrl;
|
||||
}
|
||||
}
|
||||
|
||||
finishInitialLoad(record, succeeded) {
|
||||
if (!record.initialLoading) return;
|
||||
record.initialLoading = false;
|
||||
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
||||
record.initialReady = succeeded;
|
||||
record.initialFailed = !succeeded;
|
||||
this.pumpInitialQueue();
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
handleIntersections(entries) {
|
||||
for (const entry of entries) {
|
||||
for (const record of this.records) {
|
||||
if (record.target !== entry.target) continue;
|
||||
record.visible = entry.isIntersecting;
|
||||
}
|
||||
}
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
canRun(record) {
|
||||
return !record.disposed
|
||||
&& record.group.active
|
||||
&& this.isRecordVisible(record)
|
||||
&& document.visibilityState === "visible"
|
||||
&& !this.reducedMotion.matches
|
||||
&& Number(record.animation.frame_count) > 1;
|
||||
}
|
||||
|
||||
shouldRun(record) {
|
||||
let selected = 0;
|
||||
for (const candidate of this.records) {
|
||||
if (!this.canRun(candidate)) continue;
|
||||
if (candidate === record) return selected < this.maxConcurrent;
|
||||
selected += 1;
|
||||
if (selected >= this.maxConcurrent) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
syncRecord(record) {
|
||||
if (!this.shouldRun(record)) {
|
||||
record.loadController?.abort();
|
||||
record.loadController = null;
|
||||
record.loading = false;
|
||||
record.queued = false;
|
||||
record.preloads.clear();
|
||||
if (record.schedule) this.cache.delete(this.cacheKey(record.animation));
|
||||
record.schedule = null;
|
||||
record.startedAt = null;
|
||||
record.currentIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (!record.initialReady) return;
|
||||
if (record.schedule) return;
|
||||
const cached = this.cache.get(this.cacheKey(record.animation));
|
||||
if (cached) {
|
||||
record.schedule = cached;
|
||||
record.startedAt = performance.now();
|
||||
return;
|
||||
}
|
||||
if (!record.loading && !record.queued) {
|
||||
record.queued = true;
|
||||
this.queue.push(record);
|
||||
this.pumpQueue();
|
||||
}
|
||||
}
|
||||
|
||||
syncAll() {
|
||||
for (const record of this.records) this.syncInitial(record);
|
||||
this.pumpInitialQueue();
|
||||
for (const record of this.records) this.syncRecord(record);
|
||||
this.scheduleTick();
|
||||
}
|
||||
|
||||
pumpQueue() {
|
||||
while (this.activeLoads < this.maxConcurrent && this.queue.length) {
|
||||
const record = this.queue.shift();
|
||||
record.queued = false;
|
||||
if (!this.shouldRun(record) || record.disposed || record.schedule) continue;
|
||||
this.loadRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecord(record) {
|
||||
record.loading = true;
|
||||
record.loadController = new AbortController();
|
||||
this.activeLoads += 1;
|
||||
try {
|
||||
const frames = await loadCompleteAnimationPreview(record.animation, {
|
||||
signal: record.loadController.signal,
|
||||
});
|
||||
if (record.disposed || !this.shouldRun(record)) return;
|
||||
record.schedule = frames;
|
||||
record.startedAt = performance.now();
|
||||
this.cache.set(this.cacheKey(record.animation), frames);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && error?.code !== "ANIMATION_PREVIEW_STALE") {
|
||||
// The first frame remains usable; a later refresh can retry the full schedule.
|
||||
}
|
||||
} finally {
|
||||
record.loading = false;
|
||||
record.loadController = null;
|
||||
this.activeLoads -= 1;
|
||||
this.pumpQueue();
|
||||
this.scheduleTick();
|
||||
}
|
||||
}
|
||||
|
||||
preloadAhead(record, index) {
|
||||
const wanted = new Set();
|
||||
const length = record.schedule.length;
|
||||
for (let step = 1; step <= ANIMATION_PREVIEW_PRELOAD_COUNT && step < length; step += 1) {
|
||||
const nextIndex = (index + step) % length;
|
||||
wanted.add(nextIndex);
|
||||
if (!record.preloads.has(nextIndex)) {
|
||||
const preload = new Image();
|
||||
preload.decoding = "async";
|
||||
preload.src = record.schedule[nextIndex].thumbnail_url;
|
||||
record.preloads.set(nextIndex, preload);
|
||||
}
|
||||
}
|
||||
for (const existing of record.preloads.keys()) {
|
||||
if (!wanted.has(existing)) record.preloads.delete(existing);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleTick() {
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
let nextDelay = Infinity;
|
||||
const now = performance.now();
|
||||
for (const record of this.records) {
|
||||
if (!this.shouldRun(record) || !record.schedule?.length) continue;
|
||||
if (record.startedAt === null) record.startedAt = now;
|
||||
const selected = animationPreviewFrameAtElapsed(record.schedule, now - record.startedAt);
|
||||
if (!selected) continue;
|
||||
if (record.currentIndex !== selected.index) {
|
||||
record.currentIndex = selected.index;
|
||||
record.image.src = selected.frame.thumbnail_url;
|
||||
record.onFrame?.({
|
||||
frame: selected.frame,
|
||||
index: selected.index,
|
||||
total: record.schedule.length,
|
||||
});
|
||||
this.preloadAhead(record, selected.index);
|
||||
}
|
||||
nextDelay = Math.min(nextDelay, selected.remainingMs);
|
||||
}
|
||||
if (Number.isFinite(nextDelay)) {
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.scheduleTick();
|
||||
}, Math.max(16, Math.ceil(nextDelay)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedEngine = null;
|
||||
|
||||
function getSharedEngine() {
|
||||
if (!sharedEngine) sharedEngine = new AnimationPreviewEngine();
|
||||
return sharedEngine;
|
||||
}
|
||||
|
||||
export function createAnimationPreviewGroup() {
|
||||
return getSharedEngine().createGroup();
|
||||
}
|
||||
|
||||
export function setAnimationPreviewMaxConcurrent(value) {
|
||||
return getSharedEngine().setMaxConcurrent(value);
|
||||
}
|
||||
|
||||
export function createAnimationThumbnailBox(group, animation) {
|
||||
const imageBox = document.createElement("div");
|
||||
imageBox.className = "template-image-box";
|
||||
if (!animation.thumbnail_url || !animation.frame_count) {
|
||||
imageBox.classList.add("template-image-empty");
|
||||
imageBox.setAttribute("aria-label", "空动图");
|
||||
return imageBox;
|
||||
}
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "template-image";
|
||||
image.alt = `${animation.name} 动图缩略图`;
|
||||
image.width = 64;
|
||||
image.height = 64;
|
||||
image.draggable = false;
|
||||
image.loading = "lazy";
|
||||
imageBox.append(image);
|
||||
group.register({
|
||||
animation,
|
||||
image,
|
||||
initialUrl: animation.thumbnail_url,
|
||||
target: imageBox,
|
||||
onLoad: () => {
|
||||
image.hidden = false;
|
||||
imageBox.classList.remove("template-image-error");
|
||||
imageBox.removeAttribute("aria-label");
|
||||
},
|
||||
onInitialError: () => {
|
||||
image.hidden = true;
|
||||
imageBox.classList.add("template-image-error");
|
||||
imageBox.setAttribute("aria-label", "缩略图加载失败");
|
||||
},
|
||||
});
|
||||
return imageBox;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export const MIN_FRAME_DURATION_MS = 50;
|
||||
export const MAX_FRAME_DURATION_MS = 604800000;
|
||||
|
||||
export function effectiveFrameName(animationName, frame, position = frame?.position) {
|
||||
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
|
||||
if (customName) return customName;
|
||||
return `${String(animationName || "动图").trim() || "动图"}-第${position}帧`;
|
||||
}
|
||||
|
||||
export function animationEditContextName(animationName, frame, position = frame?.position) {
|
||||
const normalizedAnimationName = String(animationName || "动图").trim() || "动图";
|
||||
const customName = typeof frame?.name === "string" ? frame.name.trim() : "";
|
||||
return `${normalizedAnimationName}-${customName || `第${position}帧`}`;
|
||||
}
|
||||
|
||||
export function animationPreviewFrameAtElapsed(frames, elapsedMs) {
|
||||
if (!Array.isArray(frames) || !frames.length) return null;
|
||||
const durations = frames.map((frame) => Number(frame?.duration_ms));
|
||||
if (durations.some((duration) => !Number.isFinite(duration) || duration <= 0)) return null;
|
||||
const totalDurationMs = durations.reduce((total, duration) => total + duration, 0);
|
||||
const normalizedElapsed = Number.isFinite(Number(elapsedMs)) ? Number(elapsedMs) : 0;
|
||||
const cycleElapsedMs = ((normalizedElapsed % totalDurationMs) + totalDurationMs) % totalDurationMs;
|
||||
let boundary = 0;
|
||||
for (let index = 0; index < frames.length; index += 1) {
|
||||
boundary += durations[index];
|
||||
if (cycleElapsedMs < boundary) {
|
||||
return {
|
||||
frame: frames[index],
|
||||
index,
|
||||
remainingMs: boundary - cycleElapsedMs,
|
||||
totalDurationMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatFrameDuration(durationMs, unit = "ms") {
|
||||
const duration = Number(durationMs);
|
||||
if (!Number.isInteger(duration)) return "";
|
||||
if (unit === "ms") return String(duration);
|
||||
if (unit !== "s") throw new Error("unknown animation duration unit");
|
||||
return (duration / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
export function validateFrameDuration(value, unit = "ms") {
|
||||
const normalized = typeof value === "string" ? value.trim() : String(value ?? "").trim();
|
||||
const millisecondsPattern = /^\d+$/;
|
||||
const secondsPattern = /^\d+(?:\.\d{1,3})?$/;
|
||||
if (!normalized || (unit === "ms" ? !millisecondsPattern.test(normalized) : !secondsPattern.test(normalized))) {
|
||||
return {
|
||||
valid: false,
|
||||
duration: null,
|
||||
error: unit === "ms"
|
||||
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
|
||||
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
|
||||
};
|
||||
}
|
||||
const numeric = Number(normalized);
|
||||
const duration = unit === "s" ? numeric * 1000 : numeric;
|
||||
if (!Number.isInteger(duration) || duration < MIN_FRAME_DURATION_MS || duration > MAX_FRAME_DURATION_MS) {
|
||||
return {
|
||||
valid: false,
|
||||
duration: null,
|
||||
error: unit === "ms"
|
||||
? `请输入 ${MIN_FRAME_DURATION_MS}..${MAX_FRAME_DURATION_MS} 之间的整数毫秒值。`
|
||||
: "请输入 0.050..604800 之间、最多三位小数的秒数。",
|
||||
};
|
||||
}
|
||||
return { valid: true, duration, error: "" };
|
||||
}
|
||||
|
||||
export function reorderSelectedFrameIds(frameIds, selectedFrameIds, insertAfterFrameId = null) {
|
||||
if (!Array.isArray(frameIds) || !Array.isArray(selectedFrameIds) || !selectedFrameIds.length) return null;
|
||||
const selected = new Set(selectedFrameIds);
|
||||
if (selected.size !== selectedFrameIds.length || selectedFrameIds.some((id) => !frameIds.includes(id))) return null;
|
||||
if (insertAfterFrameId !== null && (!frameIds.includes(insertAfterFrameId) || selected.has(insertAfterFrameId))) return null;
|
||||
const moving = frameIds.filter((id) => selected.has(id));
|
||||
const remaining = frameIds.filter((id) => !selected.has(id));
|
||||
const insertAt = insertAfterFrameId === null ? 0 : remaining.indexOf(insertAfterFrameId) + 1;
|
||||
return [...remaining.slice(0, insertAt), ...moving, ...remaining.slice(insertAt)];
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { DEFAULT_WORKSPACE_ID, announce, apiJson, getWorkspaces, markAppStale } from "./core.js";
|
||||
import "./interaction-feedback.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js";
|
||||
import { setBoardWorkspace } from "./scene-board.js";
|
||||
import { renderUiCopy } from "./ui-copy-runtime.js";
|
||||
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
|
||||
import {
|
||||
ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT,
|
||||
setAnimationPreviewMaxConcurrent,
|
||||
} from "./animation-thumbnail-preview.js";
|
||||
import "./color-picker.js";
|
||||
import "./views/canvas.js";
|
||||
import { refreshStatus } from "./views/device.js";
|
||||
import "./views/text.js";
|
||||
import "./views/media-import.js";
|
||||
import "./views/animations.js";
|
||||
import "./views/templates.js";
|
||||
import "./views/settings.js";
|
||||
|
||||
const drawer = document.getElementById("workspace-drawer");
|
||||
const backdrop = document.getElementById("drawer-backdrop");
|
||||
const menuButton = document.getElementById("drawer-toggle");
|
||||
const navigation = document.getElementById("workspace-nav");
|
||||
const workspaceOrderToggle = document.getElementById("workspace-order-toggle");
|
||||
const workspaceOrderFeedback = document.getElementById("workspace-order-feedback");
|
||||
const currentTitle = document.getElementById("current-workspace-title");
|
||||
const saveTemplateButton = document.getElementById("save-template");
|
||||
const definitions = getWorkspaces();
|
||||
const buttons = new Map();
|
||||
let activeWorkspace = null;
|
||||
let savedWorkspaceOrder = definitions.map((definition) => definition.id);
|
||||
let draftWorkspaceOrder = null;
|
||||
let workspaceOrderEditing = false;
|
||||
let workspaceOrderBusy = false;
|
||||
let workspaceOrderLoadPromise = null;
|
||||
let statusPollPending = false;
|
||||
const loadedAppVersion = document.querySelector('meta[name="app-version"]')?.content || null;
|
||||
|
||||
function setWorkspaceOrderToggleLabel() {
|
||||
if (workspaceOrderEditing) {
|
||||
renderUiCopy("copy.dynamic.workspace_order.save", workspaceOrderToggle, "保存");
|
||||
} else {
|
||||
renderUiCopy("copy.dynamic.workspace_order.edit", workspaceOrderToggle, "自定义项目位置");
|
||||
}
|
||||
}
|
||||
|
||||
function setWorkspaceOrderFeedback(copyId, defaultText, values = {}, state = "ready") {
|
||||
renderUiCopy(copyId, workspaceOrderFeedback, defaultText, values);
|
||||
workspaceOrderFeedback.hidden = false;
|
||||
workspaceOrderFeedback.dataset.state = state;
|
||||
workspaceOrderFeedback.setAttribute("role", state === "error" ? "alert" : "status");
|
||||
announce(workspaceOrderFeedback.textContent, state === "error");
|
||||
}
|
||||
|
||||
function clearWorkspaceOrderFeedback() {
|
||||
workspaceOrderFeedback.hidden = true;
|
||||
delete workspaceOrderFeedback.dataset.state;
|
||||
}
|
||||
|
||||
function orderedDefinitions(orderIds) {
|
||||
return resolveWorkspaceOrder(definitions, orderIds);
|
||||
}
|
||||
|
||||
function buildNavigation() {
|
||||
const orderIds = workspaceOrderEditing ? draftWorkspaceOrder : savedWorkspaceOrder;
|
||||
const ordered = orderedDefinitions(orderIds);
|
||||
navigation.replaceChildren();
|
||||
navigation.classList.toggle("is-order-editing", workspaceOrderEditing);
|
||||
buttons.clear();
|
||||
ordered.forEach((definition, index) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "nav-item-row";
|
||||
row.dataset.workspace = definition.id;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "nav-item";
|
||||
button.dataset.workspace = definition.id;
|
||||
button.textContent = definition.title;
|
||||
if (workspaceOrderEditing) {
|
||||
button.setAttribute("aria-disabled", "true");
|
||||
button.dataset.unavailableReason = "请先保存项目位置";
|
||||
} else {
|
||||
button.addEventListener("click", () => {
|
||||
navigate(definition.id);
|
||||
requestCloseDrawer(true);
|
||||
});
|
||||
}
|
||||
if (activeWorkspace?.id === definition.id) button.setAttribute("aria-current", "page");
|
||||
row.appendChild(button);
|
||||
buttons.set(definition.id, button);
|
||||
|
||||
if (workspaceOrderEditing) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "nav-order-actions";
|
||||
const moveUp = document.createElement("button");
|
||||
moveUp.type = "button";
|
||||
moveUp.className = "nav-order-action";
|
||||
moveUp.dataset.orderAction = "up";
|
||||
moveUp.dataset.workspace = definition.id;
|
||||
moveUp.setAttribute("aria-label", `上移${definition.title}`);
|
||||
renderUiCopy("copy.dynamic.workspace_order.move_up", moveUp, "上移");
|
||||
moveUp.addEventListener("click", () => moveDraftWorkspace(index, -1, "up"));
|
||||
|
||||
const moveDown = document.createElement("button");
|
||||
moveDown.type = "button";
|
||||
moveDown.className = "nav-order-action";
|
||||
moveDown.dataset.orderAction = "down";
|
||||
moveDown.dataset.workspace = definition.id;
|
||||
moveDown.setAttribute("aria-label", `下移${definition.title}`);
|
||||
renderUiCopy("copy.dynamic.workspace_order.move_down", moveDown, "下移");
|
||||
moveDown.addEventListener("click", () => moveDraftWorkspace(index, 1, "down"));
|
||||
actions.append(moveUp, moveDown);
|
||||
row.appendChild(actions);
|
||||
if (index === 0) setControlUnavailable(moveUp, "当前已经是第一项,不能继续上移。");
|
||||
if (index === ordered.length - 1) {
|
||||
setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
|
||||
}
|
||||
}
|
||||
navigation.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function moveDraftWorkspace(index, offset, action) {
|
||||
const moved = moveWorkspaceOrder(draftWorkspaceOrder, index, offset);
|
||||
if (!moved) return;
|
||||
const workspaceId = draftWorkspaceOrder[index];
|
||||
draftWorkspaceOrder = moved;
|
||||
clearWorkspaceOrderFeedback();
|
||||
buildNavigation();
|
||||
Array.from(navigation.querySelectorAll(`[data-order-action="${action}"]`))
|
||||
.find((button) => button.dataset.workspace === workspaceId)
|
||||
?.focus();
|
||||
}
|
||||
|
||||
async function loadWorkspaceOrder() {
|
||||
if (workspaceOrderLoadPromise) return workspaceOrderLoadPromise;
|
||||
navigation.setAttribute("aria-busy", "true");
|
||||
workspaceOrderLoadPromise = apiJson("/api/config")
|
||||
.then((config) => {
|
||||
try {
|
||||
setAnimationPreviewMaxConcurrent(config.animation_preview_max_concurrent);
|
||||
} catch {
|
||||
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
|
||||
}
|
||||
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
|
||||
if (!workspaceOrderEditing) buildNavigation();
|
||||
return savedWorkspaceOrder;
|
||||
})
|
||||
.catch((error) => {
|
||||
setAnimationPreviewMaxConcurrent(ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
navigation.removeAttribute("aria-busy");
|
||||
workspaceOrderLoadPromise = null;
|
||||
});
|
||||
return workspaceOrderLoadPromise;
|
||||
}
|
||||
|
||||
async function beginWorkspaceOrderEdit() {
|
||||
if (workspaceOrderBusy || workspaceOrderEditing) return;
|
||||
workspaceOrderBusy = true;
|
||||
setControlBusy(workspaceOrderToggle);
|
||||
renderUiCopy("copy.dynamic.workspace_order.loading", workspaceOrderToggle, "正在读取…");
|
||||
try {
|
||||
await loadWorkspaceOrder();
|
||||
draftWorkspaceOrder = [...savedWorkspaceOrder];
|
||||
workspaceOrderEditing = true;
|
||||
clearWorkspaceOrderFeedback();
|
||||
buildNavigation();
|
||||
} catch (error) {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.load_failed",
|
||||
"项目位置读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
workspaceOrderBusy = false;
|
||||
setControlReady(workspaceOrderToggle);
|
||||
setWorkspaceOrderToggleLabel();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkspaceOrder() {
|
||||
if (workspaceOrderBusy || !workspaceOrderEditing || !draftWorkspaceOrder) return;
|
||||
workspaceOrderBusy = true;
|
||||
setControlBusy(workspaceOrderToggle);
|
||||
renderUiCopy("copy.dynamic.workspace_order.saving", workspaceOrderToggle, "正在保存…");
|
||||
try {
|
||||
const config = await apiJson("/api/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ workspace_order: draftWorkspaceOrder }),
|
||||
});
|
||||
savedWorkspaceOrder = orderedDefinitions(config.workspace_order).map((definition) => definition.id);
|
||||
draftWorkspaceOrder = null;
|
||||
workspaceOrderEditing = false;
|
||||
buildNavigation();
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.saved",
|
||||
"项目位置已保存",
|
||||
{},
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.save_failed",
|
||||
"项目位置保存失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
workspaceOrderBusy = false;
|
||||
setControlReady(workspaceOrderToggle);
|
||||
setWorkspaceOrderToggleLabel();
|
||||
}
|
||||
}
|
||||
|
||||
function remindWorkspaceOrderSave() {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.save_required",
|
||||
"请先保存项目位置",
|
||||
);
|
||||
workspaceOrderToggle.focus();
|
||||
}
|
||||
|
||||
function resolveWorkspaceId() {
|
||||
const hashId = decodeURIComponent(window.location.hash.replace(/^#/, ""));
|
||||
return definitions.some((item) => item.id === hashId) ? hashId : DEFAULT_WORKSPACE_ID;
|
||||
}
|
||||
|
||||
function navigate(id, updateHash = true) {
|
||||
const next = definitions.find((item) => item.id === id)
|
||||
|| definitions.find((item) => item.id === DEFAULT_WORKSPACE_ID)
|
||||
|| definitions[0];
|
||||
if (!next || activeWorkspace?.id === next.id) return;
|
||||
|
||||
if (activeWorkspace) {
|
||||
document.getElementById(activeWorkspace.elementId).hidden = true;
|
||||
buttons.get(activeWorkspace.id)?.removeAttribute("aria-current");
|
||||
activeWorkspace.onLeave?.();
|
||||
}
|
||||
|
||||
activeWorkspace = next;
|
||||
const element = document.getElementById(next.elementId);
|
||||
element.hidden = false;
|
||||
setBoardWorkspace(next.id);
|
||||
saveTemplateButton.hidden = !next.capabilities.includes("scene-template-save");
|
||||
currentTitle.textContent = next.title;
|
||||
buttons.get(next.id)?.setAttribute("aria-current", "page");
|
||||
document.title = `${next.title} · 奇妙小屏幕控制器`;
|
||||
Promise.resolve(next.onEnter?.()).catch((error) => {
|
||||
console.error(`工作区 ${next.id} 初始化失败`, error);
|
||||
const message = `${next.title}初始化失败:${error.message}`;
|
||||
const feedback = element.querySelector("[aria-live]");
|
||||
if (feedback) {
|
||||
feedback.textContent = message;
|
||||
feedback.dataset.state = "error";
|
||||
}
|
||||
announce(message, true);
|
||||
});
|
||||
|
||||
if (updateHash && window.location.hash !== `#${next.id}`) {
|
||||
history.pushState(null, "", `#${next.id}`);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
drawer.classList.add("open");
|
||||
drawer.removeAttribute("inert");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
backdrop.hidden = false;
|
||||
document.body.classList.add("drawer-open");
|
||||
menuButton.setAttribute("aria-expanded", "true");
|
||||
menuButton.setAttribute("aria-label", "关闭功能菜单");
|
||||
if (!workspaceOrderEditing && !workspaceOrderBusy) {
|
||||
loadWorkspaceOrder().catch((error) => {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.load_failed",
|
||||
"项目位置读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer(restoreFocus = false) {
|
||||
drawer.classList.remove("open");
|
||||
drawer.setAttribute("inert", "");
|
||||
drawer.setAttribute("aria-hidden", "true");
|
||||
backdrop.hidden = true;
|
||||
document.body.classList.remove("drawer-open");
|
||||
menuButton.setAttribute("aria-expanded", "false");
|
||||
menuButton.setAttribute("aria-label", "打开功能菜单");
|
||||
if (restoreFocus) menuButton.focus();
|
||||
}
|
||||
|
||||
function requestCloseDrawer(restoreFocus = false) {
|
||||
if (workspaceOrderEditing || workspaceOrderBusy) {
|
||||
remindWorkspaceOrderSave();
|
||||
return false;
|
||||
}
|
||||
closeDrawer(restoreFocus);
|
||||
return true;
|
||||
}
|
||||
|
||||
workspaceOrderToggle.addEventListener("click", () => {
|
||||
if (workspaceOrderEditing) saveWorkspaceOrder();
|
||||
else beginWorkspaceOrderEdit();
|
||||
});
|
||||
|
||||
menuButton.addEventListener("click", () => {
|
||||
if (drawer.classList.contains("open")) {
|
||||
requestCloseDrawer(true);
|
||||
} else {
|
||||
openDrawer();
|
||||
}
|
||||
});
|
||||
backdrop.addEventListener("click", () => requestCloseDrawer(true));
|
||||
window.addEventListener("hashchange", () => navigate(resolveWorkspaceId(), false));
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && drawer.classList.contains("open")) {
|
||||
event.preventDefault();
|
||||
requestCloseDrawer(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" && drawer.classList.contains("open")) {
|
||||
const focusable = [menuButton, ...Array.from(drawer.querySelectorAll("button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled)"))]
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
buildNavigation();
|
||||
setWorkspaceOrderToggleLabel();
|
||||
navigate(resolveWorkspaceId(), false);
|
||||
loadWorkspaceOrder().catch((error) => {
|
||||
setWorkspaceOrderFeedback(
|
||||
"copy.dynamic.workspace_order.load_failed",
|
||||
"项目位置读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById("reload-app").addEventListener("click", () => window.location.reload());
|
||||
|
||||
async function pollStatus() {
|
||||
if (statusPollPending || document.visibilityState !== "visible") return;
|
||||
statusPollPending = true;
|
||||
try {
|
||||
const status = await refreshStatus({ source: "poll" });
|
||||
const currentVersion = status.service?.app_version;
|
||||
if (loadedAppVersion && currentVersion && currentVersion !== loadedAppVersion) {
|
||||
markAppStale();
|
||||
}
|
||||
} catch (_error) {
|
||||
// The device workspace reports persistent failures when the user opens it.
|
||||
} finally {
|
||||
statusPollPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.setInterval(pollStatus, 5000);
|
||||
window.addEventListener("focus", pollStatus);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") pollStatus();
|
||||
});
|
||||
pollStatus();
|
||||
@@ -0,0 +1,174 @@
|
||||
export const BRUSH_MODE_NORMAL = "normal";
|
||||
export const BRUSH_MODE_PIXEL = "pixel";
|
||||
export const CANVAS_VIEW_MIN_SCALE = 1;
|
||||
export const CANVAS_VIEW_MAX_SCALE = 8;
|
||||
export const CANVAS_UNDO_LIMIT = 50;
|
||||
|
||||
const MODE_SPECS = Object.freeze({
|
||||
[BRUSH_MODE_NORMAL]: Object.freeze({ min: 1, max: 16, presets: Object.freeze([1, 2, 4, 8]) }),
|
||||
[BRUSH_MODE_PIXEL]: Object.freeze({ min: 1, max: 4, presets: Object.freeze([1, 2, 3, 4]) }),
|
||||
});
|
||||
|
||||
function record(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
export function brushModeSpec(mode) {
|
||||
return MODE_SPECS[mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL];
|
||||
}
|
||||
|
||||
export function normalizeBrushSize(value, mode) {
|
||||
const spec = brushModeSpec(mode);
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Math.max(spec.min, Math.min(spec.max, Number.isFinite(parsed) ? parsed : spec.min));
|
||||
}
|
||||
|
||||
export function restoreCanvasToolState(savedV2, savedV1 = null) {
|
||||
const current = record(savedV2);
|
||||
const legacy = record(savedV1);
|
||||
const source = current || legacy || {};
|
||||
return {
|
||||
paintColor: typeof source.paintColor === "string" ? source.paintColor : null,
|
||||
backgroundColor: typeof source.backgroundColor === "string" ? source.backgroundColor : null,
|
||||
tool: source.tool === "eraser" ? "eraser" : "brush",
|
||||
pixelEditEnabled: Boolean(current?.pixelEditEnabled),
|
||||
normalBrushSize: normalizeBrushSize(
|
||||
current?.normalBrushSize ?? source.brushSize,
|
||||
BRUSH_MODE_NORMAL,
|
||||
),
|
||||
pixelBrushSize: normalizeBrushSize(current?.pixelBrushSize, BRUSH_MODE_PIXEL),
|
||||
};
|
||||
}
|
||||
|
||||
export function linePoints(from, to) {
|
||||
const points = [];
|
||||
let x = from.x;
|
||||
let y = from.y;
|
||||
const dx = Math.abs(to.x - from.x);
|
||||
const sx = from.x < to.x ? 1 : -1;
|
||||
const dy = -Math.abs(to.y - from.y);
|
||||
const sy = from.y < to.y ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
points.push({ x, y });
|
||||
if (x === to.x && y === to.y) break;
|
||||
const doubled = 2 * error;
|
||||
if (doubled >= dy) {
|
||||
error += dy;
|
||||
x += sx;
|
||||
}
|
||||
if (doubled <= dx) {
|
||||
error += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function brushStampPoints(centerX, centerY, size, mode, width = 64, height = 64) {
|
||||
const normalizedMode = mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
|
||||
const diameter = normalizeBrushSize(size, normalizedMode);
|
||||
const start = -Math.floor((diameter - 1) / 2);
|
||||
const radius = Math.max(0.25, (diameter - 0.5) / 2);
|
||||
const shapeCenter = (diameter - 1) / 2;
|
||||
const points = [];
|
||||
for (let localY = 0; localY < diameter; localY += 1) {
|
||||
for (let localX = 0; localX < diameter; localX += 1) {
|
||||
if (normalizedMode === BRUSH_MODE_NORMAL) {
|
||||
const normalizedX = (localX - shapeCenter) / radius;
|
||||
const normalizedY = (localY - shapeCenter) / radius;
|
||||
if ((normalizedX * normalizedX) + (normalizedY * normalizedY) > 1) continue;
|
||||
}
|
||||
const x = centerX + start + localX;
|
||||
const y = centerY + start + localY;
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) points.push({ x, y });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
export function pixelBuffersEqual(left, right) {
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class PixelUndoHistory {
|
||||
constructor(limit = CANVAS_UNDO_LIMIT) {
|
||||
this.limit = Math.max(1, Number.isInteger(limit) ? limit : CANVAS_UNDO_LIMIT);
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.entries.length;
|
||||
}
|
||||
|
||||
record(before, after) {
|
||||
if (pixelBuffersEqual(before, after)) return false;
|
||||
this.entries.push(Uint8Array.from(before));
|
||||
if (this.entries.length > this.limit) this.entries.splice(0, this.entries.length - this.limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
return this.entries.pop() || null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.entries.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCanvasScale(value) {
|
||||
const scale = Number(value);
|
||||
if (!Number.isFinite(scale)) return CANVAS_VIEW_MIN_SCALE;
|
||||
return Math.max(CANVAS_VIEW_MIN_SCALE, Math.min(CANVAS_VIEW_MAX_SCALE, scale));
|
||||
}
|
||||
|
||||
export function clampCanvasView(
|
||||
view,
|
||||
{ viewportWidth, viewportHeight, baseSize, minVisible = 48 },
|
||||
) {
|
||||
const scale = normalizeCanvasScale(view?.scale);
|
||||
const width = Math.max(1, Number(baseSize) || 1) * scale;
|
||||
const height = width;
|
||||
const visibleX = Math.min(Math.max(1, Number(minVisible) || 1), width, viewportWidth);
|
||||
const visibleY = Math.min(Math.max(1, Number(minVisible) || 1), height, viewportHeight);
|
||||
const limitX = Math.max(0, (viewportWidth + width) / 2 - visibleX);
|
||||
const limitY = Math.max(0, (viewportHeight + height) / 2 - visibleY);
|
||||
return {
|
||||
scale,
|
||||
x: Math.max(-limitX, Math.min(limitX, Number(view?.x) || 0)),
|
||||
y: Math.max(-limitY, Math.min(limitY, Number(view?.y) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export function zoomCanvasView(view, nextScale, anchor, bounds) {
|
||||
const scale = normalizeCanvasScale(nextScale);
|
||||
const previousScale = normalizeCanvasScale(view?.scale);
|
||||
const centerX = bounds.viewportWidth / 2;
|
||||
const centerY = bounds.viewportHeight / 2;
|
||||
const ratio = scale / previousScale;
|
||||
const x = (anchor.x - centerX) - ratio * (anchor.x - centerX - (Number(view?.x) || 0));
|
||||
const y = (anchor.y - centerY) - ratio * (anchor.y - centerY - (Number(view?.y) || 0));
|
||||
return clampCanvasView({ scale, x, y }, bounds);
|
||||
}
|
||||
|
||||
export function clampFloatingButton(position, {
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
size = 56,
|
||||
inset = 8,
|
||||
}) {
|
||||
const half = size / 2;
|
||||
const minX = inset + half;
|
||||
const maxX = Math.max(minX, viewportWidth - inset - half);
|
||||
const minY = inset + half;
|
||||
const maxY = Math.max(minY, viewportHeight - inset - half);
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, Number(position?.x) || minX)),
|
||||
y: Math.max(minY, Math.min(maxY, Number(position?.y) || minY)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, Number(value)));
|
||||
}
|
||||
|
||||
export function normalizeHex(value) {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(String(value).trim());
|
||||
return match ? `#${match[1].toUpperCase()}` : null;
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }) {
|
||||
return `#${[r, g, b]
|
||||
.map((channel) => Math.round(clamp(channel, 0, 255)).toString(16).padStart(2, "0"))
|
||||
.join("")}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function hexToRgb(value) {
|
||||
const hex = normalizeHex(value);
|
||||
if (!hex) throw new TypeError("颜色必须是 #RRGGBB");
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHsv({ r, g, b }) {
|
||||
const red = clamp(r, 0, 255) / 255;
|
||||
const green = clamp(g, 0, 255) / 255;
|
||||
const blue = clamp(b, 0, 255) / 255;
|
||||
const maximum = Math.max(red, green, blue);
|
||||
const minimum = Math.min(red, green, blue);
|
||||
const delta = maximum - minimum;
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
|
||||
else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
|
||||
else hue = 60 * ((red - green) / delta + 4);
|
||||
}
|
||||
if (hue < 0) hue += 360;
|
||||
return {
|
||||
h: Math.round(hue),
|
||||
s: Math.round(maximum === 0 ? 0 : (delta / maximum) * 100),
|
||||
v: Math.round(maximum * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function hsvToRgb({ h, s, v }) {
|
||||
const hue = ((Number(h) % 360) + 360) % 360;
|
||||
const saturation = clamp(s, 0, 100) / 100;
|
||||
const value = clamp(v, 0, 100) / 100;
|
||||
const chroma = value * saturation;
|
||||
const segment = hue / 60;
|
||||
const secondary = chroma * (1 - Math.abs((segment % 2) - 1));
|
||||
let channels = [0, 0, 0];
|
||||
if (segment < 1) channels = [chroma, secondary, 0];
|
||||
else if (segment < 2) channels = [secondary, chroma, 0];
|
||||
else if (segment < 3) channels = [0, chroma, secondary];
|
||||
else if (segment < 4) channels = [0, secondary, chroma];
|
||||
else if (segment < 5) channels = [secondary, 0, chroma];
|
||||
else channels = [chroma, 0, secondary];
|
||||
const match = value - chroma;
|
||||
return {
|
||||
r: Math.round((channels[0] + match) * 255),
|
||||
g: Math.round((channels[1] + match) * 255),
|
||||
b: Math.round((channels[2] + match) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRecentColors(colors, value, limit = 10) {
|
||||
const normalized = normalizeHex(value);
|
||||
if (!normalized) return Array.isArray(colors) ? colors.slice(0, limit) : [];
|
||||
const existing = Array.isArray(colors)
|
||||
? colors.map(normalizeHex).filter(Boolean).filter((color) => color !== normalized)
|
||||
: [];
|
||||
return [normalized, ...existing].slice(0, limit);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { announce, apiJson, createLocalState, setActiveButton } from "./core.js";
|
||||
import {
|
||||
clamp,
|
||||
hexToRgb,
|
||||
hsvToRgb,
|
||||
normalizeHex,
|
||||
rgbToHex,
|
||||
rgbToHsv,
|
||||
updateRecentColors,
|
||||
} from "./color-math.js";
|
||||
import { setControlBusy, setControlReady } from "./interaction-feedback.js";
|
||||
|
||||
const localState = createLocalState("colors", 1);
|
||||
const savedState = localState.load({});
|
||||
let recentColors = Array.isArray(savedState.recentColors)
|
||||
? savedState.recentColors.map(normalizeHex).filter(Boolean).slice(0, 10)
|
||||
: [];
|
||||
let mode = savedState.mode === "rgb" ? "rgb" : "hsv";
|
||||
let palette = [];
|
||||
let activeTrigger = null;
|
||||
let originalColor = "#000000";
|
||||
let currentRgb = hexToRgb(originalColor);
|
||||
let currentHsv = rgbToHsv(currentRgb);
|
||||
let paletteEditing = false;
|
||||
let pickerReadonly = false;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "color-picker-overlay";
|
||||
overlay.className = "color-picker-overlay";
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML = `
|
||||
<section class="color-picker-panel" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
|
||||
<header class="color-picker-header">
|
||||
<div><p class="eyebrow">软件自有控件</p><h2 id="color-picker-title">选择颜色</h2></div>
|
||||
<button class="compact-button" type="button" data-color-action="cancel" aria-label="关闭颜色选择器">关闭</button>
|
||||
</header>
|
||||
<div class="color-comparison" aria-label="原颜色与新颜色对比">
|
||||
<div><span>原颜色</span><i id="color-original-preview"></i></div>
|
||||
<div><span>新颜色</span><i id="color-current-preview"></i></div>
|
||||
</div>
|
||||
<div class="color-mode-tabs segmented" role="tablist" aria-label="颜色模式">
|
||||
<button type="button" role="tab" data-color-mode="hsv">HSV</button>
|
||||
<button type="button" role="tab" data-color-mode="rgb">RGB</button>
|
||||
</div>
|
||||
<div id="color-hsv-panel" class="color-mode-panel" role="tabpanel">
|
||||
<div id="color-sv-field" class="color-sv-field" role="slider" tabindex="0" aria-label="饱和度和明度" aria-valuemin="0" aria-valuemax="100">
|
||||
<span id="color-sv-handle" class="color-track-handle"></span>
|
||||
</div>
|
||||
<div id="color-hue-track" class="color-linear-track color-hue-track" role="slider" tabindex="0" aria-label="色相" aria-valuemin="0" aria-valuemax="360">
|
||||
<span id="color-hue-handle" class="color-track-handle"></span>
|
||||
</div>
|
||||
<div class="color-number-grid">
|
||||
<label>H<input id="color-h" type="number" min="0" max="360" inputmode="numeric"></label>
|
||||
<label>S<input id="color-s" type="number" min="0" max="100" inputmode="numeric"></label>
|
||||
<label>V<input id="color-v" type="number" min="0" max="100" inputmode="numeric"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="color-rgb-panel" class="color-mode-panel" role="tabpanel" hidden>
|
||||
<div class="rgb-channel"><span>R</span><div class="color-linear-track" data-rgb-track="r" role="slider" tabindex="0" aria-label="红色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-r" type="number" min="0" max="255" inputmode="numeric"></div>
|
||||
<div class="rgb-channel"><span>G</span><div class="color-linear-track" data-rgb-track="g" role="slider" tabindex="0" aria-label="绿色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-g" type="number" min="0" max="255" inputmode="numeric"></div>
|
||||
<div class="rgb-channel"><span>B</span><div class="color-linear-track" data-rgb-track="b" role="slider" tabindex="0" aria-label="蓝色通道" aria-valuemin="0" aria-valuemax="255"><span class="color-track-handle"></span></div><input id="color-b" type="number" min="0" max="255" inputmode="numeric"></div>
|
||||
</div>
|
||||
<label class="color-hex-field">HEX<input id="color-hex" type="text" maxlength="7" autocapitalize="characters" spellcheck="false" inputmode="text"></label>
|
||||
<section class="color-collection" aria-labelledby="recent-colors-title">
|
||||
<div class="color-collection-heading"><h3 id="recent-colors-title">最近颜色</h3></div>
|
||||
<div id="recent-colors" class="color-swatch-grid"></div>
|
||||
</section>
|
||||
<section class="color-collection" aria-labelledby="palette-colors-title">
|
||||
<div class="color-collection-heading"><h3 id="palette-colors-title">设备收藏色</h3><div><button type="button" class="compact-button" data-color-action="favorite">收藏当前</button><button type="button" class="compact-button" data-color-action="edit-palette">编辑</button></div></div>
|
||||
<div id="palette-colors" class="color-swatch-grid"></div>
|
||||
<p id="palette-status" class="hint" aria-live="polite"></p>
|
||||
</section>
|
||||
<footer class="color-picker-actions">
|
||||
<button type="button" data-color-action="cancel">取消</button>
|
||||
<button type="button" class="primary-button" data-color-action="confirm">使用此颜色</button>
|
||||
</footer>
|
||||
</section>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const panel = overlay.querySelector(".color-picker-panel");
|
||||
const hsvPanel = document.getElementById("color-hsv-panel");
|
||||
const rgbPanel = document.getElementById("color-rgb-panel");
|
||||
const svField = document.getElementById("color-sv-field");
|
||||
const hueTrack = document.getElementById("color-hue-track");
|
||||
const modeButtons = Array.from(overlay.querySelectorAll("[data-color-mode]"));
|
||||
const rgbTracks = Array.from(overlay.querySelectorAll("[data-rgb-track]"));
|
||||
|
||||
function persistLocalColors() {
|
||||
localState.save({ recentColors, mode });
|
||||
}
|
||||
|
||||
function renderTrigger(trigger) {
|
||||
const value = normalizeHex(trigger.value) || "#000000";
|
||||
trigger.value = value;
|
||||
trigger.querySelector(".color-trigger-swatch").style.background = value;
|
||||
trigger.querySelector(".color-trigger-value").textContent = value;
|
||||
}
|
||||
|
||||
function setMode(nextMode) {
|
||||
mode = nextMode === "rgb" ? "rgb" : "hsv";
|
||||
setActiveButton(modeButtons, modeButtons.find((button) => button.dataset.colorMode === mode));
|
||||
modeButtons.forEach((button) => button.setAttribute("aria-selected", String(button.dataset.colorMode === mode)));
|
||||
hsvPanel.hidden = mode !== "hsv";
|
||||
rgbPanel.hidden = mode !== "rgb";
|
||||
persistLocalColors();
|
||||
}
|
||||
|
||||
function setCurrentRgb(rgb) {
|
||||
if (pickerReadonly) return;
|
||||
currentRgb = {
|
||||
r: Math.round(clamp(rgb.r, 0, 255)),
|
||||
g: Math.round(clamp(rgb.g, 0, 255)),
|
||||
b: Math.round(clamp(rgb.b, 0, 255)),
|
||||
};
|
||||
currentHsv = rgbToHsv(currentRgb);
|
||||
renderPicker();
|
||||
}
|
||||
|
||||
function setCurrentHsv(hsv) {
|
||||
if (pickerReadonly) return;
|
||||
currentHsv = {
|
||||
h: Math.round(clamp(hsv.h, 0, 360)),
|
||||
s: Math.round(clamp(hsv.s, 0, 100)),
|
||||
v: Math.round(clamp(hsv.v, 0, 100)),
|
||||
};
|
||||
currentRgb = hsvToRgb(currentHsv);
|
||||
renderPicker();
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
const hex = rgbToHex(currentRgb);
|
||||
document.getElementById("color-original-preview").style.background = originalColor;
|
||||
document.getElementById("color-current-preview").style.background = hex;
|
||||
document.getElementById("color-hex").value = hex;
|
||||
for (const channel of ["r", "g", "b"]) document.getElementById(`color-${channel}`).value = currentRgb[channel];
|
||||
for (const channel of ["h", "s", "v"]) document.getElementById(`color-${channel}`).value = currentHsv[channel];
|
||||
|
||||
svField.style.background = `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${currentHsv.h} 100% 50%))`;
|
||||
const svHandle = document.getElementById("color-sv-handle");
|
||||
svHandle.style.left = `${currentHsv.s}%`;
|
||||
svHandle.style.top = `${100 - currentHsv.v}%`;
|
||||
svField.setAttribute("aria-valuenow", String(currentHsv.s));
|
||||
svField.setAttribute("aria-valuetext", `饱和度 ${currentHsv.s}%,明度 ${currentHsv.v}%`);
|
||||
document.getElementById("color-hue-handle").style.left = `${currentHsv.h / 3.6}%`;
|
||||
hueTrack.setAttribute("aria-valuenow", String(currentHsv.h));
|
||||
|
||||
rgbTracks.forEach((track) => {
|
||||
const channel = track.dataset.rgbTrack;
|
||||
const start = { ...currentRgb, [channel]: 0 };
|
||||
const end = { ...currentRgb, [channel]: 255 };
|
||||
track.style.background = `linear-gradient(to right, ${rgbToHex(start)}, ${rgbToHex(end)})`;
|
||||
track.querySelector(".color-track-handle").style.left = `${(currentRgb[channel] / 255) * 100}%`;
|
||||
track.setAttribute("aria-valuenow", String(currentRgb[channel]));
|
||||
});
|
||||
renderSwatches();
|
||||
}
|
||||
|
||||
function swatchButton(color, label) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "color-swatch";
|
||||
button.style.background = color;
|
||||
button.title = color;
|
||||
button.setAttribute("aria-label", `${label} ${color}`);
|
||||
button.addEventListener("click", () => setCurrentRgb(hexToRgb(color)));
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderSwatches() {
|
||||
const recentContainer = document.getElementById("recent-colors");
|
||||
recentContainer.replaceChildren(...recentColors.map((color) => swatchButton(color, "最近颜色")));
|
||||
if (!recentColors.length) recentContainer.textContent = "还没有最近颜色";
|
||||
|
||||
const paletteContainer = document.getElementById("palette-colors");
|
||||
paletteContainer.replaceChildren(...palette.map((color) => {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className = "palette-swatch";
|
||||
wrapper.appendChild(swatchButton(color, "收藏颜色"));
|
||||
if (paletteEditing) {
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "palette-remove";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", `删除收藏颜色 ${color}`);
|
||||
remove.addEventListener("click", () => deleteFavorite(color, remove));
|
||||
wrapper.appendChild(remove);
|
||||
}
|
||||
return wrapper;
|
||||
}));
|
||||
if (!palette.length) paletteContainer.textContent = "收藏色板为空";
|
||||
}
|
||||
|
||||
async function loadPalette() {
|
||||
const status = document.getElementById("palette-status");
|
||||
status.textContent = "正在读取设备收藏色…";
|
||||
try {
|
||||
const response = await apiJson("/api/colors/palette");
|
||||
palette = response.colors.map(normalizeHex).filter(Boolean);
|
||||
status.textContent = `${palette.length} / ${response.limit}`;
|
||||
renderSwatches();
|
||||
} catch (error) {
|
||||
status.textContent = `收藏色读取失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addFavorite(control) {
|
||||
setControlBusy(control, { label: "正在收藏…", message: "正在收藏当前颜色…" });
|
||||
try {
|
||||
const response = await apiJson("/api/colors/palette", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ color: rgbToHex(currentRgb) }),
|
||||
});
|
||||
palette = response.colors;
|
||||
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
|
||||
renderSwatches();
|
||||
} catch (error) {
|
||||
announce(`收藏颜色失败:${error.message}`, true);
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFavorite(color, control) {
|
||||
setControlBusy(control, { message: `正在删除收藏颜色 ${color}…` });
|
||||
try {
|
||||
const response = await apiJson(`/api/colors/palette/${color.slice(1)}`, { method: "DELETE" });
|
||||
palette = response.colors;
|
||||
document.getElementById("palette-status").textContent = `${palette.length} / ${response.limit}`;
|
||||
renderSwatches();
|
||||
} catch (error) {
|
||||
announce(`删除收藏颜色失败:${error.message}`, true);
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
function trackRatio(event, track) {
|
||||
const rect = track.getBoundingClientRect();
|
||||
return clamp((event.clientX - rect.left) / rect.width, 0, 1);
|
||||
}
|
||||
|
||||
function bindHorizontalTrack(track, update) {
|
||||
let activePointer = null;
|
||||
const move = (event) => {
|
||||
if (activePointer !== event.pointerId) return;
|
||||
update(trackRatio(event, track));
|
||||
};
|
||||
track.addEventListener("pointerdown", (event) => {
|
||||
activePointer = event.pointerId;
|
||||
track.setPointerCapture(event.pointerId);
|
||||
update(trackRatio(event, track));
|
||||
});
|
||||
track.addEventListener("pointermove", move);
|
||||
const finish = (event) => { if (activePointer === event.pointerId) activePointer = null; };
|
||||
track.addEventListener("pointerup", finish);
|
||||
track.addEventListener("pointercancel", finish);
|
||||
}
|
||||
|
||||
bindHorizontalTrack(hueTrack, (ratio) => setCurrentHsv({ ...currentHsv, h: ratio * 360 }));
|
||||
rgbTracks.forEach((track) => bindHorizontalTrack(track, (ratio) => {
|
||||
setCurrentRgb({ ...currentRgb, [track.dataset.rgbTrack]: ratio * 255 });
|
||||
}));
|
||||
|
||||
let svPointer = null;
|
||||
function updateSvFromPointer(event) {
|
||||
if (event.pointerId !== svPointer) return;
|
||||
const rect = svField.getBoundingClientRect();
|
||||
setCurrentHsv({
|
||||
...currentHsv,
|
||||
s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
|
||||
v: clamp((1 - ((event.clientY - rect.top) / rect.height)) * 100, 0, 100),
|
||||
});
|
||||
}
|
||||
svField.addEventListener("pointerdown", (event) => {
|
||||
svPointer = event.pointerId;
|
||||
svField.setPointerCapture(event.pointerId);
|
||||
updateSvFromPointer(event);
|
||||
});
|
||||
svField.addEventListener("pointermove", updateSvFromPointer);
|
||||
svField.addEventListener("pointerup", () => { svPointer = null; });
|
||||
svField.addEventListener("pointercancel", () => { svPointer = null; });
|
||||
|
||||
function sliderKeyDelta(event, large = 10) {
|
||||
if (["ArrowRight", "ArrowUp"].includes(event.key)) return event.shiftKey ? large : 1;
|
||||
if (["ArrowLeft", "ArrowDown"].includes(event.key)) return event.shiftKey ? -large : -1;
|
||||
if (event.key === "PageUp") return large;
|
||||
if (event.key === "PageDown") return -large;
|
||||
return 0;
|
||||
}
|
||||
hueTrack.addEventListener("keydown", (event) => {
|
||||
const delta = sliderKeyDelta(event, 10);
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
setCurrentHsv({ ...currentHsv, h: clamp(currentHsv.h + delta, 0, 360) });
|
||||
});
|
||||
rgbTracks.forEach((track) => track.addEventListener("keydown", (event) => {
|
||||
const delta = sliderKeyDelta(event, 10);
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
const channel = track.dataset.rgbTrack;
|
||||
setCurrentRgb({ ...currentRgb, [channel]: currentRgb[channel] + delta });
|
||||
}));
|
||||
svField.addEventListener("keydown", (event) => {
|
||||
const delta = event.shiftKey ? 10 : 1;
|
||||
const next = { ...currentHsv };
|
||||
if (event.key === "ArrowLeft") next.s -= delta;
|
||||
else if (event.key === "ArrowRight") next.s += delta;
|
||||
else if (event.key === "ArrowDown") next.v -= delta;
|
||||
else if (event.key === "ArrowUp") next.v += delta;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
setCurrentHsv(next);
|
||||
});
|
||||
|
||||
for (const channel of ["r", "g", "b"]) {
|
||||
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
|
||||
setCurrentRgb({ ...currentRgb, [channel]: event.target.value });
|
||||
});
|
||||
}
|
||||
for (const channel of ["h", "s", "v"]) {
|
||||
document.getElementById(`color-${channel}`).addEventListener("input", (event) => {
|
||||
setCurrentHsv({ ...currentHsv, [channel]: event.target.value });
|
||||
});
|
||||
}
|
||||
document.getElementById("color-hex").addEventListener("input", (event) => {
|
||||
const hex = normalizeHex(event.target.value);
|
||||
if (hex) setCurrentRgb(hexToRgb(hex));
|
||||
});
|
||||
|
||||
function closePicker() {
|
||||
overlay.hidden = true;
|
||||
document.body.classList.remove("color-picker-open");
|
||||
const trigger = activeTrigger;
|
||||
activeTrigger = null;
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function confirmPicker() {
|
||||
if (!activeTrigger || pickerReadonly) return;
|
||||
setColorValue(activeTrigger, rgbToHex(currentRgb), { recordHistory: true });
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function openPicker(trigger) {
|
||||
activeTrigger = trigger;
|
||||
pickerReadonly = trigger.dataset.colorReadonly === "true";
|
||||
originalColor = normalizeHex(trigger.value) || "#000000";
|
||||
currentRgb = hexToRgb(originalColor);
|
||||
currentHsv = rgbToHsv(currentRgb);
|
||||
paletteEditing = false;
|
||||
overlay.querySelector("[data-color-action='edit-palette']").classList.remove("active");
|
||||
setMode(mode);
|
||||
renderPicker();
|
||||
panel.classList.toggle("color-picker-readonly", pickerReadonly);
|
||||
document.getElementById("color-picker-title").textContent = pickerReadonly ? "查看颜色" : "选择颜色";
|
||||
panel.querySelectorAll(".color-collection, .color-picker-actions").forEach((element) => {
|
||||
element.hidden = pickerReadonly;
|
||||
});
|
||||
for (const id of ["color-h", "color-s", "color-v", "color-r", "color-g", "color-b", "color-hex"]) {
|
||||
document.getElementById(id).readOnly = pickerReadonly;
|
||||
}
|
||||
[svField, hueTrack, ...rgbTracks].forEach((track) => {
|
||||
track.setAttribute("aria-disabled", String(pickerReadonly));
|
||||
track.tabIndex = pickerReadonly ? -1 : 0;
|
||||
});
|
||||
overlay.hidden = false;
|
||||
document.body.classList.add("color-picker-open");
|
||||
if (!pickerReadonly) loadPalette();
|
||||
window.requestAnimationFrame(() => panel.querySelector("[data-color-action='cancel']").focus());
|
||||
}
|
||||
|
||||
export function setColorValue(trigger, value, { recordHistory = true } = {}) {
|
||||
const color = normalizeHex(value);
|
||||
if (!color) throw new TypeError("颜色必须是 #RRGGBB");
|
||||
trigger.value = color;
|
||||
renderTrigger(trigger);
|
||||
if (recordHistory) {
|
||||
recentColors = updateRecentColors(recentColors, color, 10);
|
||||
persistLocalColors();
|
||||
}
|
||||
trigger.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
trigger.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
export function refreshColorTrigger(trigger) {
|
||||
renderTrigger(trigger);
|
||||
}
|
||||
|
||||
modeButtons.forEach((button) => button.addEventListener("click", () => setMode(button.dataset.colorMode)));
|
||||
overlay.addEventListener("click", (event) => {
|
||||
if (event.target === overlay) closePicker();
|
||||
const action = event.target.closest("[data-color-action]")?.dataset.colorAction;
|
||||
if (action === "cancel") closePicker();
|
||||
else if (action === "confirm") confirmPicker();
|
||||
else if (action === "favorite") addFavorite(event.target.closest("[data-color-action='favorite']"));
|
||||
else if (action === "edit-palette") {
|
||||
paletteEditing = !paletteEditing;
|
||||
event.target.classList.toggle("active", paletteEditing);
|
||||
renderSwatches();
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (overlay.hidden) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(panel.querySelectorAll("button:not(:disabled), input:not(:disabled), [tabindex='0']"))
|
||||
.filter((element) => !element.closest("[hidden]") && element.getClientRects().length > 0);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-color-picker]").forEach((trigger) => {
|
||||
renderTrigger(trigger);
|
||||
trigger.addEventListener("click", () => openPicker(trigger));
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const workspaces = new Map();
|
||||
const PERSISTENCE_POLICIES = new Set(["none", "local", "server"]);
|
||||
let appStale = false;
|
||||
export const DEFAULT_API_TIMEOUT_MS = 15000;
|
||||
|
||||
export const DEFAULT_WORKSPACE_ID = "device";
|
||||
|
||||
export function isAppStale() {
|
||||
return appStale;
|
||||
}
|
||||
|
||||
export function markAppStale() {
|
||||
if (appStale) return;
|
||||
appStale = true;
|
||||
document.getElementById("app-version-notice")?.removeAttribute("hidden");
|
||||
announce("控制程序已更新,请刷新页面后再操作设备", true);
|
||||
document.dispatchEvent(new CustomEvent("matrix:app-stale"));
|
||||
}
|
||||
|
||||
function ensureWriteAllowed(path, options) {
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const isWrite = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
||||
if (appStale && isWrite && !path.startsWith("/api/preview/")) {
|
||||
const error = new Error("页面版本已过期,请刷新后再提交此操作");
|
||||
error.status = 409;
|
||||
error.code = "APP_STALE";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorkspace(definition) {
|
||||
const required = ["id", "title", "elementId", "order", "persistence"];
|
||||
for (const key of required) {
|
||||
if (definition[key] === undefined || definition[key] === null || definition[key] === "") {
|
||||
throw new Error(`工作区缺少 ${key}`);
|
||||
}
|
||||
}
|
||||
if (workspaces.has(definition.id)) {
|
||||
throw new Error(`工作区 ${definition.id} 重复注册`);
|
||||
}
|
||||
if (!PERSISTENCE_POLICIES.has(definition.persistence)) {
|
||||
throw new Error(`工作区 ${definition.id} 的持久化策略无效`);
|
||||
}
|
||||
const capabilities = definition.capabilities || [];
|
||||
if (!Array.isArray(capabilities) || capabilities.some((item) => typeof item !== "string")) {
|
||||
throw new Error(`工作区 ${definition.id} 的能力声明无效`);
|
||||
}
|
||||
workspaces.set(definition.id, Object.freeze({
|
||||
...definition,
|
||||
capabilities: Object.freeze([...new Set(capabilities)]),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getWorkspaces() {
|
||||
return Array.from(workspaces.values()).sort((left, right) => (
|
||||
left.order - right.order
|
||||
|| left.title.localeCompare(right.title, "zh-CN")
|
||||
));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(path, options) {
|
||||
const { timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const forwardAbort = () => controller.abort(externalSignal?.reason);
|
||||
if (externalSignal?.aborted) forwardAbort();
|
||||
else externalSignal?.addEventListener("abort", forwardAbort, { once: true });
|
||||
const timer = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
return await fetch(path, { ...fetchOptions, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
const timeoutError = new Error("请求超时,请检查核桃派网络或服务状态后重试");
|
||||
timeoutError.code = "REQUEST_TIMEOUT";
|
||||
throw timeoutError;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", forwardAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson(path, options = {}) {
|
||||
ensureWriteAllowed(path, options);
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.detail || response.statusText);
|
||||
error.status = response.status;
|
||||
error.detail = data.detail;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiBlob(path, options = {}) {
|
||||
ensureWriteAllowed(path, options);
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
try {
|
||||
const data = await response.json();
|
||||
message = data.detail || message;
|
||||
} catch (_error) {
|
||||
// Keep the HTTP status text when the error body is not JSON.
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function apiFile(path, file, options = {}) {
|
||||
ensureWriteAllowed(path, options);
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
method: options.method || "POST",
|
||||
body: file,
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/octet-stream", ...(options.headers || {}) },
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_error) {
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(data?.detail || response.statusText);
|
||||
error.status = response.status;
|
||||
error.detail = data?.detail;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function announce(text, isError = false) {
|
||||
const message = document.getElementById("last-message");
|
||||
message.textContent = text;
|
||||
message.classList.toggle("message-error", isError);
|
||||
message.setAttribute("role", isError ? "alert" : "status");
|
||||
}
|
||||
|
||||
export function createLocalState(namespace, version) {
|
||||
const key = `matrixController:${namespace}:v${version}`;
|
||||
return {
|
||||
load(fallback = null) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null ? fallback : JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
save(value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (_error) {
|
||||
announce("浏览器无法保存当前编辑草稿", true);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function setActiveButton(buttons, activeButton) {
|
||||
buttons.forEach((button) => {
|
||||
const active = button === activeButton;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
}
|
||||
|
||||
export function debounce(callback, wait) {
|
||||
let timer = null;
|
||||
const wrapped = (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
wrapped.flush = (...args) => {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
callback(...args);
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export const CURRENT_DISPLAY_SPEEDS = Object.freeze([0.5, 1, 1.5, 2]);
|
||||
|
||||
export function formatPlaybackTime(valueMs) {
|
||||
const milliseconds = Math.max(0, Math.floor(Number(valueMs) || 0));
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
const hundredths = Math.floor((milliseconds % 1000) / 10);
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function projectPlaybackPosition(playback, elapsedMs = 0) {
|
||||
if (!playback?.active || !Number.isFinite(Number(playback.total_duration_ms))) return null;
|
||||
const total = Math.max(1, Number(playback.total_duration_ms));
|
||||
const base = Math.max(0, Number(playback.position_ms) || 0);
|
||||
if (playback.paused) return Math.min(total - 1, base);
|
||||
const speed = Number(playback.speed) || 1;
|
||||
const projected = base + Math.max(0, Number(elapsedMs) || 0) * speed;
|
||||
return Math.min(total - 1, projected % total);
|
||||
}
|
||||
|
||||
export class LatestSeekDispatcher {
|
||||
constructor(send, {
|
||||
intervalMs = 100,
|
||||
now = () => performance.now(),
|
||||
setTimer = (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimer = (timer) => window.clearTimeout(timer),
|
||||
onError = () => {},
|
||||
} = {}) {
|
||||
this.send = send;
|
||||
this.intervalMs = intervalMs;
|
||||
this.now = now;
|
||||
this.setTimer = setTimer;
|
||||
this.clearTimer = clearTimer;
|
||||
this.onError = onError;
|
||||
this.inFlight = false;
|
||||
this.lastStartedAt = Number.NEGATIVE_INFINITY;
|
||||
this.queued = null;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
enqueue(positionMs, { flush = false } = {}) {
|
||||
this.queued = { positionMs, flush: flush || Boolean(this.queued?.flush) };
|
||||
this.#drain();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.queued = null;
|
||||
if (this.timer !== null) this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
#drain() {
|
||||
if (this.inFlight || this.queued === null) return;
|
||||
const waitMs = this.intervalMs - (this.now() - this.lastStartedAt);
|
||||
if (!this.queued.flush && waitMs > 0) {
|
||||
if (this.timer === null) {
|
||||
this.timer = this.setTimer(() => {
|
||||
this.timer = null;
|
||||
this.#drain();
|
||||
}, waitMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.timer !== null) this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
const item = this.queued;
|
||||
this.queued = null;
|
||||
this.inFlight = true;
|
||||
this.lastStartedAt = this.now();
|
||||
Promise.resolve()
|
||||
.then(() => this.send(item.positionMs))
|
||||
.catch((error) => this.onError(error, item.positionMs))
|
||||
.finally(() => {
|
||||
this.inFlight = false;
|
||||
this.#drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
const SOURCE_ORDER = Object.freeze(["automatic", "imported", "system"]);
|
||||
const SOURCE_LABELS = Object.freeze({
|
||||
automatic: "自动字体",
|
||||
imported: "已导入字体",
|
||||
system: "系统字体",
|
||||
});
|
||||
|
||||
export function normalizeFontItems(items) {
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
for (const raw of Array.isArray(items) ? items : []) {
|
||||
if (!raw || typeof raw.id !== "string" || !raw.id || seen.has(raw.id)) continue;
|
||||
if (!SOURCE_ORDER.includes(raw.source)) continue;
|
||||
seen.add(raw.id);
|
||||
const family = String(raw.family || "未命名字体").trim() || "未命名字体";
|
||||
const style = String(raw.style || "Regular").trim() || "Regular";
|
||||
normalized.push(Object.freeze({
|
||||
id: raw.id,
|
||||
label: String(raw.label || family).trim() || family,
|
||||
family,
|
||||
style,
|
||||
source: raw.source,
|
||||
}));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function filterFontItems(items, query = "") {
|
||||
const needle = String(query).trim().toLocaleLowerCase("zh-CN");
|
||||
if (!needle) return [...items];
|
||||
return items.filter((item) => (
|
||||
`${item.label} ${item.family} ${item.style}`.toLocaleLowerCase("zh-CN").includes(needle)
|
||||
));
|
||||
}
|
||||
|
||||
export function groupFontItems(items) {
|
||||
return SOURCE_ORDER
|
||||
.map((source) => ({
|
||||
source,
|
||||
label: SOURCE_LABELS[source],
|
||||
items: items.filter((item) => item.source === source),
|
||||
}))
|
||||
.filter((group) => group.items.length);
|
||||
}
|
||||
|
||||
export function createFontPicker({ input, listbox, onCommit }) {
|
||||
let items = [];
|
||||
let selectedId = "default";
|
||||
let visibleItems = [];
|
||||
let activeIndex = -1;
|
||||
let enabled = false;
|
||||
let closeTimer = null;
|
||||
|
||||
function selectedItem() {
|
||||
return items.find((item) => item.id === selectedId) || null;
|
||||
}
|
||||
|
||||
function selectedLabel() {
|
||||
return selectedItem()?.label || "当前字体不可用,将自动回退";
|
||||
}
|
||||
|
||||
function restoreLabel() {
|
||||
input.value = selectedLabel();
|
||||
input.classList.toggle("font-reference-missing", !selectedItem());
|
||||
}
|
||||
|
||||
function close({ restore = true } = {}) {
|
||||
window.clearTimeout(closeTimer);
|
||||
listbox.hidden = true;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
activeIndex = -1;
|
||||
if (restore) restoreLabel();
|
||||
}
|
||||
|
||||
function optionId(index) {
|
||||
return `${listbox.id}-option-${index}`;
|
||||
}
|
||||
|
||||
function setActive(index) {
|
||||
if (!visibleItems.length) {
|
||||
activeIndex = -1;
|
||||
input.removeAttribute("aria-activedescendant");
|
||||
return;
|
||||
}
|
||||
activeIndex = Math.max(0, Math.min(index, visibleItems.length - 1));
|
||||
input.setAttribute("aria-activedescendant", optionId(activeIndex));
|
||||
listbox.querySelectorAll('[role="option"]').forEach((option, optionIndex) => {
|
||||
const active = optionIndex === activeIndex;
|
||||
option.classList.toggle("active", active);
|
||||
if (active) option.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
function commit(item) {
|
||||
if (!item) return;
|
||||
selectedId = item.id;
|
||||
restoreLabel();
|
||||
close({ restore: false });
|
||||
onCommit?.(item);
|
||||
}
|
||||
|
||||
function render(query = "") {
|
||||
visibleItems = filterFontItems(items, query);
|
||||
const groups = groupFontItems(visibleItems);
|
||||
const nodes = [];
|
||||
let optionIndex = 0;
|
||||
for (const group of groups) {
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "font-option-group";
|
||||
heading.textContent = group.label;
|
||||
heading.setAttribute("role", "presentation");
|
||||
nodes.push(heading);
|
||||
for (const item of group.items) {
|
||||
const option = document.createElement("div");
|
||||
option.id = optionId(optionIndex);
|
||||
option.className = "font-option";
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-selected", String(item.id === selectedId));
|
||||
option.setAttribute("aria-label", item.label);
|
||||
option.dataset.fontId = item.id;
|
||||
const label = document.createElement("span");
|
||||
label.textContent = item.family;
|
||||
const source = document.createElement("small");
|
||||
source.textContent = item.style;
|
||||
source.setAttribute("aria-hidden", "true");
|
||||
option.append(label, source);
|
||||
option.addEventListener("pointerdown", (event) => event.preventDefault());
|
||||
option.addEventListener("click", () => commit(item));
|
||||
nodes.push(option);
|
||||
optionIndex += 1;
|
||||
}
|
||||
}
|
||||
if (!visibleItems.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "font-options-empty";
|
||||
empty.textContent = "没有匹配的字体";
|
||||
nodes.push(empty);
|
||||
}
|
||||
listbox.replaceChildren(...nodes);
|
||||
activeIndex = visibleItems.findIndex((item) => item.id === selectedId);
|
||||
if (activeIndex < 0 && visibleItems.length) activeIndex = 0;
|
||||
setActive(activeIndex);
|
||||
}
|
||||
|
||||
function open(query = "") {
|
||||
if (!enabled) return;
|
||||
render(query);
|
||||
listbox.hidden = false;
|
||||
input.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
input.addEventListener("focus", () => {
|
||||
if (!enabled) return;
|
||||
input.select();
|
||||
open();
|
||||
});
|
||||
input.addEventListener("click", () => open());
|
||||
input.addEventListener("input", (event) => {
|
||||
event.stopPropagation();
|
||||
open(input.value);
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (!enabled) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (listbox.hidden) open();
|
||||
setActive(activeIndex + (event.key === "ArrowDown" ? 1 : -1));
|
||||
} else if (event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
if (listbox.hidden) open();
|
||||
setActive(event.key === "Home" ? 0 : visibleItems.length - 1);
|
||||
} else if (event.key === "Enter" && !listbox.hidden) {
|
||||
event.preventDefault();
|
||||
commit(visibleItems[activeIndex]);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", () => {
|
||||
closeTimer = window.setTimeout(() => close(), 0);
|
||||
});
|
||||
|
||||
restoreLabel();
|
||||
return Object.freeze({
|
||||
setItems(nextItems) {
|
||||
items = normalizeFontItems(nextItems);
|
||||
restoreLabel();
|
||||
if (!listbox.hidden) render();
|
||||
},
|
||||
setValue(identifier) {
|
||||
selectedId = typeof identifier === "string" && identifier ? identifier : "default";
|
||||
restoreLabel();
|
||||
},
|
||||
getValue() {
|
||||
return selectedId;
|
||||
},
|
||||
setEnabled(value) {
|
||||
enabled = Boolean(value);
|
||||
input.readOnly = !enabled;
|
||||
if (!enabled) close();
|
||||
},
|
||||
open,
|
||||
close,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#111318">
|
||||
<title>奇妙小屏幕控制器</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<button id="drawer-toggle" class="drawer-toggle" type="button" aria-controls="workspace-drawer" aria-expanded="false" aria-label="打开功能菜单">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path class="drawer-toggle-menu-icon" d="M4 7h16M4 12h16M4 17h16"></path>
|
||||
<path class="drawer-toggle-close-icon" d="M6 6l12 12M18 6L6 18"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<aside id="workspace-drawer" class="drawer" aria-label="功能选择" aria-hidden="true" inert>
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="64 × 64 点阵屏"></p>
|
||||
<p class="brand">奇妙小屏幕控制器</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workspace-order-editor">
|
||||
<button id="workspace-order-toggle" class="workspace-order-toggle compact-button" type="button">自定义项目位置</button>
|
||||
<p id="workspace-order-feedback" class="workspace-order-feedback" role="status" aria-live="polite" hidden></p>
|
||||
</div>
|
||||
<nav id="workspace-nav" class="workspace-nav" aria-label="工作区"></nav>
|
||||
<p class="drawer-note" data-ui-copy-default-text="局域网设备控制台"></p>
|
||||
</aside>
|
||||
|
||||
<button id="drawer-backdrop" class="drawer-backdrop" type="button" aria-label="关闭功能菜单" hidden></button>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-copy">
|
||||
<h1 id="current-workspace-title">设备状态和测试</h1>
|
||||
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
|
||||
</div>
|
||||
<div class="topbar-statuses" aria-label="设备实时状态">
|
||||
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
|
||||
当前画面:正在读取…
|
||||
</button>
|
||||
<button id="topbar-cpu" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看 CPU 占用详情">
|
||||
CPU -
|
||||
</button>
|
||||
<button id="topbar-memory" class="resource-indicator" data-ui-copy-value data-status="starting" type="button" aria-haspopup="dialog" aria-controls="resource-details-dialog" aria-label="查看内存占用详情">
|
||||
RAM -
|
||||
</button>
|
||||
<div
|
||||
id="topbar-low-voltage-warning"
|
||||
class="low-voltage-warning"
|
||||
data-mode="disabled"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></div>
|
||||
<div id="topbar-voltage" class="voltage-indicator" data-ui-copy-value data-status="starting" role="status" aria-live="polite">
|
||||
正在读取电压…
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="app-version-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
|
||||
<div>
|
||||
<strong>控制程序已更新</strong>
|
||||
<p>当前页面版本已过期。画板仍可编辑和导出,但刷新前不会再向设备或模板提交操作。</p>
|
||||
</div>
|
||||
<button id="reload-app" class="primary-button" type="button">刷新到新版本</button>
|
||||
</section>
|
||||
|
||||
<section id="draft-conflict-notice" class="sync-notice sync-notice-warning" role="alert" hidden>
|
||||
<div>
|
||||
<strong>另一标签页修改了画板草稿</strong>
|
||||
<p>请选择载入另一标签页的最新草稿,或明确保留当前标签页的草稿。</p>
|
||||
</div>
|
||||
<div class="sync-notice-actions">
|
||||
<button id="load-external-draft" type="button">载入其他标签草稿</button>
|
||||
<button id="keep-current-draft" class="primary-button" type="button">保留当前草稿</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="board-shell" class="board-shell" aria-labelledby="board-title">
|
||||
<div id="standard-board-shell">
|
||||
<div class="board-heading">
|
||||
<div>
|
||||
<p id="board-eyebrow" class="eyebrow" data-ui-copy-value>64 × 64 统一画板</p>
|
||||
<h2 id="board-title" data-ui-copy-value>合成预览</h2>
|
||||
</div>
|
||||
<div class="board-heading-context">
|
||||
<span id="board-mode" class="orientation-label" data-ui-copy-value>正向 0° · 统一画板</span>
|
||||
<section id="animation-edit-notice" class="animation-edit-notice" aria-live="polite" hidden>
|
||||
<strong id="animation-edit-name">正在编辑:</strong>
|
||||
<span id="animation-edit-status">已保存</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div id="board-stage" class="board-stage">
|
||||
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
|
||||
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
|
||||
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
|
||||
</div>
|
||||
<p id="board-status" class="board-status" aria-live="polite">正在恢复画板草稿…</p>
|
||||
<div class="board-actions">
|
||||
<button id="apply-composition" class="primary-button" type="button">应用统一画板</button>
|
||||
<button id="exit-animation-edit" type="button" hidden>退出动图编辑</button>
|
||||
<button id="export-composition" type="button">导出合成 PNG</button>
|
||||
<button id="save-template" type="button" hidden>保存为模板</button>
|
||||
</div>
|
||||
<div id="canvas-edit-assistive" class="canvas-edit-assistive" hidden>
|
||||
<button id="canvas-assistive-toggle" class="canvas-assistive-toggle" type="button"
|
||||
aria-controls="canvas-assistive-menu" aria-expanded="false" aria-label="打开画布编辑菜单">
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden>
|
||||
<p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
|
||||
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
|
||||
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
|
||||
<button id="exit-canvas-edit" type="button" role="menuitem">退出编辑</button>
|
||||
<button id="move-canvas-view" type="button" role="menuitem">移动画布视线</button>
|
||||
<button id="continue-canvas-edit" type="button" role="menuitem" aria-disabled="true"
|
||||
data-unavailable-reason="当前已经是绘画模式。">继续编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section id="animation-preview-shell" class="animation-preview-shell" aria-labelledby="animation-preview-shell-title" hidden>
|
||||
<h2 id="animation-preview-shell-title" class="sr-only">动图与当前画板预览</h2>
|
||||
<article class="animation-preview-card">
|
||||
<p class="eyebrow">已保存内容</p>
|
||||
<h3>当前动图</h3>
|
||||
<div class="animation-small-preview" aria-label="当前打开动图的循环预览">
|
||||
<img id="animation-preview-image" alt="" width="64" height="64" hidden>
|
||||
<span id="animation-preview-placeholder">请先打开一个动图</span>
|
||||
</div>
|
||||
<p id="animation-preview-status" class="animation-preview-status" aria-live="polite">请先打开一个动图</p>
|
||||
</article>
|
||||
<article class="animation-preview-card">
|
||||
<p class="eyebrow">新建帧来源</p>
|
||||
<h3>当前画板</h3>
|
||||
<div class="animation-small-preview">
|
||||
<canvas id="animation-board-preview" width="64" height="64" aria-label="当前统一画板预览"></canvas>
|
||||
</div>
|
||||
<p class="animation-preview-status">“从当前画板新建帧”将复制此画面</p>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<main id="workspace-root" class="workspace-root">
|
||||
<section id="workspace-device" class="workspace" aria-label="设备状态和测试" hidden>
|
||||
<div class="device-refresh-row">
|
||||
<button id="refresh-status" class="secondary-button" type="button">刷新状态</button>
|
||||
</div>
|
||||
|
||||
<article class="panel status-panel">
|
||||
<div class="panel-heading">
|
||||
<h3>屏幕状态</h3>
|
||||
<span class="status-badge">仅为程序设定值,屏幕实际情况软件无法得知</span>
|
||||
</div>
|
||||
<dl class="status-grid status-grid-compact">
|
||||
<div><dt>屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)</dt><dd id="status-orientation" data-ui-copy-value>-</dd></div>
|
||||
<div><dt>亮度(实际亮度会受到电压保护功能限制)</dt><dd id="status-brightness" data-ui-copy-value>-</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<h3>纯色测试</h3>
|
||||
<div class="choice-grid color-choices" role="group" aria-label="纯色测试选择">
|
||||
<button type="button" data-test-fill="#000000">全黑(不亮)</button>
|
||||
<button type="button" data-test-fill="#FF0000">全红</button>
|
||||
<button type="button" data-test-fill="#00FF00">全绿</button>
|
||||
<button type="button" data-test-fill="#0000FF">全蓝</button>
|
||||
<button type="button" data-test-fill="#FFFFFF">全白(高亮度会导致严重耗电)</button>
|
||||
<button id="custom-test-fill" type="button">自定义纯色</button>
|
||||
</div>
|
||||
<div class="color-field"><span>颜色 RGB / HEX</span>
|
||||
<button id="test-color" class="color-trigger" type="button" value="#40A0FF" data-color-picker data-color-readonly="true" aria-label="查看测试颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#40A0FF</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="display-test-controls" class="display-test-controls" hidden>
|
||||
<div class="panel-heading">
|
||||
<label for="test-brightness">测试亮度(此亮度独立于屏幕亮度)</label>
|
||||
<output id="test-brightness-value" data-ui-copy-value for="test-brightness" class="brightness-output">50%</output>
|
||||
</div>
|
||||
<input id="test-brightness" class="brightness-slider" type="range" min="1" max="100" value="50">
|
||||
<p id="test-brightness-protection" class="brightness-protection-status" role="status" aria-live="polite" hidden></p>
|
||||
<p id="display-test-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
<button id="exit-display-test" class="danger-button" type="button">退出测试</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel device-storage-panel">
|
||||
<div class="panel-heading"><h3>设备容量</h3></div>
|
||||
<section class="template-storage device-storage-grid" aria-label="设备与软件空间">
|
||||
<div><span>设备总空间</span><strong id="device-total-space" data-ui-copy-value>-</strong></div>
|
||||
<div><span>设备可用空间</span><strong id="device-free-space" data-ui-copy-value>-</strong></div>
|
||||
<div><span>当前软件总占用空间</span><strong id="device-software-size" data-ui-copy-value>-</strong></div>
|
||||
<div><span>模板占用空间</span><strong id="device-template-size" data-ui-copy-value>-</strong></div>
|
||||
</section>
|
||||
<p id="device-storage-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="workspace-canvas" class="workspace" aria-labelledby="canvas-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="canvas-title" aria-label="像素画布" data-ui-copy-default-text="像素画布"></h2>
|
||||
</div>
|
||||
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
|
||||
</div>
|
||||
|
||||
<article class="panel canvas-workbench">
|
||||
<div class="canvas-toolbar">
|
||||
<div class="color-field"><span>画笔颜色</span>
|
||||
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="color-field"><span>背景颜色</span>
|
||||
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="segmented canvas-tools" role="group" aria-label="绘制工具">
|
||||
<button type="button" data-canvas-tool="brush">画笔</button>
|
||||
<button type="button" data-canvas-tool="eraser">橡皮</button>
|
||||
<button type="button" data-canvas-tool="eyedropper">吸管</button>
|
||||
</div>
|
||||
<div class="brush-size-control">
|
||||
<div class="brush-size-heading"><span>笔尖粗细</span><output id="brush-size-value" data-ui-copy-value for="brush-size">1 px</output></div>
|
||||
<div class="brush-size-row">
|
||||
<span id="brush-size-preview" class="brush-size-preview" aria-hidden="true"></span>
|
||||
<input id="brush-size" type="range" min="1" max="16" value="1" aria-label="画笔和橡皮粗细">
|
||||
</div>
|
||||
<div class="brush-size-presets" role="group" aria-label="笔尖粗细快捷选择">
|
||||
<button type="button" data-brush-size="1">1</button><button type="button" data-brush-size="2">2</button>
|
||||
<button type="button" data-brush-size="4">4</button><button type="button" data-brush-size="8">8</button>
|
||||
</div>
|
||||
<button id="pixel-edit-toggle" class="pixel-edit-toggle" type="button" aria-pressed="false" aria-describedby="pixel-edit-status">开启单像素编辑</button>
|
||||
<p id="pixel-edit-status" class="pixel-edit-status" aria-live="polite" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="choice-grid canvas-actions">
|
||||
<button id="fill-canvas" type="button">填充背景</button>
|
||||
<button id="clear-canvas" type="button">清空像素层</button>
|
||||
</div>
|
||||
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
|
||||
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="workspace-text" class="workspace" aria-labelledby="text-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="text-title" aria-label="文字显示" data-ui-copy-default-text="文字显示"></h2>
|
||||
</div>
|
||||
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
|
||||
</div>
|
||||
|
||||
<article class="panel text-elements-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h3>文字元素</h3>
|
||||
<p id="text-selection-status" class="subtle">新建文字或从列表选择一个元素</p>
|
||||
</div>
|
||||
<span class="status-badge" data-ui-copy-default-text="Pillow"></span>
|
||||
</div>
|
||||
<div class="text-element-actions">
|
||||
<button id="create-text" class="primary-button" type="button">新建文字</button>
|
||||
<button id="duplicate-text" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">复制选中</button>
|
||||
<button id="delete-text" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先新建或选择一个文字元素。">删除选中</button>
|
||||
</div>
|
||||
<div id="text-element-list" class="text-element-list" role="list" aria-label="文字元素列表"></div>
|
||||
</article>
|
||||
|
||||
<form id="text-form" class="panel text-form" autocomplete="off">
|
||||
<fieldset id="text-content-fields" disabled>
|
||||
<label class="wide-field">文字内容
|
||||
<textarea id="text-value" rows="2" maxlength="512"></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div class="font-field wide-field">
|
||||
<label id="text-font-label" for="text-font">字体</label>
|
||||
<div class="font-control-row">
|
||||
<div class="font-combobox-shell">
|
||||
<input id="text-font" type="search" role="combobox" value="自动多语言字体 — 自动回退"
|
||||
aria-labelledby="text-font-label" aria-autocomplete="list" aria-expanded="false"
|
||||
aria-controls="text-font-options" autocapitalize="off" autocomplete="off" spellcheck="false">
|
||||
<div id="text-font-options" class="font-options" role="listbox" aria-label="可用字体" hidden></div>
|
||||
</div>
|
||||
<button id="import-font" type="button">导入字体</button>
|
||||
<input id="font-file" class="sr-only" type="file" accept=".ttf,.otf,.ttc,.otc">
|
||||
</div>
|
||||
<span class="hint">选择设备已有字体,或导入 TTF、OTF、TTC、OTC 文件;缺字时会自动回退。</span>
|
||||
<p id="font-feedback" class="font-feedback" role="status" aria-live="polite" hidden></p>
|
||||
</div>
|
||||
<fieldset id="text-fields" disabled>
|
||||
<div class="form-grid">
|
||||
<label>字号
|
||||
<input id="text-size" type="number" min="1" max="64" value="12" inputmode="numeric">
|
||||
</label>
|
||||
<label>X 坐标
|
||||
<input id="text-x" type="number" min="-128" max="128" value="4" inputmode="numeric">
|
||||
</label>
|
||||
<label>Y 坐标
|
||||
<input id="text-y" type="number" min="-128" max="128" value="24" inputmode="numeric">
|
||||
</label>
|
||||
<label>对齐
|
||||
<select id="text-align">
|
||||
<option value="left">左对齐</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右对齐</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="color-field"><span>文字颜色</span>
|
||||
<button id="text-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择文字颜色">
|
||||
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="media-import-title" data-ui-copy-default-text="媒体内容转换"></h2>
|
||||
</div>
|
||||
<button id="refresh-media-imports" class="secondary-button" type="button" data-ui-copy-default-text="刷新任务"></button>
|
||||
</div>
|
||||
<form id="media-upload-form" class="panel media-upload-panel">
|
||||
<div class="panel-heading">
|
||||
<h3 data-ui-copy-default-text="导入图片、动图或视频"></h3>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="media-source-field"><span data-ui-copy-default-text="源文件"></span>
|
||||
<input id="media-source-file" type="file" required
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/avif,image/heic,image/heif,image/bmp,image/tiff,image/jp2,video/*,.mkv,.m4v,.mpeg,.mpg">
|
||||
</label>
|
||||
</div>
|
||||
<button id="media-upload-button" class="primary-button" type="submit">上传文件</button>
|
||||
<progress id="media-upload-progress" max="100" value="0" hidden></progress>
|
||||
</form>
|
||||
<p id="media-import-feedback" class="settings-feedback" aria-live="polite"
|
||||
data-ui-copy-default-text="选择文件并填写保存名称。"></p>
|
||||
<div id="media-import-jobs" class="media-job-list" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<section id="workspace-animations" class="workspace" aria-labelledby="animations-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div><p class="eyebrow" data-ui-copy-default-text="内容"></p><h2 id="animations-title">动图管理</h2></div>
|
||||
<button id="refresh-animations" class="secondary-button" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="animation-toolbar panel">
|
||||
<button id="create-animation" class="primary-button" type="button">新建动图</button>
|
||||
<button id="add-animation-frame" type="button" aria-disabled="true" data-unavailable-reason="请先新建或打开一个动图,再把当前画板添加为帧。">从当前画板新建帧</button>
|
||||
</div>
|
||||
<p id="animation-feedback" class="settings-feedback" aria-live="polite">请选择或新建动图。</p>
|
||||
<section id="animation-frame-panel" class="panel" hidden>
|
||||
<div class="panel-heading"><h3 id="animation-frame-title">当前动图</h3></div>
|
||||
<div id="animation-batch-toolbar" class="animation-batch-toolbar" aria-label="动图帧批量操作">
|
||||
<p id="animation-selection-status" aria-live="polite">未选择帧</p>
|
||||
<div class="animation-batch-actions">
|
||||
<button id="animation-batch-copy" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量复制</button>
|
||||
<button id="animation-batch-move" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量移动</button>
|
||||
<button id="animation-batch-delete" class="danger-button" type="button" aria-disabled="true" data-unavailable-reason="请先勾选至少一帧。">批量删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="animation-frames" class="template-grid" aria-label="动图帧"></div>
|
||||
<nav id="animation-frame-pagination" class="animation-frame-pagination" aria-label="动图帧分页" hidden>
|
||||
<button id="animation-page-prev" type="button">上一页</button>
|
||||
<p id="animation-page-status" aria-live="polite">第 1 / 1 页 · 共 0 帧</p>
|
||||
<button id="animation-page-next" type="button">下一页</button>
|
||||
</nav>
|
||||
</section>
|
||||
<section class="animation-existing" aria-labelledby="animation-existing-title">
|
||||
<div class="panel-heading"><h3 id="animation-existing-title">保存的动图</h3></div>
|
||||
<div id="animation-folders" class="animation-folder-list" aria-label="已有动图"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="workspace-templates" class="workspace" aria-labelledby="templates-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
|
||||
<h2 id="templates-title">模板管理</h2>
|
||||
</div>
|
||||
<div class="workspace-heading-actions">
|
||||
<button id="select-default-content" class="secondary-button" type="button" aria-pressed="false">设置默认显示内容</button>
|
||||
<button id="refresh-templates" class="secondary-button" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="template-feedback" class="settings-feedback" aria-live="polite">正在读取模板…</p>
|
||||
<div id="template-grid" class="template-grid" aria-label="已保存模板"></div>
|
||||
</section>
|
||||
|
||||
<section id="workspace-settings" class="workspace" aria-labelledby="settings-title" hidden>
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow" data-ui-copy-default-text="设备"></p>
|
||||
<h2 id="settings-title">系统设置</h2>
|
||||
</div>
|
||||
</div>
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h3>屏幕亮度</h3>
|
||||
<p class="hint">拖动时自动应用,松手后立即保存。(使用电池时,不建议亮度长期高于70)</p>
|
||||
</div>
|
||||
<output id="brightness-value" data-ui-copy-value for="config-brightness" class="brightness-output">40%</output>
|
||||
</div>
|
||||
<input id="config-brightness" class="brightness-slider" type="range" min="1" max="100" value="40">
|
||||
<p
|
||||
id="brightness-protection-status"
|
||||
class="brightness-protection-status"
|
||||
data-mode="inactive"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>屏幕方向</h3>
|
||||
<p class="hint">角度仅作为4个方向的标识符,和现实方向无关,设置到实际方向正确之后自动储存在系统内。</p>
|
||||
</div>
|
||||
<div class="orientation-grid" role="group" aria-label="屏幕方向(设定值和现实角度无关,仅为软件内部旋转值)">
|
||||
<button type="button" data-orientation="0">0°</button>
|
||||
<button type="button" data-orientation="90">90°</button>
|
||||
<button type="button" data-orientation="180">180°</button>
|
||||
<button type="button" data-orientation="270">270°</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>屏幕扫描刷新率</h3>
|
||||
<p class="hint">设置屏幕的最高扫描频率,不建议低于80Hz。更低的频率可以大大降低功耗,但会导致屏幕闪烁。</p>
|
||||
</div>
|
||||
<div id="matrix-refresh-rate-grid" class="refresh-rate-grid" role="group" aria-label="屏幕扫描刷新率上限">
|
||||
<button type="button" data-matrix-refresh-rate="15">15 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="20">20 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="30">30 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="45">45 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="60">60 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="80">80 Hz</button>
|
||||
<button type="button" data-matrix-refresh-rate="100">100 Hz</button>
|
||||
</div>
|
||||
<p class="hint" data-ui-copy-default-text="这里设置的是扫描上限,不是保证精确达到的实时测量值,也不同于下方网页预览刷新间隔。"></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card voltage-setting-card">
|
||||
<div>
|
||||
<h3>供电电压</h3>
|
||||
<p class="hint">设备的输入电压应该为5V,稍微低一点是正常的。但如果严重偏低请充电。</p>
|
||||
</div>
|
||||
<dl class="voltage-details">
|
||||
<div><dt>当前电压</dt><dd id="voltage-current" data-ui-copy-value>-</dd></div>
|
||||
<div><dt>传感器状态</dt><dd id="voltage-sensor-status" data-ui-copy-value>正在读取</dd></div>
|
||||
<div><dt>校准状态</dt><dd id="voltage-calibration-status" data-ui-copy-value>未校准</dd></div>
|
||||
<div><dt>校准时间</dt><dd id="voltage-calibrated-at" data-ui-copy-value>-</dd></div>
|
||||
</dl>
|
||||
<div class="voltage-actions">
|
||||
<button id="start-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="正在读取电压传感器状态,请稍候。">校准电压传感器</button>
|
||||
<button id="reset-voltage-calibration" type="button">恢复标称值</button>
|
||||
</div>
|
||||
<div class="low-voltage-protection-setting">
|
||||
<div class="low-voltage-protection-copy">
|
||||
<h3>低电压亮度保护</h3>
|
||||
<p id="low-voltage-protection-description" class="hint">开启后,电压降低时会限制实际亮度;严重欠压时会临时屏蔽用户画面。限制亮度解除之后恢复到用户设置的亮度</p>
|
||||
</div>
|
||||
<label class="switch-control" for="low-voltage-protection-enabled">
|
||||
<input
|
||||
id="low-voltage-protection-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="低电压亮度保护"
|
||||
aria-describedby="low-voltage-protection-description low-voltage-protection-feedback"
|
||||
aria-disabled="true"
|
||||
data-unavailable-reason="正在读取保护开关状态,请稍候。"
|
||||
>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span id="low-voltage-protection-switch-state" class="switch-state">关闭</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
id="low-voltage-protection-feedback"
|
||||
class="low-voltage-protection-feedback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>预览刷新间隔</h3>
|
||||
<p class="hint">设置设备状态和系统设置中当前屏幕画面的刷新速度。</p>
|
||||
</div>
|
||||
<label class="number-unit-field" for="preview-refresh-interval">
|
||||
<input
|
||||
id="preview-refresh-interval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60000"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
value="1000"
|
||||
>
|
||||
<span>毫秒</span>
|
||||
</label>
|
||||
<p id="preview-refresh-warning" class="preview-refresh-warning" role="status" hidden>
|
||||
刷新间隔过短,会增加核桃派负载,可能影响系统运行。
|
||||
</p>
|
||||
<p id="preview-refresh-error" class="error-text" role="alert" hidden></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card wifi-setting-card">
|
||||
<div>
|
||||
<h3>WiFi设置</h3>
|
||||
<p class="hint">设备会自动连接保存的WiFi。如果一段时间没有连接,会显示设备保存的WiFi信息,请开启对应WiFi用于连接设备。设备连接WiFi后,如果没有进入网页端就会显示对应ip</p>
|
||||
</div>
|
||||
<div class="wifi-fields">
|
||||
<label>WiFi 名称(SSID)
|
||||
<input id="wifi-ssid" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-ssid-modified" required>
|
||||
<span id="wifi-ssid-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>WiFi 密码
|
||||
<input id="wifi-password" data-ui-copy-ignore type="text" autocomplete="off" aria-describedby="wifi-password-modified" placeholder="读取中…">
|
||||
<span id="wifi-password-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<div class="wifi-dhcp-row">
|
||||
<div>
|
||||
<strong>自动获取 IP(DHCP)</strong>
|
||||
<p id="wifi-dhcp-description" class="hint">关闭后可指定固定 IPv4 地址。</p>
|
||||
</div>
|
||||
<label class="switch-control" for="wifi-dhcp-enabled">
|
||||
<input id="wifi-dhcp-enabled" type="checkbox" role="switch" aria-describedby="wifi-dhcp-description wifi-dhcp-modified" checked>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span id="wifi-dhcp-state" class="switch-state">开启</span>
|
||||
</label>
|
||||
<p id="wifi-dhcp-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</p>
|
||||
</div>
|
||||
<div id="wifi-manual-fields" class="wifi-manual-fields" hidden>
|
||||
<label>静态 IPv4 地址
|
||||
<input id="wifi-address" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-address-modified" placeholder="192.168.1.50">
|
||||
<span id="wifi-address-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>网关
|
||||
<input id="wifi-gateway" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-gateway-modified" placeholder="192.168.1.1">
|
||||
<span id="wifi-gateway-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>子网前缀(选填)
|
||||
<input id="wifi-prefix" data-ui-copy-ignore type="number" min="1" max="32" step="1" inputmode="numeric" aria-describedby="wifi-prefix-modified" placeholder="24">
|
||||
<span id="wifi-prefix-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
<label>DNS(选填)
|
||||
<input id="wifi-dns" data-ui-copy-ignore type="text" inputmode="decimal" aria-describedby="wifi-dns-modified" placeholder="留空使用网关,多个用逗号分隔">
|
||||
<span id="wifi-dns-modified" class="wifi-modified-note" role="status" hidden>对应内容已经修改</span>
|
||||
</label>
|
||||
</div>
|
||||
<p id="wifi-active-status" class="hint" role="status">正在读取当前网络…</p>
|
||||
</div>
|
||||
<div class="wifi-actions">
|
||||
<button id="wifi-apply-immediate" type="button">立即生效(会导致网页断开)</button>
|
||||
<button id="wifi-apply-next-boot" type="button">下次开机生效</button>
|
||||
</div>
|
||||
<p id="wifi-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card wifi-prompt-setting-card">
|
||||
<div>
|
||||
<h3>网络提示显示</h3>
|
||||
<p class="hint">设置设备开机后等待多少秒再显示网络连接提示。</p>
|
||||
</div>
|
||||
<div class="wifi-prompt-delay-row">
|
||||
<label class="number-unit-field" for="wifi-prompt-delay">
|
||||
<input id="wifi-prompt-delay" type="number" min="1" max="3600" step="1" inputmode="numeric" value="30">
|
||||
<span>秒后显示网络提示</span>
|
||||
</label>
|
||||
<button id="wifi-prompt-delay-save" type="button">保存</button>
|
||||
</div>
|
||||
<p id="wifi-prompt-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card ota-setting-card">
|
||||
<div>
|
||||
<h3>软件版本</h3>
|
||||
</div>
|
||||
<dl class="ota-version-details">
|
||||
<div><dt>当前软件版本</dt><dd id="ota-current-version" data-ui-copy-value>读取中…</dd></div>
|
||||
<div><dt>最新功能更新时间</dt><dd id="ota-feature-updated-at" data-ui-copy-value>读取中…</dd></div>
|
||||
</dl>
|
||||
<input id="ota-file-input" class="sr-only" type="file" accept=".ota,application/octet-stream">
|
||||
<div class="ota-actions">
|
||||
<button id="ota-select-file" class="primary-button" type="button">导入 OTA 更新</button>
|
||||
<span class="ota-power-warning">不要在更新期间断电。</span>
|
||||
</div>
|
||||
<div id="ota-progress-group" class="ota-progress-group" hidden>
|
||||
<div class="ota-progress-heading">
|
||||
<strong id="ota-progress-stage" data-ui-copy-value>准备更新</strong>
|
||||
<span id="ota-progress-value" data-ui-copy-value>0%</span>
|
||||
</div>
|
||||
<progress id="ota-progress" max="100" value="0">0%</progress>
|
||||
</div>
|
||||
<p id="ota-feedback" class="settings-feedback" role="status" aria-live="polite" aria-atomic="true" hidden></p>
|
||||
<div id="ota-failure-log-actions" class="ota-failure-log-actions" hidden>
|
||||
<button id="ota-view-failure-log" type="button">查看失败日志</button>
|
||||
<button id="ota-copy-failure-log" type="button">复制日志</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div class="low-voltage-protection-setting">
|
||||
<div class="low-voltage-protection-copy">
|
||||
<h3>性能模式</h3>
|
||||
<p id="performance-mode-description" class="hint">如果使用出现卡顿,可以打开此选项,但会降低续航。</p>
|
||||
</div>
|
||||
<label class="switch-control" for="performance-mode-enabled">
|
||||
<input
|
||||
id="performance-mode-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="性能模式"
|
||||
aria-describedby="performance-mode-description performance-mode-feedback"
|
||||
aria-disabled="true"
|
||||
data-unavailable-reason="正在读取 CPU 调频状态,请稍候。"
|
||||
>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
<span id="performance-mode-switch-state" class="switch-state">关闭</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
id="performance-mode-feedback"
|
||||
class="low-voltage-protection-feedback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>正在读取 CPU 调频状态…</p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card">
|
||||
<div>
|
||||
<h3>模板界面同时播放的动图数量</h3>
|
||||
<p id="animation-preview-concurrency-warning" class="hint">不建议开启太多,否则会出现严重的性能问题。</p>
|
||||
</div>
|
||||
<label class="number-unit-field" for="animation-preview-max-concurrent">
|
||||
<input
|
||||
id="animation-preview-max-concurrent"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
value="2"
|
||||
aria-describedby="animation-preview-concurrency-warning animation-preview-concurrency-feedback"
|
||||
>
|
||||
<span>个</span>
|
||||
</label>
|
||||
<p
|
||||
id="animation-preview-concurrency-feedback"
|
||||
class="settings-feedback"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
hidden
|
||||
></p>
|
||||
</article>
|
||||
|
||||
<article class="panel setting-card frp-setting-card">
|
||||
<div>
|
||||
<h3>frp配置</h3>
|
||||
<p class="hint">此功能通常是远程排查使用,请保证你知道你在做什么。</p>
|
||||
</div>
|
||||
<dl class="frp-status-details">
|
||||
<div><dt>当前配置</dt><dd id="frp-selected-name">无</dd></div>
|
||||
<div><dt>运行状态</dt><dd id="frp-service-state">关闭</dd></div>
|
||||
</dl>
|
||||
<p id="frp-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
|
||||
<div class="frp-actions">
|
||||
<button id="frp-service-toggle" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先导入或新建 FRP 配置。">启动frp</button>
|
||||
<button id="frp-edit-config" type="button">编辑配置文件</button>
|
||||
<button id="network-diagnostics-run" type="button">网络检测</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p id="settings-feedback" class="settings-feedback" aria-live="polite" hidden></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="template-name-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<div class="template-dialog-heading">
|
||||
<h2 id="template-dialog-title">保存为模板</h2>
|
||||
<p id="template-dialog-note" hidden></p>
|
||||
</div>
|
||||
<label>模板名称
|
||||
<input id="template-name-input" type="text" maxlength="80" autocomplete="off" required>
|
||||
</label>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel" formnovalidate>取消</button>
|
||||
<button class="primary-button" type="submit" value="save">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="template-confirm-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>确认模板操作</h2>
|
||||
<p id="template-confirm-message"></p>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button class="primary-button" type="submit" value="confirm">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="template-save-choice-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>保存模板</h2>
|
||||
<p id="template-save-choice-message">请选择保存方式。</p>
|
||||
<div class="template-dialog-actions template-save-choice-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button id="template-save-new" type="submit" value="new">另存为新模板</button>
|
||||
<button id="template-save-current" class="primary-button" type="submit" value="current">保存到当前模板</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="library-copy-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>选择复制目标</h2>
|
||||
<label>复制到
|
||||
<select id="library-copy-target"><option value="static">静态模板</option></select>
|
||||
</label>
|
||||
<div class="template-dialog-actions"><button value="cancel">取消</button><button class="primary-button" value="copy">复制</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-dirty-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>当前动图帧尚未保存</h2>
|
||||
<p id="animation-dirty-message">是否应用当前修改到动图?</p>
|
||||
<div class="template-dialog-actions"><button value="cancel">取消</button><button value="discard">否,放弃修改</button><button class="primary-button" value="save">应用修改</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-operation-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2 id="animation-operation-title">确认动图操作</h2>
|
||||
<p id="animation-operation-message"></p>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button id="animation-operation-confirm" class="primary-button" type="submit" value="confirm">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-copy-destination-dialog" class="template-dialog">
|
||||
<form method="dialog">
|
||||
<h2>选择批量复制目标</h2>
|
||||
<label>复制到动图
|
||||
<select id="animation-copy-destination"></select>
|
||||
</label>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button class="primary-button" type="submit" value="next">下一步</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="animation-position-dialog" class="template-dialog animation-position-dialog">
|
||||
<form method="dialog">
|
||||
<h2 id="animation-position-title">选择插入位置</h2>
|
||||
<p id="animation-position-hint">选择目标帧后,所选帧将插入到该帧之后;选择“插入到最前”则置于第一帧前。</p>
|
||||
<label>插入位置
|
||||
<select id="animation-position-target"></select>
|
||||
</label>
|
||||
<div class="template-dialog-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button class="primary-button" type="submit" value="confirm">确认位置</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="voltage-calibration-dialog" class="template-dialog voltage-calibration-dialog">
|
||||
<form method="dialog">
|
||||
<h2>校准电压传感器</h2>
|
||||
<p>保持整机正常约 5V。把可靠万用表并联到 ADC 相同的屏幕输入 5V/GND 测量点,再输入万用表读数。</p>
|
||||
<label>万用表参考电压(V)
|
||||
<input id="voltage-reference-input" type="number" min="4.500" max="5.500" step="0.001" inputmode="decimal" value="5.000" required>
|
||||
</label>
|
||||
<p id="voltage-calibration-dialog-feedback" class="hint" aria-live="polite">点击“采样并预览”,约需 1 秒。</p>
|
||||
<dl id="voltage-calibration-preview" class="voltage-details" hidden>
|
||||
<div><dt>参考电压</dt><dd id="calibration-reference-result">-</dd></div>
|
||||
<div><dt>未校准读数</dt><dd id="calibration-raw-result">-</dd></div>
|
||||
<div><dt>当前系数</dt><dd id="calibration-current-factor">-</dd></div>
|
||||
<div><dt>建议系数</dt><dd id="calibration-proposed-factor">-</dd></div>
|
||||
<div><dt>预计结果</dt><dd id="calibration-projected-result">-</dd></div>
|
||||
</dl>
|
||||
<div class="voltage-calibration-actions">
|
||||
<button type="submit" value="cancel">取消</button>
|
||||
<button id="preview-voltage-calibration" type="button">采样并预览</button>
|
||||
<button id="confirm-voltage-calibration" class="primary-button" type="button" aria-disabled="true" data-unavailable-reason="请先完成“采样并预览”,再保存校准。">保存校准</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="current-display-dialog" class="template-dialog current-display-dialog" aria-labelledby="current-display-dialog-title">
|
||||
<div class="current-display-dialog-body">
|
||||
<div class="current-display-dialog-heading">
|
||||
<div>
|
||||
<h2 id="current-display-dialog-title">当前画面预览</h2>
|
||||
<p id="current-display-dialog-name" class="subtle" data-ui-copy-value>正在读取…</p>
|
||||
</div>
|
||||
<button id="current-display-dialog-close" type="button" aria-label="关闭当前画面预览">关闭</button>
|
||||
</div>
|
||||
<button id="current-display-preview-surface" class="current-display-preview-surface" type="button" aria-label="当前实屏逻辑帧预览">
|
||||
<img id="current-display-preview-image" alt="当前实屏逻辑帧" hidden>
|
||||
<span id="current-display-preview-placeholder">正在读取当前画面…</span>
|
||||
</button>
|
||||
<section id="current-display-playback-controls" class="current-display-playback-controls" aria-label="动图播放控制" hidden>
|
||||
<span id="current-display-seek-unavailable-reason" class="sr-only">请先完成当前进度跳转。</span>
|
||||
<div class="current-display-progress-heading">
|
||||
<label for="current-display-progress">播放位置</label>
|
||||
<output id="current-display-progress-time" for="current-display-progress" data-ui-copy-value>0:00.00 / 0:00.00</output>
|
||||
</div>
|
||||
<input id="current-display-progress" type="range" min="0" max="0" step="1" value="0" aria-label="动图播放位置">
|
||||
<div class="current-display-playback-actions">
|
||||
<button id="current-display-play-pause" class="primary-button" type="button">暂停</button>
|
||||
<div id="current-display-speed-controls" class="current-display-speed-controls" role="group" aria-label="动图播放倍速">
|
||||
<button type="button" data-playback-speed="0.5">0.5×</button>
|
||||
<button type="button" data-playback-speed="1">1×</button>
|
||||
<button type="button" data-playback-speed="1.5">1.5×</button>
|
||||
<button type="button" data-playback-speed="2">2×</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="current-display-playback-feedback" class="hint" aria-live="polite" hidden></p>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="resource-details-dialog" class="template-dialog resource-details-dialog" aria-labelledby="resource-details-title">
|
||||
<form method="dialog">
|
||||
<h2 id="resource-details-title">资源占用详情</h2>
|
||||
<p id="resource-details-message" class="resource-details-message" role="status"></p>
|
||||
<section class="resource-details-section" aria-labelledby="resource-cpu-title">
|
||||
<h3 id="resource-cpu-title">CPU</h3>
|
||||
<dl class="resource-details-grid">
|
||||
<div><dt>应用占用核心</dt><dd id="resource-cpu-app-cores">-</dd></div>
|
||||
<div><dt>折合单核占用</dt><dd id="resource-cpu-single-percent">-</dd></div>
|
||||
<div><dt>占整机 CPU</dt><dd id="resource-cpu-app-percent">-</dd></div>
|
||||
<div><dt>整机 CPU 总占用</dt><dd id="resource-cpu-total-percent">-</dd></div>
|
||||
</dl>
|
||||
<div id="resource-core-list" class="resource-core-list" aria-label="每核心 CPU 占用"></div>
|
||||
</section>
|
||||
<section class="resource-details-section" aria-labelledby="resource-memory-title">
|
||||
<h3 id="resource-memory-title">内存</h3>
|
||||
<dl class="resource-details-grid">
|
||||
<div><dt>应用常驻内存</dt><dd id="resource-memory-app">-</dd></div>
|
||||
<div><dt>应用占整机内存</dt><dd id="resource-memory-app-percent">-</dd></div>
|
||||
<div><dt>整机内存总占用</dt><dd id="resource-memory-total-percent">-</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="template-dialog-actions resource-dialog-actions">
|
||||
<button id="resource-details-close" class="primary-button" type="submit" value="close">关闭</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="ota-failure-log-dialog" class="template-dialog ota-failure-log-dialog" aria-labelledby="ota-failure-log-title">
|
||||
<div class="ota-failure-log-dialog-body">
|
||||
<div class="template-dialog-heading">
|
||||
<div>
|
||||
<h2 id="ota-failure-log-title">OTA 更新失败日志</h2>
|
||||
<p>可将完整内容复制后提供给维护人员排查。</p>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="ota-failure-log-content" tabindex="0" aria-label="OTA 更新失败诊断日志"></pre>
|
||||
<p id="ota-failure-log-feedback" class="settings-feedback" role="status" aria-live="polite" hidden></p>
|
||||
<div class="template-dialog-actions">
|
||||
<button id="ota-failure-log-close" type="button">关闭</button>
|
||||
<button id="ota-failure-log-copy" class="primary-button" type="button">复制全部日志</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="frp-config-dialog" class="template-dialog frp-config-dialog" aria-labelledby="frp-config-dialog-title">
|
||||
<div class="frp-config-dialog-body">
|
||||
<div class="template-dialog-heading">
|
||||
<div>
|
||||
<h2 id="frp-config-dialog-title">编辑 FRP 配置文件</h2>
|
||||
<p>配置可能包含服务器地址和令牌,请只在可信网络中操作。</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>已保存配置
|
||||
<select id="frp-config-select"><option value="">暂无配置</option></select>
|
||||
</label>
|
||||
<label>配置名称
|
||||
<input id="frp-config-name" type="text" maxlength="80" autocomplete="off" placeholder="远程维护">
|
||||
</label>
|
||||
<label>文件格式
|
||||
<select id="frp-config-format">
|
||||
<option value=".toml">TOML</option>
|
||||
<option value=".yaml">YAML</option>
|
||||
<option value=".json">JSON</option>
|
||||
<option value=".ini">INI(兼容格式)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>配置原文
|
||||
<textarea id="frp-config-content" data-ui-copy-ignore spellcheck="false" aria-describedby="frp-config-feedback"></textarea>
|
||||
</label>
|
||||
<input id="frp-config-file" class="sr-only" type="file" accept=".toml,.yaml,.yml,.json,.ini,text/plain,application/json">
|
||||
<p id="frp-config-feedback" class="settings-feedback" role="status" aria-live="polite"></p>
|
||||
<div class="frp-config-secondary-actions">
|
||||
<button id="frp-config-new" type="button">新建空白配置</button>
|
||||
<button id="frp-config-upload" type="button">上传配置文件</button>
|
||||
<button id="frp-config-delete" class="danger-button" type="button">删除</button>
|
||||
</div>
|
||||
<div class="template-dialog-actions">
|
||||
<button id="frp-config-close" type="button">关闭</button>
|
||||
<button id="frp-config-save" class="primary-button" type="button">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="network-diagnostics-dialog" class="template-dialog network-diagnostics-dialog" aria-labelledby="network-diagnostics-title">
|
||||
<div class="network-diagnostics-dialog-body">
|
||||
<h2 id="network-diagnostics-title">网络检测结果</h2>
|
||||
<p id="network-diagnostics-summary" role="status">正在检测…</p>
|
||||
<ul id="network-diagnostics-checks"></ul>
|
||||
<div class="template-dialog-actions">
|
||||
<button id="network-diagnostics-close" class="primary-button" type="button">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="control-unavailable-tooltip" class="control-unavailable-tooltip" role="tooltip" hidden></div>
|
||||
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
|
||||
<noscript>此控制台需要启用 JavaScript。</noscript>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,182 @@
|
||||
import { announce } from "./core.js";
|
||||
|
||||
const tooltip = document.getElementById("control-unavailable-tooltip");
|
||||
const toast = document.getElementById("control-feedback-toast");
|
||||
const controlRecords = new WeakMap();
|
||||
let descriptionSequence = 0;
|
||||
let toastTimer = null;
|
||||
|
||||
function recordFor(control) {
|
||||
if (!controlRecords.has(control)) {
|
||||
controlRecords.set(control, {
|
||||
label: "textContent" in control ? control.textContent : "",
|
||||
description: null,
|
||||
describedBy: control.getAttribute("aria-describedby") || "",
|
||||
});
|
||||
}
|
||||
return controlRecords.get(control);
|
||||
}
|
||||
|
||||
function removeDescription(control) {
|
||||
const record = recordFor(control);
|
||||
record.description?.remove();
|
||||
record.description = null;
|
||||
if (record.describedBy) control.setAttribute("aria-describedby", record.describedBy);
|
||||
else control.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
function setDescription(control, reason) {
|
||||
const record = recordFor(control);
|
||||
if (!record.description) {
|
||||
const description = document.createElement("span");
|
||||
description.id = `control-unavailable-reason-${++descriptionSequence}`;
|
||||
description.className = "sr-only";
|
||||
control.insertAdjacentElement("afterend", description);
|
||||
record.description = description;
|
||||
}
|
||||
record.description.textContent = reason;
|
||||
const ids = [record.describedBy, record.description.id].filter(Boolean).join(" ");
|
||||
control.setAttribute("aria-describedby", ids);
|
||||
}
|
||||
|
||||
function restoreLabel(control) {
|
||||
const record = recordFor(control);
|
||||
if ("textContent" in control && control.dataset.busyLabelApplied === "true") {
|
||||
control.textContent = record.label;
|
||||
}
|
||||
delete control.dataset.busyLabelApplied;
|
||||
}
|
||||
|
||||
function clearBusy(control) {
|
||||
restoreLabel(control);
|
||||
if ("disabled" in control) control.disabled = false;
|
||||
control.removeAttribute("aria-busy");
|
||||
delete control.dataset.controlState;
|
||||
}
|
||||
|
||||
export function setControlReady(control) {
|
||||
clearBusy(control);
|
||||
removeDescription(control);
|
||||
control.removeAttribute("aria-disabled");
|
||||
delete control.dataset.unavailableReason;
|
||||
}
|
||||
|
||||
export function setControlUnavailable(control, reason) {
|
||||
if (!reason || !String(reason).trim()) throw new Error("不可用控件必须提供原因");
|
||||
clearBusy(control);
|
||||
control.setAttribute("aria-disabled", "true");
|
||||
control.dataset.unavailableReason = String(reason).trim();
|
||||
setDescription(control, control.dataset.unavailableReason);
|
||||
}
|
||||
|
||||
export function setControlBusy(control, { label = "", message = "" } = {}) {
|
||||
const record = recordFor(control);
|
||||
const wasBusy = control.dataset.controlState === "busy";
|
||||
removeDescription(control);
|
||||
control.removeAttribute("aria-disabled");
|
||||
delete control.dataset.unavailableReason;
|
||||
control.dataset.controlState = "busy";
|
||||
control.setAttribute("aria-busy", "true");
|
||||
if (label && "textContent" in control) {
|
||||
if (control.dataset.busyLabelApplied !== "true") record.label = control.textContent;
|
||||
control.textContent = label;
|
||||
control.dataset.busyLabelApplied = "true";
|
||||
}
|
||||
if ("disabled" in control) control.disabled = true;
|
||||
if (message && !wasBusy) announce(message);
|
||||
}
|
||||
|
||||
export async function runControlAction(control, options, action) {
|
||||
setControlBusy(control, options);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableControl(target) {
|
||||
return target instanceof Element
|
||||
? target.closest('[aria-disabled="true"][data-unavailable-reason]')
|
||||
: null;
|
||||
}
|
||||
|
||||
function positionTooltip(control, text = control.dataset.unavailableReason) {
|
||||
if (!tooltip || !text) return;
|
||||
tooltip.textContent = text;
|
||||
tooltip.hidden = false;
|
||||
const controlRect = control.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
const left = Math.min(
|
||||
window.innerWidth - tooltipRect.width - margin,
|
||||
Math.max(margin, controlRect.left + (controlRect.width - tooltipRect.width) / 2),
|
||||
);
|
||||
let top = controlRect.top - tooltipRect.height - margin;
|
||||
if (top < margin) top = controlRect.bottom + margin;
|
||||
tooltip.style.left = `${Math.round(left)}px`;
|
||||
tooltip.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
function overflowTooltipTarget(target) {
|
||||
return target instanceof Element ? target.closest("[data-hover-tooltip]") : null;
|
||||
}
|
||||
|
||||
function canShowOverflowTooltip(target, event) {
|
||||
return event.pointerType === "mouse"
|
||||
&& window.matchMedia("(hover: hover) and (pointer: fine)").matches
|
||||
&& target.scrollWidth > target.clientWidth;
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (tooltip) tooltip.hidden = true;
|
||||
}
|
||||
|
||||
function showToast(reason) {
|
||||
if (!toast) return;
|
||||
window.clearTimeout(toastTimer);
|
||||
toast.textContent = reason;
|
||||
toast.hidden = false;
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.hidden = true;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control) {
|
||||
positionTooltip(control);
|
||||
return;
|
||||
}
|
||||
const overflowTarget = overflowTooltipTarget(event.target);
|
||||
if (overflowTarget && canShowOverflowTooltip(overflowTarget, event)) {
|
||||
positionTooltip(overflowTarget, overflowTarget.dataset.hoverTooltip);
|
||||
}
|
||||
});
|
||||
document.addEventListener("pointerout", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control && !control.contains(event.relatedTarget)) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
const overflowTarget = overflowTooltipTarget(event.target);
|
||||
if (overflowTarget && !overflowTarget.contains(event.relatedTarget)) hideTooltip();
|
||||
});
|
||||
document.addEventListener("focusin", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control) positionTooltip(control);
|
||||
});
|
||||
document.addEventListener("focusout", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (control) hideTooltip();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
const control = unavailableControl(event.target);
|
||||
if (!control) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const reason = control.dataset.unavailableReason;
|
||||
hideTooltip();
|
||||
showToast(reason);
|
||||
announce(reason);
|
||||
}, true);
|
||||
@@ -0,0 +1,56 @@
|
||||
const OUTPUT_PIXELS = 64;
|
||||
|
||||
function positiveNumber(value, field) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) {
|
||||
throw new TypeError(field + " must be a positive number");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
export function cropGeometry({
|
||||
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom, centerX, centerY,
|
||||
}) {
|
||||
const viewWidth = positiveNumber(viewportWidth, "viewportWidth");
|
||||
const viewHeight = positiveNumber(viewportHeight, "viewportHeight");
|
||||
const imageWidth = positiveNumber(sourceWidth, "sourceWidth");
|
||||
const imageHeight = positiveNumber(sourceHeight, "sourceHeight");
|
||||
const scaleZoom = positiveNumber(zoom, "zoom");
|
||||
const x = Number(centerX), y = Number(centerY);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
throw new TypeError("crop center must be finite");
|
||||
}
|
||||
const scale = Math.min(viewWidth / imageWidth, viewHeight / imageHeight) * scaleZoom;
|
||||
const width = imageWidth * scale;
|
||||
const height = imageHeight * scale;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: viewWidth / 2 - x * width,
|
||||
top: viewHeight / 2 - y * height,
|
||||
};
|
||||
}
|
||||
|
||||
export function cropCenterBounds({
|
||||
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
|
||||
}) {
|
||||
const geometry = cropGeometry({
|
||||
viewportWidth, viewportHeight, sourceWidth, sourceHeight, zoom,
|
||||
centerX: 0.5, centerY: 0.5,
|
||||
});
|
||||
const visibleX = Math.min(Number(viewportWidth) / OUTPUT_PIXELS, geometry.width);
|
||||
const visibleY = Math.min(Number(viewportHeight) / OUTPUT_PIXELS, geometry.height);
|
||||
return {
|
||||
minX: (visibleX - Number(viewportWidth) / 2) / geometry.width,
|
||||
maxX: 1 + (Number(viewportWidth) / 2 - visibleX) / geometry.width,
|
||||
minY: (visibleY - Number(viewportHeight) / 2) / geometry.height,
|
||||
maxY: 1 + (Number(viewportHeight) / 2 - visibleY) / geometry.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampCropCenter(value, bounds) {
|
||||
return {
|
||||
centerX: Math.max(bounds.minX, Math.min(bounds.maxX, Number(value.centerX))),
|
||||
centerY: Math.max(bounds.minY, Math.min(bounds.maxY, Number(value.centerY))),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function shouldReuseMediaJobCard(rendered, incoming) {
|
||||
if (!rendered || !incoming || rendered.id !== incoming.id) return false;
|
||||
if (rendered.state !== "awaiting_settings" || incoming.state !== "awaiting_settings") return false;
|
||||
return rendered.dirty || rendered.updatedAt === incoming.updated_at;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
||||
export const SCENE_VERSION = 1;
|
||||
export const SCENE_WIDTH = 64;
|
||||
export const SCENE_HEIGHT = 64;
|
||||
export const RGB_CHANNELS = 3;
|
||||
export const RGBA_CHANNELS = 4;
|
||||
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS;
|
||||
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS;
|
||||
|
||||
export const SCENE_STORAGE_KEY = "matrixController:scene";
|
||||
export const LEGACY_STORAGE_KEYS = Object.freeze({
|
||||
sceneV1: "matrixController:scene:v1",
|
||||
canvasV3: "matrixController:canvas:v3",
|
||||
canvasV2: "matrixController:canvas:v2",
|
||||
canvasRgb: "matrixCanvasRgb",
|
||||
textV1: "matrixController:text:v1",
|
||||
});
|
||||
|
||||
export const DEFAULT_TEXT_ELEMENT = Object.freeze({
|
||||
type: "text",
|
||||
text: "ok",
|
||||
font: "default",
|
||||
size: 12,
|
||||
x: 32,
|
||||
y: 26,
|
||||
align: "center",
|
||||
color: "#FFFFFF",
|
||||
revision: 0,
|
||||
});
|
||||
|
||||
const ALIGNMENTS = new Set(["left", "center", "right"]);
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function finiteInteger(value, fallback, minimum, maximum) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(minimum, Math.min(maximum, Math.round(number)));
|
||||
}
|
||||
|
||||
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
|
||||
return typeof value === "string" && HEX_COLOR.test(value.trim())
|
||||
? value.trim().toUpperCase()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const id = value.trim();
|
||||
return id && id.length <= 128 ? id : null;
|
||||
}
|
||||
|
||||
function byteArray(value, expectedLength) {
|
||||
if (value === null || value === undefined || typeof value.length !== "number") return null;
|
||||
if (value.length !== expectedLength) return null;
|
||||
const result = new Uint8ClampedArray(expectedLength);
|
||||
for (let index = 0; index < expectedLength; index += 1) {
|
||||
const channel = value[index];
|
||||
if (!Number.isInteger(channel) || channel < 0 || channel > 255) return null;
|
||||
result[index] = channel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encodeBase64(bytes) {
|
||||
let encoded = "";
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index];
|
||||
const hasSecond = index + 1 < bytes.length;
|
||||
const hasThird = index + 2 < bytes.length;
|
||||
const second = hasSecond ? bytes[index + 1] : 0;
|
||||
const third = hasThird ? bytes[index + 2] : 0;
|
||||
encoded += BASE64_ALPHABET[first >> 2];
|
||||
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)];
|
||||
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "=";
|
||||
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "=";
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof value !== "string" || value.length % 4 !== 0) return null;
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
|
||||
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
||||
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding);
|
||||
let output = 0;
|
||||
for (let index = 0; index < value.length; index += 4) {
|
||||
const first = BASE64_ALPHABET.indexOf(value[index]);
|
||||
const second = BASE64_ALPHABET.indexOf(value[index + 1]);
|
||||
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]);
|
||||
const fourth = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3]);
|
||||
const packed = (first << 18) | (second << 12) | (third << 6) | fourth;
|
||||
if (output < result.length) result[output++] = (packed >> 16) & 255;
|
||||
if (output < result.length) result[output++] = (packed >> 8) & 255;
|
||||
if (output < result.length) result[output++] = packed & 255;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseJsonValue(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceValue(source, alias, storageKey) {
|
||||
if (!isRecord(source)) return null;
|
||||
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias];
|
||||
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey];
|
||||
return null;
|
||||
}
|
||||
|
||||
function copyTextElement(element) {
|
||||
return {
|
||||
id: element.id,
|
||||
type: "text",
|
||||
text: element.text,
|
||||
font: element.font,
|
||||
size: element.size,
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
align: element.align,
|
||||
color: element.color,
|
||||
revision: element.revision,
|
||||
};
|
||||
}
|
||||
|
||||
function storageTextElement(element) {
|
||||
const { revision: _revision, ...stored } = copyTextElement(element);
|
||||
return stored;
|
||||
}
|
||||
|
||||
function normalizedElementList(elements) {
|
||||
if (!Array.isArray(elements)) return [];
|
||||
const normalized = [];
|
||||
const usedIds = new Set();
|
||||
for (const value of elements) {
|
||||
const element = normalizeTextElement(value, { strict: true });
|
||||
if (!element || usedIds.has(element.id)) continue;
|
||||
usedIds.add(element.id);
|
||||
normalized.push(element);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertScene(scene) {
|
||||
if (!isRecord(scene)) throw new TypeError("scene must be an object");
|
||||
if (scene.version !== SCENE_VERSION || scene.width !== SCENE_WIDTH || scene.height !== SCENE_HEIGHT) {
|
||||
throw new RangeError("scene must be version 1 with a 64x64 frame");
|
||||
}
|
||||
const pixels = byteArray(scene.pixels, RGB_BYTE_LENGTH);
|
||||
if (!pixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
|
||||
if (!Array.isArray(scene.elements)) throw new TypeError("scene elements must be an array");
|
||||
const elements = normalizedElementList(scene.elements);
|
||||
if (elements.length !== scene.elements.length) throw new TypeError("scene contains an invalid or duplicate element");
|
||||
return { pixels, elements };
|
||||
}
|
||||
|
||||
function sceneWithElements(scene, elements) {
|
||||
const checked = assertScene(scene);
|
||||
return {
|
||||
version: SCENE_VERSION,
|
||||
width: SCENE_WIDTH,
|
||||
height: SCENE_HEIGHT,
|
||||
pixels: checked.pixels,
|
||||
elements: elements.map(copyTextElement),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRgbPixels(color = [0, 0, 0]) {
|
||||
const channels = byteArray(color, RGB_CHANNELS);
|
||||
if (!channels) throw new RangeError("color must contain three RGB bytes");
|
||||
const pixels = new Uint8ClampedArray(RGB_BYTE_LENGTH);
|
||||
for (let offset = 0; offset < pixels.length; offset += RGB_CHANNELS) pixels.set(channels, offset);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
export function createScene({ pixels = createRgbPixels(), elements = [] } = {}) {
|
||||
const checkedPixels = byteArray(pixels, RGB_BYTE_LENGTH);
|
||||
if (!checkedPixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
|
||||
return {
|
||||
version: SCENE_VERSION,
|
||||
width: SCENE_WIDTH,
|
||||
height: SCENE_HEIGHT,
|
||||
pixels: checkedPixels,
|
||||
elements: normalizedElementList(elements),
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneToStorageValue(scene) {
|
||||
const checked = assertScene(scene);
|
||||
return {
|
||||
version: SCENE_VERSION,
|
||||
width: SCENE_WIDTH,
|
||||
height: SCENE_HEIGHT,
|
||||
pixelRgb: encodeBase64(checked.pixels),
|
||||
elements: checked.elements.map(storageTextElement),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeScene(scene) {
|
||||
return JSON.stringify(sceneToStorageValue(scene));
|
||||
}
|
||||
|
||||
export function deserializeScene(value) {
|
||||
const stored = parseJsonValue(value);
|
||||
if (!isRecord(stored)) return null;
|
||||
if (stored.version !== SCENE_VERSION || stored.width !== SCENE_WIDTH || stored.height !== SCENE_HEIGHT) return null;
|
||||
const decoded = decodeBase64(stored.pixelRgb);
|
||||
if (!decoded || decoded.length !== RGB_BYTE_LENGTH) return null;
|
||||
return createScene({
|
||||
pixels: decoded,
|
||||
elements: normalizedElementList(stored.elements),
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectSceneStorageValue(value) {
|
||||
if (value === null || value === undefined) return { status: "missing", scene: null };
|
||||
let stored = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
stored = JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return { status: "damaged", scene: null };
|
||||
}
|
||||
}
|
||||
if (!isRecord(stored)) return { status: "damaged", scene: null };
|
||||
if (Number.isInteger(stored.version) && stored.version > SCENE_VERSION) {
|
||||
return { status: "future", scene: null };
|
||||
}
|
||||
const restored = deserializeScene(stored);
|
||||
return restored
|
||||
? { status: "valid", scene: restored }
|
||||
: { status: "damaged", scene: null };
|
||||
}
|
||||
|
||||
export function nextTextElementId(elements = []) {
|
||||
const used = new Set(
|
||||
Array.isArray(elements)
|
||||
? elements.map((element) => normalizeId(element?.id)).filter(Boolean)
|
||||
: [],
|
||||
);
|
||||
let sequence = 1;
|
||||
while (used.has(`text-${sequence}`)) sequence += 1;
|
||||
return `text-${sequence}`;
|
||||
}
|
||||
|
||||
export function normalizeTextElement(value = {}, { id = null, strict = false } = {}) {
|
||||
if (!isRecord(value) || (value.type !== undefined && value.type !== "text")) return null;
|
||||
if (strict && (typeof value.text !== "string" || !normalizeId(value.id))) return null;
|
||||
const normalizedId = normalizeId(value.id) || normalizeId(id) || "text-1";
|
||||
const text = typeof value.text === "string" ? value.text.slice(0, 512) : DEFAULT_TEXT_ELEMENT.text;
|
||||
const font = typeof value.font === "string" && value.font.trim()
|
||||
? value.font.trim().slice(0, 512)
|
||||
: DEFAULT_TEXT_ELEMENT.font;
|
||||
return {
|
||||
id: normalizedId,
|
||||
type: "text",
|
||||
text,
|
||||
font,
|
||||
size: finiteInteger(value.size, DEFAULT_TEXT_ELEMENT.size, 1, 64),
|
||||
x: finiteInteger(value.x, DEFAULT_TEXT_ELEMENT.x, -128, 128),
|
||||
y: finiteInteger(value.y, DEFAULT_TEXT_ELEMENT.y, -128, 128),
|
||||
align: ALIGNMENTS.has(value.align) ? value.align : DEFAULT_TEXT_ELEMENT.align,
|
||||
color: normalizeColor(value.color),
|
||||
revision: finiteInteger(value.revision, 0, 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTextElement(existingElements = [], overrides = {}) {
|
||||
const id = nextTextElementId(existingElements);
|
||||
return normalizeTextElement({ ...DEFAULT_TEXT_ELEMENT, ...overrides, id }, { id });
|
||||
}
|
||||
|
||||
export function addTextElement(scene, overrides = {}) {
|
||||
const checked = assertScene(scene);
|
||||
const element = createTextElement(checked.elements, overrides);
|
||||
return sceneWithElements(scene, [...checked.elements, element]);
|
||||
}
|
||||
|
||||
export function updateTextElement(scene, id, patch = {}) {
|
||||
const checked = assertScene(scene);
|
||||
const targetId = normalizeId(id);
|
||||
const index = checked.elements.findIndex((element) => element.id === targetId);
|
||||
if (index < 0 || !isRecord(patch)) return scene;
|
||||
const current = checked.elements[index];
|
||||
const updated = normalizeTextElement({
|
||||
...current,
|
||||
...patch,
|
||||
id: current.id,
|
||||
type: "text",
|
||||
revision: current.revision + 1,
|
||||
}, { id: current.id });
|
||||
const elements = checked.elements.map((element, elementIndex) => (
|
||||
elementIndex === index ? updated : copyTextElement(element)
|
||||
));
|
||||
return sceneWithElements(scene, elements);
|
||||
}
|
||||
|
||||
export function deleteTextElement(scene, id) {
|
||||
const checked = assertScene(scene);
|
||||
const targetId = normalizeId(id);
|
||||
if (!checked.elements.some((element) => element.id === targetId)) return scene;
|
||||
return sceneWithElements(scene, checked.elements.filter((element) => element.id !== targetId));
|
||||
}
|
||||
|
||||
export function duplicateTextElement(scene, id) {
|
||||
const checked = assertScene(scene);
|
||||
const source = checked.elements.find((element) => element.id === normalizeId(id));
|
||||
if (!source) return scene;
|
||||
const duplicate = createTextElement(checked.elements, {
|
||||
...source,
|
||||
x: source.x + 2,
|
||||
y: source.y + 2,
|
||||
revision: 0,
|
||||
});
|
||||
return sceneWithElements(scene, [...checked.elements, duplicate]);
|
||||
}
|
||||
|
||||
function legacyCanvasPixels(value) {
|
||||
const parsed = parseJsonValue(value);
|
||||
if (!isRecord(parsed)) return null;
|
||||
return byteArray(parsed.pixels, RGB_BYTE_LENGTH);
|
||||
}
|
||||
|
||||
function legacyCsvPixels(value) {
|
||||
if (typeof value !== "string") return byteArray(value, RGB_BYTE_LENGTH);
|
||||
const channels = value.split(",").map((channel) => Number(channel));
|
||||
return byteArray(channels, RGB_BYTE_LENGTH);
|
||||
}
|
||||
|
||||
function migrateTextV1(value, options) {
|
||||
const parsed = parseJsonValue(value);
|
||||
if (!isRecord(parsed)) return null;
|
||||
const text = parsed["text-value"] ?? parsed.text;
|
||||
if (typeof text !== "string") return null;
|
||||
return normalizeTextElement({
|
||||
id: "text-1",
|
||||
type: "text",
|
||||
text,
|
||||
font: parsed["text-font"] ?? parsed.font ?? options.defaultFont,
|
||||
size: parsed["text-size"] ?? parsed.size ?? options.defaultTextSize,
|
||||
x: parsed["text-x"] ?? parsed.x,
|
||||
y: parsed["text-y"] ?? parsed.y,
|
||||
align: parsed["text-align"] ?? parsed.align,
|
||||
color: parsed["text-color"] ?? parsed.color,
|
||||
}, { id: "text-1" });
|
||||
}
|
||||
|
||||
export function migrateLegacyScene(source = {}, options = {}) {
|
||||
const migrationOptions = {
|
||||
defaultFont: typeof options.defaultFont === "string" && options.defaultFont.trim()
|
||||
? options.defaultFont.trim()
|
||||
: DEFAULT_TEXT_ELEMENT.font,
|
||||
defaultTextSize: finiteInteger(options.defaultTextSize, DEFAULT_TEXT_ELEMENT.size, 1, 64),
|
||||
};
|
||||
const canvasCandidates = [
|
||||
["canvasV3", LEGACY_STORAGE_KEYS.canvasV3],
|
||||
["canvasV2", LEGACY_STORAGE_KEYS.canvasV2],
|
||||
];
|
||||
let pixels = null;
|
||||
for (const [alias, storageKey] of canvasCandidates) {
|
||||
pixels = legacyCanvasPixels(sourceValue(source, alias, storageKey));
|
||||
if (pixels) break;
|
||||
}
|
||||
if (!pixels) {
|
||||
pixels = legacyCsvPixels(sourceValue(source, "matrixCanvasRgb", LEGACY_STORAGE_KEYS.canvasRgb));
|
||||
}
|
||||
const text = migrateTextV1(
|
||||
sourceValue(source, "textV1", LEGACY_STORAGE_KEYS.textV1),
|
||||
migrationOptions,
|
||||
);
|
||||
if (!pixels && !text) return null;
|
||||
return createScene({
|
||||
pixels: pixels || createRgbPixels(),
|
||||
elements: text ? [text] : [],
|
||||
});
|
||||
}
|
||||
|
||||
function draftLoadResult(scene, {
|
||||
canPersist,
|
||||
status,
|
||||
notice = null,
|
||||
} = {}) {
|
||||
return { scene, canPersist, status, notice };
|
||||
}
|
||||
|
||||
function blockedDraft(scene, status, notice) {
|
||||
return draftLoadResult(scene, { canPersist: false, status, notice });
|
||||
}
|
||||
|
||||
function readStorageSnapshot(storage) {
|
||||
if (!storage || typeof storage.getItem !== "function") {
|
||||
throw new TypeError("browser storage is unavailable");
|
||||
}
|
||||
const values = {};
|
||||
values.current = storage.getItem(SCENE_STORAGE_KEY);
|
||||
for (const [alias, key] of Object.entries(LEGACY_STORAGE_KEYS)) {
|
||||
values[alias] = storage.getItem(key);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function invalidSplitLegacyKeys(values) {
|
||||
const invalid = [];
|
||||
if (values.canvasV3 !== null && !legacyCanvasPixels(values.canvasV3)) invalid.push(LEGACY_STORAGE_KEYS.canvasV3);
|
||||
if (values.canvasV2 !== null && !legacyCanvasPixels(values.canvasV2)) invalid.push(LEGACY_STORAGE_KEYS.canvasV2);
|
||||
if (values.canvasRgb !== null && !legacyCsvPixels(values.canvasRgb)) invalid.push(LEGACY_STORAGE_KEYS.canvasRgb);
|
||||
if (values.textV1 !== null && !migrateTextV1(values.textV1, {
|
||||
defaultFont: DEFAULT_TEXT_ELEMENT.font,
|
||||
defaultTextSize: DEFAULT_TEXT_ELEMENT.size,
|
||||
})) invalid.push(LEGACY_STORAGE_KEYS.textV1);
|
||||
return invalid;
|
||||
}
|
||||
|
||||
function writeMigratedDraft(storage, scene, hasLegacyData) {
|
||||
try {
|
||||
if (typeof storage.setItem !== "function") throw new TypeError("browser storage is read-only");
|
||||
storage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
|
||||
} catch (_error) {
|
||||
return blockedDraft(
|
||||
scene,
|
||||
"write-failed",
|
||||
hasLegacyData
|
||||
? "浏览器无法写入升级后的画板草稿;旧草稿已原样保留,本页不会覆盖它。"
|
||||
: "浏览器无法初始化画板草稿;本页编辑不会自动保存或应用。",
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasLegacyData) {
|
||||
return draftLoadResult(scene, { canPersist: true, status: "created" });
|
||||
}
|
||||
|
||||
const cleanupFailures = [];
|
||||
for (const key of Object.values(LEGACY_STORAGE_KEYS)) {
|
||||
try {
|
||||
if (typeof storage.removeItem !== "function") throw new TypeError("browser storage cannot remove old keys");
|
||||
storage.removeItem(key);
|
||||
} catch (_error) {
|
||||
cleanupFailures.push(key);
|
||||
}
|
||||
}
|
||||
if (cleanupFailures.length) {
|
||||
return draftLoadResult(scene, {
|
||||
canPersist: true,
|
||||
status: "cleanup-warning",
|
||||
notice: "画板草稿已升级,但部分旧版草稿键无法清理;旧值仍被保留。",
|
||||
});
|
||||
}
|
||||
return draftLoadResult(scene, {
|
||||
canPersist: true,
|
||||
status: "migrated",
|
||||
notice: "已将旧版浏览器画板草稿升级到当前格式。",
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSceneDraft(storage) {
|
||||
const blankScene = createScene();
|
||||
let values;
|
||||
try {
|
||||
values = readStorageSnapshot(storage);
|
||||
} catch (_error) {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"read-failed",
|
||||
"浏览器草稿存储不可用;本页不会自动保存或应用空白画板。",
|
||||
);
|
||||
}
|
||||
|
||||
const current = inspectSceneStorageValue(values.current);
|
||||
if (current.status === "valid") {
|
||||
return draftLoadResult(current.scene, { canPersist: true, status: "current" });
|
||||
}
|
||||
if (current.status === "future") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"future-version",
|
||||
"检测到更高版本的浏览器画板草稿;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
if (current.status === "damaged") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"damaged-current",
|
||||
"浏览器画板草稿损坏或格式无法识别;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
|
||||
const legacyScene = inspectSceneStorageValue(values.sceneV1);
|
||||
if (legacyScene.status === "future") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"future-legacy-version",
|
||||
"旧版草稿键中包含更高版本数据;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
if (legacyScene.status === "damaged") {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"damaged-legacy-scene",
|
||||
"旧版画板草稿损坏或无法迁移;原值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
|
||||
const hasSplitLegacyData = ["canvasV3", "canvasV2", "canvasRgb", "textV1"]
|
||||
.some((alias) => values[alias] !== null);
|
||||
const hasLegacyData = values.sceneV1 !== null || hasSplitLegacyData;
|
||||
const invalidLegacyKeys = invalidSplitLegacyKeys(values);
|
||||
const migrated = migrateLegacyScene({
|
||||
canvasV3: values.canvasV3,
|
||||
canvasV2: values.canvasV2,
|
||||
matrixCanvasRgb: values.canvasRgb,
|
||||
textV1: values.textV1,
|
||||
});
|
||||
if (invalidLegacyKeys.length) {
|
||||
return blockedDraft(
|
||||
legacyScene.scene || migrated || blankScene,
|
||||
"legacy-migration-failed",
|
||||
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
if (legacyScene.status === "valid") {
|
||||
return writeMigratedDraft(storage, legacyScene.scene, hasLegacyData);
|
||||
}
|
||||
if (hasSplitLegacyData && !migrated) {
|
||||
return blockedDraft(
|
||||
blankScene,
|
||||
"legacy-migration-failed",
|
||||
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
|
||||
);
|
||||
}
|
||||
return writeMigratedDraft(storage, migrated || blankScene, hasLegacyData);
|
||||
}
|
||||
|
||||
function compositeInto(target, rgba) {
|
||||
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < rgba.length; sourceOffset += 4, targetOffset += 3) {
|
||||
const alpha = rgba[sourceOffset + 3];
|
||||
if (alpha === 0) continue;
|
||||
if (alpha === 255) {
|
||||
target[targetOffset] = rgba[sourceOffset];
|
||||
target[targetOffset + 1] = rgba[sourceOffset + 1];
|
||||
target[targetOffset + 2] = rgba[sourceOffset + 2];
|
||||
continue;
|
||||
}
|
||||
const inverse = 255 - alpha;
|
||||
target[targetOffset] = Math.round((rgba[sourceOffset] * alpha + target[targetOffset] * inverse) / 255);
|
||||
target[targetOffset + 1] = Math.round((rgba[sourceOffset + 1] * alpha + target[targetOffset + 1] * inverse) / 255);
|
||||
target[targetOffset + 2] = Math.round((rgba[sourceOffset + 2] * alpha + target[targetOffset + 2] * inverse) / 255);
|
||||
}
|
||||
}
|
||||
|
||||
export function compositeRgbaLayer(pixels, rgba) {
|
||||
const result = byteArray(pixels, RGB_BYTE_LENGTH);
|
||||
const layer = byteArray(rgba, RGBA_BYTE_LENGTH);
|
||||
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
|
||||
if (!layer) throw new RangeError("layer must contain 64x64 RGBA8888 bytes");
|
||||
compositeInto(result, layer);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function compositeRgbaLayers(pixels, layers = []) {
|
||||
const result = byteArray(pixels, RGB_BYTE_LENGTH);
|
||||
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
|
||||
if (!Array.isArray(layers)) throw new TypeError("layers must be an array");
|
||||
for (const value of layers) {
|
||||
const layer = byteArray(value, RGBA_BYTE_LENGTH);
|
||||
if (!layer) throw new RangeError("each layer must contain 64x64 RGBA8888 bytes");
|
||||
compositeInto(result, layer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export const DRAG_START_GUARD_PX = 8;
|
||||
|
||||
const INTERACTIVE_SELECTOR = "button, input, select, textarea, a[href], [contenteditable]:not([contenteditable='false'])";
|
||||
|
||||
export function pointInsideExpandedRect(x, y, rect, padding = DRAG_START_GUARD_PX) {
|
||||
return x >= rect.left - padding
|
||||
&& x <= rect.right + padding
|
||||
&& y >= rect.top - padding
|
||||
&& y <= rect.bottom + padding;
|
||||
}
|
||||
|
||||
export function isProtectedDragStart(card, event, padding = DRAG_START_GUARD_PX) {
|
||||
const target = typeof Element !== "undefined" && event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest(INTERACTIVE_SELECTOR)) return true;
|
||||
if (!Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return false;
|
||||
return [...card.querySelectorAll(INTERACTIVE_SELECTOR)].some((control) => (
|
||||
pointInsideExpandedRect(event.clientX, event.clientY, control.getBoundingClientRect(), padding)
|
||||
));
|
||||
}
|
||||
|
||||
export function protectCardDragStart(card, { locked = false } = {}) {
|
||||
let dragging = false;
|
||||
let restoreListeners = null;
|
||||
|
||||
const restore = () => {
|
||||
restoreListeners?.();
|
||||
restoreListeners = null;
|
||||
if (!locked && !dragging) card.draggable = true;
|
||||
};
|
||||
|
||||
card.draggable = !locked;
|
||||
card.addEventListener("pointerdown", (event) => {
|
||||
if (locked || dragging) return;
|
||||
restore();
|
||||
if (!isProtectedDragStart(card, event)) {
|
||||
card.draggable = true;
|
||||
return;
|
||||
}
|
||||
card.draggable = false;
|
||||
const pointerId = event.pointerId;
|
||||
const finish = (finishEvent) => {
|
||||
if (finishEvent.pointerId !== pointerId) return;
|
||||
queueMicrotask(restore);
|
||||
};
|
||||
window.addEventListener("pointerup", finish, true);
|
||||
window.addEventListener("pointercancel", finish, true);
|
||||
restoreListeners = () => {
|
||||
window.removeEventListener("pointerup", finish, true);
|
||||
window.removeEventListener("pointercancel", finish, true);
|
||||
};
|
||||
}, true);
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
if (locked || !card.draggable || isProtectedDragStart(card, event)) {
|
||||
event.preventDefault();
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
restoreListeners?.();
|
||||
restoreListeners = null;
|
||||
card.classList.add("is-dragging");
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
dragging = false;
|
||||
card.classList.remove("is-dragging");
|
||||
restore();
|
||||
});
|
||||
|
||||
return { restore };
|
||||
}
|
||||
|
||||
export function reorderByKey(items, sourceKey, targetKey) {
|
||||
const order = [...items];
|
||||
const from = order.indexOf(sourceKey);
|
||||
const target = order.indexOf(targetKey);
|
||||
if (from < 0 || target < 0 || from === target) return null;
|
||||
order.splice(target, 0, order.splice(from, 1)[0]);
|
||||
return order;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
export function createTemplateEditContext(template, baseline) {
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
revision: template.revision,
|
||||
readOnly: Boolean(template.read_only ?? template.readOnly),
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
|
||||
export function templateEditSnapshot(context, serializedScene) {
|
||||
if (!context) return null;
|
||||
return {
|
||||
id: context.id,
|
||||
name: context.name,
|
||||
revision: context.revision,
|
||||
readOnly: Boolean(context.readOnly),
|
||||
dirty: serializedScene !== context.baseline,
|
||||
};
|
||||
}
|
||||
|
||||
export function sceneNeedsTemplateSave(context, serializedScene) {
|
||||
return !context || serializedScene !== context.baseline;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
{
|
||||
"copy.dynamic.template.save_bound": {
|
||||
"text": "保存模板",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::save_bound"
|
||||
},
|
||||
"copy.dynamic.template.save_new": {
|
||||
"text": "保存为模板",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::save_new"
|
||||
},
|
||||
"copy.dynamic.template.save_choice": {
|
||||
"text": "请选择“{name}”的保存方式。",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::save_choice"
|
||||
},
|
||||
"copy.dynamic.template.unsaved_replace": {
|
||||
"text": "当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
|
||||
"scope": "templates",
|
||||
"source": "views/templates.js::unsaved_replace"
|
||||
},
|
||||
"copy.dynamic.animation.page_status": {
|
||||
"text": "第 {page} / {pages} 页 · 共 {total} 帧",
|
||||
"scope": "animations",
|
||||
"source": "views/animations.js::page_status"
|
||||
},
|
||||
"copy.dynamic.workspace_order.edit": {
|
||||
"text": "自定义项目位置",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_edit"
|
||||
},
|
||||
"copy.dynamic.workspace_order.save": {
|
||||
"text": "保存",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_save"
|
||||
},
|
||||
"copy.dynamic.workspace_order.loading": {
|
||||
"text": "正在读取…",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_loading"
|
||||
},
|
||||
"copy.dynamic.workspace_order.move_up": {
|
||||
"text": "上移",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_move_up"
|
||||
},
|
||||
"copy.dynamic.workspace_order.move_down": {
|
||||
"text": "下移",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_move_down"
|
||||
},
|
||||
"copy.dynamic.workspace_order.first_item": {
|
||||
"text": "当前已经是第一项,不能继续上移。",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_first_item"
|
||||
},
|
||||
"copy.dynamic.workspace_order.last_item": {
|
||||
"text": "当前已经是最后一项,不能继续下移。",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_last_item"
|
||||
},
|
||||
"copy.dynamic.workspace_order.saving": {
|
||||
"text": "正在保存…",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_saving"
|
||||
},
|
||||
"copy.dynamic.workspace_order.saved": {
|
||||
"text": "项目位置已保存",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_saved"
|
||||
},
|
||||
"copy.dynamic.workspace_order.save_required": {
|
||||
"text": "请先保存项目位置",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_save_required"
|
||||
},
|
||||
"copy.dynamic.workspace_order.load_failed": {
|
||||
"text": "项目位置读取失败:{reason}",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_load_failed"
|
||||
},
|
||||
"copy.dynamic.workspace_order.save_failed": {
|
||||
"text": "项目位置保存失败:{reason}",
|
||||
"scope": "drawer",
|
||||
"source": "app.js::workspace_order_save_failed"
|
||||
},
|
||||
"copy.dynamic.board.monitor_eyebrow": {
|
||||
"text": "64 × 64 屏幕监视器",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::monitor_eyebrow",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.monitor_title": {
|
||||
"text": "当前屏幕画面",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::monitor_title"
|
||||
},
|
||||
"copy.dynamic.board.monitor_mode": {
|
||||
"text": "预览当前显示屏幕画面",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::monitor_mode",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.composition_eyebrow": {
|
||||
"text": "64 × 64 统一画板",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::composition_eyebrow",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.composition_title": {
|
||||
"text": "合成预览",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::composition_title",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.composition_mode": {
|
||||
"text": "正向 0° · 统一画板",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::composition_mode",
|
||||
"default_hidden": true
|
||||
},
|
||||
"copy.dynamic.board.animation_edit_mode": {
|
||||
"text": "动图帧编辑 · 不会直接更新实屏",
|
||||
"scope": "scene-board",
|
||||
"source": "scene-board.js::animation_edit_mode"
|
||||
},
|
||||
"copy.dynamic.device.current_content": {
|
||||
"text": "当前画面:{name}",
|
||||
"scope": "topbar",
|
||||
"source": "views/device.js::current_content"
|
||||
},
|
||||
"copy.dynamic.device.voltage_ok": {
|
||||
"text": "屏幕输入电压 {volts} V",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_ok"
|
||||
},
|
||||
"copy.dynamic.device.voltage_disconnected": {
|
||||
"text": "电压传感器断开",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_disconnected"
|
||||
},
|
||||
"copy.dynamic.device.voltage_error": {
|
||||
"text": "电压读取异常",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_error"
|
||||
},
|
||||
"copy.dynamic.device.voltage_loading": {
|
||||
"text": "正在读取电压…",
|
||||
"scope": "device",
|
||||
"source": "views/device.js::voltage_loading"
|
||||
},
|
||||
"copy.dynamic.text.font_selected": {
|
||||
"text": "已选择字体“{name}”",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_selected"
|
||||
},
|
||||
"copy.dynamic.text.font_type_error": {
|
||||
"text": "只支持 TTF、OTF、TTC、OTC 字体文件。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_type_error"
|
||||
},
|
||||
"copy.dynamic.text.font_size_error": {
|
||||
"text": "字体文件不能超过 32 MiB。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_size_error"
|
||||
},
|
||||
"copy.dynamic.text.font_importing": {
|
||||
"text": "正在导入字体“{name}”…",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_importing"
|
||||
},
|
||||
"copy.dynamic.text.font_imported": {
|
||||
"text": "字体“{name}”已导入",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_imported"
|
||||
},
|
||||
"copy.dynamic.text.font_imported_applied": {
|
||||
"text": "字体“{name}”已导入并应用到当前文字",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_imported_applied"
|
||||
},
|
||||
"copy.dynamic.text.font_exists": {
|
||||
"text": "字体“{name}”已经存在",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_exists"
|
||||
},
|
||||
"copy.dynamic.text.font_exists_applied": {
|
||||
"text": "字体“{name}”已经存在,已应用到当前文字",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_exists_applied"
|
||||
},
|
||||
"copy.dynamic.text.font_error_stale": {
|
||||
"text": "页面版本已过期,请刷新后再导入字体。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_stale"
|
||||
},
|
||||
"copy.dynamic.text.font_error_timeout": {
|
||||
"text": "字体导入超时,请检查核桃派网络或服务状态后重试。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_timeout"
|
||||
},
|
||||
"copy.dynamic.text.font_error_too_large": {
|
||||
"text": "字体导入失败:文件超过 32 MiB。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_too_large"
|
||||
},
|
||||
"copy.dynamic.text.font_error_type": {
|
||||
"text": "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_type"
|
||||
},
|
||||
"copy.dynamic.text.font_error_invalid": {
|
||||
"text": "字体导入失败:文件损坏或不包含可用字体。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_invalid"
|
||||
},
|
||||
"copy.dynamic.text.font_error_storage": {
|
||||
"text": "字体导入失败:设备存储空间不足。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_storage"
|
||||
},
|
||||
"copy.dynamic.text.font_error_network": {
|
||||
"text": "字体导入失败:无法连接设备,请检查网络后重试。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_network"
|
||||
},
|
||||
"copy.dynamic.text.font_error_unknown": {
|
||||
"text": "字体导入失败:服务器未能完成导入,请稍后重试。",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_error_unknown"
|
||||
},
|
||||
"copy.dynamic.text.font_catalog_failed": {
|
||||
"text": "字体目录读取失败,当前字体值已保留:{error}",
|
||||
"scope": "text",
|
||||
"source": "views/text.js::font_catalog_failed"
|
||||
},
|
||||
"copy.dynamic.media.state_uploading": {"text": "上传中", "scope": "media-import", "source": "views/media-import.js::state_uploading"},
|
||||
"copy.dynamic.media.state_analyzing": {"text": "分析中", "scope": "media-import", "source": "views/media-import.js::state_analyzing"},
|
||||
"copy.dynamic.media.state_awaiting": {"text": "等待设置", "scope": "media-import", "source": "views/media-import.js::state_awaiting"},
|
||||
"copy.dynamic.media.state_queued": {"text": "排队中", "scope": "media-import", "source": "views/media-import.js::state_queued"},
|
||||
"copy.dynamic.media.state_converting": {"text": "转换中", "scope": "media-import", "source": "views/media-import.js::state_converting"},
|
||||
"copy.dynamic.media.state_failed": {"text": "失败", "scope": "media-import", "source": "views/media-import.js::state_failed"},
|
||||
"copy.dynamic.media.queue_position": {"text": "{state} · 队列第 {position} 位", "scope": "media-import", "source": "views/media-import.js::queue_position"},
|
||||
"copy.dynamic.media.preview": {"text": "代表帧 {index}", "scope": "media-import", "source": "views/media-import.js::preview"},
|
||||
"copy.dynamic.media.output_name": {"text": "保存名称", "scope": "media-import", "source": "views/media-import.js::output_name"},
|
||||
"copy.dynamic.media.fit_mode": {"text": "1:1 适配", "scope": "media-import", "source": "views/media-import.js::fit_mode"},
|
||||
"copy.dynamic.media.mode_crop": {"text": "自由取景", "scope": "media-import", "source": "views/media-import.js::mode_crop"},
|
||||
"copy.dynamic.media.mode_contain": {"text": "完整包含", "scope": "media-import", "source": "views/media-import.js::mode_contain"},
|
||||
"copy.dynamic.media.mode_stretch": {"text": "拉伸", "scope": "media-import", "source": "views/media-import.js::mode_stretch"},
|
||||
"copy.dynamic.media.crop_zoom": {"text": "取景缩放", "scope": "media-import", "source": "views/media-import.js::crop_zoom"},
|
||||
"copy.dynamic.media.transparency": {"text": "透明像素填充色", "scope": "media-import", "source": "views/media-import.js::transparency"},
|
||||
"copy.dynamic.media.padding": {"text": "留白颜色", "scope": "media-import", "source": "views/media-import.js::padding"},
|
||||
"copy.dynamic.media.convert": {"text": "保存设置并开始转换", "scope": "media-import", "source": "views/media-import.js::convert"},
|
||||
"copy.dynamic.media.cancel_source": {"text": "取消并删除源文件", "scope": "media-import", "source": "views/media-import.js::cancel_source"},
|
||||
"copy.dynamic.media.retry": {"text": "重试", "scope": "media-import", "source": "views/media-import.js::retry"},
|
||||
"copy.dynamic.media.cancel": {"text": "取消并删除", "scope": "media-import", "source": "views/media-import.js::cancel"},
|
||||
"copy.dynamic.media.deleted": {"text": "任务和源文件已删除。", "scope": "media-import", "source": "views/media-import.js::deleted"},
|
||||
"copy.dynamic.media.delete_failed": {"text": "删除任务失败:{error}", "scope": "media-import", "source": "views/media-import.js::delete_failed"},
|
||||
"copy.dynamic.media.queued": {"text": "任务已加入转换队列。", "scope": "media-import", "source": "views/media-import.js::queued"},
|
||||
"copy.dynamic.media.name_conflict": {"text": "保存名称已存在,请更换名称。", "scope": "media-import", "source": "views/media-import.js::name_conflict"},
|
||||
"copy.dynamic.media.convert_failed": {"text": "无法开始转换:{error}", "scope": "media-import", "source": "views/media-import.js::convert_failed"},
|
||||
"copy.dynamic.media.retry_failed": {"text": "重试失败:{error}", "scope": "media-import", "source": "views/media-import.js::retry_failed"},
|
||||
"copy.dynamic.media.completed": {"text": "“{name}”转换完成,已加入内容库。", "scope": "media-import", "source": "views/media-import.js::completed"},
|
||||
"copy.dynamic.media.empty": {"text": "当前没有等待或转换中的任务。", "scope": "media-import", "source": "views/media-import.js::empty"},
|
||||
"copy.dynamic.media.list_failed": {"text": "任务列表读取失败:{error}", "scope": "media-import", "source": "views/media-import.js::list_failed"},
|
||||
"copy.dynamic.media.uploaded": {"text": "上传完成,设备正在分析媒体。", "scope": "media-import", "source": "views/media-import.js::uploaded"},
|
||||
"copy.dynamic.media.upload_failed": {"text": "上传失败:{error}", "scope": "media-import", "source": "views/media-import.js::upload_failed"},
|
||||
"copy.dynamic.current_display.loading": {"text": "正在读取当前画面…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_loading"},
|
||||
"copy.dynamic.current_display.load_failed": {"text": "当前画面读取失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_load_failed"},
|
||||
"copy.dynamic.current_display.pause": {"text": "暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pause"},
|
||||
"copy.dynamic.current_display.resume": {"text": "继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resume"},
|
||||
"copy.dynamic.current_display.pausing": {"text": "正在暂停…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_pausing"},
|
||||
"copy.dynamic.current_display.resuming": {"text": "正在继续播放…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resuming"},
|
||||
"copy.dynamic.current_display.paused": {"text": "动图已暂停", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_paused"},
|
||||
"copy.dynamic.current_display.resumed": {"text": "动图继续播放", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_resumed"},
|
||||
"copy.dynamic.current_display.speed_changing": {"text": "正在切换到 {speed}×…", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changing"},
|
||||
"copy.dynamic.current_display.speed_changed": {"text": "已切换到 {speed}×", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_speed_changed"},
|
||||
"copy.dynamic.current_display.control_failed": {"text": "动图播放控制失败:{reason}", "scope": "dialog:current-display-dialog", "source": "views/device.js::current_display_control_failed"}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__ || null;
|
||||
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
|
||||
const targetRegistry = new Map();
|
||||
const accessibleNameState = new WeakMap();
|
||||
let activeDraft = bootstrap?.initial_draft || { overrides: {} };
|
||||
|
||||
function formatTemplate(text, values = {}) {
|
||||
return String(text).replace(placeholderPattern, (_match, name) => values[name] ?? `{${name}}`);
|
||||
}
|
||||
|
||||
function preserveAccessibleName(record, text) {
|
||||
const host = record.host;
|
||||
if (!host) return;
|
||||
if (!accessibleNameState.has(host)) accessibleNameState.set(host, host.getAttribute("aria-label"));
|
||||
host.setAttribute("aria-label", text || record.defaultText);
|
||||
}
|
||||
|
||||
function restoreAccessibleName(record) {
|
||||
if (!record.host || !accessibleNameState.has(record.host)) return;
|
||||
const original = accessibleNameState.get(record.host);
|
||||
if (original === null) record.host.removeAttribute("aria-label");
|
||||
else record.host.setAttribute("aria-label", original);
|
||||
accessibleNameState.delete(record.host);
|
||||
}
|
||||
|
||||
function applyRecord(record) {
|
||||
if (!record.target.isConnected) return;
|
||||
const override = activeDraft.overrides?.[record.copyId] || {};
|
||||
const sourceText = override.text ?? record.defaultText;
|
||||
const renderedText = formatTemplate(sourceText, record.values);
|
||||
if (record.attribute) {
|
||||
record.target.setAttribute(record.attribute, renderedText);
|
||||
return;
|
||||
}
|
||||
const hidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(record.defaultHidden);
|
||||
record.target.textContent = hidden ? "" : renderedText;
|
||||
if (hidden) preserveAccessibleName(record, renderedText);
|
||||
else restoreAccessibleName(record);
|
||||
if (override.font_size_px) {
|
||||
if (!record.fontCaptured) {
|
||||
record.originalFontSize = record.target.style.fontSize;
|
||||
record.fontCaptured = true;
|
||||
}
|
||||
record.target.style.fontSize = `${override.font_size_px}px`;
|
||||
} else if (record.fontCaptured) {
|
||||
record.target.style.fontSize = record.originalFontSize;
|
||||
record.fontCaptured = false;
|
||||
}
|
||||
record.target.classList.toggle("ui-copy-has-override", Boolean(activeDraft.overrides?.[record.copyId]));
|
||||
}
|
||||
|
||||
function registerRecord(record) {
|
||||
const previous = targetRegistry.get(record.target);
|
||||
if (previous) {
|
||||
record.originalFontSize = previous.originalFontSize;
|
||||
record.fontCaptured = previous.fontCaptured;
|
||||
}
|
||||
targetRegistry.set(record.target, record);
|
||||
if (bootstrap) record.target.dataset.uiCopyId = record.copyId;
|
||||
applyRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function renderUiCopy(copyId, element, defaultText, values = {}, options = {}) {
|
||||
if (!(element instanceof Element)) return;
|
||||
if (!bootstrap) {
|
||||
const renderedText = formatTemplate(defaultText, values);
|
||||
const record = { host: element, defaultText };
|
||||
element.textContent = options.defaultHidden ? "" : renderedText;
|
||||
if (options.defaultHidden) preserveAccessibleName(record, renderedText);
|
||||
else restoreAccessibleName(record);
|
||||
return;
|
||||
}
|
||||
registerRecord({
|
||||
copyId,
|
||||
target: element,
|
||||
host: element,
|
||||
defaultText,
|
||||
values,
|
||||
kind: "dynamic_template",
|
||||
attribute: null,
|
||||
defaultHidden: Boolean(options.defaultHidden),
|
||||
});
|
||||
}
|
||||
|
||||
export function renderUiValue(element, value) {
|
||||
if (!(element instanceof Element)) return;
|
||||
const priorRecord = targetRegistry.get(element);
|
||||
restoreAccessibleName(priorRecord || { host: element });
|
||||
targetRegistry.delete(element);
|
||||
delete element.dataset.uiCopyId;
|
||||
element.textContent = String(value ?? "");
|
||||
}
|
||||
|
||||
export function registerStaticUiCopy(copyId, target, host, defaultText, kind, attribute = null, defaultHidden = false) {
|
||||
if (!bootstrap || !(target instanceof Element)) return null;
|
||||
return registerRecord({
|
||||
copyId,
|
||||
target,
|
||||
host: host || target,
|
||||
defaultText,
|
||||
values: {},
|
||||
kind,
|
||||
attribute,
|
||||
defaultHidden: Boolean(defaultHidden),
|
||||
});
|
||||
}
|
||||
|
||||
export function setUiCopyDraft(nextDraft) {
|
||||
activeDraft = nextDraft;
|
||||
for (const record of targetRegistry.values()) applyRecord(record);
|
||||
}
|
||||
|
||||
export function applyUiCopyId(copyId) {
|
||||
for (const record of targetRegistry.values()) {
|
||||
if (record.copyId === copyId) applyRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
export function uiCopyRecords(copyId = null) {
|
||||
const records = Array.from(targetRegistry.values()).filter((record) => record.target.isConnected);
|
||||
return copyId === null ? records : records.filter((record) => record.copyId === copyId);
|
||||
}
|
||||
|
||||
export function unregisterUiCopySubtree(root) {
|
||||
for (const [target] of targetRegistry) {
|
||||
if (!target.isConnected || target === root || root?.contains?.(target)) targetRegistry.delete(target);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
import {
|
||||
announce,
|
||||
createLocalState,
|
||||
registerWorkspace,
|
||||
setActiveButton,
|
||||
} from "../core.js";
|
||||
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
|
||||
import { hexToRgb, rgbToHex } from "../color-math.js";
|
||||
import {
|
||||
BRUSH_MODE_NORMAL,
|
||||
BRUSH_MODE_PIXEL,
|
||||
PixelUndoHistory,
|
||||
brushModeSpec,
|
||||
brushStampPoints,
|
||||
clampCanvasView,
|
||||
clampFloatingButton,
|
||||
linePoints,
|
||||
normalizeBrushSize,
|
||||
restoreCanvasToolState,
|
||||
zoomCanvasView,
|
||||
} from "../canvas-tools-model.js";
|
||||
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import {
|
||||
boardPoint,
|
||||
commitPixelChanges,
|
||||
fillPixelLayer,
|
||||
getBoardOverlay,
|
||||
getInteractionMode,
|
||||
getPixelBuffer,
|
||||
renderPixelDraft,
|
||||
} from "../scene-board.js";
|
||||
|
||||
const canvas = getBoardOverlay();
|
||||
const localState = createLocalState("canvas-tools", 2);
|
||||
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
|
||||
const legacyState = createLocalState("canvas", 3).load(null)
|
||||
|| createLocalState("canvas", 2).load(null)
|
||||
|| {};
|
||||
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
|
||||
const paintColor = document.getElementById("paint-color");
|
||||
const backgroundColor = document.getElementById("background-color");
|
||||
const brushSizeInput = document.getElementById("brush-size");
|
||||
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
|
||||
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
|
||||
const pixelEditStatus = document.getElementById("pixel-edit-status");
|
||||
const pixelGrid = document.getElementById("pixel-grid");
|
||||
const boardStage = document.getElementById("board-stage");
|
||||
const canvasWorkbench = document.querySelector(".canvas-workbench");
|
||||
const startEditButton = document.getElementById("start-canvas-edit");
|
||||
const assistive = document.getElementById("canvas-edit-assistive");
|
||||
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
|
||||
const assistiveMenu = document.getElementById("canvas-assistive-menu");
|
||||
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
|
||||
const undoButton = document.getElementById("undo-canvas-edit");
|
||||
const exitEditButton = document.getElementById("exit-canvas-edit");
|
||||
const moveViewButton = document.getElementById("move-canvas-view");
|
||||
const continueEditButton = document.getElementById("continue-canvas-edit");
|
||||
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
|
||||
let tool = savedTools.tool;
|
||||
let previousDrawingTool = tool;
|
||||
let pixelEditEnabled = savedTools.pixelEditEnabled;
|
||||
let normalBrushSize = savedTools.normalBrushSize;
|
||||
let pixelBrushSize = savedTools.pixelBrushSize;
|
||||
let drawing = false;
|
||||
let drawingPointerId = null;
|
||||
let lastPoint = null;
|
||||
let drawingBefore = null;
|
||||
let editorActive = false;
|
||||
let editorMode = "draw";
|
||||
let view = { scale: 1, x: 0, y: 0 };
|
||||
let viewGesture = null;
|
||||
let assistiveDrag = null;
|
||||
let suppressAssistiveClick = false;
|
||||
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
|
||||
const viewPointers = new Map();
|
||||
const undoHistory = new PixelUndoHistory();
|
||||
|
||||
paintColor.value = savedTools.paintColor || paintColor.value;
|
||||
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
|
||||
|
||||
function brushMode() {
|
||||
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
|
||||
}
|
||||
|
||||
function currentBrushSize() {
|
||||
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
|
||||
}
|
||||
|
||||
function setPixel(x, y, rgb) {
|
||||
if (x < 0 || x > 63 || y < 0 || y > 63) return;
|
||||
const offset = (y * 64 + x) * 3;
|
||||
const pixels = getPixelBuffer();
|
||||
pixels[offset] = rgb.r;
|
||||
pixels[offset + 1] = rgb.g;
|
||||
pixels[offset + 2] = rgb.b;
|
||||
}
|
||||
|
||||
function activeRgb() {
|
||||
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
|
||||
}
|
||||
|
||||
function stampBrush(centerX, centerY, rgb) {
|
||||
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
|
||||
.forEach((point) => setPixel(point.x, point.y, rgb));
|
||||
}
|
||||
|
||||
function paintLine(from, to) {
|
||||
const rgb = activeRgb();
|
||||
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
|
||||
}
|
||||
|
||||
function paintEvent(event) {
|
||||
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
|
||||
for (const sample of samples.length ? samples : [event]) {
|
||||
const point = boardPoint(sample);
|
||||
paintLine(lastPoint || point, point);
|
||||
lastPoint = point;
|
||||
}
|
||||
renderPixelDraft();
|
||||
}
|
||||
|
||||
function pixelSnapshot() {
|
||||
return Uint8Array.from(getPixelBuffer());
|
||||
}
|
||||
|
||||
function updateUndoControl() {
|
||||
if (undoHistory.size > 0) setControlReady(undoButton);
|
||||
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
|
||||
}
|
||||
|
||||
function clearUndoHistory() {
|
||||
undoHistory.clear();
|
||||
updateUndoControl();
|
||||
}
|
||||
|
||||
function recordPixelOperation(before) {
|
||||
const recorded = undoHistory.record(before, getPixelBuffer());
|
||||
updateUndoControl();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function viewportBounds() {
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
baseSize: Math.max(1, boardStage.offsetWidth),
|
||||
minVisible: 48,
|
||||
};
|
||||
}
|
||||
|
||||
function applyCanvasView(next = view) {
|
||||
view = clampCanvasView(next, viewportBounds());
|
||||
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
|
||||
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
|
||||
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
|
||||
}
|
||||
|
||||
function resetCanvasView() {
|
||||
view = { scale: 1, x: 0, y: 0 };
|
||||
applyCanvasView();
|
||||
}
|
||||
|
||||
function applyAssistivePosition(next = assistivePosition) {
|
||||
assistivePosition = clampFloatingButton(next, {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
size: 56,
|
||||
inset: 8,
|
||||
});
|
||||
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
|
||||
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
|
||||
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
|
||||
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
|
||||
}
|
||||
|
||||
function resetAssistivePosition() {
|
||||
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
|
||||
}
|
||||
|
||||
function closeAssistiveMenu({ restoreFocus = false } = {}) {
|
||||
assistiveMenu.hidden = true;
|
||||
assistiveToggle.setAttribute("aria-expanded", "false");
|
||||
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
|
||||
if (restoreFocus && editorActive) assistiveToggle.focus();
|
||||
}
|
||||
|
||||
function openAssistiveMenu() {
|
||||
applyAssistivePosition();
|
||||
assistiveMenu.hidden = false;
|
||||
assistiveToggle.setAttribute("aria-expanded", "true");
|
||||
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
|
||||
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
|
||||
firstAvailable?.focus();
|
||||
}
|
||||
|
||||
function toggleAssistiveMenu() {
|
||||
if (assistiveMenu.hidden) openAssistiveMenu();
|
||||
else closeAssistiveMenu({ restoreFocus: true });
|
||||
}
|
||||
|
||||
function updateEditorModeControls() {
|
||||
const moving = editorMode === "move";
|
||||
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
|
||||
else delete document.body.dataset.canvasEditorMode;
|
||||
editorModeHint.textContent = moving
|
||||
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
|
||||
: "绘画模式:直接在画布上绘制。";
|
||||
if (moving) {
|
||||
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
|
||||
setControlReady(continueEditButton);
|
||||
} else {
|
||||
setControlReady(moveViewButton);
|
||||
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
|
||||
}
|
||||
updateUndoControl();
|
||||
}
|
||||
|
||||
function resetViewGesture() {
|
||||
const points = Array.from(viewPointers.values());
|
||||
if (points.length >= 2) {
|
||||
const [first, second] = points;
|
||||
viewGesture = {
|
||||
kind: "pinch",
|
||||
startView: { ...view },
|
||||
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
|
||||
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
|
||||
};
|
||||
} else if (points.length === 1) {
|
||||
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
|
||||
} else {
|
||||
viewGesture = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditorMode(mode) {
|
||||
if (!editorActive) return;
|
||||
if (mode === "move") finishDrawing();
|
||||
editorMode = mode === "move" ? "move" : "draw";
|
||||
viewPointers.clear();
|
||||
viewGesture = null;
|
||||
updateEditorModeControls();
|
||||
closeAssistiveMenu({ restoreFocus: true });
|
||||
announce(editorMode === "move"
|
||||
? "已进入移动画布视线模式:单指拖动,双指缩放"
|
||||
: "已继续编辑画布");
|
||||
}
|
||||
|
||||
function enterCanvasEditor() {
|
||||
if (editorActive || getInteractionMode() !== "pixels") return;
|
||||
editorActive = true;
|
||||
editorMode = "draw";
|
||||
canvas.dataset.canvasEditing = "true";
|
||||
document.body.classList.add("canvas-edit-active");
|
||||
assistive.hidden = false;
|
||||
closeAssistiveMenu();
|
||||
updateEditorModeControls();
|
||||
window.requestAnimationFrame(() => {
|
||||
resetCanvasView();
|
||||
resetAssistivePosition();
|
||||
assistiveToggle.focus();
|
||||
});
|
||||
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
|
||||
}
|
||||
|
||||
function exitCanvasEditor({ returnToTools = true } = {}) {
|
||||
if (!editorActive) return;
|
||||
finishDrawing();
|
||||
editorActive = false;
|
||||
editorMode = "draw";
|
||||
viewPointers.clear();
|
||||
viewGesture = null;
|
||||
closeAssistiveMenu();
|
||||
assistive.hidden = true;
|
||||
delete canvas.dataset.canvasEditing;
|
||||
document.body.classList.remove("canvas-edit-active");
|
||||
delete document.body.dataset.canvasEditorMode;
|
||||
boardStage.style.removeProperty("--canvas-view-scale");
|
||||
boardStage.style.removeProperty("--canvas-view-x");
|
||||
boardStage.style.removeProperty("--canvas-view-y");
|
||||
view = { scale: 1, x: 0, y: 0 };
|
||||
if (returnToTools) {
|
||||
window.requestAnimationFrame(() => {
|
||||
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
startEditButton.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
|
||||
}
|
||||
|
||||
function undoPixelOperation() {
|
||||
const previous = undoHistory.undo();
|
||||
if (!previous) {
|
||||
updateUndoControl();
|
||||
return;
|
||||
}
|
||||
getPixelBuffer().set(previous);
|
||||
commitPixelChanges("pixel-undo");
|
||||
updateUndoControl();
|
||||
announce("已撤销上一个像素操作");
|
||||
}
|
||||
|
||||
function persistTools() {
|
||||
localState.save({
|
||||
paintColor: paintColor.value,
|
||||
backgroundColor: backgroundColor.value,
|
||||
tool,
|
||||
pixelEditEnabled,
|
||||
normalBrushSize,
|
||||
pixelBrushSize,
|
||||
});
|
||||
}
|
||||
|
||||
function renderBrushSize() {
|
||||
const mode = brushMode();
|
||||
const brushSize = currentBrushSize();
|
||||
const spec = brushModeSpec(mode);
|
||||
brushSizeInput.min = String(spec.min);
|
||||
brushSizeInput.max = String(spec.max);
|
||||
brushSizeInput.value = String(brushSize);
|
||||
document.getElementById("brush-size-value").textContent = pixelEditEnabled
|
||||
? `${brushSize} × ${brushSize}`
|
||||
: `${brushSize} px`;
|
||||
const preview = document.getElementById("brush-size-preview");
|
||||
const previewSize = Math.max(4, brushSize * 2);
|
||||
preview.style.width = `${previewSize}px`;
|
||||
preview.style.height = `${previewSize}px`;
|
||||
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
|
||||
brushSizePresets.forEach((button, index) => {
|
||||
const value = spec.presets[index];
|
||||
button.dataset.brushSize = String(value);
|
||||
button.textContent = String(value);
|
||||
});
|
||||
setActiveButton(
|
||||
brushSizePresets,
|
||||
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
|
||||
);
|
||||
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
|
||||
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
|
||||
pixelEditStatus.hidden = !pixelEditEnabled;
|
||||
pixelEditStatus.textContent = pixelEditEnabled
|
||||
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
|
||||
: "";
|
||||
}
|
||||
|
||||
function setBrushSize(value) {
|
||||
const normalized = normalizeBrushSize(value, brushMode());
|
||||
if (pixelEditEnabled) pixelBrushSize = normalized;
|
||||
else normalBrushSize = normalized;
|
||||
renderBrushSize();
|
||||
persistTools();
|
||||
}
|
||||
|
||||
function updatePixelGridVisibility() {
|
||||
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
|
||||
}
|
||||
|
||||
function togglePixelEditMode() {
|
||||
pixelEditEnabled = !pixelEditEnabled;
|
||||
renderBrushSize();
|
||||
updatePixelGridVisibility();
|
||||
persistTools();
|
||||
announce(pixelEditEnabled
|
||||
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
|
||||
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
|
||||
}
|
||||
|
||||
function selectTool(button) {
|
||||
tool = button.dataset.canvasTool;
|
||||
if (tool !== "eyedropper") previousDrawingTool = tool;
|
||||
setActiveButton(toolButtons, button);
|
||||
persistTools();
|
||||
}
|
||||
|
||||
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!editorActive || getInteractionMode() !== "pixels") return;
|
||||
if (editorMode === "move") {
|
||||
event.preventDefault();
|
||||
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
resetViewGesture();
|
||||
return;
|
||||
}
|
||||
if (drawing) return;
|
||||
if (tool === "eyedropper") {
|
||||
const point = boardPoint(event);
|
||||
const offset = (point.y * 64 + point.x) * 3;
|
||||
const pixels = getPixelBuffer();
|
||||
setColorValue(paintColor, rgbToHex({
|
||||
r: pixels[offset],
|
||||
g: pixels[offset + 1],
|
||||
b: pixels[offset + 2],
|
||||
}), { recordHistory: true });
|
||||
tool = previousDrawingTool;
|
||||
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
|
||||
persistTools();
|
||||
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
drawing = true;
|
||||
drawingPointerId = event.pointerId;
|
||||
drawingBefore = pixelSnapshot();
|
||||
lastPoint = boardPoint(event);
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
paintEvent(event);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
|
||||
event.preventDefault();
|
||||
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const points = Array.from(viewPointers.values());
|
||||
if (viewGesture?.kind === "pinch" && points.length >= 2) {
|
||||
const [first, second] = points;
|
||||
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
|
||||
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
|
||||
const scaled = zoomCanvasView(
|
||||
viewGesture.startView,
|
||||
viewGesture.startView.scale * (distance / viewGesture.startDistance),
|
||||
viewGesture.startMidpoint,
|
||||
viewportBounds(),
|
||||
);
|
||||
applyCanvasView({
|
||||
...scaled,
|
||||
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
|
||||
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
|
||||
});
|
||||
} else if (viewGesture?.kind === "pan" && points.length === 1) {
|
||||
applyCanvasView({
|
||||
...viewGesture.startView,
|
||||
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
|
||||
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (drawing && drawingPointerId === event.pointerId && editorActive
|
||||
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
|
||||
});
|
||||
|
||||
function finishDrawing() {
|
||||
if (!drawing) return;
|
||||
drawing = false;
|
||||
drawingPointerId = null;
|
||||
lastPoint = null;
|
||||
const changed = recordPixelOperation(drawingBefore);
|
||||
drawingBefore = null;
|
||||
if (changed) commitPixelChanges("pixel-draw");
|
||||
persistTools();
|
||||
}
|
||||
|
||||
function finishCanvasPointer(event) {
|
||||
if (viewPointers.delete(event.pointerId)) {
|
||||
resetViewGesture();
|
||||
return;
|
||||
}
|
||||
if (drawingPointerId === event.pointerId) finishDrawing();
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerup", finishCanvasPointer);
|
||||
canvas.addEventListener("pointercancel", finishCanvasPointer);
|
||||
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
if (!editorActive || editorMode !== "move") return;
|
||||
event.preventDefault();
|
||||
const factor = Math.exp(-event.deltaY * 0.002);
|
||||
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
}, viewportBounds()));
|
||||
}, { passive: false });
|
||||
|
||||
paintColor.addEventListener("input", persistTools);
|
||||
backgroundColor.addEventListener("input", persistTools);
|
||||
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
|
||||
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
|
||||
pixelEditToggle.addEventListener("click", togglePixelEditMode);
|
||||
document.getElementById("fill-canvas").addEventListener("click", () => {
|
||||
const before = pixelSnapshot();
|
||||
const rgb = hexToRgb(backgroundColor.value);
|
||||
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
|
||||
recordPixelOperation(before);
|
||||
announce("背景颜色已填充到像素层,文字元素保持不变");
|
||||
});
|
||||
document.getElementById("clear-canvas").addEventListener("click", () => {
|
||||
const before = pixelSnapshot();
|
||||
fillPixelLayer([0, 0, 0]);
|
||||
recordPixelOperation(before);
|
||||
announce("像素层已清空,文字元素保持不变");
|
||||
});
|
||||
|
||||
startEditButton.addEventListener("click", enterCanvasEditor);
|
||||
undoButton.addEventListener("click", undoPixelOperation);
|
||||
exitEditButton.addEventListener("click", () => exitCanvasEditor());
|
||||
moveViewButton.addEventListener("click", () => setEditorMode("move"));
|
||||
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
|
||||
|
||||
assistiveToggle.addEventListener("pointerdown", (event) => {
|
||||
if (!editorActive) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
assistiveDrag = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
origin: { ...assistivePosition },
|
||||
moved: false,
|
||||
};
|
||||
assistiveToggle.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
assistiveToggle.addEventListener("pointermove", (event) => {
|
||||
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const dx = event.clientX - assistiveDrag.startX;
|
||||
const dy = event.clientY - assistiveDrag.startY;
|
||||
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
|
||||
if (assistiveDrag.moved) {
|
||||
closeAssistiveMenu();
|
||||
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
|
||||
}
|
||||
});
|
||||
|
||||
function finishAssistiveDrag(event) {
|
||||
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
|
||||
suppressAssistiveClick = assistiveDrag.moved;
|
||||
assistiveDrag = null;
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
|
||||
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
|
||||
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
|
||||
assistiveToggle.addEventListener("click", () => {
|
||||
if (suppressAssistiveClick) {
|
||||
suppressAssistiveClick = false;
|
||||
return;
|
||||
}
|
||||
toggleAssistiveMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!editorActive || event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
|
||||
else exitCanvasEditor();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!editorActive) return;
|
||||
applyCanvasView();
|
||||
applyAssistivePosition();
|
||||
});
|
||||
|
||||
document.addEventListener("matrix:composition-applied", clearUndoHistory);
|
||||
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
|
||||
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
|
||||
|
||||
refreshColorTrigger(paintColor);
|
||||
refreshColorTrigger(backgroundColor);
|
||||
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
|
||||
renderBrushSize();
|
||||
persistTools();
|
||||
updateEditorModeControls();
|
||||
|
||||
registerWorkspace({
|
||||
id: "canvas",
|
||||
title: "像素画布",
|
||||
elementId: "workspace-canvas",
|
||||
order: 30,
|
||||
persistence: "local",
|
||||
capabilities: ["scene-template-save"],
|
||||
onEnter() {
|
||||
renderPixelDraft();
|
||||
updatePixelGridVisibility();
|
||||
},
|
||||
onLeave() {
|
||||
exitCanvasEditor({ returnToTools: false });
|
||||
finishDrawing();
|
||||
pixelGrid.hidden = true;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,960 @@
|
||||
import {
|
||||
announce,
|
||||
apiBlob,
|
||||
apiJson,
|
||||
registerWorkspace,
|
||||
setActiveButton,
|
||||
} from "../core.js";
|
||||
import { refreshCurrentDisplayPreview } from "../scene-board.js";
|
||||
import { refreshColorTrigger } from "../color-picker.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import { renderUiCopy, renderUiValue } from "../ui-copy-runtime.js";
|
||||
import {
|
||||
LatestSeekDispatcher,
|
||||
formatPlaybackTime,
|
||||
projectPlaybackPosition,
|
||||
} from "../current-display-playback.js";
|
||||
|
||||
const statusEls = {
|
||||
orientation: document.getElementById("status-orientation"),
|
||||
brightness: document.getElementById("status-brightness"),
|
||||
currentContent: document.getElementById("topbar-current-content"),
|
||||
cpu: document.getElementById("topbar-cpu"),
|
||||
memory: document.getElementById("topbar-memory"),
|
||||
lowVoltageWarning: document.getElementById("topbar-low-voltage-warning"),
|
||||
voltage: document.getElementById("topbar-voltage"),
|
||||
};
|
||||
const fillButtons = Array.from(document.querySelectorAll("[data-test-fill]"));
|
||||
const customFillButton = document.getElementById("custom-test-fill");
|
||||
const customColor = document.getElementById("test-color");
|
||||
const refreshStatusButton = document.getElementById("refresh-status");
|
||||
const displayTestControls = document.getElementById("display-test-controls");
|
||||
const testBrightness = document.getElementById("test-brightness");
|
||||
const testBrightnessValue = document.getElementById("test-brightness-value");
|
||||
const testBrightnessProtection = document.getElementById("test-brightness-protection");
|
||||
const displayTestFeedback = document.getElementById("display-test-feedback");
|
||||
const exitDisplayTest = document.getElementById("exit-display-test");
|
||||
const storageEls = {
|
||||
total: document.getElementById("device-total-space"),
|
||||
free: document.getElementById("device-free-space"),
|
||||
software: document.getElementById("device-software-size"),
|
||||
templates: document.getElementById("device-template-size"),
|
||||
feedback: document.getElementById("device-storage-feedback"),
|
||||
};
|
||||
const resourceDialog = document.getElementById("resource-details-dialog");
|
||||
const resourceDialogClose = document.getElementById("resource-details-close");
|
||||
const resourceDetailsMessage = document.getElementById("resource-details-message");
|
||||
const resourceCoreList = document.getElementById("resource-core-list");
|
||||
const resourceDetailEls = {
|
||||
cpuAppCores: document.getElementById("resource-cpu-app-cores"),
|
||||
cpuSinglePercent: document.getElementById("resource-cpu-single-percent"),
|
||||
cpuAppPercent: document.getElementById("resource-cpu-app-percent"),
|
||||
cpuTotalPercent: document.getElementById("resource-cpu-total-percent"),
|
||||
memoryApp: document.getElementById("resource-memory-app"),
|
||||
memoryAppPercent: document.getElementById("resource-memory-app-percent"),
|
||||
memoryTotalPercent: document.getElementById("resource-memory-total-percent"),
|
||||
};
|
||||
const currentDisplayDialog = document.getElementById("current-display-dialog");
|
||||
const currentDisplayDialogName = document.getElementById("current-display-dialog-name");
|
||||
const currentDisplayDialogClose = document.getElementById("current-display-dialog-close");
|
||||
const currentDisplayPreviewSurface = document.getElementById("current-display-preview-surface");
|
||||
const currentDisplayPreviewImage = document.getElementById("current-display-preview-image");
|
||||
const currentDisplayPreviewPlaceholder = document.getElementById("current-display-preview-placeholder");
|
||||
const currentDisplayPlaybackControls = document.getElementById("current-display-playback-controls");
|
||||
const currentDisplayProgress = document.getElementById("current-display-progress");
|
||||
const currentDisplayProgressTime = document.getElementById("current-display-progress-time");
|
||||
const currentDisplayPlayPause = document.getElementById("current-display-play-pause");
|
||||
const currentDisplaySpeedButtons = Array.from(document.querySelectorAll("[data-playback-speed]"));
|
||||
const currentDisplayPlaybackFeedback = document.getElementById("current-display-playback-feedback");
|
||||
const currentDisplaySeekUnavailableReason = document.getElementById("current-display-seek-unavailable-reason");
|
||||
let loadedOnce = false;
|
||||
let lastStatusIdentity = null;
|
||||
let latestResources = null;
|
||||
let latestProtection = null;
|
||||
let latestStatus = null;
|
||||
let currentDisplayOpenIdentity = null;
|
||||
let currentDisplayPlayback = null;
|
||||
let currentDisplayPlaybackSampledAt = 0;
|
||||
let currentDisplayPreviewTimer = null;
|
||||
let currentDisplayPreviewPending = false;
|
||||
let currentDisplayPreviewRequested = false;
|
||||
let currentDisplayPreviewSequence = 0;
|
||||
let currentDisplayPreviewUrl = null;
|
||||
let currentDisplayAnimationFrame = null;
|
||||
let currentDisplayDragging = false;
|
||||
let currentDisplayFinalSeekPosition = null;
|
||||
let currentDisplayControlPending = false;
|
||||
let customTestColor = "#40A0FF";
|
||||
let testActionPending = false;
|
||||
let desiredTestBrightness = null;
|
||||
let applyingTestBrightness = false;
|
||||
let storageTimer = null;
|
||||
const STORAGE_REFRESH_INTERVAL_MS = 300000;
|
||||
|
||||
const LOW_VOLTAGE_WARNING_TEXT = Object.freeze({
|
||||
limiting: "电压过低,屏幕亮度限制",
|
||||
critical: "电压过低,屏幕已经禁用,请充电",
|
||||
});
|
||||
|
||||
function finiteNumber(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const number = finiteNumber(value);
|
||||
return number === null ? "-" : `${number.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatMemoryDetailed(bytes) {
|
||||
const number = finiteNumber(bytes);
|
||||
if (number === null || number < 0) return "-";
|
||||
const mebibytes = number / (1024 * 1024);
|
||||
return `${mebibytes.toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatMemoryCompact(bytes) {
|
||||
const number = finiteNumber(bytes);
|
||||
if (number === null || number < 0) return "-";
|
||||
return `${Math.round(number / (1024 * 1024))}M`;
|
||||
}
|
||||
|
||||
function formatCoreEquivalent(value) {
|
||||
const number = finiteNumber(value);
|
||||
return number === null ? "-" : `${number.toFixed(1)}核`;
|
||||
}
|
||||
|
||||
function renderCoreMeters(cpu) {
|
||||
resourceCoreList.replaceChildren();
|
||||
const count = Number.isInteger(Number(cpu.logical_cpu_count))
|
||||
? Math.max(0, Number(cpu.logical_cpu_count))
|
||||
: 0;
|
||||
const values = Array.isArray(cpu.cores_percent) ? cpu.cores_percent : [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const value = finiteNumber(values[index]);
|
||||
const row = document.createElement("div");
|
||||
row.className = "resource-core-row";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `CPU ${index + 1}`;
|
||||
const progress = document.createElement("progress");
|
||||
progress.max = 100;
|
||||
progress.value = value === null ? 0 : Math.max(0, Math.min(100, value));
|
||||
progress.setAttribute("aria-label", `CPU ${index + 1} 占用`);
|
||||
const output = document.createElement("output");
|
||||
output.textContent = formatPercent(value);
|
||||
row.append(label, progress, output);
|
||||
resourceCoreList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateResourceDetails(resources) {
|
||||
const status = resources?.status || "starting";
|
||||
const cpu = resources?.cpu || {};
|
||||
const memory = resources?.memory || {};
|
||||
const coreCount = finiteNumber(cpu.logical_cpu_count);
|
||||
const coreEquivalent = finiteNumber(cpu.application_core_equivalent);
|
||||
resourceDetailEls.cpuAppCores.textContent = coreEquivalent === null
|
||||
? "-"
|
||||
: `${coreEquivalent.toFixed(2)} / ${coreCount ?? "-"} 核`;
|
||||
resourceDetailEls.cpuSinglePercent.textContent = coreEquivalent === null
|
||||
? "-"
|
||||
: formatPercent(coreEquivalent * 100);
|
||||
resourceDetailEls.cpuAppPercent.textContent = formatPercent(cpu.application_percent);
|
||||
resourceDetailEls.cpuTotalPercent.textContent = formatPercent(cpu.total_percent);
|
||||
resourceDetailEls.memoryApp.textContent = formatMemoryDetailed(memory.application_bytes);
|
||||
resourceDetailEls.memoryAppPercent.textContent = formatPercent(memory.application_percent);
|
||||
resourceDetailEls.memoryTotalPercent.textContent = formatPercent(memory.total_percent);
|
||||
renderCoreMeters(cpu);
|
||||
|
||||
if (status === "error") {
|
||||
resourceDetailsMessage.textContent = "资源读取异常,屏幕、电压和其他控制功能不受影响。";
|
||||
} else if (status !== "ok") {
|
||||
resourceDetailsMessage.textContent = "正在收集资源样本…";
|
||||
} else if (coreEquivalent === null) {
|
||||
resourceDetailsMessage.textContent = "正在等待第二个 CPU 差分样本;内存数据已可用。";
|
||||
} else {
|
||||
resourceDetailsMessage.textContent = "";
|
||||
}
|
||||
resourceDetailsMessage.hidden = resourceDetailsMessage.textContent === "";
|
||||
}
|
||||
|
||||
function showTopbarResources(resources) {
|
||||
latestResources = resources || null;
|
||||
const status = resources?.status || "starting";
|
||||
statusEls.cpu.dataset.status = status;
|
||||
statusEls.memory.dataset.status = status;
|
||||
if (status === "error") {
|
||||
statusEls.cpu.textContent = "CPU !";
|
||||
statusEls.memory.textContent = "RAM !";
|
||||
statusEls.cpu.setAttribute("aria-label", "CPU 读取异常,查看资源占用详情");
|
||||
statusEls.memory.setAttribute("aria-label", "内存读取异常,查看资源占用详情");
|
||||
updateResourceDetails(resources);
|
||||
return;
|
||||
}
|
||||
const cpu = resources?.cpu || {};
|
||||
const memory = resources?.memory || {};
|
||||
const cpuCompact = finiteNumber(cpu.application_core_equivalent) === null
|
||||
? "CPU -"
|
||||
: `CPU ${formatCoreEquivalent(cpu.application_core_equivalent)} · ${formatPercent(cpu.total_percent)}`;
|
||||
const memoryCompact = finiteNumber(memory.application_bytes) === null
|
||||
? "RAM -"
|
||||
: `RAM ${formatMemoryCompact(memory.application_bytes)} · ${formatPercent(memory.total_percent)}`;
|
||||
statusEls.cpu.textContent = cpuCompact;
|
||||
statusEls.memory.textContent = memoryCompact;
|
||||
statusEls.cpu.setAttribute("aria-label", `${cpuCompact},查看 CPU 占用详情`);
|
||||
statusEls.memory.setAttribute("aria-label", `${memoryCompact},查看内存占用详情`);
|
||||
updateResourceDetails(resources);
|
||||
}
|
||||
|
||||
function openResourceDetails() {
|
||||
updateResourceDetails(latestResources);
|
||||
if (!resourceDialog.open) resourceDialog.showModal();
|
||||
resourceDialogClose.focus();
|
||||
}
|
||||
|
||||
statusEls.cpu.addEventListener("click", openResourceDetails);
|
||||
statusEls.memory.addEventListener("click", openResourceDetails);
|
||||
|
||||
function activeCurrentDisplayPlayback(status = latestStatus) {
|
||||
const content = status?.state?.current_content;
|
||||
const playback = status?.state?.animation_playback;
|
||||
if (
|
||||
content?.category !== "animation"
|
||||
|| playback?.active !== true
|
||||
|| typeof playback.session_id !== "string"
|
||||
|| playback.animation_id !== content.id
|
||||
) return null;
|
||||
return playback;
|
||||
}
|
||||
|
||||
function currentDisplayIdentity(status = latestStatus) {
|
||||
const content = status?.state?.current_content || {};
|
||||
const playback = activeCurrentDisplayPlayback(status);
|
||||
return [
|
||||
content.category || "unsaved",
|
||||
content.id || "",
|
||||
content.name || "未保存内容",
|
||||
playback?.session_id || "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function setCurrentDisplayPlaybackFeedback(copyId = "", defaultText = "", values = {}, state = "idle") {
|
||||
if (copyId) renderUiCopy(copyId, currentDisplayPlaybackFeedback, defaultText, values);
|
||||
else renderUiValue(currentDisplayPlaybackFeedback, "");
|
||||
currentDisplayPlaybackFeedback.dataset.state = state;
|
||||
currentDisplayPlaybackFeedback.hidden = !defaultText;
|
||||
}
|
||||
|
||||
function syncCurrentDisplayControlAvailability() {
|
||||
const controls = [currentDisplayPlayPause, ...currentDisplaySpeedButtons];
|
||||
if (currentDisplayControlPending) {
|
||||
[currentDisplayProgress, ...controls].forEach((control) => setControlBusy(control));
|
||||
return;
|
||||
}
|
||||
setControlReady(currentDisplayProgress);
|
||||
if (currentDisplayDragging) {
|
||||
const reason = currentDisplaySeekUnavailableReason.textContent || "请先完成当前进度跳转。";
|
||||
controls.forEach((control) => setControlUnavailable(control, reason));
|
||||
} else {
|
||||
controls.forEach((control) => setControlReady(control));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentDisplayPosition(positionMs, { force = false } = {}) {
|
||||
if (!currentDisplayPlayback) return;
|
||||
const total = Math.max(1, Number(currentDisplayPlayback.total_duration_ms) || 1);
|
||||
const position = Math.max(0, Math.min(total - 1, Number(positionMs) || 0));
|
||||
if (force || !currentDisplayDragging) currentDisplayProgress.value = String(Math.floor(position));
|
||||
currentDisplayProgressTime.textContent = `${formatPlaybackTime(position)} / ${formatPlaybackTime(total)}`;
|
||||
}
|
||||
|
||||
function applyCurrentDisplayPlayback(playback, { preservePosition = currentDisplayDragging } = {}) {
|
||||
if (!playback?.active) return;
|
||||
currentDisplayPlayback = { ...playback };
|
||||
currentDisplayPlaybackSampledAt = performance.now();
|
||||
const total = Math.max(1, Number(playback.total_duration_ms) || 1);
|
||||
currentDisplayProgress.max = String(total - 1);
|
||||
renderUiCopy(
|
||||
playback.paused ? "copy.dynamic.current_display.resume" : "copy.dynamic.current_display.pause",
|
||||
currentDisplayPlayPause,
|
||||
playback.paused ? "继续播放" : "暂停",
|
||||
);
|
||||
currentDisplayPlayPause.setAttribute("aria-pressed", String(Boolean(playback.paused)));
|
||||
currentDisplaySpeedButtons.forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(Number(button.dataset.playbackSpeed) === Number(playback.speed)));
|
||||
});
|
||||
if (!preservePosition) renderCurrentDisplayPosition(playback.position_ms, { force: true });
|
||||
syncCurrentDisplayControlAvailability();
|
||||
if (currentDisplayAnimationFrame === null) {
|
||||
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
|
||||
}
|
||||
}
|
||||
|
||||
function animateCurrentDisplayProgress(now) {
|
||||
currentDisplayAnimationFrame = null;
|
||||
if (!currentDisplayDialog.open || currentDisplayPlaybackControls.hidden || !currentDisplayPlayback) return;
|
||||
if (!currentDisplayDragging) {
|
||||
renderCurrentDisplayPosition(
|
||||
projectPlaybackPosition(currentDisplayPlayback, now - currentDisplayPlaybackSampledAt),
|
||||
);
|
||||
}
|
||||
currentDisplayAnimationFrame = window.requestAnimationFrame(animateCurrentDisplayProgress);
|
||||
}
|
||||
|
||||
function scheduleCurrentDisplayPreview(delay = null) {
|
||||
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
|
||||
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
|
||||
const configured = Number(latestStatus?.ui?.preview_refresh_interval_ms);
|
||||
const wait = delay === null ? Math.max(1, Number.isFinite(configured) ? configured : 1000) : delay;
|
||||
currentDisplayPreviewTimer = window.setTimeout(() => {
|
||||
currentDisplayPreviewTimer = null;
|
||||
refreshCurrentDisplayDialogFrame();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
async function refreshCurrentDisplayDialogFrame() {
|
||||
if (!currentDisplayDialog.open || document.visibilityState !== "visible") return;
|
||||
if (currentDisplayPreviewPending) {
|
||||
currentDisplayPreviewRequested = true;
|
||||
return;
|
||||
}
|
||||
currentDisplayPreviewPending = true;
|
||||
currentDisplayPreviewRequested = false;
|
||||
const sequence = currentDisplayPreviewSequence;
|
||||
try {
|
||||
const blob = await apiBlob("/api/display/current-frame");
|
||||
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
|
||||
const nextUrl = URL.createObjectURL(blob);
|
||||
const previousUrl = currentDisplayPreviewUrl;
|
||||
currentDisplayPreviewUrl = nextUrl;
|
||||
currentDisplayPreviewImage.src = nextUrl;
|
||||
currentDisplayPreviewImage.hidden = false;
|
||||
currentDisplayPreviewPlaceholder.hidden = true;
|
||||
if (previousUrl !== null) URL.revokeObjectURL(previousUrl);
|
||||
} catch (error) {
|
||||
if (!currentDisplayDialog.open || sequence !== currentDisplayPreviewSequence) return;
|
||||
currentDisplayPreviewImage.hidden = true;
|
||||
currentDisplayPreviewPlaceholder.hidden = false;
|
||||
renderUiCopy(
|
||||
"copy.dynamic.current_display.load_failed",
|
||||
currentDisplayPreviewPlaceholder,
|
||||
"当前画面读取失败:{reason}",
|
||||
{ reason: error.message },
|
||||
);
|
||||
} finally {
|
||||
currentDisplayPreviewPending = false;
|
||||
if (!currentDisplayDialog.open) return;
|
||||
if (sequence !== currentDisplayPreviewSequence) {
|
||||
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
|
||||
return;
|
||||
}
|
||||
if (currentDisplayPreviewRequested) scheduleCurrentDisplayPreview(0);
|
||||
else scheduleCurrentDisplayPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function requestCurrentDisplayPreviewRefresh() {
|
||||
if (currentDisplayPreviewPending) {
|
||||
currentDisplayPreviewRequested = true;
|
||||
return;
|
||||
}
|
||||
scheduleCurrentDisplayPreview(0);
|
||||
}
|
||||
|
||||
function cleanupCurrentDisplayDialog() {
|
||||
currentDisplayOpenIdentity = null;
|
||||
currentDisplayPlayback = null;
|
||||
currentDisplayDragging = false;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
currentDisplayControlPending = false;
|
||||
currentDisplayPreviewSequence += 1;
|
||||
currentDisplayPreviewRequested = false;
|
||||
seekDispatcher.clear();
|
||||
if (currentDisplayPreviewTimer !== null) window.clearTimeout(currentDisplayPreviewTimer);
|
||||
currentDisplayPreviewTimer = null;
|
||||
if (currentDisplayAnimationFrame !== null) window.cancelAnimationFrame(currentDisplayAnimationFrame);
|
||||
currentDisplayAnimationFrame = null;
|
||||
if (currentDisplayPreviewUrl !== null) URL.revokeObjectURL(currentDisplayPreviewUrl);
|
||||
currentDisplayPreviewUrl = null;
|
||||
currentDisplayPreviewImage.removeAttribute("src");
|
||||
currentDisplayPreviewImage.hidden = true;
|
||||
currentDisplayPreviewPlaceholder.hidden = false;
|
||||
renderUiCopy(
|
||||
"copy.dynamic.current_display.loading",
|
||||
currentDisplayPreviewPlaceholder,
|
||||
"正在读取当前画面…",
|
||||
);
|
||||
setCurrentDisplayPlaybackFeedback();
|
||||
}
|
||||
|
||||
function closeCurrentDisplayDialog() {
|
||||
if (currentDisplayDialog.open) currentDisplayDialog.close();
|
||||
else cleanupCurrentDisplayDialog();
|
||||
}
|
||||
|
||||
async function openCurrentDisplayDialog() {
|
||||
if (currentDisplayDialog.open) return;
|
||||
setControlBusy(statusEls.currentContent);
|
||||
try {
|
||||
await refreshStatus({ source: "manual" });
|
||||
} catch (_error) {
|
||||
if (!latestStatus) return;
|
||||
} finally {
|
||||
setControlReady(statusEls.currentContent);
|
||||
}
|
||||
const content = latestStatus?.state?.current_content || { name: "未保存内容" };
|
||||
const playback = activeCurrentDisplayPlayback();
|
||||
currentDisplayOpenIdentity = currentDisplayIdentity();
|
||||
currentDisplayDialogName.textContent = content.name || "未保存内容";
|
||||
currentDisplayPlaybackControls.hidden = !playback;
|
||||
currentDisplayDialog.showModal();
|
||||
currentDisplayPreviewSequence += 1;
|
||||
if (playback) applyCurrentDisplayPlayback(playback, { preservePosition: false });
|
||||
refreshCurrentDisplayDialogFrame();
|
||||
if (playback) currentDisplayProgress.focus();
|
||||
else currentDisplayPreviewSurface.focus();
|
||||
}
|
||||
|
||||
function syncCurrentDisplayDialogStatus(status) {
|
||||
latestStatus = status;
|
||||
if (!currentDisplayDialog.open) return;
|
||||
const identity = currentDisplayIdentity(status);
|
||||
if (identity !== currentDisplayOpenIdentity) {
|
||||
closeCurrentDisplayDialog();
|
||||
return;
|
||||
}
|
||||
const playback = activeCurrentDisplayPlayback(status);
|
||||
if (playback) applyCurrentDisplayPlayback(playback);
|
||||
}
|
||||
|
||||
async function sendCurrentDisplayPlaybackPatch(fields) {
|
||||
const sessionId = currentDisplayPlayback?.session_id;
|
||||
if (!sessionId) throw new Error("当前动图播放会话已经结束");
|
||||
const result = await apiJson("/api/display/animation-playback", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ session_id: sessionId, ...fields }),
|
||||
});
|
||||
const playback = result.animation_playback;
|
||||
if (
|
||||
currentDisplayDialog.open
|
||||
&& currentDisplayPlayback?.session_id === sessionId
|
||||
&& playback?.session_id === sessionId
|
||||
) {
|
||||
if (latestStatus?.state) latestStatus.state.animation_playback = playback;
|
||||
if (Number.isInteger(result.state_revision) && latestStatus?.service?.instance_id) {
|
||||
lastStatusIdentity = `${latestStatus.service.instance_id}:${result.state_revision}`;
|
||||
}
|
||||
applyCurrentDisplayPlayback(playback);
|
||||
requestCurrentDisplayPreviewRefresh();
|
||||
}
|
||||
return playback;
|
||||
}
|
||||
|
||||
function handleCurrentDisplayPlaybackError(error) {
|
||||
const defaultText = "动图播放控制失败:{reason}";
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
"copy.dynamic.current_display.control_failed",
|
||||
defaultText,
|
||||
{ reason: error.message },
|
||||
"error",
|
||||
);
|
||||
announce(`动图播放控制失败:${error.message}`, true);
|
||||
if (error.status === 409) {
|
||||
closeCurrentDisplayDialog();
|
||||
refreshStatus({ source: "manual" }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCurrentDisplaySeek(positionMs) {
|
||||
const playback = await sendCurrentDisplayPlaybackPatch({ position_ms: Math.round(positionMs) });
|
||||
if (currentDisplayFinalSeekPosition === positionMs) {
|
||||
currentDisplayDragging = false;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
applyCurrentDisplayPlayback(playback, { preservePosition: false });
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
}
|
||||
|
||||
const seekDispatcher = new LatestSeekDispatcher(sendCurrentDisplaySeek, {
|
||||
intervalMs: 100,
|
||||
onError: (error, positionMs) => {
|
||||
if (currentDisplayFinalSeekPosition === positionMs) {
|
||||
currentDisplayDragging = false;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
handleCurrentDisplayPlaybackError(error);
|
||||
},
|
||||
});
|
||||
|
||||
statusEls.currentContent.addEventListener("click", openCurrentDisplayDialog);
|
||||
currentDisplayDialogClose.addEventListener("click", closeCurrentDisplayDialog);
|
||||
currentDisplayDialog.addEventListener("close", cleanupCurrentDisplayDialog);
|
||||
currentDisplayDialog.addEventListener("click", (event) => {
|
||||
if (currentDisplayPlaybackControls.hidden || event.target === currentDisplayDialog) {
|
||||
closeCurrentDisplayDialog();
|
||||
}
|
||||
});
|
||||
|
||||
currentDisplayProgress.addEventListener("pointerdown", () => {
|
||||
currentDisplayDragging = true;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
});
|
||||
currentDisplayProgress.addEventListener("input", () => {
|
||||
currentDisplayDragging = true;
|
||||
currentDisplayFinalSeekPosition = null;
|
||||
const position = Number(currentDisplayProgress.value);
|
||||
renderCurrentDisplayPosition(position, { force: true });
|
||||
syncCurrentDisplayControlAvailability();
|
||||
seekDispatcher.enqueue(position);
|
||||
});
|
||||
function flushCurrentDisplaySeek() {
|
||||
if (!currentDisplayDragging) return;
|
||||
const position = Number(currentDisplayProgress.value);
|
||||
currentDisplayFinalSeekPosition = position;
|
||||
seekDispatcher.enqueue(position, { flush: true });
|
||||
}
|
||||
currentDisplayProgress.addEventListener("change", flushCurrentDisplaySeek);
|
||||
currentDisplayProgress.addEventListener("pointerup", flushCurrentDisplaySeek);
|
||||
currentDisplayProgress.addEventListener("pointercancel", flushCurrentDisplaySeek);
|
||||
|
||||
currentDisplayPlayPause.addEventListener("click", async () => {
|
||||
if (!currentDisplayPlayback || currentDisplayControlPending || currentDisplayDragging) return;
|
||||
currentDisplayControlPending = true;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
currentDisplayPlayback.paused
|
||||
? "copy.dynamic.current_display.resuming"
|
||||
: "copy.dynamic.current_display.pausing",
|
||||
currentDisplayPlayback.paused ? "正在继续播放…" : "正在暂停…",
|
||||
{},
|
||||
"pending",
|
||||
);
|
||||
try {
|
||||
const playback = await sendCurrentDisplayPlaybackPatch({ paused: !currentDisplayPlayback.paused });
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
playback.paused
|
||||
? "copy.dynamic.current_display.paused"
|
||||
: "copy.dynamic.current_display.resumed",
|
||||
playback.paused ? "动图已暂停" : "动图继续播放",
|
||||
{},
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
handleCurrentDisplayPlaybackError(error);
|
||||
} finally {
|
||||
currentDisplayControlPending = false;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
currentDisplaySpeedButtons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const speed = Number(button.dataset.playbackSpeed);
|
||||
if (
|
||||
!currentDisplayPlayback
|
||||
|| currentDisplayControlPending
|
||||
|| currentDisplayDragging
|
||||
|| speed === Number(currentDisplayPlayback.speed)
|
||||
) return;
|
||||
currentDisplayControlPending = true;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
"copy.dynamic.current_display.speed_changing",
|
||||
"正在切换到 {speed}×…",
|
||||
{ speed },
|
||||
"pending",
|
||||
);
|
||||
try {
|
||||
await sendCurrentDisplayPlaybackPatch({ speed });
|
||||
setCurrentDisplayPlaybackFeedback(
|
||||
"copy.dynamic.current_display.speed_changed",
|
||||
"已切换到 {speed}×",
|
||||
{ speed },
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
handleCurrentDisplayPlaybackError(error);
|
||||
} finally {
|
||||
currentDisplayControlPending = false;
|
||||
syncCurrentDisplayControlAvailability();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!currentDisplayDialog.open) return;
|
||||
if (document.visibilityState === "visible") requestCurrentDisplayPreviewRefresh();
|
||||
else if (currentDisplayPreviewTimer !== null) {
|
||||
window.clearTimeout(currentDisplayPreviewTimer);
|
||||
currentDisplayPreviewTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function showTopbarVoltage(voltage) {
|
||||
const status = voltage?.status || "starting";
|
||||
statusEls.voltage.dataset.status = status;
|
||||
if (status === "ok" && Number.isFinite(Number(voltage.volts))) {
|
||||
renderUiCopy(
|
||||
"copy.dynamic.device.voltage_ok",
|
||||
statusEls.voltage,
|
||||
"屏幕输入电压 {volts} V",
|
||||
{ volts: Number(voltage.volts).toFixed(2) },
|
||||
);
|
||||
} else if (status === "disconnected") {
|
||||
renderUiCopy("copy.dynamic.device.voltage_disconnected", statusEls.voltage, "电压传感器断开");
|
||||
} else if (status === "error") {
|
||||
renderUiCopy("copy.dynamic.device.voltage_error", statusEls.voltage, "电压读取异常");
|
||||
} else {
|
||||
renderUiCopy("copy.dynamic.device.voltage_loading", statusEls.voltage, "正在读取电压…");
|
||||
}
|
||||
}
|
||||
|
||||
function showCurrentContent(state) {
|
||||
const name = typeof state?.current_content?.name === "string"
|
||||
? state.current_content.name
|
||||
: "未保存内容";
|
||||
renderUiCopy(
|
||||
"copy.dynamic.device.current_content",
|
||||
statusEls.currentContent,
|
||||
"当前画面:{name}",
|
||||
{ name },
|
||||
);
|
||||
}
|
||||
|
||||
function showTopbarLowVoltageProtection(protection) {
|
||||
const requestedMode = typeof protection?.mode === "string" ? protection.mode : "unavailable";
|
||||
const warningMode = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, requestedMode);
|
||||
const mode = warningMode
|
||||
? requestedMode
|
||||
: ["disabled", "unavailable", "inactive"].includes(requestedMode)
|
||||
? requestedMode
|
||||
: "unavailable";
|
||||
const previousMode = statusEls.lowVoltageWarning.dataset.mode;
|
||||
const visible = Object.prototype.hasOwnProperty.call(LOW_VOLTAGE_WARNING_TEXT, mode);
|
||||
|
||||
statusEls.lowVoltageWarning.dataset.mode = mode;
|
||||
if (!visible) {
|
||||
statusEls.lowVoltageWarning.hidden = true;
|
||||
if (previousMode !== mode) statusEls.lowVoltageWarning.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousMode !== mode) {
|
||||
statusEls.lowVoltageWarning.textContent = LOW_VOLTAGE_WARNING_TEXT[mode];
|
||||
}
|
||||
statusEls.lowVoltageWarning.hidden = false;
|
||||
}
|
||||
|
||||
function showDeviceBrightness(state) {
|
||||
const userBrightness = finiteNumber(state?.brightness);
|
||||
const effectiveBrightness = finiteNumber(state?.effective_brightness);
|
||||
if (userBrightness === null) {
|
||||
statusEls.brightness.textContent = "-";
|
||||
return;
|
||||
}
|
||||
if (effectiveBrightness === null) {
|
||||
statusEls.brightness.textContent = `${userBrightness}%`;
|
||||
return;
|
||||
}
|
||||
statusEls.brightness.textContent = `用户 ${userBrightness}% · 实际 ${effectiveBrightness}%`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = bytes / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && size >= 1024; index += 1) {
|
||||
size /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
function normalizedHex(value) {
|
||||
const match = /^#([0-9A-Fa-f]{6})$/.exec(String(value || "").trim());
|
||||
return match ? `#${match[1].toUpperCase()}` : null;
|
||||
}
|
||||
|
||||
function showTestColor(value, { editable = false } = {}) {
|
||||
const color = normalizedHex(value) || customTestColor;
|
||||
customColor.value = color;
|
||||
customColor.dataset.colorReadonly = String(!editable);
|
||||
customColor.setAttribute("aria-label", editable ? "编辑自定义测试颜色" : "查看测试颜色");
|
||||
refreshColorTrigger(customColor);
|
||||
}
|
||||
|
||||
function setDisplayTestFeedback(text = "", state = "idle") {
|
||||
displayTestFeedback.textContent = text;
|
||||
displayTestFeedback.dataset.state = state;
|
||||
displayTestFeedback.hidden = !text;
|
||||
}
|
||||
|
||||
function showTestProtection(testValue, effectiveValue) {
|
||||
const mode = latestProtection?.mode;
|
||||
if (mode !== "limiting" && mode !== "critical") {
|
||||
testBrightnessProtection.hidden = true;
|
||||
testBrightnessProtection.textContent = "";
|
||||
return;
|
||||
}
|
||||
testBrightnessProtection.dataset.mode = mode;
|
||||
if (mode === "critical") {
|
||||
testBrightnessProtection.textContent = "电压过低,纯色测试暂时由 35% 亮度的低电图标覆盖;退出或恢复电压前,测试颜色和亮度会保留。";
|
||||
} else {
|
||||
const limit = finiteNumber(latestProtection?.brightness_limit_percent);
|
||||
const effective = finiteNumber(effectiveValue);
|
||||
testBrightnessProtection.textContent = `电压过低,当前允许的最高亮度为 ${limit ?? "-"}%;测试设置 ${testValue}%,实际 ${effective ?? "-"}%。`;
|
||||
}
|
||||
testBrightnessProtection.hidden = false;
|
||||
}
|
||||
|
||||
function syncDisplayTest(state, protection = latestProtection) {
|
||||
latestProtection = protection || null;
|
||||
const displayTest = state?.display_test || {};
|
||||
const active = displayTest.active === true;
|
||||
displayTestControls.hidden = !active;
|
||||
if (!active) {
|
||||
setActiveButton(fillButtons, null);
|
||||
customFillButton.classList.remove("active");
|
||||
customFillButton.setAttribute("aria-pressed", "false");
|
||||
showTestColor(customTestColor);
|
||||
showTestProtection(50, state?.effective_brightness);
|
||||
return;
|
||||
}
|
||||
|
||||
const color = normalizedHex(displayTest.color) || customTestColor;
|
||||
const brightness = Math.max(1, Math.min(100, Number(displayTest.brightness) || 50));
|
||||
const matchingFixed = fillButtons.find((button) => button.dataset.testFill === color) || null;
|
||||
setActiveButton(fillButtons, matchingFixed);
|
||||
customFillButton.classList.toggle("active", !matchingFixed);
|
||||
customFillButton.setAttribute("aria-pressed", String(!matchingFixed));
|
||||
testBrightness.value = String(brightness);
|
||||
testBrightnessValue.textContent = `${brightness}%`;
|
||||
showTestColor(color, { editable: !matchingFixed });
|
||||
showTestProtection(brightness, state?.effective_brightness);
|
||||
}
|
||||
|
||||
export async function refreshDeviceStorage() {
|
||||
storageEls.feedback.textContent = "正在读取设备容量…";
|
||||
storageEls.feedback.dataset.state = "pending";
|
||||
storageEls.feedback.hidden = false;
|
||||
try {
|
||||
const data = await apiJson("/api/device-storage");
|
||||
storageEls.total.textContent = formatBytes(data.device_total_bytes);
|
||||
storageEls.free.textContent = formatBytes(data.device_free_bytes);
|
||||
storageEls.software.textContent = formatBytes(data.software_bytes);
|
||||
storageEls.templates.textContent = formatBytes(data.templates_bytes);
|
||||
storageEls.feedback.textContent = "";
|
||||
storageEls.feedback.hidden = true;
|
||||
return data;
|
||||
} catch (error) {
|
||||
storageEls.feedback.textContent = `容量读取失败:${error.message}`;
|
||||
storageEls.feedback.dataset.state = "error";
|
||||
announce(storageEls.feedback.textContent, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshStatus({ source = "manual" } = {}) {
|
||||
try {
|
||||
const data = await apiJson("/api/status");
|
||||
syncCurrentDisplayDialogStatus(data);
|
||||
statusEls.orientation.textContent = `${data.state.orientation}°`;
|
||||
showDeviceBrightness(data.state);
|
||||
showCurrentContent(data.state);
|
||||
showTopbarResources(data.resources);
|
||||
showTopbarLowVoltageProtection(data.power?.low_voltage_protection);
|
||||
showTopbarVoltage(data.power?.screen_input_voltage);
|
||||
syncDisplayTest(data.state, data.power?.low_voltage_protection);
|
||||
const statusIdentity = `${data.service?.instance_id || "legacy"}:${data.state.revision ?? "legacy"}`;
|
||||
const externalChange = source === "poll" && lastStatusIdentity !== null && statusIdentity !== lastStatusIdentity;
|
||||
lastStatusIdentity = statusIdentity;
|
||||
document.dispatchEvent(new CustomEvent("matrix:status", {
|
||||
detail: { ...data, externalChange },
|
||||
}));
|
||||
if (externalChange) {
|
||||
announce("设备状态已由其他页面更新;本页画板未被自动应用");
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (source !== "poll") announce(`状态读取失败:${error.message}`, true);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
fillButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => startDisplayTest(
|
||||
button,
|
||||
button.dataset.testFill,
|
||||
button.textContent.trim(),
|
||||
));
|
||||
});
|
||||
|
||||
async function startDisplayTest(button, color, label, { openEditor = false } = {}) {
|
||||
if (testActionPending) return;
|
||||
testActionPending = true;
|
||||
[...fillButtons, customFillButton].forEach((control) => setControlBusy(control));
|
||||
setDisplayTestFeedback(`正在显示${label}…`, "pending");
|
||||
try {
|
||||
await apiJson("/api/display/test/fill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ color }),
|
||||
});
|
||||
showTestColor(color, { editable: openEditor });
|
||||
setDisplayTestFeedback(`${label}测试已开始`, "success");
|
||||
announce(`${label}测试已开始`);
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
if (openEditor) {
|
||||
showTestColor(color, { editable: true });
|
||||
customColor.click();
|
||||
}
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`纯色测试失败:${error.message}`, "error");
|
||||
announce(`纯色测试失败:${error.message}`, true);
|
||||
} finally {
|
||||
testActionPending = false;
|
||||
[...fillButtons, customFillButton].forEach((control) => setControlReady(control));
|
||||
}
|
||||
}
|
||||
|
||||
customFillButton.addEventListener("click", () => startDisplayTest(
|
||||
customFillButton,
|
||||
customTestColor,
|
||||
"自定义纯色",
|
||||
{ openEditor: true },
|
||||
));
|
||||
|
||||
customColor.addEventListener("change", async () => {
|
||||
if (customColor.dataset.colorReadonly === "true") return;
|
||||
const requested = normalizedHex(customColor.value);
|
||||
if (!requested || requested === customTestColor) return;
|
||||
setControlBusy(customColor, { message: "正在保存并应用自定义纯色…" });
|
||||
setDisplayTestFeedback("正在保存并应用自定义纯色…", "pending");
|
||||
try {
|
||||
const config = await apiJson("/api/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ custom_test_color: requested }),
|
||||
});
|
||||
customTestColor = normalizedHex(config.custom_test_color) || requested;
|
||||
await apiJson("/api/display/test/fill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ color: customTestColor }),
|
||||
});
|
||||
setDisplayTestFeedback(`自定义纯色 ${customTestColor} 已保存并应用`, "success");
|
||||
announce(`自定义纯色 ${customTestColor} 已保存并应用`);
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`自定义纯色保存或应用失败:${error.message}`, "error");
|
||||
announce(displayTestFeedback.textContent, true);
|
||||
await refreshStatus().catch(() => {});
|
||||
} finally {
|
||||
setControlReady(customColor);
|
||||
}
|
||||
});
|
||||
|
||||
async function processTestBrightnessQueue() {
|
||||
if (applyingTestBrightness) return;
|
||||
applyingTestBrightness = true;
|
||||
while (desiredTestBrightness !== null) {
|
||||
const requested = desiredTestBrightness;
|
||||
desiredTestBrightness = null;
|
||||
setDisplayTestFeedback(`正在应用测试亮度 ${requested}%…`, "pending");
|
||||
try {
|
||||
const response = await apiJson("/api/display/test/brightness", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ brightness: requested }),
|
||||
});
|
||||
const applied = Number(response.display_test?.brightness) || requested;
|
||||
testBrightness.value = String(applied);
|
||||
testBrightnessValue.textContent = `${applied}%`;
|
||||
showTestProtection(applied, response.effective_brightness);
|
||||
setDisplayTestFeedback(`测试亮度 ${applied}% 已生效,不会修改用户亮度`, "success");
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`测试亮度应用失败:${error.message}`, "error");
|
||||
announce(displayTestFeedback.textContent, true);
|
||||
if (desiredTestBrightness === null) await refreshStatus().catch(() => {});
|
||||
}
|
||||
}
|
||||
applyingTestBrightness = false;
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus().catch(() => {});
|
||||
}
|
||||
|
||||
function queueTestBrightness(value) {
|
||||
desiredTestBrightness = Math.max(1, Math.min(100, Number(value)));
|
||||
void processTestBrightnessQueue();
|
||||
}
|
||||
|
||||
testBrightness.addEventListener("input", () => {
|
||||
testBrightnessValue.textContent = `${testBrightness.value}%`;
|
||||
showTestProtection(testBrightness.value, null);
|
||||
queueTestBrightness(testBrightness.value);
|
||||
});
|
||||
testBrightness.addEventListener("change", () => queueTestBrightness(testBrightness.value));
|
||||
|
||||
exitDisplayTest.addEventListener("click", async () => {
|
||||
setControlBusy(exitDisplayTest, { label: "正在退出…", message: "正在退出纯色测试…" });
|
||||
setDisplayTestFeedback("正在退出纯色测试…", "pending");
|
||||
try {
|
||||
await apiJson("/api/display/test", { method: "DELETE" });
|
||||
setDisplayTestFeedback();
|
||||
announce("已退出纯色测试,恢复进入测试前的设备状态");
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
} catch (error) {
|
||||
setDisplayTestFeedback(`退出测试失败:${error.message}`, "error");
|
||||
announce(displayTestFeedback.textContent, true);
|
||||
} finally {
|
||||
setControlReady(exitDisplayTest);
|
||||
}
|
||||
});
|
||||
|
||||
refreshStatusButton.addEventListener("click", async () => {
|
||||
setControlBusy(refreshStatusButton, { label: "正在刷新…" });
|
||||
announce("正在刷新设备状态…");
|
||||
try {
|
||||
await refreshStatus();
|
||||
announce("设备状态已刷新");
|
||||
} catch (_error) {
|
||||
// refreshStatus already reports the failure.
|
||||
} finally {
|
||||
setControlReady(refreshStatusButton);
|
||||
}
|
||||
});
|
||||
|
||||
registerWorkspace({
|
||||
id: "device",
|
||||
title: "设备状态和测试",
|
||||
elementId: "workspace-device",
|
||||
order: 10,
|
||||
persistence: "none",
|
||||
async onEnter() {
|
||||
if (!loadedOnce) {
|
||||
loadedOnce = true;
|
||||
try {
|
||||
const config = await apiJson("/api/config");
|
||||
customTestColor = normalizedHex(config.custom_test_color) || customTestColor;
|
||||
showTestColor(customTestColor);
|
||||
} catch (error) {
|
||||
announce(`自定义纯色读取失败:${error.message}`, true);
|
||||
}
|
||||
}
|
||||
await refreshStatus();
|
||||
await refreshDeviceStorage().catch(() => {});
|
||||
window.clearInterval(storageTimer);
|
||||
storageTimer = window.setInterval(() => refreshDeviceStorage().catch(() => {}), STORAGE_REFRESH_INTERVAL_MS);
|
||||
},
|
||||
onLeave() {
|
||||
window.clearInterval(storageTimer);
|
||||
storageTimer = null;
|
||||
},
|
||||
});
|
||||
|
||||
document.addEventListener("matrix:composition-applied", () => refreshStatus().catch(() => {}));
|
||||
@@ -0,0 +1,322 @@
|
||||
import { announce, apiJson } from "../core.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
|
||||
const selectedName = document.getElementById("frp-selected-name");
|
||||
const serviceState = document.getElementById("frp-service-state");
|
||||
const feedback = document.getElementById("frp-feedback");
|
||||
const serviceToggle = document.getElementById("frp-service-toggle");
|
||||
const editButton = document.getElementById("frp-edit-config");
|
||||
const diagnosticsButton = document.getElementById("network-diagnostics-run");
|
||||
const dialog = document.getElementById("frp-config-dialog");
|
||||
const configSelect = document.getElementById("frp-config-select");
|
||||
const configName = document.getElementById("frp-config-name");
|
||||
const configFormat = document.getElementById("frp-config-format");
|
||||
const configContent = document.getElementById("frp-config-content");
|
||||
const configFile = document.getElementById("frp-config-file");
|
||||
const configFeedback = document.getElementById("frp-config-feedback");
|
||||
const newButton = document.getElementById("frp-config-new");
|
||||
const uploadButton = document.getElementById("frp-config-upload");
|
||||
const deleteButton = document.getElementById("frp-config-delete");
|
||||
const closeButton = document.getElementById("frp-config-close");
|
||||
const saveButton = document.getElementById("frp-config-save");
|
||||
const diagnosticsDialog = document.getElementById("network-diagnostics-dialog");
|
||||
const diagnosticsSummary = document.getElementById("network-diagnostics-summary");
|
||||
const diagnosticsChecks = document.getElementById("network-diagnostics-checks");
|
||||
const diagnosticsClose = document.getElementById("network-diagnostics-close");
|
||||
|
||||
const SUPPORTED_SUFFIXES = new Set([".toml", ".yaml", ".yml", ".json", ".ini"]);
|
||||
const MAX_CONFIG_BYTES = 1024 * 1024;
|
||||
let state = null;
|
||||
let editingId = null;
|
||||
let generation = 0;
|
||||
|
||||
function setMessage(element, text, kind = "idle") {
|
||||
element.textContent = text;
|
||||
element.dataset.state = kind;
|
||||
element.hidden = !text;
|
||||
}
|
||||
|
||||
function profileById(id) {
|
||||
return state?.profiles?.find((profile) => profile.id === id) || null;
|
||||
}
|
||||
|
||||
function serviceLabel(service) {
|
||||
if (!service?.installed) return "frpc 未安装";
|
||||
if (service.running) return service.enabled ? "运行中(开机自动启动)" : "运行中";
|
||||
if (service.state === "failed") return "启动失败";
|
||||
return service.enabled ? "等待启动" : "关闭";
|
||||
}
|
||||
|
||||
function renderState(next) {
|
||||
state = next;
|
||||
const selected = profileById(next?.selected_id);
|
||||
selectedName.textContent = selected?.name || "无";
|
||||
serviceState.textContent = serviceLabel(next?.service);
|
||||
const running = next?.service?.running === true;
|
||||
serviceToggle.textContent = running || next?.service?.enabled ? "关闭frp" : "启动frp";
|
||||
if (!next?.available) {
|
||||
setControlUnavailable(serviceToggle, next?.error || "FRP 配置目录不可用。");
|
||||
setMessage(feedback, next?.error || "FRP 配置目录不可用。", "error");
|
||||
} else if (!next?.service?.installed) {
|
||||
setControlUnavailable(serviceToggle, "frpc 系统软件尚未安装。");
|
||||
setMessage(feedback, "frpc 系统软件尚未安装。", "error");
|
||||
} else if (!selected && !running) {
|
||||
setControlUnavailable(serviceToggle, "请先导入或新建 FRP 配置。");
|
||||
setMessage(feedback, "尚未保存 FRP 配置。", "idle");
|
||||
} else {
|
||||
setControlReady(serviceToggle);
|
||||
setMessage(feedback, next?.service?.last_error || "", next?.service?.last_error ? "error" : "idle");
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfileOptions() {
|
||||
configSelect.replaceChildren();
|
||||
if (!state?.profiles?.length) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = "暂无配置";
|
||||
configSelect.append(option);
|
||||
configSelect.disabled = true;
|
||||
} else {
|
||||
configSelect.disabled = false;
|
||||
state.profiles.forEach((profile) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = profile.id;
|
||||
option.textContent = profile.name;
|
||||
configSelect.append(option);
|
||||
});
|
||||
configSelect.value = editingId || state.selected_id || state.profiles[0].id;
|
||||
}
|
||||
deleteButton.disabled = !editingId;
|
||||
}
|
||||
|
||||
async function requestText(path, options = {}) {
|
||||
const response = await fetch(path, { ...options, cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
let message = response.statusText;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function loadProfile(id) {
|
||||
const currentGeneration = generation;
|
||||
const profile = profileById(id);
|
||||
if (!profile) {
|
||||
editingId = null;
|
||||
configName.value = "";
|
||||
configContent.value = "";
|
||||
renderProfileOptions();
|
||||
return;
|
||||
}
|
||||
setMessage(configFeedback, "正在读取配置…");
|
||||
const content = await requestText(`/api/frp/configs/${encodeURIComponent(id)}`);
|
||||
if (generation !== currentGeneration) return;
|
||||
editingId = id;
|
||||
configName.value = profile.name;
|
||||
const suffix = profile.filename.slice(profile.filename.lastIndexOf(".")).toLowerCase();
|
||||
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
|
||||
configContent.value = content;
|
||||
renderProfileOptions();
|
||||
setMessage(configFeedback, "");
|
||||
}
|
||||
|
||||
export async function loadFrpSettings() {
|
||||
const currentGeneration = ++generation;
|
||||
try {
|
||||
const next = await apiJson("/api/frp");
|
||||
if (generation !== currentGeneration) return;
|
||||
renderState(next);
|
||||
} catch (error) {
|
||||
setMessage(feedback, `FRP 状态读取失败:${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export function discardFrpDrafts() {
|
||||
generation += 1;
|
||||
editingId = null;
|
||||
configName.value = "";
|
||||
configContent.value = "";
|
||||
configFile.value = "";
|
||||
setMessage(configFeedback, "");
|
||||
if (dialog.open) dialog.close();
|
||||
if (diagnosticsDialog.open) diagnosticsDialog.close();
|
||||
}
|
||||
|
||||
editButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await loadFrpSettings();
|
||||
editingId = state?.selected_id || state?.profiles?.[0]?.id || null;
|
||||
renderProfileOptions();
|
||||
if (editingId) await loadProfile(editingId);
|
||||
else {
|
||||
configName.value = "";
|
||||
configFormat.value = ".toml";
|
||||
configContent.value = "";
|
||||
}
|
||||
dialog.showModal();
|
||||
(editingId ? configContent : configName).focus();
|
||||
} catch (error) {
|
||||
announce(`打开 FRP 配置失败:${error.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("close", () => {
|
||||
configFile.value = "";
|
||||
setMessage(configFeedback, "");
|
||||
});
|
||||
|
||||
newButton.addEventListener("click", () => {
|
||||
editingId = null;
|
||||
configSelect.value = "";
|
||||
configName.value = "";
|
||||
configFormat.value = ".toml";
|
||||
configContent.value = "";
|
||||
deleteButton.disabled = true;
|
||||
setMessage(configFeedback, "请输入完整配置;保存前会使用 frpc 官方校验。", "idle");
|
||||
configName.focus();
|
||||
});
|
||||
|
||||
uploadButton.addEventListener("click", () => configFile.click());
|
||||
configFile.addEventListener("change", async () => {
|
||||
const file = configFile.files?.[0];
|
||||
if (!file) return;
|
||||
const suffix = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
|
||||
if (!SUPPORTED_SUFFIXES.has(suffix)) {
|
||||
setMessage(configFeedback, "请选择 TOML、YAML、JSON 或 INI 配置文件。", "error");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_CONFIG_BYTES) {
|
||||
setMessage(configFeedback, "配置文件不能超过 1 MiB。", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await file.text();
|
||||
editingId = null;
|
||||
configName.value = file.name.slice(0, -suffix.length);
|
||||
configFormat.value = suffix === ".yml" ? ".yaml" : suffix;
|
||||
configContent.value = content;
|
||||
deleteButton.disabled = true;
|
||||
setMessage(configFeedback, "文件已读取,点击保存后执行官方校验。", "idle");
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `读取文件失败:${error.message}`, "error");
|
||||
} finally {
|
||||
configFile.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
configSelect.addEventListener("change", async () => {
|
||||
const id = configSelect.value;
|
||||
if (!id) return;
|
||||
try {
|
||||
if (id !== state?.selected_id) {
|
||||
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(id)}/select`, { method: "POST" });
|
||||
renderState(result.frp);
|
||||
}
|
||||
await loadProfile(id);
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `切换配置失败:${error.message}`, "error");
|
||||
renderProfileOptions();
|
||||
}
|
||||
});
|
||||
|
||||
saveButton.addEventListener("click", async () => {
|
||||
const name = configName.value.trim();
|
||||
const content = configContent.value;
|
||||
if (!name || !content.trim()) {
|
||||
setMessage(configFeedback, "请输入配置名称和完整配置内容。", "error");
|
||||
return;
|
||||
}
|
||||
setControlBusy(saveButton, { label: "正在校验…", message: "正在使用 frpc 校验配置" });
|
||||
try {
|
||||
const suffix = configFormat.value;
|
||||
const path = editingId
|
||||
? `/api/frp/configs/${encodeURIComponent(editingId)}?name=${encodeURIComponent(name)}`
|
||||
: `/api/frp/configs?filename=${encodeURIComponent(`frpc${suffix}`)}&name=${encodeURIComponent(name)}`;
|
||||
const response = await fetch(path, {
|
||||
method: editingId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: content,
|
||||
cache: "no-store",
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || response.statusText);
|
||||
editingId = result.profile.id;
|
||||
renderState(result.frp);
|
||||
renderProfileOptions();
|
||||
setMessage(configFeedback, result.frp.service.running ? "配置已保存并重启 FRP。" : "配置已保存并通过校验。", "success");
|
||||
announce("FRP 配置已保存");
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `配置保存失败:${error.message}`, "error");
|
||||
} finally {
|
||||
setControlReady(saveButton);
|
||||
}
|
||||
});
|
||||
|
||||
deleteButton.addEventListener("click", async () => {
|
||||
if (!editingId) return;
|
||||
const profile = profileById(editingId);
|
||||
if (!window.confirm(`确认删除 FRP 配置“${profile?.name || "未命名"}”?`)) return;
|
||||
setControlBusy(deleteButton, { label: "正在删除…" });
|
||||
try {
|
||||
const result = await apiJson(`/api/frp/configs/${encodeURIComponent(editingId)}`, { method: "DELETE" });
|
||||
renderState(result.frp);
|
||||
editingId = result.frp.selected_id || result.frp.profiles?.[0]?.id || null;
|
||||
renderProfileOptions();
|
||||
if (editingId) await loadProfile(editingId);
|
||||
else {
|
||||
configName.value = "";
|
||||
configContent.value = "";
|
||||
}
|
||||
announce("FRP 配置已删除");
|
||||
} catch (error) {
|
||||
setMessage(configFeedback, `删除失败:${error.message}`, "error");
|
||||
} finally {
|
||||
setControlReady(deleteButton);
|
||||
}
|
||||
});
|
||||
|
||||
serviceToggle.addEventListener("click", async () => {
|
||||
const stopping = state?.service?.running || state?.service?.enabled;
|
||||
setControlBusy(serviceToggle, { label: stopping ? "正在关闭…" : "正在启动…" });
|
||||
try {
|
||||
const result = await apiJson(`/api/frp/service/${stopping ? "stop" : "start"}`, { method: "POST" });
|
||||
renderState(result.frp);
|
||||
announce(stopping ? "FRP 已关闭" : "FRP 已启动");
|
||||
} catch (error) {
|
||||
setMessage(feedback, `${stopping ? "关闭" : "启动"} FRP 失败:${error.message}`, "error");
|
||||
await loadFrpSettings();
|
||||
}
|
||||
});
|
||||
|
||||
diagnosticsButton.addEventListener("click", async () => {
|
||||
diagnosticsSummary.textContent = "正在检测网络,请稍候…";
|
||||
diagnosticsSummary.dataset.state = "idle";
|
||||
diagnosticsChecks.replaceChildren();
|
||||
diagnosticsDialog.showModal();
|
||||
setControlBusy(diagnosticsButton, { label: "正在检测…" });
|
||||
try {
|
||||
const result = await apiJson("/api/network/diagnostics", { method: "POST" });
|
||||
diagnosticsSummary.textContent = result.diagnosis;
|
||||
diagnosticsSummary.dataset.state = result.overall === "ok" ? "success" : "error";
|
||||
result.checks.forEach((check) => {
|
||||
const item = document.createElement("li");
|
||||
item.dataset.state = check.ok ? "success" : (check.warning ? "warning" : "error");
|
||||
item.textContent = `${check.ok ? "通过" : (check.warning ? "提示" : "失败")}:${check.message}`;
|
||||
diagnosticsChecks.append(item);
|
||||
});
|
||||
} catch (error) {
|
||||
diagnosticsSummary.textContent = `网络检测失败:${error.message}`;
|
||||
diagnosticsSummary.dataset.state = "error";
|
||||
} finally {
|
||||
setControlReady(diagnosticsButton);
|
||||
}
|
||||
});
|
||||
|
||||
diagnosticsClose.addEventListener("click", () => diagnosticsDialog.close());
|
||||
|
||||
document.addEventListener("matrix:status", (event) => {
|
||||
if (event.detail?.frp) renderState(event.detail.frp);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { announce, apiJson, registerWorkspace } from "../core.js";
|
||||
import { clampCropCenter, cropCenterBounds, cropGeometry } from "../media-crop-geometry.js";
|
||||
import { shouldReuseMediaJobCard } from "../media-import-ui-model.js";
|
||||
import { renderUiCopy } from "../ui-copy-runtime.js";
|
||||
|
||||
const form = document.getElementById("media-upload-form");
|
||||
const fileInput = document.getElementById("media-source-file");
|
||||
const uploadButton = document.getElementById("media-upload-button");
|
||||
const uploadProgress = document.getElementById("media-upload-progress");
|
||||
const refreshButton = document.getElementById("refresh-media-imports");
|
||||
const feedback = document.getElementById("media-import-feedback");
|
||||
const jobsRoot = document.getElementById("media-import-jobs");
|
||||
const stateNames = {
|
||||
uploading: ["copy.dynamic.media.state_uploading", "上传中"],
|
||||
analyzing: ["copy.dynamic.media.state_analyzing", "分析中"],
|
||||
awaiting_settings: ["copy.dynamic.media.state_awaiting", "等待设置"],
|
||||
queued: ["copy.dynamic.media.state_queued", "排队中"],
|
||||
converting: ["copy.dynamic.media.state_converting", "转换中"],
|
||||
failed: ["copy.dynamic.media.state_failed", "失败"],
|
||||
};
|
||||
let initialized = false;
|
||||
let pollingTimer = 0;
|
||||
let priorJobs = new Map();
|
||||
let emptyState = null;
|
||||
|
||||
function setFeedback(copyId, defaultText, values = {}, error = false) {
|
||||
renderUiCopy(copyId, feedback, defaultText, values);
|
||||
feedback.dataset.state = error ? "error" : "success";
|
||||
}
|
||||
|
||||
function makeButton(copyId, text, className = "") {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
renderUiCopy(copyId, element, text);
|
||||
if (className) element.className = className;
|
||||
return element;
|
||||
}
|
||||
|
||||
function labelControl(copyId, text, control) {
|
||||
const label = document.createElement("label");
|
||||
const span = document.createElement("span");
|
||||
renderUiCopy(copyId, span, text);
|
||||
label.append(span, control);
|
||||
return label;
|
||||
}
|
||||
|
||||
async function cancelJob(id) {
|
||||
try {
|
||||
await apiJson("/api/media-imports/" + id, { method: "DELETE" });
|
||||
setFeedback("copy.dynamic.media.deleted", "任务和源文件已删除。");
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.delete_failed", "删除任务失败:{error}", { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
function settingsCard(job, card) {
|
||||
const markDirty = () => { card.dataset.localDraft = "true"; };
|
||||
const stage = document.createElement("div");
|
||||
stage.className = "media-crop-stage";
|
||||
stage.dataset.fit = job.settings.fit_mode;
|
||||
const sourcePlane = document.createElement("div");
|
||||
sourcePlane.className = "media-crop-source";
|
||||
const image = document.createElement("img");
|
||||
image.alt = job.filename + " 代表帧";
|
||||
image.src = job.previews[0] || "";
|
||||
sourcePlane.append(image);
|
||||
stage.append(sourcePlane);
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "media-preview-tabs";
|
||||
job.previews.forEach((url, index) => {
|
||||
const tab = makeButton("copy.dynamic.media.preview", "代表帧 " + (index + 1));
|
||||
renderUiCopy("copy.dynamic.media.preview", tab, "代表帧 {index}", { index: index + 1 });
|
||||
tab.setAttribute("aria-pressed", index === 0 ? "true" : "false");
|
||||
tab.addEventListener("click", () => {
|
||||
markDirty();
|
||||
image.src = url;
|
||||
tabs.querySelectorAll("button").forEach((item) => {
|
||||
item.setAttribute("aria-pressed", item === tab ? "true" : "false");
|
||||
});
|
||||
});
|
||||
tabs.append(tab);
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "media-settings-grid";
|
||||
const name = document.createElement("input");
|
||||
name.type = "text"; name.maxLength = 80; name.value = job.name;
|
||||
const fit = document.createElement("select");
|
||||
[
|
||||
["crop", "copy.dynamic.media.mode_crop", "自由取景"],
|
||||
["contain", "copy.dynamic.media.mode_contain", "完整包含"],
|
||||
["stretch", "copy.dynamic.media.mode_stretch", "拉伸"],
|
||||
].forEach((pair) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = pair[0];
|
||||
renderUiCopy(pair[1], option, pair[2]);
|
||||
option.selected = pair[0] === job.settings.fit_mode;
|
||||
fit.append(option);
|
||||
});
|
||||
const zoom = document.createElement("input");
|
||||
zoom.type = "range"; zoom.min = "1"; zoom.max = "16";
|
||||
zoom.step = "0.05"; zoom.value = job.settings.zoom;
|
||||
const transparency = document.createElement("input");
|
||||
transparency.type = "color"; transparency.value = job.settings.transparency_color;
|
||||
const padding = document.createElement("input");
|
||||
padding.type = "color"; padding.value = job.settings.padding_color;
|
||||
grid.append(
|
||||
labelControl("copy.dynamic.media.output_name", "保存名称", name),
|
||||
labelControl("copy.dynamic.media.fit_mode", "1:1 适配", fit),
|
||||
labelControl("copy.dynamic.media.crop_zoom", "取景缩放", zoom),
|
||||
labelControl("copy.dynamic.media.transparency", "透明像素填充色", transparency),
|
||||
labelControl("copy.dynamic.media.padding", "留白颜色", padding),
|
||||
);
|
||||
|
||||
let centerX = Number(job.settings.center_x);
|
||||
let centerY = Number(job.settings.center_y);
|
||||
function sourceSize() {
|
||||
return {
|
||||
width: image.naturalWidth || 1,
|
||||
height: image.naturalHeight || 1,
|
||||
};
|
||||
}
|
||||
function cropBounds() {
|
||||
const source = sourceSize();
|
||||
return cropCenterBounds({
|
||||
viewportWidth: stage.clientWidth || 1,
|
||||
viewportHeight: stage.clientHeight || 1,
|
||||
sourceWidth: source.width,
|
||||
sourceHeight: source.height,
|
||||
zoom: Number(zoom.value),
|
||||
});
|
||||
}
|
||||
function clampCenter() {
|
||||
const clamped = clampCropCenter({ centerX, centerY }, cropBounds());
|
||||
centerX = clamped.centerX;
|
||||
centerY = clamped.centerY;
|
||||
}
|
||||
function updateCropVisual() {
|
||||
stage.dataset.fit = fit.value;
|
||||
stage.style.backgroundColor = padding.value;
|
||||
sourcePlane.style.backgroundColor = transparency.value;
|
||||
const source = sourceSize();
|
||||
if (fit.value === "stretch") {
|
||||
sourcePlane.style.width = "100%";
|
||||
sourcePlane.style.height = "100%";
|
||||
sourcePlane.style.left = "0";
|
||||
sourcePlane.style.top = "0";
|
||||
return;
|
||||
}
|
||||
if (fit.value === "crop") clampCenter();
|
||||
const geometry = cropGeometry({
|
||||
viewportWidth: stage.clientWidth || 1,
|
||||
viewportHeight: stage.clientHeight || 1,
|
||||
sourceWidth: source.width,
|
||||
sourceHeight: source.height,
|
||||
zoom: fit.value === "crop" ? Number(zoom.value) : 1,
|
||||
centerX: fit.value === "crop" ? centerX : 0.5,
|
||||
centerY: fit.value === "crop" ? centerY : 0.5,
|
||||
});
|
||||
sourcePlane.style.width = geometry.width + "px";
|
||||
sourcePlane.style.height = geometry.height + "px";
|
||||
sourcePlane.style.left = geometry.left + "px";
|
||||
sourcePlane.style.top = geometry.top + "px";
|
||||
}
|
||||
name.addEventListener("input", markDirty);
|
||||
fit.addEventListener("change", () => { markDirty(); updateCropVisual(); });
|
||||
zoom.addEventListener("input", () => { markDirty(); updateCropVisual(); });
|
||||
transparency.addEventListener("input", () => { markDirty(); updateCropVisual(); });
|
||||
padding.addEventListener("input", () => { markDirty(); updateCropVisual(); });
|
||||
let drag = null;
|
||||
stage.addEventListener("pointerdown", (event) => {
|
||||
if (fit.value !== "crop") return;
|
||||
drag = { x: event.clientX, y: event.clientY, centerX, centerY };
|
||||
stage.setPointerCapture(event.pointerId);
|
||||
});
|
||||
stage.addEventListener("pointermove", (event) => {
|
||||
if (!drag) return;
|
||||
markDirty();
|
||||
const source = sourceSize();
|
||||
const geometry = cropGeometry({
|
||||
viewportWidth: stage.clientWidth || 1,
|
||||
viewportHeight: stage.clientHeight || 1,
|
||||
sourceWidth: source.width,
|
||||
sourceHeight: source.height,
|
||||
zoom: Number(zoom.value), centerX: drag.centerX, centerY: drag.centerY,
|
||||
});
|
||||
centerX = drag.centerX - (event.clientX - drag.x) / geometry.width;
|
||||
centerY = drag.centerY - (event.clientY - drag.y) / geometry.height;
|
||||
clampCenter();
|
||||
updateCropVisual();
|
||||
});
|
||||
stage.addEventListener("pointerup", () => { drag = null; });
|
||||
stage.addEventListener("pointercancel", () => { drag = null; });
|
||||
image.addEventListener("load", updateCropVisual);
|
||||
const cropResizeObserver = new ResizeObserver(updateCropVisual);
|
||||
cropResizeObserver.observe(stage);
|
||||
card.mediaCropDispose = () => cropResizeObserver.disconnect();
|
||||
updateCropVisual();
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "media-job-actions";
|
||||
const convert = makeButton("copy.dynamic.media.convert", "保存设置并开始转换", "primary-button");
|
||||
const cancel = makeButton("copy.dynamic.media.cancel_source", "取消并删除源文件", "danger-button");
|
||||
actions.append(convert, cancel);
|
||||
convert.addEventListener("click", async () => {
|
||||
convert.disabled = true;
|
||||
try {
|
||||
await apiJson("/api/media-imports/" + job.id + "/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: name.value, fit_mode: fit.value, center_x: centerX, center_y: centerY,
|
||||
zoom: Number(zoom.value), transparency_color: transparency.value,
|
||||
padding_color: padding.value,
|
||||
}),
|
||||
});
|
||||
await apiJson("/api/media-imports/" + job.id + "/convert", { method: "POST", body: "{}" });
|
||||
setFeedback("copy.dynamic.media.queued", "任务已加入转换队列。");
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
if (error.message === "output name already exists") {
|
||||
setFeedback("copy.dynamic.media.name_conflict", "保存名称已存在,请更换名称。", {}, true);
|
||||
} else {
|
||||
setFeedback("copy.dynamic.media.convert_failed", "无法开始转换:{error}", { error: error.message }, true);
|
||||
}
|
||||
convert.disabled = false;
|
||||
}
|
||||
});
|
||||
cancel.addEventListener("click", () => cancelJob(job.id));
|
||||
card.append(tabs, stage, grid, actions);
|
||||
}
|
||||
|
||||
function renderJob(job) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "media-job-card";
|
||||
card.dataset.jobId = job.id;
|
||||
card.dataset.jobState = job.state;
|
||||
card.dataset.serverUpdatedAt = job.updated_at;
|
||||
card.dataset.localDraft = "false";
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "media-job-heading";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = job.name;
|
||||
const state = document.createElement("p");
|
||||
const stateCopy = stateNames[job.state];
|
||||
if (job.queue_position) {
|
||||
renderUiCopy("copy.dynamic.media.queue_position", state, "{state} · 队列第 {position} 位", {
|
||||
state: stateCopy ? stateCopy[1] : job.state, position: job.queue_position,
|
||||
});
|
||||
} else if (stateCopy) {
|
||||
renderUiCopy(stateCopy[0], state, stateCopy[1]);
|
||||
} else {
|
||||
state.textContent = job.state;
|
||||
}
|
||||
heading.append(title, state);
|
||||
const source = document.createElement("p");
|
||||
source.textContent = job.filename + " · " + (job.source_size / 1024 / 1024).toFixed(1) + " MiB";
|
||||
card.append(heading, source);
|
||||
if (job.state === "awaiting_settings") {
|
||||
settingsCard(job, card);
|
||||
return card;
|
||||
}
|
||||
const progress = document.createElement("progress");
|
||||
progress.max = 100; progress.value = job.progress || 0;
|
||||
card.append(progress);
|
||||
if (job.error) {
|
||||
const error = document.createElement("p");
|
||||
error.textContent = job.error; error.className = "settings-feedback";
|
||||
error.dataset.state = "error"; card.append(error);
|
||||
}
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "media-job-actions";
|
||||
if (job.state === "failed") {
|
||||
const retry = makeButton("copy.dynamic.media.retry", "重试", "primary-button");
|
||||
retry.addEventListener("click", async () => {
|
||||
try {
|
||||
await apiJson("/api/media-imports/" + job.id + "/retry", { method: "POST", body: "{}" });
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.retry_failed", "重试失败:{error}", { error: error.message }, true);
|
||||
}
|
||||
});
|
||||
actions.append(retry);
|
||||
}
|
||||
const cancel = makeButton("copy.dynamic.media.cancel", "取消并删除", "danger-button");
|
||||
cancel.addEventListener("click", () => cancelJob(job.id));
|
||||
actions.append(cancel); card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderedCardState(card) {
|
||||
if (!card) return null;
|
||||
return {
|
||||
id: card.dataset.jobId,
|
||||
state: card.dataset.jobState,
|
||||
updatedAt: card.dataset.serverUpdatedAt,
|
||||
dirty: card.dataset.localDraft === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileJobCards(jobs) {
|
||||
const oldCards = new Map(
|
||||
Array.from(jobsRoot.querySelectorAll(":scope > .media-job-card"))
|
||||
.map((card) => [card.dataset.jobId, card]),
|
||||
);
|
||||
if (jobs.length) {
|
||||
emptyState?.remove();
|
||||
jobs.forEach((job, index) => {
|
||||
const oldCard = oldCards.get(job.id) || null;
|
||||
const card = shouldReuseMediaJobCard(renderedCardState(oldCard), job)
|
||||
? oldCard
|
||||
: renderJob(job);
|
||||
if (oldCard && card !== oldCard) {
|
||||
oldCard.mediaCropDispose?.();
|
||||
oldCard.replaceWith(card);
|
||||
}
|
||||
const position = jobsRoot.children[index] || null;
|
||||
if (position !== card) jobsRoot.insertBefore(card, position);
|
||||
oldCards.delete(job.id);
|
||||
});
|
||||
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
|
||||
return;
|
||||
}
|
||||
oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); });
|
||||
if (!emptyState) {
|
||||
emptyState = document.createElement("p");
|
||||
emptyState.className = "media-job-empty";
|
||||
renderUiCopy("copy.dynamic.media.empty", emptyState, "当前没有等待或转换中的任务。");
|
||||
}
|
||||
if (!emptyState.isConnected) jobsRoot.append(emptyState);
|
||||
}
|
||||
|
||||
async function refreshJobs() {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const result = await apiJson("/api/media-imports");
|
||||
const next = new Map(result.jobs.map((job) => [job.id, job]));
|
||||
for (const pair of priorJobs) {
|
||||
const id = pair[0], job = pair[1];
|
||||
if (!next.has(id) && ["queued", "converting"].includes(job.state)) {
|
||||
const message = "“" + job.name + "”转换完成,已加入内容库。";
|
||||
setFeedback("copy.dynamic.media.completed", "“{name}”转换完成,已加入内容库。", { name: job.name });
|
||||
announce(message);
|
||||
document.getElementById("refresh-templates")?.click();
|
||||
document.getElementById("refresh-animations")?.click();
|
||||
}
|
||||
}
|
||||
priorJobs = next;
|
||||
reconcileJobCards(result.jobs);
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.list_failed", "任务列表读取失败:{error}", { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
function upload(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/media-imports?filename=" + encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.responseType = "json";
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) uploadProgress.value = event.loaded * 100 / event.total;
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
|
||||
else reject(new Error(xhr.response?.detail || xhr.statusText || "上传失败"));
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("上传连接中断")));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadButton.disabled = true; uploadProgress.hidden = false; uploadProgress.value = 0;
|
||||
try {
|
||||
await upload(file);
|
||||
form.reset();
|
||||
setFeedback("copy.dynamic.media.uploaded", "上传完成,设备正在分析媒体。");
|
||||
await refreshJobs();
|
||||
} catch (error) {
|
||||
setFeedback("copy.dynamic.media.upload_failed", "上传失败:{error}", { error: error.message }, true);
|
||||
} finally {
|
||||
uploadButton.disabled = false; uploadProgress.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
refreshButton.addEventListener("click", refreshJobs);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
window.clearInterval(pollingTimer);
|
||||
if (!document.hidden && initialized) {
|
||||
refreshJobs(); pollingTimer = window.setInterval(refreshJobs, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
if (!initialized) {
|
||||
initialized = true; pollingTimer = window.setInterval(refreshJobs, 2000);
|
||||
}
|
||||
await refreshJobs();
|
||||
}
|
||||
|
||||
registerWorkspace({
|
||||
id: "media-import", title: "媒体内容转换", elementId: "workspace-media-import",
|
||||
order: 45, persistence: "server",
|
||||
capabilities: ["media-upload", "background-conversion"], onEnter: initialize,
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,627 @@
|
||||
import { announce, apiJson, registerWorkspace } from "../core.js";
|
||||
import {
|
||||
animationEditState,
|
||||
enterTemplateEdit,
|
||||
getScene,
|
||||
markTemplateSaved,
|
||||
ordinarySceneNeedsTemplateSave,
|
||||
refreshCurrentDisplayPreview,
|
||||
templateEditState,
|
||||
} from "../scene-board.js";
|
||||
import { sceneToStorageValue } from "../scene-model.js";
|
||||
import { protectCardDragStart, reorderByKey } from "../sortable-card.js";
|
||||
import { resolveAnimationEditBeforeNavigation } from "./animations.js";
|
||||
import { refreshStatus } from "./device.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import { renderUiCopy } from "../ui-copy-runtime.js";
|
||||
import {
|
||||
createAnimationPreviewGroup,
|
||||
createAnimationThumbnailBox,
|
||||
} from "../animation-thumbnail-preview.js";
|
||||
|
||||
const grid = document.getElementById("template-grid");
|
||||
const feedback = document.getElementById("template-feedback");
|
||||
const refreshButton = document.getElementById("refresh-templates");
|
||||
const selectDefaultButton = document.getElementById("select-default-content");
|
||||
const saveButton = document.getElementById("save-template");
|
||||
const nameDialog = document.getElementById("template-name-dialog");
|
||||
const nameDialogTitle = document.getElementById("template-dialog-title");
|
||||
const nameDialogNote = document.getElementById("template-dialog-note");
|
||||
const nameInput = document.getElementById("template-name-input");
|
||||
const confirmDialog = document.getElementById("template-confirm-dialog");
|
||||
const confirmMessage = document.getElementById("template-confirm-message");
|
||||
const saveChoiceDialog = document.getElementById("template-save-choice-dialog");
|
||||
const saveChoiceMessage = document.getElementById("template-save-choice-message");
|
||||
const saveCurrentButton = document.getElementById("template-save-current");
|
||||
const copyDialog = document.getElementById("library-copy-dialog");
|
||||
const copyTarget = document.getElementById("library-copy-target");
|
||||
|
||||
let loading = false;
|
||||
let animationList = [];
|
||||
let activeAnimation = null;
|
||||
let libraryOrder = { items: [], revision: "" };
|
||||
let draggingLibraryKey = null;
|
||||
let defaultContent = null;
|
||||
let selectingDefault = false;
|
||||
const demoRestrictedMessage = "演示案例不得编辑,请复制";
|
||||
const libraryDragType = "application/x-matrix-library-item";
|
||||
const templateAnimationPreviewGroup = createAnimationPreviewGroup();
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value);
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "-";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = bytes / 1024;
|
||||
let unit = units[0];
|
||||
for (let index = 1; index < units.length && size >= 1024; index += 1) {
|
||||
size /= 1024;
|
||||
unit = units[index];
|
||||
}
|
||||
return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`;
|
||||
}
|
||||
|
||||
function askName(title, initial = "", { note = "" } = {}) {
|
||||
nameDialogTitle.textContent = title;
|
||||
nameDialogNote.textContent = note;
|
||||
nameDialogNote.hidden = !note;
|
||||
nameInput.value = initial;
|
||||
nameInput.required = true;
|
||||
nameDialog.returnValue = "";
|
||||
nameDialog.showModal();
|
||||
window.setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 0);
|
||||
return new Promise((resolve) => {
|
||||
nameDialog.addEventListener("close", () => {
|
||||
resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function askConfirmation(message, { copyId = "", values = {} } = {}) {
|
||||
if (copyId) renderUiCopy(copyId, confirmMessage, message, values);
|
||||
else confirmMessage.textContent = message;
|
||||
confirmDialog.returnValue = "";
|
||||
confirmDialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
confirmDialog.addEventListener("close", () => {
|
||||
resolve(confirmDialog.returnValue === "confirm");
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function actionButton(label, action, className = "") {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
if (className) button.className = className;
|
||||
button.addEventListener("click", () => action(button));
|
||||
return button;
|
||||
}
|
||||
|
||||
function restrictDemoAction(control, message = demoRestrictedMessage) {
|
||||
control.classList.add("demo-restricted-action");
|
||||
setControlUnavailable(control, message);
|
||||
return control;
|
||||
}
|
||||
|
||||
function syncSaveButton() {
|
||||
if (!animationEditState() && templateEditState()) {
|
||||
renderUiCopy("copy.dynamic.template.save_bound", saveButton, "保存模板");
|
||||
} else {
|
||||
renderUiCopy("copy.dynamic.template.save_new", saveButton, "保存为模板");
|
||||
}
|
||||
}
|
||||
|
||||
function askSaveChoice(context) {
|
||||
renderUiCopy(
|
||||
"copy.dynamic.template.save_choice",
|
||||
saveChoiceMessage,
|
||||
"请选择“{name}”的保存方式。",
|
||||
{ name: context.name },
|
||||
);
|
||||
saveCurrentButton.classList.remove("demo-restricted-action");
|
||||
setControlReady(saveCurrentButton);
|
||||
if (context.readOnly) {
|
||||
restrictDemoAction(saveCurrentButton, "演示案例不得覆盖,请另存为新模板");
|
||||
}
|
||||
saveChoiceDialog.returnValue = "";
|
||||
saveChoiceDialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
saveChoiceDialog.addEventListener("close", () => {
|
||||
resolve(["current", "new"].includes(saveChoiceDialog.returnValue)
|
||||
? saveChoiceDialog.returnValue
|
||||
: "cancel");
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmOrdinarySceneReplacement(name) {
|
||||
if (!ordinarySceneNeedsTemplateSave()) return true;
|
||||
return askConfirmation(
|
||||
"当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?",
|
||||
{
|
||||
copyId: "copy.dynamic.template.unsaved_replace",
|
||||
values: { name },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
loading = value;
|
||||
if (value) {
|
||||
setControlBusy(refreshButton);
|
||||
setControlBusy(selectDefaultButton);
|
||||
setControlBusy(saveButton);
|
||||
grid.setAttribute("aria-busy", "true");
|
||||
} else {
|
||||
setControlReady(refreshButton);
|
||||
setControlReady(selectDefaultButton);
|
||||
setControlReady(saveButton);
|
||||
grid.removeAttribute("aria-busy");
|
||||
}
|
||||
grid.toggleAttribute("inert", value);
|
||||
}
|
||||
|
||||
function isDefaultContent(type, id) {
|
||||
return defaultContent?.type === type && defaultContent?.id === id;
|
||||
}
|
||||
|
||||
function syncDefaultSelectionButton() {
|
||||
selectDefaultButton.setAttribute("aria-pressed", String(selectingDefault));
|
||||
selectDefaultButton.classList.toggle("active", selectingDefault);
|
||||
selectDefaultButton.textContent = selectingDefault ? "取消设置默认内容" : "设置默认显示内容";
|
||||
}
|
||||
|
||||
async function chooseDefaultContent(type, item) {
|
||||
await mutate(async () => {
|
||||
defaultContent = await apiJson("/api/display/default-content", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ type, id: item.id }),
|
||||
});
|
||||
selectingDefault = false;
|
||||
syncDefaultSelectionButton();
|
||||
await refreshStatus();
|
||||
}, `已将“${item.name}”设为默认显示内容并立即显示`);
|
||||
}
|
||||
|
||||
function decorateDefaultContent(card, type, item, { available = true } = {}) {
|
||||
if (isDefaultContent(type, item.id)) {
|
||||
card.classList.add("template-default-content");
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "template-default-badge";
|
||||
badge.textContent = "当前默认";
|
||||
card.insertBefore(badge, card.querySelector(".template-card-content-actions"));
|
||||
}
|
||||
if (!selectingDefault) return card;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "template-card-actions template-card-default-actions";
|
||||
const choose = actionButton(
|
||||
isDefaultContent(type, item.id) ? "当前默认" : "设为默认",
|
||||
() => chooseDefaultContent(type, item),
|
||||
"primary-button",
|
||||
);
|
||||
if (isDefaultContent(type, item.id)) {
|
||||
setControlUnavailable(choose, "这项已经是当前默认显示内容。");
|
||||
} else if (!available) {
|
||||
setControlUnavailable(choose, "空动图不能设为默认,请先添加至少一帧。");
|
||||
}
|
||||
actions.append(choose);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
async function mutate(action, successMessage) {
|
||||
setBusy(true);
|
||||
feedback.textContent = "正在处理模板操作…";
|
||||
feedback.dataset.state = "pending";
|
||||
try {
|
||||
await action();
|
||||
announce(successMessage);
|
||||
setBusy(false);
|
||||
await refreshTemplates();
|
||||
} catch (error) {
|
||||
const conflict = error.code !== "APP_STALE" && (error.status === 409 || error.status === 428);
|
||||
feedback.textContent = conflict
|
||||
? "模板已被其他页面修改,列表已刷新;请检查最新内容后重新操作。"
|
||||
: error.message;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
if (conflict) {
|
||||
setBusy(false);
|
||||
await refreshTemplates();
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function appendContentActions(card, { play, edit, copy, rename, remove }) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "template-card-actions template-card-content-actions";
|
||||
remove.classList.add("template-card-delete-action");
|
||||
actions.append(play, edit, copy, rename, remove);
|
||||
card.append(actions);
|
||||
}
|
||||
|
||||
async function playStaticTemplate(template) {
|
||||
await mutate(async () => {
|
||||
await apiJson(`/api/templates/${template.id}/play`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": `"${template.revision}"` },
|
||||
body: "{}",
|
||||
});
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
}, `开始播放“${template.name}”`);
|
||||
}
|
||||
|
||||
async function editStaticTemplate(template) {
|
||||
if (!await resolveAnimationEditBeforeNavigation()) return;
|
||||
if (!await confirmOrdinarySceneReplacement(template.name)) return;
|
||||
setBusy(true);
|
||||
feedback.textContent = `正在读取“${template.name}”…`;
|
||||
feedback.dataset.state = "pending";
|
||||
try {
|
||||
const full = await apiJson(`/api/templates/${template.id}`);
|
||||
enterTemplateEdit(full);
|
||||
window.location.hash = "#canvas";
|
||||
announce(`正在编辑模板“${template.name}”`);
|
||||
} catch (error) {
|
||||
feedback.textContent = `模板读取失败:${error.message}`;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(template) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "template-card";
|
||||
|
||||
const imageBox = document.createElement("div");
|
||||
imageBox.className = "template-image-box";
|
||||
const image = document.createElement("img");
|
||||
image.className = "template-image";
|
||||
image.src = template.thumbnail_url;
|
||||
image.alt = `${template.name} 模板缩略图`;
|
||||
image.width = 64;
|
||||
image.height = 64;
|
||||
image.draggable = false;
|
||||
image.loading = "lazy";
|
||||
image.addEventListener("error", () => {
|
||||
image.hidden = true;
|
||||
imageBox.classList.add("template-image-error");
|
||||
imageBox.setAttribute("aria-label", "缩略图加载失败");
|
||||
});
|
||||
imageBox.appendChild(image);
|
||||
|
||||
const name = document.createElement("h3");
|
||||
name.className = "template-card-name";
|
||||
name.textContent = template.name;
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "content-type-badge";
|
||||
badge.textContent = "静态";
|
||||
const size = document.createElement("p");
|
||||
size.className = "template-card-size";
|
||||
size.textContent = `占用 ${formatBytes(template.size_bytes)}`;
|
||||
|
||||
const play = actionButton("播放", () => playStaticTemplate(template), "primary-button");
|
||||
const edit = actionButton("编辑", () => editStaticTemplate(template));
|
||||
const copy = actionButton("复制", (control) => copyStaticTemplate(template, control));
|
||||
const rename = actionButton("重命名", async () => {
|
||||
const nextName = await askName("重命名模板", template.name);
|
||||
if (!nextName || nextName === template.name) return;
|
||||
mutate(() => apiJson(`/api/templates/${template.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "If-Match": `"${template.revision}"` },
|
||||
body: JSON.stringify({ name: nextName }),
|
||||
}), `模板已重命名为“${nextName}”`);
|
||||
});
|
||||
const remove = actionButton("删除", async () => {
|
||||
if (!await askConfirmation(`永久删除模板“${template.name}”及其缩略图吗?`)) return;
|
||||
mutate(() => apiJson(`/api/templates/${template.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "If-Match": `"${template.revision}"` },
|
||||
}), `模板“${template.name}”已删除`);
|
||||
}, "danger-button");
|
||||
if (template.read_only) {
|
||||
restrictDemoAction(rename);
|
||||
restrictDemoAction(remove);
|
||||
}
|
||||
|
||||
card.append(imageBox, badge, name, size);
|
||||
appendContentActions(card, { play, edit, copy, rename, remove });
|
||||
return decorateDefaultContent(card, "template", template);
|
||||
}
|
||||
|
||||
async function chooseCopyTarget() {
|
||||
const data = await apiJson("/api/animations");
|
||||
copyTarget.replaceChildren(new Option("静态模板", "static"), ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}:${item.revision}`)));
|
||||
copyDialog.returnValue = ""; copyDialog.showModal();
|
||||
await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true }));
|
||||
if (copyDialog.returnValue !== "copy") return null;
|
||||
const [type, animationId, revision] = copyTarget.value.split(":");
|
||||
return { type, animationId, revision };
|
||||
}
|
||||
|
||||
async function copyStaticTemplate(template, control) {
|
||||
setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" });
|
||||
try {
|
||||
const target = await chooseCopyTarget(); if (!target) return;
|
||||
await mutate(() => apiJson("/api/library/copy", { method: "POST", body: JSON.stringify({
|
||||
source_type: "template", source_id: template.id, source_revision: template.revision,
|
||||
destination_type: target.type, destination_animation_id: target.animationId || null, destination_revision: target.revision || null,
|
||||
}) }), `模板“${template.name}”已复制`);
|
||||
} catch (error) {
|
||||
feedback.textContent = `复制目标读取失败:${error.message}`;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
} finally {
|
||||
setControlReady(control);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnimationCard(animation) {
|
||||
const card = document.createElement("article"); card.className = "template-card";
|
||||
const imageBox = createAnimationThumbnailBox(templateAnimationPreviewGroup, animation);
|
||||
const badge = document.createElement("span"); badge.className = "content-type-badge content-type-animation"; badge.textContent = "动图";
|
||||
const name = document.createElement("h3"); name.className = "template-card-name animation-folder-name"; name.textContent = animation.name;
|
||||
name.dataset.hoverTooltip = animation.name;
|
||||
const active = activeAnimation?.id === animation.id;
|
||||
const meta = document.createElement("p"); meta.className = "template-card-size"; meta.textContent = `${animation.frame_count} 帧 · ${(animation.total_duration_ms / 1000).toFixed(2)} 秒 · ${formatBytes(animation.size_bytes)}${active ? activeAnimation.revision === animation.revision ? " · 正在播放" : " · 正在播放旧版本" : ""}`;
|
||||
const play = actionButton("播放", () => mutate(async () => {
|
||||
await apiJson(`/api/animations/${animation.id}/play`, {
|
||||
method: "POST",
|
||||
headers: { "If-Match": `"${animation.revision}"` },
|
||||
body: "{}",
|
||||
});
|
||||
refreshCurrentDisplayPreview();
|
||||
await refreshStatus();
|
||||
}, `开始播放“${animation.name}”`), "primary-button");
|
||||
if (!animation.frame_count) setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。");
|
||||
const copy = actionButton("复制", () => mutate(() => apiJson(`/api/animations/${animation.id}/copy`, { method: "POST", body: "{}" }), `动图“${animation.name}”已复制`));
|
||||
const edit = actionButton("编辑", () => { window.location.hash = "#animations"; document.dispatchEvent(new CustomEvent("matrix:open-animation", { detail: { id: animation.id } })); });
|
||||
const rename = actionButton("重命名", async () => { const next = await askName("重命名动图", animation.name); if (next) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "PATCH", headers: { "If-Match": `"${animation.revision}"` }, body: JSON.stringify({ name: next }) }), "动图已重命名"); });
|
||||
const remove = actionButton("删除", async () => { if (await askConfirmation(`永久删除动图“${animation.name}”及全部帧吗?`)) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "DELETE", headers: { "If-Match": `"${animation.revision}"` } }), "动图已删除"); }, "danger-button");
|
||||
if (animation.read_only) {
|
||||
restrictDemoAction(edit);
|
||||
restrictDemoAction(rename);
|
||||
restrictDemoAction(remove);
|
||||
}
|
||||
card.append(imageBox, badge, name, meta);
|
||||
appendContentActions(card, { play, edit, copy, rename, remove });
|
||||
return decorateDefaultContent(card, "animation", animation, { available: animation.frame_count > 0 });
|
||||
}
|
||||
|
||||
function libraryItemKey(item) {
|
||||
return `${item.type}:${item.id}`;
|
||||
}
|
||||
|
||||
function clearLibraryDragIndicators() {
|
||||
grid.querySelectorAll(".library-drop-target").forEach((card) => card.classList.remove("library-drop-target"));
|
||||
}
|
||||
|
||||
function saveLibraryOrder(keys) {
|
||||
const byKey = new Map(libraryOrder.items.map((item) => [libraryItemKey(item), item]));
|
||||
const items = keys.map((key) => byKey.get(key)).filter(Boolean);
|
||||
if (items.length !== libraryOrder.items.length) return;
|
||||
mutate(() => apiJson("/api/library/order", {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": `"${libraryOrder.revision}"` },
|
||||
body: JSON.stringify({ items }),
|
||||
}), "模板顺序已更新");
|
||||
}
|
||||
|
||||
function moveLibraryItem(index, offset) {
|
||||
const keys = libraryOrder.items.map(libraryItemKey);
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= keys.length) return;
|
||||
[keys[index], keys[target]] = [keys[target], keys[index]];
|
||||
saveLibraryOrder(keys);
|
||||
}
|
||||
|
||||
function prepareDemoCard(card) {
|
||||
card.classList.add("library-demo-fixed");
|
||||
card.querySelectorAll("img").forEach((image) => { image.draggable = false; });
|
||||
protectCardDragStart(card, { locked: true });
|
||||
const orderActions = document.createElement("div");
|
||||
orderActions.className = "template-card-actions template-card-order-actions";
|
||||
const moveUp = restrictDemoAction(
|
||||
actionButton("上移", () => {}),
|
||||
"演示案例固定在最前,不能移动。",
|
||||
);
|
||||
const moveDown = restrictDemoAction(
|
||||
actionButton("下移", () => {}),
|
||||
"演示案例固定在最前,不能移动。",
|
||||
);
|
||||
orderActions.append(moveUp, moveDown);
|
||||
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
|
||||
return card;
|
||||
}
|
||||
|
||||
function prepareSortableLibraryCard(card, item, index, total) {
|
||||
const key = libraryItemKey(item);
|
||||
card.classList.add("sortable-card", "library-sortable-card");
|
||||
card.dataset.libraryKey = key;
|
||||
protectCardDragStart(card);
|
||||
|
||||
const orderActions = document.createElement("div");
|
||||
orderActions.className = "template-card-actions template-card-order-actions";
|
||||
const moveUp = actionButton("上移", () => moveLibraryItem(index, -1));
|
||||
const moveDown = actionButton("下移", () => moveLibraryItem(index, 1));
|
||||
if (index === 0) setControlUnavailable(moveUp, "两个演示案例固定在最前,当前已经是第一项。");
|
||||
if (index === total - 1) setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。");
|
||||
orderActions.append(moveUp, moveDown);
|
||||
card.insertBefore(orderActions, card.querySelector(".template-card-default-actions"));
|
||||
|
||||
card.addEventListener("dragstart", (event) => {
|
||||
if (event.defaultPrevented) return;
|
||||
draggingLibraryKey = key;
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(libraryDragType, key);
|
||||
event.dataTransfer.setData("text/plain", key);
|
||||
});
|
||||
card.addEventListener("dragover", (event) => {
|
||||
if (!draggingLibraryKey || draggingLibraryKey === key) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
clearLibraryDragIndicators();
|
||||
card.classList.add("library-drop-target");
|
||||
});
|
||||
card.addEventListener("dragleave", (event) => {
|
||||
if (!card.contains(event.relatedTarget)) card.classList.remove("library-drop-target");
|
||||
});
|
||||
card.addEventListener("drop", (event) => {
|
||||
if (!draggingLibraryKey) return;
|
||||
event.preventDefault();
|
||||
const source = event.dataTransfer.getData(libraryDragType) || draggingLibraryKey;
|
||||
const keys = libraryOrder.items.map(libraryItemKey);
|
||||
const reordered = reorderByKey(keys, source, key);
|
||||
clearLibraryDragIndicators();
|
||||
if (reordered) saveLibraryOrder(reordered);
|
||||
});
|
||||
card.addEventListener("dragend", () => {
|
||||
draggingLibraryKey = null;
|
||||
clearLibraryDragIndicators();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
export async function refreshTemplates() {
|
||||
if (loading) return;
|
||||
setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取模板和动图…" });
|
||||
feedback.textContent = "正在读取模板…";
|
||||
feedback.dataset.state = "pending";
|
||||
try {
|
||||
const [data, animations, order, selectedDefault] = await Promise.all([
|
||||
apiJson("/api/templates?include_demo=true"),
|
||||
apiJson("/api/animations?include_demo=true"),
|
||||
apiJson("/api/library/order"),
|
||||
apiJson("/api/display/default-content"),
|
||||
]);
|
||||
defaultContent = selectedDefault;
|
||||
animationList = animations.animations;
|
||||
libraryOrder = order;
|
||||
const demoCards = [
|
||||
...data.templates.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "static" })),
|
||||
...animationList.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "animation" })),
|
||||
].sort((left, right) => left.demo_order - right.demo_order);
|
||||
const userItems = [
|
||||
...data.templates.filter((item) => !item.is_demo).map((item) => ({ type: "template", id: item.id, value: item })),
|
||||
...animationList.filter((item) => !item.is_demo).map((item) => ({ type: "animation", id: item.id, value: item })),
|
||||
];
|
||||
const userByKey = new Map(userItems.map((item) => [libraryItemKey(item), item]));
|
||||
const orderedUsers = order.items.map((item) => userByKey.get(libraryItemKey(item))).filter(Boolean);
|
||||
templateAnimationPreviewGroup.clear();
|
||||
const renderedDemos = demoCards.map((item) => prepareDemoCard(item.content_type === "static" ? renderCard(item) : renderAnimationCard(item)));
|
||||
const renderedUsers = orderedUsers.map((item, index) => prepareSortableLibraryCard(
|
||||
item.type === "template" ? renderCard(item.value) : renderAnimationCard(item.value),
|
||||
item,
|
||||
index,
|
||||
orderedUsers.length,
|
||||
));
|
||||
grid.replaceChildren(...renderedDemos, ...renderedUsers);
|
||||
if (!userItems.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "template-empty panel";
|
||||
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
|
||||
grid.appendChild(empty);
|
||||
}
|
||||
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
|
||||
feedback.dataset.state = "ready";
|
||||
announce(feedback.textContent);
|
||||
} catch (error) {
|
||||
feedback.textContent = `模板读取失败:${error.message}`;
|
||||
feedback.dataset.state = "error";
|
||||
announce(feedback.textContent, true);
|
||||
} finally {
|
||||
setControlReady(refreshButton);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
|
||||
const name = await askName("保存为模板", initial, { note });
|
||||
if (!name) return;
|
||||
await mutate(async () => {
|
||||
const result = await apiJson("/api/templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, scene: sceneToStorageValue(getScene()) }),
|
||||
});
|
||||
if (bind) markTemplateSaved(result);
|
||||
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-save" } }));
|
||||
return result;
|
||||
}, `模板“${name}”已保存`);
|
||||
}
|
||||
|
||||
async function saveSceneToCurrentTemplate(context) {
|
||||
await mutate(async () => {
|
||||
const result = await apiJson(`/api/templates/${context.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "If-Match": `"${context.revision}"` },
|
||||
body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }),
|
||||
});
|
||||
markTemplateSaved(result);
|
||||
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-update" } }));
|
||||
return result;
|
||||
}, `模板“${context.name}”已保存`);
|
||||
}
|
||||
|
||||
saveButton.addEventListener("click", async () => {
|
||||
if (animationEditState()) {
|
||||
await saveSceneAsNew({
|
||||
note: "当前帧将被保存为一个静态图模板",
|
||||
bind: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const context = templateEditState();
|
||||
if (!context) {
|
||||
await saveSceneAsNew();
|
||||
return;
|
||||
}
|
||||
const choice = await askSaveChoice(context);
|
||||
if (choice === "current") {
|
||||
await saveSceneToCurrentTemplate(context);
|
||||
} else if (choice === "new") {
|
||||
await saveSceneAsNew({ initial: `${context.name} - 副本` });
|
||||
}
|
||||
});
|
||||
|
||||
refreshButton.addEventListener("click", refreshTemplates);
|
||||
selectDefaultButton.addEventListener("click", async () => {
|
||||
if (loading) return;
|
||||
selectingDefault = !selectingDefault;
|
||||
syncDefaultSelectionButton();
|
||||
announce(selectingDefault ? "请选择下方内容设为默认显示" : "已取消设置默认显示内容");
|
||||
await refreshTemplates();
|
||||
});
|
||||
document.addEventListener("matrix:library-change", refreshTemplates);
|
||||
document.addEventListener("matrix:template-edit-change", syncSaveButton);
|
||||
document.addEventListener("matrix:animation-edit-change", syncSaveButton);
|
||||
document.addEventListener("matrix:status", (event) => {
|
||||
const state = event.detail?.state;
|
||||
activeAnimation = state?.animation_active ? { id: state.animation_id, revision: state.animation_revision } : null;
|
||||
});
|
||||
|
||||
registerWorkspace({
|
||||
id: "templates",
|
||||
title: "模板管理",
|
||||
elementId: "workspace-templates",
|
||||
order: 60,
|
||||
persistence: "server",
|
||||
onEnter: async () => {
|
||||
templateAnimationPreviewGroup.setActive(true);
|
||||
await refreshTemplates();
|
||||
},
|
||||
onLeave: () => {
|
||||
templateAnimationPreviewGroup.setActive(false);
|
||||
templateAnimationPreviewGroup.clear();
|
||||
},
|
||||
});
|
||||
|
||||
syncDefaultSelectionButton();
|
||||
syncSaveButton();
|
||||
@@ -0,0 +1,349 @@
|
||||
import { announce, apiFile, apiJson, registerWorkspace } from "../core.js";
|
||||
import { refreshColorTrigger } from "../color-picker.js";
|
||||
import { createFontPicker } from "../font-picker.js";
|
||||
import { renderUiCopy } from "../ui-copy-runtime.js";
|
||||
import {
|
||||
runControlAction,
|
||||
setControlReady,
|
||||
setControlUnavailable,
|
||||
} from "../interaction-feedback.js";
|
||||
import {
|
||||
addTextElement,
|
||||
deleteTextElement,
|
||||
duplicateTextElement,
|
||||
getScene,
|
||||
getSelectedTextElement,
|
||||
selectTextElement,
|
||||
subscribeScene,
|
||||
updateTextElement,
|
||||
} from "../scene-board.js";
|
||||
|
||||
const form = document.getElementById("text-form");
|
||||
const fields = document.getElementById("text-fields");
|
||||
const contentFields = document.getElementById("text-content-fields");
|
||||
const list = document.getElementById("text-element-list");
|
||||
const selectionStatus = document.getElementById("text-selection-status");
|
||||
const duplicateButton = document.getElementById("duplicate-text");
|
||||
const deleteButton = document.getElementById("delete-text");
|
||||
const colorTrigger = document.getElementById("text-color");
|
||||
const fontInput = document.getElementById("text-font");
|
||||
const fontOptions = document.getElementById("text-font-options");
|
||||
const importFontButton = document.getElementById("import-font");
|
||||
const fontFileInput = document.getElementById("font-file");
|
||||
const fontFeedback = document.getElementById("font-feedback");
|
||||
let initialized = false;
|
||||
let suppressForm = false;
|
||||
let renderedSelectionId = null;
|
||||
let defaults = { font: "default", size: 12 };
|
||||
let fontCatalogReady = false;
|
||||
let fontMaxUploadBytes = 32 * 1024 * 1024;
|
||||
let acceptedFontExtensions = [".ttf", ".otf", ".ttc", ".otc"];
|
||||
|
||||
const fontPicker = createFontPicker({
|
||||
input: fontInput,
|
||||
listbox: fontOptions,
|
||||
onCommit(item) {
|
||||
const selected = getSelectedTextElement();
|
||||
if (!selected) return;
|
||||
updateTextElement(selected.id, { font: item.id }, "form");
|
||||
setFontFeedbackTemplate(
|
||||
"copy.dynamic.text.font_selected",
|
||||
"已选择字体“{name}”",
|
||||
{ name: item.label },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function field(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setFontFeedback(message = "", isError = false) {
|
||||
fontFeedback.textContent = message;
|
||||
fontFeedback.hidden = !message;
|
||||
fontFeedback.dataset.state = isError ? "error" : "success";
|
||||
}
|
||||
|
||||
function setFontFeedbackTemplate(copyId, defaultText, values = {}, isError = false) {
|
||||
renderUiCopy(copyId, fontFeedback, defaultText, values);
|
||||
fontFeedback.hidden = false;
|
||||
fontFeedback.dataset.state = isError ? "error" : "success";
|
||||
}
|
||||
|
||||
function updateFontPickerAvailability(element = getSelectedTextElement()) {
|
||||
if (!fontCatalogReady) {
|
||||
fontPicker.setEnabled(false);
|
||||
setControlUnavailable(fontInput, "字体目录尚未加载完成。");
|
||||
} else if (!element) {
|
||||
fontPicker.setEnabled(false);
|
||||
setControlUnavailable(fontInput, "请先新建或选择一个文字元素;字体导入仍可使用。");
|
||||
} else {
|
||||
setControlReady(fontInput);
|
||||
fontPicker.setEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
function applyElementToFields(element) {
|
||||
suppressForm = true;
|
||||
if (element) {
|
||||
field("text-value").value = element.text;
|
||||
fontPicker.setValue(element.font);
|
||||
field("text-size").value = String(element.size);
|
||||
field("text-x").value = String(element.x);
|
||||
field("text-y").value = String(element.y);
|
||||
field("text-align").value = element.align;
|
||||
colorTrigger.value = element.color;
|
||||
} else {
|
||||
field("text-value").value = "";
|
||||
fontPicker.setValue(defaults.font);
|
||||
field("text-size").value = String(defaults.size);
|
||||
field("text-x").value = "32";
|
||||
field("text-y").value = "26";
|
||||
field("text-align").value = "center";
|
||||
colorTrigger.value = "#FFFFFF";
|
||||
}
|
||||
refreshColorTrigger(colorTrigger);
|
||||
contentFields.toggleAttribute("disabled", !element);
|
||||
fields.toggleAttribute("disabled", !element);
|
||||
updateFontPickerAvailability(element);
|
||||
renderedSelectionId = element?.id || null;
|
||||
suppressForm = false;
|
||||
}
|
||||
|
||||
function displayName(element, index) {
|
||||
const text = element.text.trim() || "(空文字)";
|
||||
return `${index + 1}. ${text.replace(/\s+/g, " ").slice(0, 28)}`;
|
||||
}
|
||||
|
||||
function renderElementList(scene, selectedId) {
|
||||
const items = scene.elements.map((element, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "text-element-item";
|
||||
button.textContent = displayName(element, index);
|
||||
button.title = `${element.text || "空文字"} · ${element.size}px · ${element.color}`;
|
||||
const selected = element.id === selectedId;
|
||||
button.classList.toggle("active", selected);
|
||||
button.setAttribute("aria-pressed", String(selected));
|
||||
button.addEventListener("click", () => selectTextElement(element.id));
|
||||
return button;
|
||||
});
|
||||
list.replaceChildren(...items);
|
||||
}
|
||||
|
||||
function renderSelection({ scene, selectedTextId, reason }) {
|
||||
const selected = scene.elements.find((element) => element.id === selectedTextId) || null;
|
||||
renderElementList(scene, selectedTextId);
|
||||
if (selected) {
|
||||
setControlReady(duplicateButton);
|
||||
setControlReady(deleteButton);
|
||||
} else {
|
||||
setControlUnavailable(duplicateButton, "请先新建或选择一个文字元素。");
|
||||
setControlUnavailable(deleteButton, "请先新建或选择一个文字元素。");
|
||||
}
|
||||
selectionStatus.textContent = selected
|
||||
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
|
||||
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
|
||||
const shouldRefreshFields = renderedSelectionId !== selectedTextId
|
||||
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
|
||||
if (shouldRefreshFields) applyElementToFields(selected);
|
||||
}
|
||||
|
||||
function numericValue(id, fallback) {
|
||||
const raw = field(id).value.trim();
|
||||
const value = Number(raw);
|
||||
return raw && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function formPatch(element) {
|
||||
return {
|
||||
text: field("text-value").value,
|
||||
font: fontPicker.getValue() || "default",
|
||||
size: numericValue("text-size", element.size),
|
||||
x: numericValue("text-x", element.x),
|
||||
y: numericValue("text-y", element.y),
|
||||
align: field("text-align").value,
|
||||
color: colorTrigger.value,
|
||||
};
|
||||
}
|
||||
|
||||
form.addEventListener("input", (event) => {
|
||||
if (event.target === fontInput || event.target === fontFileInput) return;
|
||||
if (suppressForm) return;
|
||||
const selected = getSelectedTextElement();
|
||||
if (!selected) return;
|
||||
updateTextElement(selected.id, formPatch(selected), "form");
|
||||
});
|
||||
|
||||
document.getElementById("create-text").addEventListener("click", () => {
|
||||
const created = addTextElement({
|
||||
text: "ok",
|
||||
font: defaults.font,
|
||||
size: defaults.size,
|
||||
x: 32,
|
||||
y: 26,
|
||||
align: "center",
|
||||
color: "#FFFFFF",
|
||||
});
|
||||
announce(`已创建文字“${created.text}”,编辑完成后再应用统一画板`);
|
||||
});
|
||||
|
||||
duplicateButton.addEventListener("click", () => {
|
||||
const duplicate = duplicateTextElement();
|
||||
if (duplicate) announce(`已复制文字“${duplicate.text}”并置于最上层`);
|
||||
});
|
||||
|
||||
deleteButton.addEventListener("click", () => {
|
||||
const selected = getSelectedTextElement();
|
||||
if (selected && deleteTextElement(selected.id)) announce(`已删除文字“${selected.text || "空文字"}”`);
|
||||
});
|
||||
|
||||
subscribeScene(renderSelection);
|
||||
|
||||
async function loadFontCatalog() {
|
||||
const catalog = await apiJson("/api/fonts");
|
||||
fontPicker.setItems(catalog.items);
|
||||
acceptedFontExtensions = Array.isArray(catalog.accepted_extensions)
|
||||
? catalog.accepted_extensions.map((value) => String(value).toLowerCase())
|
||||
: acceptedFontExtensions;
|
||||
fontMaxUploadBytes = Number(catalog.max_upload_bytes) || fontMaxUploadBytes;
|
||||
fontCatalogReady = true;
|
||||
updateFontPickerAvailability();
|
||||
if (catalog.warnings?.length) {
|
||||
setFontFeedback(catalog.warnings.join(";"), true);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function validateFontFile(file) {
|
||||
const extension = file.name.includes(".")
|
||||
? `.${file.name.split(".").at(-1).toLowerCase()}`
|
||||
: "";
|
||||
if (!acceptedFontExtensions.includes(extension)) {
|
||||
return "type";
|
||||
}
|
||||
if (file.size > fontMaxUploadBytes) {
|
||||
return "size";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function describeFontImportError(error) {
|
||||
if (error?.code === "APP_STALE") {
|
||||
return ["copy.dynamic.text.font_error_stale", "页面版本已过期,请刷新后再导入字体。"];
|
||||
}
|
||||
if (error?.code === "REQUEST_TIMEOUT") {
|
||||
return ["copy.dynamic.text.font_error_timeout", "字体导入超时,请检查核桃派网络或服务状态后重试。"];
|
||||
}
|
||||
const messages = {
|
||||
413: ["copy.dynamic.text.font_error_too_large", "字体导入失败:文件超过 32 MiB。"],
|
||||
415: ["copy.dynamic.text.font_error_type", "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。"],
|
||||
422: ["copy.dynamic.text.font_error_invalid", "字体导入失败:文件损坏或不包含可用字体。"],
|
||||
507: ["copy.dynamic.text.font_error_storage", "字体导入失败:设备存储空间不足。"],
|
||||
};
|
||||
if (messages[error?.status]) return messages[error.status];
|
||||
if (error instanceof TypeError) {
|
||||
return ["copy.dynamic.text.font_error_network", "字体导入失败:无法连接设备,请检查网络后重试。"];
|
||||
}
|
||||
return ["copy.dynamic.text.font_error_unknown", "字体导入失败:服务器未能完成导入,请稍后重试。"];
|
||||
}
|
||||
|
||||
importFontButton.addEventListener("click", () => fontFileInput.click());
|
||||
|
||||
fontFileInput.addEventListener("change", async () => {
|
||||
const file = fontFileInput.files?.[0];
|
||||
fontFileInput.value = "";
|
||||
if (!file) return;
|
||||
const validationError = validateFontFile(file);
|
||||
if (validationError) {
|
||||
const copy = validationError === "type"
|
||||
? ["copy.dynamic.text.font_type_error", "只支持 TTF、OTF、TTC、OTC 字体文件。"]
|
||||
: ["copy.dynamic.text.font_size_error", "字体文件不能超过 32 MiB。"];
|
||||
setFontFeedbackTemplate(copy[0], copy[1], {}, true);
|
||||
announce(fontFeedback.textContent, true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setFontFeedbackTemplate(
|
||||
"copy.dynamic.text.font_importing",
|
||||
"正在导入字体“{name}”…",
|
||||
{ name: file.name },
|
||||
);
|
||||
announce(fontFeedback.textContent);
|
||||
const result = await runControlAction(
|
||||
importFontButton,
|
||||
{ label: "正在导入…" },
|
||||
async () => apiFile(
|
||||
`/api/fonts/import?filename=${encodeURIComponent(file.name)}`,
|
||||
file,
|
||||
{ method: "POST" },
|
||||
),
|
||||
);
|
||||
await loadFontCatalog();
|
||||
const selected = getSelectedTextElement();
|
||||
const imported = result.fonts?.[0] || null;
|
||||
if (selected && imported) {
|
||||
fontPicker.setValue(imported.id);
|
||||
updateTextElement(selected.id, { font: imported.id }, "form");
|
||||
}
|
||||
const name = imported?.label || file.name;
|
||||
const feedback = result.created
|
||||
? (selected
|
||||
? ["copy.dynamic.text.font_imported_applied", "字体“{name}”已导入并应用到当前文字"]
|
||||
: ["copy.dynamic.text.font_imported", "字体“{name}”已导入"])
|
||||
: (selected
|
||||
? ["copy.dynamic.text.font_exists_applied", "字体“{name}”已经存在,已应用到当前文字"]
|
||||
: ["copy.dynamic.text.font_exists", "字体“{name}”已经存在"]);
|
||||
setFontFeedbackTemplate(feedback[0], feedback[1], { name });
|
||||
announce(fontFeedback.textContent);
|
||||
} catch (error) {
|
||||
const feedback = describeFontImportError(error);
|
||||
setFontFeedbackTemplate(feedback[0], feedback[1], {}, true);
|
||||
announce(fontFeedback.textContent, true);
|
||||
}
|
||||
});
|
||||
|
||||
async function initializeTextWorkspace() {
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
const [configResult, fontsResult] = await Promise.allSettled([
|
||||
apiJson("/api/config"),
|
||||
loadFontCatalog(),
|
||||
]);
|
||||
if (configResult.status === "fulfilled") {
|
||||
const config = configResult.value;
|
||||
defaults = {
|
||||
font: config.default_font || "default",
|
||||
size: Number(config.default_text_size) || 12,
|
||||
};
|
||||
} else {
|
||||
announce(`默认文字设置读取失败,已使用内置默认值:${configResult.reason.message}`, true);
|
||||
}
|
||||
if (fontsResult.status === "rejected") {
|
||||
fontCatalogReady = false;
|
||||
updateFontPickerAvailability();
|
||||
setFontFeedbackTemplate(
|
||||
"copy.dynamic.text.font_catalog_failed",
|
||||
"字体目录读取失败,当前字体值已保留:{error}",
|
||||
{ error: fontsResult.reason.message },
|
||||
true,
|
||||
);
|
||||
announce(fontFeedback.textContent, true);
|
||||
}
|
||||
}
|
||||
if (!getSelectedTextElement()) {
|
||||
const topmost = getScene().elements.at(-1);
|
||||
if (topmost) selectTextElement(topmost.id);
|
||||
else applyElementToFields(null);
|
||||
}
|
||||
}
|
||||
|
||||
registerWorkspace({
|
||||
id: "text",
|
||||
title: "文字显示",
|
||||
elementId: "workspace-text",
|
||||
order: 40,
|
||||
persistence: "local",
|
||||
capabilities: ["scene-template-save"],
|
||||
onEnter: initializeTextWorkspace,
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { announce, apiJson } from "../core.js";
|
||||
import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
|
||||
import { createLatchedDraftState } from "../wifi-draft-state.js";
|
||||
|
||||
const ssidInput = document.getElementById("wifi-ssid");
|
||||
const passwordInput = document.getElementById("wifi-password");
|
||||
const dhcpToggle = document.getElementById("wifi-dhcp-enabled");
|
||||
const dhcpState = document.getElementById("wifi-dhcp-state");
|
||||
const dhcpRow = dhcpToggle.closest(".wifi-dhcp-row");
|
||||
const manualFields = document.getElementById("wifi-manual-fields");
|
||||
const addressInput = document.getElementById("wifi-address");
|
||||
const gatewayInput = document.getElementById("wifi-gateway");
|
||||
const prefixInput = document.getElementById("wifi-prefix");
|
||||
const dnsInput = document.getElementById("wifi-dns");
|
||||
const immediateButton = document.getElementById("wifi-apply-immediate");
|
||||
const nextBootButton = document.getElementById("wifi-apply-next-boot");
|
||||
const activeStatus = document.getElementById("wifi-active-status");
|
||||
const feedback = document.getElementById("wifi-feedback");
|
||||
const delayInput = document.getElementById("wifi-prompt-delay");
|
||||
const delaySaveButton = document.getElementById("wifi-prompt-delay-save");
|
||||
const delayFeedback = document.getElementById("wifi-prompt-feedback");
|
||||
|
||||
const networkFields = new Map([
|
||||
["ssid", { control: ssidInput, marker: document.getElementById("wifi-ssid-modified"), highlight: ssidInput }],
|
||||
["password", { control: passwordInput, marker: document.getElementById("wifi-password-modified"), highlight: passwordInput }],
|
||||
["dhcp", { control: dhcpToggle, marker: document.getElementById("wifi-dhcp-modified"), highlight: dhcpRow }],
|
||||
["address", { control: addressInput, marker: document.getElementById("wifi-address-modified"), highlight: addressInput }],
|
||||
["gateway", { control: gatewayInput, marker: document.getElementById("wifi-gateway-modified"), highlight: gatewayInput }],
|
||||
["prefix", { control: prefixInput, marker: document.getElementById("wifi-prefix-modified"), highlight: prefixInput }],
|
||||
["dns", { control: dnsInput, marker: document.getElementById("wifi-dns-modified"), highlight: dnsInput }],
|
||||
]);
|
||||
const networkDraft = createLatchedDraftState(networkFields.keys());
|
||||
const delayDraft = createLatchedDraftState(["prompt_delay_seconds"]);
|
||||
|
||||
let wifiPending = false;
|
||||
let delayPending = false;
|
||||
let settingsLoaded = false;
|
||||
let loadGeneration = 0;
|
||||
let baseline = null;
|
||||
|
||||
function setFeedback(message, state = "idle") {
|
||||
feedback.textContent = message;
|
||||
feedback.dataset.state = state;
|
||||
feedback.hidden = !message;
|
||||
}
|
||||
|
||||
function setDelayFeedback(message, state = "idle") {
|
||||
delayFeedback.textContent = message;
|
||||
delayFeedback.dataset.state = state;
|
||||
delayFeedback.hidden = !message;
|
||||
}
|
||||
|
||||
function showDhcpMode() {
|
||||
const enabled = dhcpToggle.checked;
|
||||
dhcpState.textContent = enabled ? "开启" : "关闭";
|
||||
manualFields.hidden = enabled;
|
||||
addressInput.required = !enabled;
|
||||
gatewayInput.required = !enabled;
|
||||
}
|
||||
|
||||
function splitDns(value) {
|
||||
return value.split(/[,,\s]+/u).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function showStatus(data) {
|
||||
const active = data.active || {};
|
||||
if (active.connected) {
|
||||
activeStatus.textContent = `当前已连接:${active.ssid || "未知 WiFi"} · ${active.ipv4_address || "等待 IPv4"}`;
|
||||
} else if (data.available === false) {
|
||||
activeStatus.textContent = `网络状态不可用:${data.error || "NetworkManager 无响应"}`;
|
||||
} else {
|
||||
activeStatus.textContent = "当前未连接 WiFi";
|
||||
}
|
||||
}
|
||||
|
||||
function formStateFromResponse(data) {
|
||||
const saved = data.saved || {};
|
||||
return {
|
||||
ssid: saved.ssid || "",
|
||||
password: saved.password || "",
|
||||
passwordConfigured: Boolean(saved.password_configured),
|
||||
dhcp: (saved.ipv4_mode || "dhcp") === "dhcp",
|
||||
address: saved.address || "",
|
||||
gateway: saved.gateway || "",
|
||||
prefix: saved.prefix ?? "",
|
||||
dns: (saved.dns_servers || []).join(", "),
|
||||
promptDelay: data.prompt_delay_seconds ?? 30,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFormState(state) {
|
||||
if (!state) return;
|
||||
ssidInput.value = state.ssid;
|
||||
passwordInput.value = state.password;
|
||||
passwordInput.placeholder = state.passwordConfigured ? "" : "请输入 8..63 字节密码";
|
||||
dhcpToggle.checked = state.dhcp;
|
||||
addressInput.value = state.address;
|
||||
gatewayInput.value = state.gateway;
|
||||
prefixInput.value = state.prefix;
|
||||
dnsInput.value = state.dns;
|
||||
delayInput.value = String(state.promptDelay);
|
||||
showDhcpMode();
|
||||
}
|
||||
|
||||
function setNetworkMarker(key, modified) {
|
||||
const field = networkFields.get(key);
|
||||
field.marker.hidden = !modified;
|
||||
field.highlight.classList.toggle("wifi-field-modified", modified);
|
||||
}
|
||||
|
||||
function clearNetworkDraft() {
|
||||
networkDraft.clear();
|
||||
networkFields.forEach((_field, key) => setNetworkMarker(key, false));
|
||||
updateNetworkButtons();
|
||||
}
|
||||
|
||||
function clearDelayDraft() {
|
||||
delayDraft.clear();
|
||||
updateDelayButton();
|
||||
}
|
||||
|
||||
function updateNetworkButtons() {
|
||||
if (wifiPending) return;
|
||||
const ready = settingsLoaded && networkDraft.dirty;
|
||||
immediateButton.classList.toggle("wifi-action-ready", ready);
|
||||
nextBootButton.classList.toggle("wifi-action-ready", ready);
|
||||
if (ready) {
|
||||
setControlReady(immediateButton);
|
||||
setControlReady(nextBootButton);
|
||||
return;
|
||||
}
|
||||
const reason = settingsLoaded
|
||||
? "请先修改 WiFi 或 DHCP 设置,再选择生效方式。"
|
||||
: "请等待当前 WiFi 设置读取完成。";
|
||||
setControlUnavailable(immediateButton, reason);
|
||||
setControlUnavailable(nextBootButton, reason);
|
||||
}
|
||||
|
||||
function updateDelayButton() {
|
||||
if (delayPending) return;
|
||||
const ready = settingsLoaded && delayDraft.dirty;
|
||||
delaySaveButton.classList.toggle("wifi-action-ready", ready);
|
||||
if (ready) {
|
||||
setControlReady(delaySaveButton);
|
||||
return;
|
||||
}
|
||||
setControlUnavailable(
|
||||
delaySaveButton,
|
||||
settingsLoaded ? "请先修改网络提示等待时间。" : "请等待当前网络提示设置读取完成。",
|
||||
);
|
||||
}
|
||||
|
||||
function markNetworkField(key) {
|
||||
if (!settingsLoaded) return;
|
||||
networkDraft.mark(key);
|
||||
setNetworkMarker(key, true);
|
||||
updateNetworkButtons();
|
||||
}
|
||||
|
||||
function markDelayField() {
|
||||
if (!settingsLoaded) return;
|
||||
delayDraft.mark("prompt_delay_seconds");
|
||||
updateDelayButton();
|
||||
}
|
||||
|
||||
function syncForm(data) {
|
||||
baseline = formStateFromResponse(data);
|
||||
applyFormState(baseline);
|
||||
settingsLoaded = true;
|
||||
clearNetworkDraft();
|
||||
clearDelayDraft();
|
||||
showStatus(data);
|
||||
}
|
||||
|
||||
export async function loadWifiSettings() {
|
||||
const generation = ++loadGeneration;
|
||||
settingsLoaded = false;
|
||||
clearNetworkDraft();
|
||||
clearDelayDraft();
|
||||
try {
|
||||
const data = await apiJson("/api/network/wifi");
|
||||
if (generation !== loadGeneration) return;
|
||||
syncForm(data);
|
||||
setFeedback("");
|
||||
setDelayFeedback("");
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) return;
|
||||
setFeedback(`WiFi 设置读取失败:${error.message}`, "error");
|
||||
announce(`WiFi 设置读取失败:${error.message}`, true);
|
||||
updateNetworkButtons();
|
||||
updateDelayButton();
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequest(activation) {
|
||||
const ssid = ssidInput.value.trim();
|
||||
if (!ssid) throw new Error("请输入 WiFi 名称(SSID)");
|
||||
const password = passwordInput.value;
|
||||
if (ssid !== baseline.ssid && !password) throw new Error("修改 WiFi 名称时必须填写新密码");
|
||||
if (!password && !baseline.passwordConfigured) throw new Error("当前没有已保存密码,请填写 WiFi 密码");
|
||||
|
||||
const manual = !dhcpToggle.checked;
|
||||
if (manual && !addressInput.value.trim()) throw new Error("关闭 DHCP 后必须填写静态 IPv4 地址");
|
||||
if (manual && !gatewayInput.value.trim()) throw new Error("关闭 DHCP 后必须填写网关");
|
||||
const rawPrefix = prefixInput.value.trim();
|
||||
const prefix = rawPrefix ? Number(rawPrefix) : null;
|
||||
if (rawPrefix && (!Number.isInteger(prefix) || prefix < 1 || prefix > 32)) {
|
||||
throw new Error("子网前缀必须是 1..32 的整数");
|
||||
}
|
||||
return {
|
||||
ssid,
|
||||
password: password || null,
|
||||
ipv4_mode: manual ? "manual" : "dhcp",
|
||||
address: manual ? addressInput.value.trim() : null,
|
||||
gateway: manual ? gatewayInput.value.trim() : null,
|
||||
prefix,
|
||||
dns_servers: manual ? splitDns(dnsInput.value) : [],
|
||||
activation,
|
||||
};
|
||||
}
|
||||
|
||||
function captureNetworkBaseline(request) {
|
||||
baseline = {
|
||||
...baseline,
|
||||
ssid: request.ssid,
|
||||
password: request.password || baseline.password,
|
||||
passwordConfigured: Boolean(request.password || baseline.passwordConfigured),
|
||||
dhcp: request.ipv4_mode === "dhcp",
|
||||
address: request.address || "",
|
||||
gateway: request.gateway || "",
|
||||
prefix: request.prefix ?? "",
|
||||
dns: (request.dns_servers || []).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyWifi(activation) {
|
||||
if (wifiPending || !networkDraft.dirty) return;
|
||||
let request;
|
||||
try {
|
||||
request = buildRequest(activation);
|
||||
} catch (error) {
|
||||
setFeedback(error.message, "error");
|
||||
announce(error.message, true);
|
||||
return;
|
||||
}
|
||||
wifiPending = true;
|
||||
setControlBusy(immediateButton, {
|
||||
label: activation === "immediate" ? "正在保存…" : undefined,
|
||||
message: "正在安全保存 WiFi 与 IPv4 配置…",
|
||||
});
|
||||
setControlBusy(nextBootButton, {
|
||||
label: activation === "next_boot" ? "正在保存…" : undefined,
|
||||
});
|
||||
setFeedback(
|
||||
activation === "immediate"
|
||||
? "正在保存;设备随后会切换网络,当前页面可能断开。"
|
||||
: "正在保存下次断电开机使用的网络配置…",
|
||||
"pending",
|
||||
);
|
||||
try {
|
||||
const result = await apiJson("/api/network/wifi", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
captureNetworkBaseline(request);
|
||||
clearNetworkDraft();
|
||||
setFeedback(result.message, "success");
|
||||
announce(result.message);
|
||||
if (result.network) showStatus(result.network);
|
||||
} catch (error) {
|
||||
setFeedback(`WiFi 设置保存失败:${error.message}`, "error");
|
||||
announce(`WiFi 设置保存失败:${error.message}`, true);
|
||||
} finally {
|
||||
wifiPending = false;
|
||||
updateNetworkButtons();
|
||||
}
|
||||
}
|
||||
|
||||
function validatedPromptDelay() {
|
||||
const delay = Number(delayInput.value);
|
||||
if (!Number.isInteger(delay) || delay < 1 || delay > 3600) {
|
||||
throw new Error("网络提示等待时间必须是 1..3600 秒的整数");
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
async function savePromptDelay() {
|
||||
if (delayPending || !delayDraft.dirty) return;
|
||||
let delay;
|
||||
try {
|
||||
delay = validatedPromptDelay();
|
||||
} catch (error) {
|
||||
setDelayFeedback(error.message, "error");
|
||||
announce(error.message, true);
|
||||
return;
|
||||
}
|
||||
delayPending = true;
|
||||
setControlBusy(delaySaveButton, {
|
||||
label: "正在保存…",
|
||||
message: "正在保存网络提示等待时间…",
|
||||
});
|
||||
setDelayFeedback("正在保存网络提示等待时间…", "pending");
|
||||
try {
|
||||
const result = await apiJson("/api/network/wifi/prompt-delay", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ prompt_delay_seconds: delay }),
|
||||
});
|
||||
baseline = { ...baseline, promptDelay: result.prompt_delay_seconds };
|
||||
delayInput.value = String(result.prompt_delay_seconds);
|
||||
clearDelayDraft();
|
||||
setDelayFeedback(result.message, "success");
|
||||
announce(result.message);
|
||||
} catch (error) {
|
||||
setDelayFeedback(`网络提示等待时间保存失败:${error.message}`, "error");
|
||||
announce(`网络提示等待时间保存失败:${error.message}`, true);
|
||||
} finally {
|
||||
delayPending = false;
|
||||
updateDelayButton();
|
||||
}
|
||||
}
|
||||
|
||||
export function discardWifiSettingsDrafts() {
|
||||
loadGeneration += 1;
|
||||
applyFormState(baseline);
|
||||
settingsLoaded = false;
|
||||
clearNetworkDraft();
|
||||
clearDelayDraft();
|
||||
setFeedback("");
|
||||
setDelayFeedback("");
|
||||
}
|
||||
|
||||
for (const [key, field] of networkFields) {
|
||||
if (key === "dhcp") continue;
|
||||
field.control.addEventListener("input", () => markNetworkField(key));
|
||||
}
|
||||
dhcpToggle.addEventListener("change", () => {
|
||||
showDhcpMode();
|
||||
markNetworkField("dhcp");
|
||||
});
|
||||
delayInput.addEventListener("input", markDelayField);
|
||||
immediateButton.addEventListener("click", () => applyWifi("immediate"));
|
||||
nextBootButton.addEventListener("click", () => applyWifi("next_boot"));
|
||||
delaySaveButton.addEventListener("click", savePromptDelay);
|
||||
updateNetworkButtons();
|
||||
updateDelayButton();
|
||||
@@ -0,0 +1,25 @@
|
||||
export function createLatchedDraftState(allowedKeys) {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const modified = new Set();
|
||||
|
||||
return Object.freeze({
|
||||
mark(key) {
|
||||
if (!allowed.has(key)) throw new Error(`Unknown draft field: ${key}`);
|
||||
const wasModified = modified.has(key);
|
||||
modified.add(key);
|
||||
return !wasModified;
|
||||
},
|
||||
clear() {
|
||||
modified.clear();
|
||||
},
|
||||
has(key) {
|
||||
return modified.has(key);
|
||||
},
|
||||
get dirty() {
|
||||
return modified.size > 0;
|
||||
},
|
||||
keys() {
|
||||
return [...modified];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function resolveWorkspaceOrder(definitions, savedOrder) {
|
||||
const defaults = Array.from(definitions || []);
|
||||
const byId = new Map(defaults.map((definition) => [definition.id, definition]));
|
||||
const resolved = [];
|
||||
const seen = new Set();
|
||||
|
||||
if (Array.isArray(savedOrder)) {
|
||||
savedOrder.forEach((workspaceId) => {
|
||||
if (seen.has(workspaceId) || !byId.has(workspaceId)) return;
|
||||
seen.add(workspaceId);
|
||||
resolved.push(byId.get(workspaceId));
|
||||
});
|
||||
}
|
||||
|
||||
defaults.forEach((definition) => {
|
||||
if (seen.has(definition.id)) return;
|
||||
seen.add(definition.id);
|
||||
resolved.push(definition);
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function moveWorkspaceOrder(workspaceIds, index, offset) {
|
||||
if (!Array.isArray(workspaceIds) || !Number.isInteger(index) || !Number.isInteger(offset)) {
|
||||
return null;
|
||||
}
|
||||
const target = index + offset;
|
||||
if (index < 0 || index >= workspaceIds.length || target < 0 || target >= workspaceIds.length) {
|
||||
return null;
|
||||
}
|
||||
const moved = [...workspaceIds];
|
||||
[moved[index], moved[target]] = [moved[target], moved[index]];
|
||||
return moved;
|
||||
}
|
||||
Reference in New Issue
Block a user