初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -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;
}