Restore default template binding across browser sessions
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user