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; }