Improve Android app UI and controller flows
This commit is contained in:
+121
-573
@@ -1,577 +1,125 @@
|
||||
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);
|
||||
import * as legacy from "./scene-model-v1.js";
|
||||
import { encodePixels, decodePixels } from "./pixel-png.js";
|
||||
export const SCENE_VERSION=2, EDIT_SIZE=192, EDIT_MIN=-64, EDIT_MAX=128;
|
||||
export const {SCENE_WIDTH,SCENE_HEIGHT,RGB_BYTE_LENGTH,RGBA_BYTE_LENGTH,RGB_CHANNELS,RGBA_CHANNELS,SCENE_STORAGE_KEY,LEGACY_STORAGE_KEYS,DEFAULT_TEXT_ELEMENT,createRgbPixels,createTextElement,nextTextElementId,compositeRgbaLayer,compositeRgbaLayers,migrateLegacyScene}=legacy;
|
||||
const fields=(v,keys)=>v&&Object.keys(v).sort().join()===[...keys].sort().join();
|
||||
const uid=()=>globalThis.crypto?.randomUUID?.()||`layer-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
export function normalizeTextElement(v={},options={}) {const e=legacy.normalizeTextElement(v,options);
|
||||
return e?{...e,clip:v.clip?[...v.clip]:null}:null;}
|
||||
export function attachScene(layers) {
|
||||
const scene={version:2,width:64,height:64,layers};
|
||||
Object.defineProperty(scene,"elements",{get:()=>scene.layers.flatMap(l=>l.elements||[])});
|
||||
Object.defineProperty(scene,"pixels",{get:()=>{const rgba=scene.layers.find(l=>l.type==="brush").pixels,rgb=new Uint8ClampedArray(RGB_BYTE_LENGTH);
|
||||
for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=((y+64)*192+x+64)*4;rgb.set(rgba.subarray(i,i+3),(y*64+x)*3);}
|
||||
return rgb;}});
|
||||
return scene;
|
||||
}
|
||||
|
||||
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)));
|
||||
export function nextLayerName(layers){let n=1;
|
||||
const names=new Set(layers.map(l=>l.name));while(names.has(`图层${n}`))n++;
|
||||
return `图层${n}`;}
|
||||
export function createLayer(type,layers=[]) {return {id:uid(),name:nextLayerName(layers),type,x:0,y:0,...(type==="brush"?{background:null,backgroundColor:"#000000",pixels:new Uint8ClampedArray(192*192*4),revision:0}:{elements:[]})};}
|
||||
export function createScene({pixels=null,elements=[],layers=null}={}) {
|
||||
if(layers)return attachScene(layers);
|
||||
const brush=createLayer("brush");brush.id="layer-1";
|
||||
if(pixels)for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=(y*64+x)*3,j=((y+64)*192+x+64)*4;brush.pixels.set([pixels[i],pixels[i+1],pixels[i+2],255],j);}
|
||||
const result=[brush];
|
||||
if(elements.length)result.push({id:"layer-2",name:"图层2",type:"text",x:0,y:0,elements:elements.map(e=>normalizeTextElement(e,{strict:true})).filter(Boolean)});
|
||||
return attachScene(result);
|
||||
}
|
||||
|
||||
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
|
||||
return typeof value === "string" && HEX_COLOR.test(value.trim())
|
||||
? value.trim().toUpperCase()
|
||||
: fallback;
|
||||
export function blankScene(){return attachScene([createLayer("brush")]);}
|
||||
export function layerToStorage(layer){const common={id:layer.id,name:layer.name,type:layer.type,x:layer.x,y:layer.y};
|
||||
if(layer.type==="brush"){if(layer._encodedRevision!==layer.revision||!layer._png){layer._png=encodePixels(layer.pixels);layer._encodedRevision=layer.revision;}
|
||||
return {...common,background:layer.background,backgroundColor:layer.backgroundColor||layer.background||"#000000",pixelPng:layer._png};}
|
||||
return {...common,elements:layer.elements.map(({revision,...e})=>({...e,clip:e.clip||null}))};
|
||||
}
|
||||
|
||||
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;
|
||||
export function sceneToStorageValue(scene){return {version:2,width:64,height:64,layers:scene.layers.map(layerToStorage)};}
|
||||
export function serializeScene(scene){return JSON.stringify(sceneToStorageValue(scene));}
|
||||
export function deserializeScene(value){try{
|
||||
const raw=typeof value==="string"?JSON.parse(value):value;
|
||||
if(raw?.version===1){
|
||||
if(!fields(raw,["version","width","height","pixelRgb","elements"])||!Array.isArray(raw.elements))return null;
|
||||
const old=legacy.deserializeScene(raw);
|
||||
if(!old||old.elements.length!==raw.elements.length||raw.elements.some(e=>!fields(e,["id","type","text","font","size","x","y","align","color"])))return null;
|
||||
const upgraded=sceneToStorageValue(createScene(old));
|
||||
if(raw.elements.length)upgraded.layers[1].elements=raw.elements.map(element=>({...element,clip:null}));
|
||||
return deserializeScene(upgraded);
|
||||
}
|
||||
if(!fields(raw,["version","width","height","layers"])||raw.version!==2||raw.width!==64||raw.height!==64||!Array.isArray(raw.layers))return null;
|
||||
const ids=new Set(),elements=new Set();
|
||||
const layers=raw.layers.map(l=>{
|
||||
if(!fields(l,["id","name","type","x","y",...(l.type==="brush"?["background","pixelPng",...(Object.hasOwn(l,"backgroundColor")?["backgroundColor"]:[])]:["elements"])])||!["brush","text"].includes(l.type)||typeof l.id!=="string"||!l.id.trim()||l.id.length>128||ids.has(l.id)||typeof l.name!=="string"||!l.name.trim()||l.name.length>80||![l.x,l.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=64))throw Error("invalid layer");ids.add(l.id);
|
||||
if(l.type==="brush"){if(l.background!==null&&!/^#[0-9a-f]{6}$/i.test(l.background))throw Error("invalid background");
|
||||
if(Object.hasOwn(l,"backgroundColor")&&!/^#[0-9a-f]{6}$/i.test(l.backgroundColor))throw Error("invalid background color");
|
||||
const {pixelPng,...rest}=l;
|
||||
return {...rest,backgroundColor:l.backgroundColor||l.background||"#000000",pixels:decodePixels(pixelPng),revision:0,_png:pixelPng,_encodedRevision:0};}
|
||||
if(!Array.isArray(l.elements))throw Error("invalid text layer");
|
||||
return {...l,elements:l.elements.map(e=>{
|
||||
if(!fields(e,["id","type","text","font","size","x","y","align","color","clip"])||elements.has(e.id)||typeof e.id!=="string"||!e.id.trim()||e.id.length>128||e.type!=="text"||typeof e.text!=="string"||e.text.length>512||typeof e.font!=="string"||!e.font.trim()||e.font.length>512||!Number.isInteger(e.size)||e.size<1||e.size>64||![e.x,e.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=128)||!["left","center","right"].includes(e.align)||!/^#[0-9a-f]{6}$/i.test(e.color))throw Error("invalid text");
|
||||
if(e.clip!==null&&(!Array.isArray(e.clip)||e.clip.length!==4||!e.clip.every(n=>Number.isFinite(n)&&Math.abs(n)<=4096)||e.clip[2]<e.clip[0]||e.clip[3]<e.clip[1]))throw Error("invalid clip");elements.add(e.id);
|
||||
return normalizeTextElement(e,{strict:true});
|
||||
})};});
|
||||
return layers.some(l=>l.type==="brush")?attachScene(layers):null;
|
||||
}
|
||||
catch{return null;}}
|
||||
export function inspectSceneStorageValue(value){if(value==null)return {status:"missing",scene:null};try{const raw=typeof value==="string"?JSON.parse(value):value;
|
||||
if(Number.isInteger(raw?.version)&&raw.version>2)return {status:"future",scene:null};
|
||||
const scene=deserializeScene(raw);
|
||||
return {status:scene?"valid":"damaged",scene};}
|
||||
catch{return {status:"damaged",scene:null};}}
|
||||
export function restoreSceneDraft(storage){let raw;try{raw=storage?.getItem(SCENE_STORAGE_KEY);}
|
||||
catch{return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"浏览器草稿存储不可用;原草稿已保留。"};}
|
||||
if(raw){const result=inspectSceneStorageValue(raw);
|
||||
if(result.status!=="valid")return {...result,status:result.status==="future"?"future-version":"damaged-current",scene:blankScene(),canPersist:false,notice:"草稿损坏或来自未来版本;原值已保留。"};
|
||||
if(JSON.parse(raw).version===1){try{storage.setItem(SCENE_STORAGE_KEY,serializeScene(result.scene));}
|
||||
catch{return {...result,canPersist:false,status:"write-failed",notice:"无法保存升级草稿;原值已保留。"};}}
|
||||
return {...result,canPersist:true,status:"current"};}
|
||||
try {
|
||||
const oldValue=storage?.getItem(LEGACY_STORAGE_KEYS.sceneV1);
|
||||
if(oldValue!=null&&!deserializeScene(oldValue))return {scene:blankScene(),canPersist:false,status:"damaged-legacy",notice:"旧场景草稿无法安全迁移;全部原值已保留。"};
|
||||
} catch {return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"旧草稿读取失败;全部原值已保留。"};}
|
||||
const proxy=storage?{getItem:k=>storage.getItem(k),removeItem:k=>storage.removeItem(k),setItem:(k,v)=>{
|
||||
const oldScene=legacy.deserializeScene(v);storage.setItem(k,oldScene?serializeScene(createScene(oldScene)):v);
|
||||
}}:null;
|
||||
const old=legacy.restoreSceneDraft(proxy);
|
||||
const scene=old.status==="created"?blankScene():createScene(old.scene);
|
||||
if(old.status==="created"&&old.canPersist){
|
||||
try {storage?.setItem(SCENE_STORAGE_KEY,serializeScene(scene));}
|
||||
catch {return {...old,scene,canPersist:false,status:"write-failed",notice:"无法保存新草稿。"};}
|
||||
}
|
||||
return {...old,scene};
|
||||
}
|
||||
export function cloneScene(scene){return deserializeScene(serializeScene(scene));}
|
||||
export function addTextElement(scene,overrides={},layerId=null){const target=scene.layers.find(l=>l.type==="text"&&(layerId?l.id===layerId:true))||createLayer("text",scene.layers),element=normalizeTextElement({...legacy.createTextElement(scene.elements,overrides),clip:overrides.clip||null});
|
||||
return attachScene(scene.layers.some(l=>l.id===target.id)?scene.layers.map(l=>l.id===target.id?{...l,elements:[...l.elements,element]}:l):[...scene.layers,{...target,elements:[element]}]);}
|
||||
export function updateTextElement(scene,id,patch={}){if(!scene.elements.some(e=>e.id===id))return scene;
|
||||
return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.map(e=>e.id===id?normalizeTextElement({...e,...patch,id:e.id,revision:e.revision+1}):e)}));}
|
||||
export function deleteTextElement(scene,id){if(!scene.elements.some(e=>e.id===id))return scene;
|
||||
return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.filter(e=>e.id!==id)}));}
|
||||
export function duplicateTextElement(scene,id){const l=scene.layers.find(l=>l.elements?.some(e=>e.id===id)),e=l?.elements.find(e=>e.id===id);
|
||||
return e?addTextElement(scene,{...e,x:e.x+2,y:e.y+2},l.id):scene;}
|
||||
export function cloneLayer(layer,layers){const copy=layer.type==="brush"?{...layer,pixels:layer.pixels.slice()}:{...layer,elements:layer.elements.map(e=>({...e,id:uid(),clip:e.clip?[...e.clip]:null}))};
|
||||
return {...copy,id:uid(),name:nextLayerName(layers)};}
|
||||
export function clipTextPixels(rgba,element){if(!element.clip)return rgba;
|
||||
const out=rgba.slice(),[a,b,c,d]=element.clip;
|
||||
for(let y=0;y<192;y++)for(let x=0;x<192;x++){const u=(x-64-element.x)/element.size,v=(y-64-element.y)/element.size;
|
||||
if(u<a||u>=c||v<b||v>=d)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);}
|
||||
return out;}
|
||||
export function translatedOverflow(rgba,dx,dy){let outside=false,inside=false;
|
||||
for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(rgba[(y*192+x)*4+3]){if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)outside=true;else inside=true;}
|
||||
return {outside,inside};}
|
||||
export function clipBrushPixels(rgba,dx,dy){const out=rgba.slice();
|
||||
for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);
|
||||
return out;}
|
||||
export function textClipAt(e,dx,dy){const n=[(-64-dx-e.x)/e.size,(-64-dy-e.y)/e.size,(128-dx-e.x)/e.size,(128-dy-e.y)/e.size];
|
||||
return e.clip?[Math.max(n[0],e.clip[0]),Math.max(n[1],e.clip[1]),Math.min(n[2],e.clip[2]),Math.min(n[3],e.clip[3])]:n;}
|
||||
export function composeScene(scene,textPixels=new Map(),size=64,preview=null){const origin=size===192?-64:0,out=new Uint8ClampedArray(size*size*3);
|
||||
function paint(rgba,dx,dy){for(let y=0;y<size;y++)for(let x=0;x<size;x++){const sx=x+origin+64-dx,sy=y+origin+64-dy;
|
||||
if(sx<0||sx>=192||sy<0||sy>=192)continue;
|
||||
const i=(sy*192+sx)*4,j=(y*size+x)*3,a=rgba[i+3]/255;
|
||||
for(let c=0;c<3;c++)out[j+c]=Math.floor(rgba[i+c]*a+out[j+c]*(1-a)+0.5);}}
|
||||
for(const l of scene.layers){const dx=preview?.layerId===l.id?preview.x:l.x,dy=preview?.layerId===l.id?preview.y:l.y;
|
||||
if(l.type==="brush"){if(l.background){const color=l.background.slice(1).match(/../g).map(v=>parseInt(v,16));
|
||||
for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(x+origin>=-64+dx&&x+origin<128+dx&&y+origin>=-64+dy&&y+origin<128+dy)out.set(color,(y*size+x)*3);}paint(l.pixels,dx,dy);}
|
||||
else for(const e of l.elements){const p=textPixels.get(e.id);
|
||||
if(p)paint(p,dx,dy);}}
|
||||
return out;}
|
||||
|
||||
Reference in New Issue
Block a user