Restore default template binding across browser sessions

This commit is contained in:
2026-10-10 10:51:59 +08:00
parent 608229a497
commit 5db283c05a
20 changed files with 334 additions and 8 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
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 { setBoardWorkspace, initializeStartupBoard } from "./scene-board.js";
import { renderUiCopy } from "./ui-copy-runtime.js";
import { moveWorkspaceOrder, resolveWorkspaceOrder } from "./workspace-order.js";
import {
@@ -343,6 +343,7 @@ window.addEventListener("keydown", (event) => {
});
buildNavigation();
initializeStartupBoard();
setWorkspaceOrderToggleLabel();
navigate(resolveWorkspaceId(), false);
loadWorkspaceOrder().catch((error) => {
@@ -0,0 +1,58 @@
export const BOARD_SESSION_KEY = "matrix-board-session-v1";
function validContext(context) {
return context === null || (
context && ["template", "animation"].includes(context.type)
&& ["id", "name", "revision", "baseline"].every((key) => typeof context[key] === "string" && context[key])
&& typeof context.readOnly === "boolean"
&& (context.type !== "animation" || context.readOnly)
);
}
export function readBoardSession(storage, draft, bootId) {
const raw = storage?.getItem(BOARD_SESSION_KEY);
if (!raw) return { bootId: null, context: null };
try {
const value = JSON.parse(raw);
if (value.version !== 1 || typeof value.bootId !== "string" || !validContext(value.context)) {
throw new Error("invalid session");
}
return {
bootId: value.bootId,
context: value.bootId === bootId && value.draft === draft ? value.context : null,
};
} catch {
throw new Error("画板保存绑定损坏或来自未来版本;原值已保留,请另存当前内容后处理浏览器存储。");
}
}
export function writeBoardSession(storage, bootId, draft, context) {
storage.setItem(BOARD_SESSION_KEY, JSON.stringify({ version: 1, bootId, draft, context }));
}
// Resolve only editable static defaults and the read-only demo animation.
// Ordinary animations retain their existing explicit frame editing workflow.
export async function loadDefaultBoard(apiJson) {
const reference = await apiJson("/api/display/default-content");
if (reference.type === "template") {
return { ...await apiJson(`/api/templates/${reference.id}`), type: "template" };
}
if (reference.type !== "animation" || !reference.is_demo) return null;
const animation = await apiJson(`/api/animations/${reference.id}/frames`);
const first = animation.frames[0];
if (!first) throw new Error("默认演示动图没有帧");
const frame = await apiJson(`/api/animations/${reference.id}/frames/${first.id}`);
return { ...animation, type: "animation", read_only: true, scene: frame.scene };
}
export async function initializeDefaultBoard({ canLoad, load, apply, retain }) {
if (!canLoad()) return false;
const content = await load();
if (!canLoad()) {
retain();
return false;
}
if (content) apply(content);
else retain();
return Boolean(content);
}
@@ -1,5 +1,6 @@
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
import { announce, apiBlob, isAppStale } from "./core.js";
import { announce, apiBlob, apiJson, isAppStale } from "./core.js";
import { readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "./board-startup-model.js";
import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js";
import {
RGB_BYTE_LENGTH,
@@ -102,10 +103,61 @@ let lastExactRgb = null;
let sceneSource = null;
let draftConflict = false;
let externalDraftValue = null;
let externalDraftContext = null;
let draftPersistenceBlocked = !draftLoad.canPersist;
let draftProtectionNotice = (
draftPersistenceBlocked || draftLoad.status === "cleanup-warning"
) ? draftLoad.notice : "";
const deviceBootId = document.querySelector('meta[name="device-boot-id"]')?.content || "";
let boardBootId = "";
let boardMutation = 0;
let startupBoardPromise = null;
if (draftLoad.canPersist && deviceBootId) {
try {
const session = readBoardSession(draftStorage, serializeScene(scene), deviceBootId);
boardBootId = session.bootId || "";
templateEdit = session.context;
if (templateEdit && !deserializeScene(templateEdit.baseline)) throw new Error("画板保存基线无效;原草稿已保留。");
if (templateEdit && !sceneNeedsTemplateSave(templateEdit, serializeScene(scene)) && templateEdit.type === "template") {
sceneSource = { type: "template", id: templateEdit.id, revision: templateEdit.revision };
}
} catch (error) {
templateEdit = null;
draftPersistenceBlocked = true;
draftProtectionNotice = error.message;
}
}
export function initializeStartupBoard() {
if (startupBoardPromise) return startupBoardPromise;
const mutation = boardMutation;
const canLoad = () => Boolean(deviceBootId) && boardBootId !== deviceBootId
&& !draftPersistenceBlocked && !draftConflict && !animationEdit && boardMutation === mutation;
const retain = () => {
if (draftPersistenceBlocked || draftConflict || animationEdit) return;
boardBootId = deviceBootId;
persistScene();
};
startupBoardPromise = initializeDefaultBoard({
canLoad,
load: () => loadDefaultBoard(apiJson),
apply: (content) => {
// Validate before changing either the session marker or the current draft.
if (!deserializeScene(content.scene)) throw new Error("默认画面场景无效");
boardBootId = deviceBootId;
replaceSceneFromStorage(content.scene, {
reason: "startup-default-load",
source: content.type === "template" ? { type: "template", id: content.id, revision: content.revision } : null,
templateEditContext: content,
});
},
retain,
}).catch((error) => {
announce(`默认画面读取失败,已保留当前草稿:${error.message}`, true);
return false;
});
return startupBoardPromise;
}
const draftConflictNotice = document.getElementById("draft-conflict-notice");
const loadExternalDraftButton = document.getElementById("load-external-draft");
@@ -122,6 +174,7 @@ function blockDraftPersistence(message) {
}
export function persistScene() {
boardMutation += 1;
if (animationEdit) {
animationEdit.dirty = serializeScene(scene) !== animationEdit.baseline;
updateBoardUi();
@@ -131,7 +184,10 @@ export function persistScene() {
if (draftConflict || draftPersistenceBlocked) return false;
try {
if (!draftStorage) throw new Error("browser storage unavailable");
draftStorage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
const draft = serializeScene(scene);
// Commit metadata first: a torn pair never restores the wrong binding.
writeBoardSession(draftStorage, boardBootId, draft, templateEdit);
draftStorage.setItem(SCENE_STORAGE_KEY, draft);
return true;
} catch (_error) {
blockDraftPersistence("浏览器无法保存统一画板草稿;原草稿已保留,本页不会继续覆盖或应用它。");
@@ -157,6 +213,12 @@ window.addEventListener("storage", (event) => {
return;
}
if (external.status !== "valid") return;
try {
externalDraftContext = readBoardSession(draftStorage, event.newValue, deviceBootId).context;
} catch (error) {
blockDraftPersistence(error.message);
return;
}
externalDraftValue = event.newValue;
setDraftConflict(true);
announce("另一标签页修改了画板草稿,请选择要保留的版本", true);
@@ -164,11 +226,12 @@ window.addEventListener("storage", (event) => {
loadExternalDraftButton.addEventListener("click", async () => {
const value = externalDraftValue;
const context = externalDraftContext;
if (!value) return;
if (animationEdit && !await sceneReplacementGuard()) return;
externalDraftValue = null;
setDraftConflict(false);
replaceSceneFromStorage(value, { persist: false, reason: "tab-sync" });
replaceSceneFromStorage(value, { persist: false, reason: "tab-sync", templateEditContext: context });
announce("已载入另一标签页的最新画板草稿");
});
@@ -670,6 +733,7 @@ export function replaceSceneFromStorage(
templateEditContext = null,
} = {},
) {
boardMutation += 1;
const restored = deserializeScene(value);
if (!restored) throw new Error("模板场景无效,无法载入");
textLayers.forEach((entry) => {
@@ -719,6 +783,8 @@ export function markTemplateSaved(template) {
clearEditHistory();
templateEdit = createTemplateEditContext(template, serializeScene(scene));
sceneSource = { type: "template", id: template.id, revision: template.revision };
boardBootId = deviceBootId;
persistScene();
updateBoardUi();
document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() }));
return true;
@@ -1663,7 +1663,8 @@ dd {
font-size: 0.8rem;
}
.template-card-actions .demo-restricted-action[aria-disabled="true"] {
.template-card-actions .demo-restricted-action[aria-disabled="true"],
.template-save-choice-actions .demo-restricted-action[aria-disabled="true"] {
border-color: #000;
background: #050505;
color: #9aa1ab;
@@ -1,9 +1,10 @@
export function createTemplateEditContext(template, baseline) {
return {
type: template.type || "template",
id: template.id,
name: template.name,
revision: template.revision,
readOnly: Boolean(template.read_only ?? template.readOnly),
readOnly: template.type === "animation" || Boolean(template.read_only ?? template.readOnly),
baseline,
};
}
@@ -11,6 +12,7 @@ export function createTemplateEditContext(template, baseline) {
export function templateEditSnapshot(context, serializedScene) {
if (!context) return null;
return {
type: context.type || "template",
id: context.id,
name: context.name,
revision: context.revision,
@@ -558,6 +558,10 @@ async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) {
}
async function saveSceneToCurrentTemplate(context) {
if (context.readOnly || context.type !== "template") {
announce("演示案例不得覆盖,请另存为新模板", true);
return;
}
await mutate(async () => {
const result = await apiJson(`/api/templates/${context.id}`, {
method: "PUT",