Improve Android app UI and controller flows

This commit is contained in:
2026-10-09 16:43:58 +08:00
parent c0e26b97fe
commit 608229a497
90 changed files with 8103 additions and 1133 deletions
+1 -1
View File
@@ -1 +1 @@
2026-10-09T10:26+08:00
2026-10-09T16:12+08:00
+7 -1
View File
@@ -70,7 +70,7 @@ Unit ADC 仅使用物理脚 3/5 的 `/dev/i2c-1`(PI8/PI7),默认地址 `0x
## 数据安全
配置 schema 为 v8,包含设备共享的 `workspace_order`,并在 v7→v8 时把 `media-import` 插入文字与动图工作区之间。媒体源文件和严格 schema v1 任务记录位于 `/var/lib/matrix-screen-controller/media-import/jobs`;成功或取消立即清理,失败及等待设置保留 24 小时。只有数据文件不存在时才能创建默认值;损坏、未知更高版本或迁移失败时保留原文件并拒绝启动。更新程序必须先克隆 `/var/lib/matrix-screen-controller`,在副本上完成登记迁移并验证后再原子切换,不得把设备凭据、主机密钥、旧设备配置或测试数据打进部署载荷。
配置 schema 为 v11,包含设备共享的 `workspace_order`,v10→v11仅移除文字导航,其余顺序不变;旧文字入口跳转画图页。媒体源文件和严格 schema v1 任务记录位于 `/var/lib/matrix-screen-controller/media-import/jobs`;成功或取消立即清理,失败及等待设置保留 24 小时。只有数据文件不存在时才能创建默认值;损坏、未知更高版本或迁移失败时保留原文件并拒绝启动。更新程序必须先克隆 `/var/lib/matrix-screen-controller`,在副本上完成登记迁移并验证后再原子切换,不得把设备凭据、主机密钥、旧设备配置或测试数据打进部署载荷。
媒体转换依赖 Debian 的 `ffmpeg`、`ffprobe`、`heif-convert`(`libheif-examples`)以及带 `zscale`、`tonemap` 的 FFmpeg 构建。分析和转换由 `matrix-screen-converter@.service` 在 CPU0–1、150% CPU、384 MiB 内存、无交换区和私有网络限制下串行执行;主服务只负责流式上传、任务管理与结果展示。
@@ -185,3 +185,9 @@ python "核桃派软件源代码/scripts/export_release.py" ota --repair-current
```
此入口校验原产物及记录、候选摘要、最终源码一致性和实机验收结果;完整归档原包与记录后,按原字节晋升候选包,不再构建、不推进 VERSION、不刷新功能时间。验证或提交失败保留原产物与记录。
## 多图层及扩展编辑
画图页清空按钮下管理画笔/文字层;至少保留一个画笔层。每个文字层可编辑多段文字。小圆球菜单切换192×192扩展区、移动当前层及视线模式;实际输出始终中央64×64。移动越界确认后永久裁切,可通过会话撤销恢复。
scene v2使用有序图层、±64偏移、192 RGBA压缩PNG和文字相对裁切矩形。模板schema v2、动图schema v3;迁移先校验旧摘要,在数据副本上生成新场景/缩略图后原子切换。损坏/未知/未来版本保留原件并拒绝启动。无需新板端依赖;PNG编解码复用Pillow,浏览器使用仓库内fflate(MIT)。RGB888及手机内容库/播放协议保持不变。
@@ -19,10 +19,10 @@ from app.persistence import atomic_write_bytes
from app.templates.store import (
TemplateConflictError, TemplateError, TemplateNotFoundError,
TemplateStorageFullError, TemplateValidationError,
render_scene_thumbnail, scene_digest, validate_scene,
render_scene_thumbnail, scene_digest, validate_scene, prepare_scene,
)
ANIMATION_SCHEMA_VERSION = 2
ANIMATION_SCHEMA_VERSION = 3
NAME_LIMIT = 80
RECORD_FIELDS = frozenset({"id", "name", "created_at", "updated_at", "frames"})
FRAME_FIELDS = frozenset({
@@ -74,7 +74,7 @@ def _revision(record: dict[str, Any]) -> str:
class AnimationStore:
"""Schema-v2 animations: compact metadata plus one strict scene per frame."""
"""Schema-v3 animations: compact metadata plus one scene-v2 file per frame."""
def __init__(self, data_dir: Path, *, font_resolver: FontResolver | None = None,
cleanup_playback: bool = True) -> None:
@@ -131,7 +131,7 @@ class AnimationStore:
def _write_at(self, record: dict[str, Any], directory_id: str) -> None:
self._dir(directory_id).mkdir(parents=True, exist_ok=True)
data = json.dumps(
{"schema_version": 2, **_persisted(record)},
{"schema_version": 3, **_persisted(record)},
ensure_ascii=False, sort_keys=True, indent=2,
).encode("utf-8") + b"\n"
try:
@@ -210,22 +210,54 @@ class AnimationStore:
"updated_at": str(document["updated_at"]), "frames": frames,
}
def _migrate_v1(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
record = self._v1_record(value, expected_id)
# The entire v1 document is validated before authoritative metadata changes.
for frame in record["frames"]:
self._write_scene(expected_id, frame)
self._write_thumbnail(expected_id, frame)
self._write(record)
return {"schema_version": 2, **_persisted(record)}
def _migrate_legacy(self, value: dict[str, Any], expected_id: str) -> dict[str, Any]:
if value["schema_version"] == 1:
record = self._v1_record(value, expected_id)
else:
# Validate all old paths, digests and metadata before creating candidates.
document = dict(value)
document.pop("schema_version")
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
raise TemplateValidationError("legacy animation fields are invalid")
frames, ids = [], set()
if not isinstance(document["frames"], list) or not document["created_at"] or not document["updated_at"]:
raise TemplateValidationError("legacy animation metadata is invalid")
_name(document["name"])
for raw in document["frames"]:
if not isinstance(raw, dict) or set(raw) != FRAME_FIELDS:
raise TemplateValidationError("legacy frame fields are invalid")
ident = _uuid(raw["id"], "animation frame not found")
if ident in ids or raw["scene_file"] != f"{ident}-{raw['digest']}.json" or raw["thumbnail"] != f"{ident}-{raw['digest']}.png":
raise TemplateValidationError("legacy frame paths or ids are invalid")
ids.add(ident); _duration(raw["duration_ms"]); _name(raw["name"], optional=True)
frames.append(dict(raw, scene=self._load_scene(expected_id, raw)))
record = {**document, "frames": frames}
created = []
try:
for frame in record["frames"]:
frame["scene"] = prepare_scene(frame["scene"])
frame["digest"] = scene_digest(frame["scene"])
frame["scene_file"] = f"{frame['id']}-{frame['digest']}.json"
frame["thumbnail"] = f"{frame['id']}-{frame['digest']}.png"
for path in (self._scene_dir(expected_id)/frame["scene_file"], self._thumb_dir(expected_id)/frame["thumbnail"]):
if not path.exists(): created.append(path)
self._write_scene(expected_id, frame)
self._write_thumbnail(expected_id, frame)
self._write(record)
except Exception:
for path in created: path.unlink(missing_ok=True)
raise
return {"schema_version": 3, **_persisted(record)}
def _validate_record(self, value: Any, expected_id: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise TemplateValidationError("animation record must be an object")
if value.get("schema_version") == 1:
value = self._migrate_v1(value, expected_id)
if type(value.get("schema_version")) is not int:
raise TemplateValidationError("animation schema_version must be an integer")
if value.get("schema_version") in (1, 2):
value = self._migrate_legacy(value, expected_id)
document = dict(value)
if document.pop("schema_version", None) != 2:
if document.pop("schema_version", None) != 3:
raise TemplateValidationError("animation schema_version is unsupported")
if set(document) != RECORD_FIELDS or document.get("id") != expected_id:
raise TemplateValidationError("animation fields do not match the current schema")
@@ -252,7 +284,8 @@ class AnimationStore:
"updated_at": str(raw.get("updated_at") or ""),
"digest": digest, "thumbnail": thumbnail, "scene_file": scene_file,
}
self._load_scene(expected_id, frame)
if self._load_scene(expected_id, frame)["version"] != 2:
raise TemplateValidationError("animation schema v3 requires scene v2")
if not (self._thumb_dir(expected_id) / thumbnail).is_file():
self._write_thumbnail(expected_id, frame)
frames.append(frame)
@@ -435,7 +468,7 @@ class AnimationStore:
def _new_frame(self, source: dict[str, Any], now: str) -> dict[str, Any]:
frame_id = str(uuid4())
scene = validate_scene(source.get("scene"), strict_schema=True)
scene = prepare_scene(source.get("scene"))
digest = scene_digest(scene)
return {
"id": frame_id, "name": _name(source.get("name"), optional=True),
@@ -499,7 +532,7 @@ class AnimationStore:
def add_frame(self, animation_id: Any, scene: Any, expected: str, *,
name: Any = None, duration_ms: Any = DEFAULT_DURATION_MS) -> dict[str, Any]:
source = {
"scene": validate_scene(scene), "name": _name(name, optional=True),
"scene": prepare_scene(scene), "name": _name(name, optional=True),
"duration_ms": _duration(duration_ms),
}
with self._lock:
@@ -549,7 +582,8 @@ class AnimationStore:
frame = self._frame(record, frame_id); now = self._now()
old_thumb, old_scene = frame["thumbnail"], frame["scene_file"]
if scene is not None:
checked, digest = validate_scene(scene), scene_digest(validate_scene(scene))
checked = prepare_scene(scene)
digest = scene_digest(checked)
frame.update({
"scene": checked, "digest": digest,
"thumbnail": f"{frame['id']}-{digest}.png",
@@ -614,6 +648,37 @@ class AnimationStore:
def set_all_durations(self, animation_id: Any, duration_ms: Any, expected: str) -> dict[str, Any]:
return self.set_durations(animation_id, duration_ms, expected)
def append_layer(self, animation_id: Any, layer: Any, frame_ids: Any, expected: str) -> dict[str, Any]:
from app.scene_layers import copy_layer, validate_layer
from app.templates.store import _validate_scene_v1
try:
checked = validate_layer(layer, _validate_scene_v1)
except (ValueError, TypeError, KeyError) as exc:
raise TemplateValidationError(str(exc)) from exc
with self._lock:
record = self._read(animation_id); self._match(record, expected)
targets = self._frames(record, frame_ids)
created, now = [], self._now()
try:
for frame in targets:
scene = prepare_scene(self._load_scene(record["id"], frame))
scene["layers"].append(copy_layer(checked, scene["layers"]))
digest = scene_digest(scene)
frame.update(scene=scene, digest=digest, updated_at=now,
scene_file=f"{frame['id']}-{digest}.json",
thumbnail=f"{frame['id']}-{digest}.png")
for path in (self._scene_dir(record["id"])/frame["scene_file"], self._thumb_dir(record["id"])/frame["thumbnail"]):
if not path.exists(): created.append(path)
self._write_scene(record["id"], frame)
self._write_thumbnail(record["id"], frame)
record["updated_at"] = now
self._write(record)
except Exception:
for path in created: path.unlink(missing_ok=True)
raise
self._cleanup_orphans(record)
return self._detail(record)
def copy_frames(self, source_animation_id: Any, frame_ids: Any,
source_expected: str, destination_animation_id: Any,
destination_expected: str, insert_after_frame_id: Any = None) -> dict[str, Any]:
+8 -2
View File
@@ -35,7 +35,6 @@ DEFAULT_WORKSPACE_ORDER = [
"device",
"settings",
"canvas",
"text",
"media-import",
"animations",
"templates",
@@ -66,7 +65,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"animation_preview_max_concurrent": DEFAULT_ANIMATION_PREVIEW_MAX_CONCURRENT,
}
CONFIG_SCHEMA_VERSION = 10
CONFIG_SCHEMA_VERSION = 11
CONFIG_V1_FIELDS = frozenset({
"orientation",
"brightness",
@@ -455,6 +454,12 @@ def _migrate_config_v9_to_v10(document: dict[str, Any]) -> dict[str, Any]:
}
def _migrate_config_v10_to_v11(document: dict[str, Any]) -> dict[str, Any]:
checked = validate_config(document)
checked["workspace_order"] = [item for item in checked["workspace_order"] if item != "text"] or list(DEFAULT_WORKSPACE_ORDER)
return checked
CONFIG_MIGRATIONS = {
0: _migrate_config_v0_to_v1,
1: _migrate_config_v1_to_v2,
@@ -466,6 +471,7 @@ CONFIG_MIGRATIONS = {
7: _migrate_config_v7_to_v8,
8: _migrate_config_v8_to_v9,
9: _migrate_config_v9_to_v10,
10: _migrate_config_v10_to_v11,
}
+34 -4
View File
@@ -249,6 +249,15 @@ class PaletteColorRequest(BaseModel):
color: str
class LayerAppendRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
layer: dict[str, Any]
class FrameLayerAppendRequest(LayerAppendRequest):
frame_ids: list[str] = Field(min_length=1)
class TemplateCreateRequest(BaseModel):
name: str
scene: dict[str, Any]
@@ -353,6 +362,7 @@ class TextRequest(BaseModel):
class TextLayerRequest(BaseModel):
edit_size: Literal[64, 192] = 64
text: str = Field(min_length=0, max_length=512)
font: str = "default"
size: int = 12
@@ -1337,6 +1347,23 @@ def create_app(
except TemplateError as exc:
raise template_error(exc) from exc
@app.post("/api/templates/{template_id}/layers")
def append_template_layer(template_id: str, request: LayerAppendRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict:
try:
if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
return templates.append_layer(template_id, request.layer, normalize_if_match(if_match))
except TemplateError as exc:
raise template_error(exc) from exc
@app.post("/api/animations/{animation_id}/frames/layers")
def append_animation_layer(animation_id: str, request: FrameLayerAppendRequest, if_match: str | None = Header(default=None, alias="If-Match")) -> dict:
try:
reject_demo_animation(animation_id)
return animations.append_layer(animation_id, request.layer, request.frame_ids, normalize_if_match(if_match))
except TemplateError as exc:
raise template_error(exc) from exc
@app.put("/api/templates/{template_id}")
def update_template(
template_id: str,
@@ -1344,7 +1371,8 @@ def create_app(
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
try:
reject_demo_template(template_id)
if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
return templates.update_scene(template_id, request.scene, normalize_if_match(if_match))
except TemplateError as exc:
raise template_error(exc) from exc
@@ -1356,7 +1384,8 @@ def create_app(
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
try:
reject_demo_template(template_id)
if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
return templates.rename(template_id, request.name, normalize_if_match(if_match))
except TemplateError as exc:
raise template_error(exc) from exc
@@ -1408,7 +1437,8 @@ def create_app(
if_match: str | None = Header(default=None, alias="If-Match"),
) -> dict:
try:
reject_demo_template(template_id)
if template_id == DEMO_STATIC_ID:
raise TemplateConflictError(DEMO_RESTRICTED_MESSAGE)
expected = normalize_if_match(if_match)
current = templates.get(template_id)
if current["revision"] != expected:
@@ -1909,7 +1939,7 @@ def create_app(
try:
image = render_text_layer(
text_layer_options_from_request(request),
size=(display.width, display.height),
size=(request.edit_size, request.edit_size),
font_resolver=fonts.resolve,
)
return image_png_response(image)
@@ -0,0 +1,168 @@
"""Scene-v2 validation, legacy upgrade and logical composition. No driver I/O."""
from __future__ import annotations
import base64
import binascii
import copy
import math
from io import BytesIO
from uuid import uuid4
from PIL import Image
from app.display.text_renderer import TextOptions, render_text_layer
from app.display.colors import parse_color
SIDE = 192
ORIGIN = 64
def decode_pixels(value):
if not isinstance(value, str) or len(value) > 300000:
raise ValueError("brush PNG must be bounded base64")
try:
data = base64.b64decode(value, validate=True)
if (data[:8] != b"\x89PNG\r\n\x1a\n" or data[8:16] != b"\x00\x00\x00\rIHDR"
or data[24:29] != b"\x08\x06\x00\x00\x00"):
raise ValueError("brush PNG must be noninterlaced RGBA8")
with Image.open(BytesIO(data)) as image:
image.verify()
with Image.open(BytesIO(data)) as image:
if image.format != "PNG" or image.size != (SIDE, SIDE) or image.mode != "RGBA":
raise ValueError("brush PNG must be 192x192 RGBA")
image.load()
return image.copy()
except (OSError, binascii.Error) as exc:
raise ValueError("invalid brush PNG") from exc
def encode_pixels(image):
out = BytesIO()
image.save(out, format="PNG", compress_level=6)
return base64.b64encode(out.getvalue()).decode("ascii")
def upgrade_scene(scene):
if scene["version"] == 2:
return copy.deepcopy(scene)
pixels = Image.frombytes("RGB", (64, 64), base64.b64decode(scene["pixelRgb"])).convert("RGBA")
canvas = Image.new("RGBA", (SIDE, SIDE))
canvas.paste(pixels, (ORIGIN, ORIGIN))
layers = [dict(id="layer-1", name="图层1", type="brush", x=0, y=0,
background=None, pixelPng=encode_pixels(canvas))]
if scene["elements"]:
layers.append(dict(id="layer-2", name="图层2", type="text", x=0, y=0,
elements=[dict(element, clip=None) for element in scene["elements"]]))
return dict(version=2, width=64, height=64, layers=layers)
def validate_v2(value, validate_legacy):
if set(value) != {"version", "width", "height", "layers"} or any(type(value[k]) is not int for k in ("version", "width", "height")) or (value["version"], value["width"], value["height"]) != (2, 64, 64):
raise ValueError("scene v2 fields or dimensions are invalid")
if not isinstance(value["layers"], list) or not value["layers"]:
raise ValueError("scene must contain a brush layer")
ids, element_ids, layers = set(), set(), []
for layer in value["layers"]:
layers.append(validate_layer(layer, validate_legacy, ids, element_ids))
if not any(layer["type"] == "brush" for layer in layers):
raise ValueError("scene must retain at least one brush layer")
return dict(version=2, width=64, height=64, layers=layers)
def validate_layer(layer, validate_legacy, ids=None, element_ids=None):
ids = ids if ids is not None else set()
element_ids = element_ids if element_ids is not None else set()
if not isinstance(layer, dict) or layer.get("type") not in {"brush", "text"}:
raise ValueError("unsupported layer type")
fields = {"id", "name", "type", "x", "y"} | ({"background", "pixelPng"} if layer["type"] == "brush" else {"elements"})
if layer["type"] == "brush" and "backgroundColor" in layer:
fields.add("backgroundColor")
if set(layer) != fields:
raise ValueError("layer fields do not match schema")
ident = layer["id"]
if not isinstance(ident, str) or not ident.strip() or len(ident) > 128 or ident in ids:
raise ValueError("layer ids must be unique nonempty strings")
ids.add(ident)
if not isinstance(layer["name"], str) or not layer["name"].strip() or len(layer["name"]) > 80:
raise ValueError("layer name must contain 1..80 characters")
if any(type(layer[k]) is not int or not -64 <= layer[k] <= 64 for k in ("x", "y")):
raise ValueError("layer offset must be an integer in -64..64")
result = copy.deepcopy(layer)
if layer["type"] == "brush":
decode_pixels(layer["pixelPng"])
if "backgroundColor" in layer:
color = layer["backgroundColor"]
if not isinstance(color, str) or len(color) != 7 or not color.startswith("#"):
raise ValueError("backgroundColor must be #RRGGBB")
parse_color(color)
if layer["background"] is not None:
if not isinstance(layer["background"], str) or len(layer["background"]) != 7 or not layer["background"].startswith("#"):
raise ValueError("background must be #RRGGBB or null")
parse_color(layer["background"])
else:
if not isinstance(layer["elements"], list):
raise ValueError("text elements must be an array")
checked = []
for element in layer["elements"]:
if not isinstance(element, dict) or "clip" not in element:
raise ValueError("text clip is required")
raw = {k: v for k, v in element.items() if k != "clip"}
legacy = dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes(12288)).decode("ascii"), elements=[raw])
text = validate_legacy(legacy, strict_schema=True)["elements"][0]
if text["id"] in element_ids:
raise ValueError("text ids must be globally unique")
element_ids.add(text["id"])
clip = element["clip"]
if clip is not None and (not isinstance(clip, list) or len(clip) != 4 or any(type(n) not in (int, float) or not math.isfinite(n) or abs(n) > 4096 for n in clip) or clip[2] < clip[0] or clip[3] < clip[1]):
raise ValueError("text clip must be a finite rectangle or null")
checked.append(dict(text, clip=clip))
result["elements"] = checked
return result
def copy_layer(layer, existing):
result = copy.deepcopy(layer)
result["id"] = str(uuid4())
sequence = 1
names = {item["name"] for item in existing}
while f"图层{sequence}" in names:
sequence += 1
result["name"] = f"图层{sequence}"
for element in result.get("elements", []):
element["id"] = str(uuid4())
return result
def render_element(element, font_resolver=None):
options = TextOptions(**{k: element[k] for k in ("text", "font", "size", "align")},
x=element["x"] + ORIGIN, y=element["y"] + ORIGIN,
color=parse_color(element["color"]))
image = render_text_layer(options, size=(SIDE, SIDE), font_resolver=font_resolver)
if element.get("clip") is not None:
# Rectangle is relative to the text anchor, in units of its font size.
left, top, right, bottom = element["clip"]
pixels = image.load()
for y in range(SIDE):
for x in range(SIDE):
u=(x-ORIGIN-element["x"])/element["size"]
v=(y-ORIGIN-element["y"])/element["size"]
if not left <= u < right or not top <= v < bottom:
pixels[x,y]=(0,0,0,0)
return image
def render_v2(scene, font_resolver=None):
image = Image.new("RGBA", (SIDE, SIDE), (0, 0, 0, 255))
def paint(source, layer):
nonlocal image
shifted = Image.new("RGBA", (SIDE, SIDE))
shifted.paste(source, (layer["x"], layer["y"]))
image = Image.alpha_composite(image, shifted)
for layer in scene["layers"]:
if layer["type"] == "brush":
if layer["background"]:
paint(Image.new("RGBA", (SIDE, SIDE), parse_color(layer["background"]) + (255,)), layer)
paint(decode_pixels(layer["pixelPng"]), layer)
else:
# Match browser source-over rounding after every text element.
for element in layer["elements"]:
paint(render_element(element, font_resolver), layer)
return image.crop((ORIGIN, ORIGIN, ORIGIN+64, ORIGIN+64)).convert("RGB")
@@ -12,6 +12,7 @@ import "./color-picker.js";
import "./views/canvas.js";
import { refreshStatus } from "./views/device.js";
import "./views/text.js";
import "./views/layers.js";
import "./views/media-import.js";
import "./views/animations.js";
import "./views/templates.js";
@@ -377,3 +378,6 @@ document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") pollStatus();
});
pollStatus();
if(window.location.hash==="#text")window.location.hash="#canvas";
window.addEventListener("hashchange",()=>{if(window.location.hash==="#text")window.location.hash="#canvas";});
@@ -62,13 +62,15 @@ export function coordinateGuideTicks({
visibleStart = Number.NEGATIVE_INFINITY,
visibleEnd = Number.POSITIVE_INFINITY,
step,
count = 64,
origin = 0,
}) {
const start = Number(boardStart) || 0;
const size = Math.max(0, Number(boardSize) || 0);
const interval = COORDINATE_GUIDE_STEPS.includes(step) ? step : COORDINATE_GUIDE_STEPS[0];
const ticks = [];
for (let value = 0; value < 64; value += interval) {
const position = start + (value / 64) * size;
for (let value = origin; value < origin + count; value += interval) {
const position = start + ((value-origin) / count) * size;
if (position >= visibleStart && position <= visibleEnd) ticks.push({ value, position });
}
return ticks;
+38 -15
View File
@@ -103,6 +103,7 @@
<div id="board-stage" class="board-stage">
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
<div id="actual-screen-frame" aria-hidden="true" hidden><span>实际屏幕 64×64</span></div>
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
</div>
<canvas id="canvas-coordinate-guide" class="canvas-coordinate-guide" aria-hidden="true" hidden></canvas>
@@ -120,6 +121,16 @@
</button>
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden>
<p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
<button id="toggle-expanded-board" type="button" role="menuitem" aria-pressed="false">显示扩展画面</button>
<button id="move-current-layer" type="button" role="menuitem" aria-pressed="false">移动当前图层</button>
<section id="layer-move-controls" class="layer-move-controls" aria-label="图层偏移" hidden>
<button id="exit-layer-move" type="button">退出图层移动</button>
<p>移动图层 · 范围 −64..64</p>
<label>X <input id="layer-offset-x" type="number" min="-64" max="64" value="0" step="1"></label>
<div class="offset-steps" data-axis="x"><button type="button" data-offset="-10">−10</button><button type="button" data-offset="-1">−1</button><button type="button" data-offset="1">+1</button><button type="button" data-offset="10">+10</button></div>
<label>Y <input id="layer-offset-y" type="number" min="-64" max="64" value="0" step="1"></label>
<div class="offset-steps" data-axis="y"><button type="button" data-offset="-10">−10</button><button type="button" data-offset="-1">−1</button><button type="button" data-offset="1">+1</button><button type="button" data-offset="10">+10</button></div>
</section>
<button id="toggle-canvas-coordinates" type="button" role="menuitem" aria-pressed="false">显示坐标系</button>
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
@@ -218,13 +229,14 @@
</div>
<article class="panel canvas-workbench">
<div id="brush-layer-tools">
<div class="canvas-toolbar">
<div class="color-field"><span>画笔颜色</span>
<div class="color-field"><div class="canvas-color-heading"><span>画笔颜色</span></div>
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button>
</div>
<div class="color-field"><span>背景颜色</span>
<div class="color-field"><div class="canvas-color-heading"><span>背景颜色</span><label class="layer-background-toggle"><input id="layer-background-enabled" type="checkbox"><span>开启图层背景</span></label></div>
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
</button>
@@ -252,20 +264,8 @@
<button id="fill-canvas" type="button">填充背景</button>
<button id="clear-canvas" type="button">清空像素层</button>
</div>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section>
<section id="workspace-text" class="workspace" aria-labelledby="text-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="text-title" aria-label="文字显示" data-ui-copy-default-text="文字显示"></h2>
</div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
</div>
<div id="text-layer-tools" hidden>
<article class="panel text-elements-panel">
<div class="panel-heading">
<div>
@@ -329,6 +329,22 @@
</div>
</fieldset>
</form>
</div>
<section id="layer-panel" class="layer-panel" aria-labelledby="layer-panel-title">
<h3 id="layer-panel-title">图层</h3>
<div class="layer-actions">
<button id="new-brush-layer" type="button">新建画笔层</button><button id="new-text-layer" type="button">新建文字层</button>
<button id="copy-layer" type="button">复制图层…</button><button id="rename-layer" type="button">重命名</button>
<button id="raise-layer" type="button">上移</button><button id="lower-layer" type="button">下移</button>
<button id="delete-layer" class="danger-button" type="button">删除图层</button>
</div>
<div id="layer-list" class="layer-list" role="group" aria-label="图层顺序,上方覆盖下方"></div>
<p class="hint">上方图层覆盖下方。至少保留一个画笔层。</p>
</section>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section>
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
@@ -942,5 +958,12 @@
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<noscript>此控制台需要启用 JavaScript。</noscript>
<script type="module" src="/static/app.js"></script>
<dialog id="layer-copy-dialog" class="layer-copy-dialog">
<h2>复制当前图层</h2>
<label>目标画面 <select id="layer-copy-target"><option value="current">当前画板</option></select></label>
<section id="layer-copy-frames" hidden><p>选择目标帧</p><button id="layer-copy-all" type="button">全选</button><button id="layer-copy-none" type="button">清空选择</button><div id="layer-copy-frame-list"></div></section>
<p id="layer-copy-feedback" role="status" aria-live="polite"></p>
<div class="layer-actions"><button id="layer-copy-submit" type="button">复制到最上方</button><button id="layer-copy-cancel" type="button">取消</button></div>
</dialog>
</body>
</html>
@@ -0,0 +1,54 @@
// Fixed 192x192 RGBA PNG codec, independent of DOM and shared by Node tests.
import { zlibSync, unzlibSync } from "./vendor/fflate/fflate.js";
const SIDE = 192, STRIDE = SIDE * 4;
const SIGNATURE = [137,80,78,71,13,10,26,10];
const crcTable = Uint32Array.from({ length: 256 }, (_, n) => {
for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
return n >>> 0;
});
function crc(bytes) { let n = 0xffffffff; for (const b of bytes) n = crcTable[(n ^ b) & 255] ^ (n >>> 8); return (n ^ 0xffffffff) >>> 0; }
function chunk(name, data) {
const out = new Uint8Array(data.length + 12), view = new DataView(out.buffer);
view.setUint32(0, data.length); out.set(Array.from(name, c => c.charCodeAt(0)), 4); out.set(data, 8);
view.setUint32(out.length - 4, crc(out.subarray(4, out.length - 4))); return out;
}
export function encodePixels(rgba) {
if (rgba.length !== SIDE * STRIDE) throw new Error("画笔像素长度无效");
const ihdr = new Uint8Array(13), v = new DataView(ihdr.buffer);
v.setUint32(0, SIDE); v.setUint32(4, SIDE); ihdr[8] = 8; ihdr[9] = 6;
const raw = new Uint8Array((STRIDE + 1) * SIDE);
for (let y = 0; y < SIDE; y++) raw.set(rgba.subarray(y * STRIDE, (y + 1) * STRIDE), y * (STRIDE + 1) + 1);
const parts = [new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", zlibSync(raw, { level: 6 })), chunk("IEND", new Uint8Array())];
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0;
for (const p of parts) { png.set(p, at); at += p.length; }
let binary = ""; for (const b of png) binary += String.fromCharCode(b);
return btoa(binary);
}
function paeth(a,b,c) { const p=a+b-c, x=Math.abs(p-a), y=Math.abs(p-b), z=Math.abs(p-c); return x<=y && x<=z ? a : y<=z ? b : c; }
export function decodePixels(value) {
if (typeof value !== "string" || value.length > 300000 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error("画笔PNG无效");
const bytes = Uint8Array.from(atob(value), c => c.charCodeAt(0)), view = new DataView(bytes.buffer);
if (!SIGNATURE.every((b,i) => bytes[i] === b)) throw new Error("画笔PNG签名无效");
const parts=[]; let header=false, ended=false;
for (let at=8; at+12<=bytes.length;) {
const n=view.getUint32(at); if (n>bytes.length-at-12) throw new Error("PNG截断");
const name=String.fromCharCode(...bytes.subarray(at+4,at+8)), data=bytes.subarray(at+8,at+8+n);
if (crc(bytes.subarray(at+4,at+8+n))!==view.getUint32(at+8+n)) throw new Error("PNG校验失败");
if (name==="IHDR") { if (header || n!==13 || view.getUint32(at+8)!==SIDE || view.getUint32(at+12)!==SIDE || data[8]!==8 || data[9]!==6 || data[10] || data[11] || data[12]) throw new Error("PNG必须为192×192 RGBA8"); header=true; }
else if (name==="IDAT") { if (!header || ended) throw new Error("PNG顺序无效"); parts.push(data); }
else if (name==="IEND") { if (n || at+12!==bytes.length) throw new Error("PNG结尾无效"); ended=true; }
else if (name[0]===name[0].toUpperCase()) throw new Error("PNG不支持此块");
at+=n+12;
}
if (!header || !ended || !parts.length) throw new Error("PNG不完整");
const compressed=new Uint8Array(parts.reduce((n,p)=>n+p.length,0)); let at=0; for (const p of parts) { compressed.set(p,at); at+=p.length; }
const expected=(STRIDE+1)*SIDE, raw=unzlibSync(compressed,{out:new Uint8Array(expected+1)});
if (raw.length!==expected) throw new Error("PNG展开长度无效");
const out=new Uint8ClampedArray(SIDE*STRIDE);
for (let y=0;y<SIDE;y++) { const filter=raw[y*(STRIDE+1)]; if (filter>4) throw new Error("PNG过滤器无效");
for (let x=0;x<STRIDE;x++) { const i=y*STRIDE+x, a=x>=4?out[i-4]:0, b=y?out[i-STRIDE]:0, c=y&&x>=4?out[i-STRIDE-4]:0;
out[i]=(raw[y*(STRIDE+1)+1+x]+([0,a,b,Math.floor((a+b)/2),paeth(a,b,c)][filter]))&255;
}
}
return out;
}
+181 -108
View File
@@ -1,3 +1,4 @@
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
import { announce, apiBlob, isAppStale } from "./core.js";
import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js";
import {
@@ -34,8 +35,8 @@ compositionCanvas.width = SCENE_WIDTH;
compositionCanvas.height = SCENE_HEIGHT;
const compositionContext = compositionCanvas.getContext("2d", { willReadFrequently: true });
const scratchCanvas = document.createElement("canvas");
scratchCanvas.width = SCENE_WIDTH;
scratchCanvas.height = SCENE_HEIGHT;
scratchCanvas.width = 192;
scratchCanvas.height = 192;
const scratchContext = scratchCanvas.getContext("2d", { willReadFrequently: true });
const boardTitle = document.getElementById("board-title");
const boardEyebrow = document.getElementById("board-eyebrow");
@@ -76,6 +77,11 @@ const draftStorage = browserStorage();
const draftLoad = restoreSceneDraft(draftStorage);
let scene = draftLoad.scene;
let selectedTextId = null;
let selectedLayerId = scene.layers[0].id;
let expandedBoard = false;
let layerMovePreview = null;
let moveFrameVisible = false;
const editHistory = [];
let interactionMode = "readonly";
let activeWorkspace = "device";
let previewKind = "scene";
@@ -175,6 +181,8 @@ keepCurrentDraftButton.addEventListener("click", () => {
function elementSignature(element) {
return JSON.stringify({
layerId: scene.layers.find(layer => layer.elements?.some(item => item.id === element.id))?.id,
elementId: element.id,
text: element.text,
font: element.font,
size: element.size,
@@ -182,19 +190,13 @@ function elementSignature(element) {
y: element.y,
align: element.align,
color: element.color,
clip: element.clip,
});
}
function textPayload(element) {
return {
text: element.text,
font: element.font,
size: element.size,
x: element.x,
y: element.y,
align: element.align,
color: element.color,
};
return {text:element.text,font:element.font,size:element.size,x:element.x+64,y:element.y+64,
align:element.align,color:element.color,edit_size:192};
}
function elementById(id) {
@@ -221,13 +223,13 @@ async function imageFromBlob(blob) {
}
function alphaBounds(rgba) {
let left = SCENE_WIDTH;
let top = SCENE_HEIGHT;
let left = 192;
let top = 192;
let right = -1;
let bottom = -1;
for (let y = 0; y < SCENE_HEIGHT; y += 1) {
for (let x = 0; x < SCENE_WIDTH; x += 1) {
if (rgba[(y * SCENE_WIDTH + x) * 4 + 3] === 0) continue;
for (let y = 0; y < 192; y += 1) {
for (let x = 0; x < 192; x += 1) {
if (rgba[(y * 192 + x) * 4 + 3] === 0) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
@@ -235,7 +237,7 @@ function alphaBounds(rgba) {
}
}
if (right < left || bottom < top) return null;
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
return { x: left-64, y: top-64, width: right - left + 1, height: bottom - top + 1 };
}
function layerEntry(id) {
@@ -318,15 +320,15 @@ async function loadTextLayer(id, requestedSignature, requestedRevision) {
closeImage(image);
return;
}
scratchContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
scratchContext.clearRect(0, 0, 192, 192);
scratchContext.drawImage(image, 0, 0);
const rgba = new Uint8ClampedArray(
scratchContext.getImageData(0, 0, SCENE_WIDTH, SCENE_HEIGHT).data,
scratchContext.getImageData(0, 0, 192, 192).data,
);
closeImage(entry.image);
entry.image = image;
entry.rgba = rgba;
entry.bounds = alphaBounds(rgba);
entry.rgba = clipTextPixels(rgba, current);
entry.bounds = alphaBounds(entry.rgba);
entry.signature = requestedSignature;
entry.revision = requestedRevision;
entry.targetSignature = requestedSignature;
@@ -369,14 +371,9 @@ function layerState() {
}
function putRgb(context, rgb) {
const image = context.createImageData(SCENE_WIDTH, SCENE_HEIGHT);
for (let source = 0, target = 0; source < rgb.length; source += 3, target += 4) {
image.data[target] = rgb[source];
image.data[target + 1] = rgb[source + 1];
image.data[target + 2] = rgb[source + 2];
image.data[target + 3] = 255;
}
context.putImageData(image, 0, 0);
const side=Math.sqrt(rgb.length/3), image=context.createImageData(side,side);
for(let i=0,j=0;i<rgb.length;i+=3,j+=4) {image.data.set(rgb.subarray(i,i+3),j);image.data[j+3]=255;}
context.putImageData(image,0,0);
}
function drawTransformedLayer(context, element, entry) {
@@ -397,34 +394,35 @@ function drawTransformedLayer(context, element, entry) {
context.restore();
}
function previewTextPixels() {
const result=new Map();
for(const e of scene.elements) {
let rgba=textLayers.get(e.id)?.rgba;
if(!rgba) continue;
if(activeTransform?.id===e.id) {
const out=new Uint8ClampedArray(rgba.length), t=activeTransform;
for(let y=0;y<192;y++) for(let x=0;x<192;x++) {
const sx=Math.floor(t.kind==="drag"?x-t.dx:e.x+64+(x-e.x-64)/t.scale);
const sy=Math.floor(t.kind==="drag"?y-t.dy:e.y+64+(y-e.y-64)/t.scale);
if(sx>=0&&sx<192&&sy>=0&&sy<192) out.set(rgba.subarray((sy*192+sx)*4,(sy*192+sx)*4+4),(y*192+x)*4);
}
rgba=out;
}
result.set(e.id,rgba);
}
return result;
}
function renderComposition() {
removeUnusedLayers();
scene.elements.forEach((element) => scheduleTextLayerIfMissing(element));
const state = layerState();
if (activeTransform) {
putRgb(compositionContext, scene.pixels);
scene.elements.forEach((element) => drawTransformedLayer(
compositionContext,
element,
textLayers.get(element.id),
));
lastExactRgb = null;
} else {
const rgbaLayers = scene.elements
.map((element) => textLayers.get(element.id)?.rgba)
.filter(Boolean);
const rgb = compositeRgbaLayers(scene.pixels, rgbaLayers);
putRgb(compositionContext, rgb);
lastExactRgb = state.ready ? rgb : null;
}
if (previewKind === "scene") {
visibleContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
visibleContext.drawImage(compositionCanvas, 0, 0);
}
animationBoardPreviewContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
animationBoardPreviewContext.drawImage(compositionCanvas, 0, 0);
drawSelectionOverlay();
updateBoardUi(state);
scene.elements.forEach(scheduleTextLayerIfMissing);
const state=layerState(), pixels=previewTextPixels();
const rgb=composeScene(scene,pixels,64,layerMovePreview);
putRgb(compositionContext,rgb);
lastExactRgb=state.ready&&!activeTransform&&!layerMovePreview?rgb:null;
if(previewKind==="scene") putRgb(visibleContext,expandedBoard?composeScene(scene,pixels,192,layerMovePreview):rgb);
animationBoardPreviewContext.clearRect(0,0,64,64);
animationBoardPreviewContext.drawImage(compositionCanvas,0,0);
drawSelectionOverlay();updateBoardUi(state);
}
function scheduleTextLayerIfMissing(element) {
@@ -455,7 +453,7 @@ function updateBoardUi(state = layerState()) {
setControlUnavailable(applyButton, "浏览器无法安全保存当前草稿,请先处理页面提示后再应用。");
} else if (draftConflict) {
setControlUnavailable(applyButton, "请先解决另一标签页的草稿冲突,再应用画板。");
} else if (activeTransform) {
} else if (activeTransform || layerMovePreview) {
setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。");
} else if (!state.ready) {
setControlUnavailable(applyButton, state.errors
@@ -464,7 +462,7 @@ function updateBoardUi(state = layerState()) {
} else {
setControlReady(applyButton);
}
if (activeTransform) {
if (activeTransform || layerMovePreview) {
setControlUnavailable(exportButton, "请先完成当前文字拖动或缩放操作。");
} else if (!state.ready) {
setControlUnavailable(exportButton, state.errors
@@ -504,8 +502,8 @@ function updateBoardUi(state = layerState()) {
boardStatus.dataset.state = "pending";
} else {
boardStatus.textContent = animationEdit
? `像素底层 + ${scene.elements.length} 个文字元素 · 动图帧编辑中`
: `像素底层 + ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`;
? ` ${scene.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 动图帧编辑中`
: ` ${scene.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`;
boardStatus.dataset.state = "ready";
}
}
@@ -520,7 +518,9 @@ function fallbackBounds(element) {
}
function transformedBounds(element) {
const source = textLayers.get(element.id)?.bounds || fallbackBounds(element);
const local = textLayers.get(element.id)?.bounds || fallbackBounds(element);
const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===element.id));
const source={...local,x:local.x+(layer?.x||0),y:local.y+(layer?.y||0)};
if (!activeTransform || activeTransform.id !== element.id) return source;
if (activeTransform.kind === "drag") {
return {
@@ -530,42 +530,45 @@ function transformedBounds(element) {
};
}
return {
x: element.x + (source.x - element.x) * activeTransform.scale,
y: element.y + (source.y - element.y) * activeTransform.scale,
x: element.x+(getSelectedLayer().x||0)+(source.x-element.x-(getSelectedLayer().x||0))*activeTransform.scale,
y: element.y+(getSelectedLayer().y||0)+(source.y-element.y-(getSelectedLayer().y||0))*activeTransform.scale,
width: source.width * activeTransform.scale,
height: source.height * activeTransform.scale,
};
}
function drawSelectionOverlay() {
overlayContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
const origin=expandedBoard?-64:0;
document.getElementById("actual-screen-frame").hidden=!(expandedBoard||moveFrameVisible);
if (interactionMode !== "text" || previewKind !== "scene" || !selectedTextId) return;
const element = elementById(selectedTextId);
if (!element) return;
const bounds = transformedBounds(element);
overlayContext.save();
overlayContext.translate(-origin,-origin);
overlayContext.strokeStyle = "#72E0C5";
overlayContext.fillStyle = "#0D1015";
overlayContext.lineWidth = 0.55;
overlayContext.setLineDash([1.2, 0.8]);
overlayContext.strokeRect(bounds.x - 0.4, bounds.y - 0.4, bounds.width + 0.8, bounds.height + 0.8);
overlayContext.setLineDash([]);
const handleX = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width));
const handleY = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height));
const handleX = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.x + bounds.width));
const handleY = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.y + bounds.height));
overlayContext.fillRect(handleX - 1.5, handleY - 1.5, 3, 3);
overlayContext.strokeRect(handleX - 1.5, handleY - 1.5, 3, 3);
overlayContext.restore();
}
function emitSceneChange(reason) {
const detail = { scene, selectedTextId, reason };
const detail = { scene, selectedTextId, selectedLayerId, reason };
listeners.forEach((listener) => listener(detail));
document.dispatchEvent(new CustomEvent("matrix:scene-change", { detail }));
}
export function subscribeScene(listener) {
listeners.add(listener);
listener({ scene, selectedTextId, reason: "subscribe" });
listener({ scene, selectedTextId, selectedLayerId, reason: "subscribe" });
return () => listeners.delete(listener);
}
@@ -621,6 +624,7 @@ export function enterAnimationEdit(frame) {
export function markAnimationFrameSaved(detail) {
if (!animationEdit) return;
clearEditHistory();
animationEdit.revision = detail.revision;
if (Object.hasOwn(detail, "name")) animationEdit.frameName = detail.name;
if (detail.position) animationEdit.position = detail.position;
@@ -684,7 +688,9 @@ export function replaceSceneFromStorage(
templateEditContext.baseline || serializeScene(restored),
)
: null;
selectedTextId = scene.elements.at(-1)?.id || null;
selectedLayerId = scene.layers[0].id;
selectedTextId = getSelectedLayer()?.elements?.at(-1)?.id || null;
syncInteractionMode();
if (persist) persistScene();
scene.elements.forEach((element) => scheduleTextLayer(element, true));
showCompositionPreview();
@@ -710,6 +716,7 @@ export function enterTemplateEdit(template) {
export function markTemplateSaved(template) {
if (animationEdit) return false;
clearEditHistory();
templateEdit = createTemplateEditContext(template, serializeScene(scene));
sceneSource = { type: "template", id: template.id, revision: template.revision };
updateBoardUi();
@@ -717,22 +724,75 @@ export function markTemplateSaved(template) {
return true;
}
export function getPixelBuffer() {
return scene.pixels;
export function getSelectedLayer() {return scene.layers.find(l=>l.id===selectedLayerId)||scene.layers[0];}
export function getPixelBuffer() {return getSelectedLayer().pixels;}
export function getInteractionMode() {return interactionMode;}
export function boardExtent() {return {size:expandedBoard?192:64,origin:expandedBoard?-64:0};}
export function boardPoint(event,{round=true}={}) {
const rect=overlayCanvas.getBoundingClientRect(), {size,origin}=boardExtent();
const x=((event.clientX-rect.left)/rect.width)*size+origin, y=((event.clientY-rect.top)/rect.height)*size+origin;
return {x:round?Math.max(origin,Math.min(origin+size-1,Math.floor(x))):x,y:round?Math.max(origin,Math.min(origin+size-1,Math.floor(y))):y};
}
export function getInteractionMode() {
return interactionMode;
export function setExpandedBoard(value) {
expandedBoard=Boolean(value); const size=expandedBoard?192:64;
visibleCanvas.width=visibleCanvas.height=overlayCanvas.width=overlayCanvas.height=size;
visibleContext.imageSmoothingEnabled=false;
document.getElementById("board-stage").classList.toggle("board-expanded",expandedBoard);
renderComposition();
}
export function boardPoint(event, { round = true } = {}) {
const rect = overlayCanvas.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * SCENE_WIDTH;
const y = ((event.clientY - rect.top) / rect.height) * SCENE_HEIGHT;
return {
x: round ? Math.max(0, Math.min(63, Math.floor(x))) : Math.max(0, Math.min(64, x)),
y: round ? Math.max(0, Math.min(63, Math.floor(y))) : Math.max(0, Math.min(64, y)),
};
export function setMoveFrameVisible(value) {moveFrameVisible=value;drawSelectionOverlay();}
function syncInteractionMode() {
interactionMode=activeWorkspace==="canvas"?(getSelectedLayer().type==="text"?"text":"pixels"):"readonly";
overlayCanvas.dataset.interaction=interactionMode;
}
export function selectLayer(id) {
if(!scene.layers.some(l=>l.id===id)) return;
selectedLayerId=id;selectedTextId=getSelectedLayer().elements?.at(-1)?.id||null;
syncInteractionMode();renderComposition();emitSceneChange("selection");
}
export function rememberEdit(before=serializeScene(scene)) {
if(editHistory.at(-1)!==before) editHistory.push(before);
if(editHistory.length>50)editHistory.shift();
document.dispatchEvent(new CustomEvent("matrix:edit-history"));
}
export function editSnapshot() {return serializeScene(scene);}
export function editHistorySize() {return editHistory.length;}
export function clearEditHistory() {editHistory.length=0;document.dispatchEvent(new CustomEvent("matrix:edit-history"));}
export function undoSceneEdit() {
const value=editHistory.pop();if(!value)return false;
const selected=selectedLayerId;scene=deserializeScene(value);
if(scene.layers.some(l=>l.id===selected))selectedLayerId=selected;else selectedLayerId=scene.layers[0].id;
selectedTextId=getSelectedLayer().elements?.at(-1)?.id||null;
syncInteractionMode();scene.elements.forEach(e=>scheduleTextLayer(e,true));commitPixelChanges("undo");
document.dispatchEvent(new CustomEvent("matrix:edit-history"));return true;
}
export function changeLayer(action,value) {
const layer=getSelectedLayer(),before=editSnapshot();
if(action==="create") {const next=createLayer(value,scene.layers);scene.layers.push(next);selectLayer(next.id);}
if(action==="duplicate") {const next=cloneLayer(layer,scene.layers);scene.layers.push(next);selectLayer(next.id);}
if(action==="delete") {
if(layer.type==="brush"&&scene.layers.filter(l=>l.type==="brush").length===1) {announce("必须保留一个画笔图层",true);return;}
scene=attachScene(scene.layers.filter(l=>l.id!==layer.id));selectLayer(scene.layers.at(-1).id);
}
if(action==="rename") {const name=value.trim();if(!name||name.length>80)throw Error("图层名称需为1..80个字符");layer.name=name;}
if(action==="up"||action==="down") {const index=scene.layers.indexOf(layer),target=index+(action==="up"?1:-1);if(target<0||target>=scene.layers.length)return;[scene.layers[index],scene.layers[target]]=[scene.layers[target],scene.layers[index]];}
if(before!==editSnapshot())rememberEdit(before);commitPixelChanges("layers");
}
export function previewLayerMove(x,y) {const l=getSelectedLayer();layerMovePreview={layerId:l.id,x:Math.max(-64,Math.min(64,Math.round(x))),y:Math.max(-64,Math.min(64,Math.round(y)))};renderComposition();}
export function cancelLayerPreview() {layerMovePreview=null;renderComposition();}
export function moveSelectedLayer(x,y) {
const layer=getSelectedLayer();if(!layerState().ready)throw Error("请等待文字渲染完成后移动图层");
const nextX=Math.max(-64,Math.min(64,Math.round(x))),nextY=Math.max(-64,Math.min(64,Math.round(y)));
if(!Number.isFinite(nextX)||!Number.isFinite(nextY))throw Error("偏移必须为整数");
if(nextX!==Math.round(x)||nextY!==Math.round(y))announce("图层偏移限制为−64到64;黄色框为实际屏幕");
const checks=layer.type==="brush"?[translatedOverflow(layer.pixels,nextX,nextY)]:layer.elements.map(e=>translatedOverflow(textLayers.get(e.id).rgba,nextX,nextY));
const overflow=checks.some(c=>c.outside);previewLayerMove(nextX,nextY);
if(overflow&&!window.confirm("有背景以外的内容移出192×192编辑区域。确定后将清除越界部分;取消恢复本次移动。")) {cancelLayerPreview();emitSceneChange("move-cancel");return false;}
const before=editSnapshot();layer.x=nextX;layer.y=nextY;
if(overflow&&layer.type==="brush") {layer.pixels=clipBrushPixels(layer.pixels,nextX,nextY);layer.revision++;}
if(overflow&&layer.type==="text") layer.elements=layer.elements.flatMap((e,i)=>!checks[i].inside?[]:[checks[i].outside?{...e,clip:textClipAt(e,nextX,nextY),revision:e.revision+1}:e]);
layerMovePreview=null;if(before!==editSnapshot())rememberEdit(before);
scene.elements.forEach(e=>scheduleTextLayer(e,true));commitPixelChanges("layer-move");return true;
}
export function getBoardOverlay() {
@@ -745,17 +805,21 @@ export function renderPixelDraft() {
export function commitPixelChanges(reason = "pixels") {
sceneSource = null;
if(getSelectedLayer().type==="brush")getSelectedLayer().revision++;
persistScene();
renderComposition();
emitSceneChange(reason);
}
export function fillPixelLayer(rgb) {
scene = createScene({ pixels: createRgbPixels(rgb), elements: scene.elements });
commitPixelChanges("pixel-fill");
const l=getSelectedLayer();l.backgroundColor="#"+rgb.map(n=>n.toString(16).padStart(2,"0")).join("").toUpperCase();l.background=l.backgroundColor;commitPixelChanges("pixel-fill");
}
export function clearPixelLayer() {const l=getSelectedLayer();l.pixels.fill(0);commitPixelChanges("pixel-clear");}
export function setLayerBackground(enabled,color) {const l=getSelectedLayer();if(l.type!=="brush")return;const before=editSnapshot();l.backgroundColor=color;l.background=enabled?color:null;if(before!==editSnapshot())rememberEdit(before);commitPixelChanges("background");}
export function selectTextElement(id, reason = "selection") {
const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===id));
if(layer)selectedLayerId=layer.id;
selectedTextId = elementById(id)?.id || null;
drawSelectionOverlay();
emitSceneChange(reason);
@@ -768,8 +832,8 @@ export function getSelectedTextElement() {
export function addTextElement(overrides = {}) {
sceneSource = null;
scene = addTextToScene(scene, overrides);
const created = scene.elements.at(-1);
scene = addTextToScene(scene, overrides, selectedLayerId);
const created = getSelectedLayer().elements.at(-1);
selectedTextId = created.id;
persistScene();
scheduleTextLayer(created, true);
@@ -778,17 +842,25 @@ export function addTextElement(overrides = {}) {
return created;
}
export function updateTextElement(id, patch, reason = "update") {
const previous = scene;
scene = updateTextInScene(scene, id, patch);
if (scene === previous) return null;
sceneSource = null;
const updated = elementById(id);
persistScene();
if (updated) scheduleTextLayer(updated);
renderComposition();
emitSceneChange(reason);
return updated;
export function updateTextElement(id, patch, reason="update") {
const current=elementById(id);if(!current)return null;
const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===id));
const updated=normalizeTextElement({...current,...patch}),before=editSnapshot();
const dx=updated.x-current.x,dy=updated.y-current.y;
if(dx||dy) {
const rgba=textLayers.get(id)?.rgba;
if(!rgba||!layerState().ready){announce("请等待文字渲染完成后移动",true);emitSceneChange("move-cancel");return current;}
const check=translatedOverflow(rgba,dx+layer.x,dy+layer.y);
if(check.outside) {
if(!window.confirm("文字移出192×192编辑区域。确定后清除越界部分;取消恢复本次移动。")){emitSceneChange("move-cancel");return current;}
if(!check.inside) {scene=deleteTextFromScene(scene,id);selectedTextId=layer.elements.find(e=>e.id!==id)?.id||null;rememberEdit(before);commitPixelChanges("gesture");return null;}
patch={...patch,clip:textClipAt(updated,layer.x,layer.y)};
}
}
scene=updateTextInScene(scene,id,patch);sceneSource=null;
if(dx||dy||reason==="gesture")rememberEdit(before);
const target=elementById(id);persistScene();if(target)scheduleTextLayer(target);
renderComposition();emitSceneChange(reason);return target;
}
export function duplicateTextElement(id = selectedTextId) {
@@ -796,7 +868,7 @@ export function duplicateTextElement(id = selectedTextId) {
scene = duplicateTextInScene(scene, id);
if (scene.elements.length === previousLength) return null;
sceneSource = null;
const duplicate = scene.elements.at(-1);
const duplicate = getSelectedLayer().elements.at(-1);
selectedTextId = duplicate.id;
persistScene();
scheduleTextLayer(duplicate, true);
@@ -810,7 +882,7 @@ export function deleteTextElement(id = selectedTextId) {
scene = deleteTextFromScene(scene, id);
if (scene.elements.length === previousLength) return false;
sceneSource = null;
selectedTextId = scene.elements.at(-1)?.id || null;
selectedTextId = getSelectedLayer().elements?.at(-1)?.id || null;
persistScene();
renderComposition();
emitSceneChange("delete");
@@ -818,8 +890,9 @@ export function deleteTextElement(id = selectedTextId) {
}
function hitTestText(point) {
for (let index = scene.elements.length - 1; index >= 0; index -= 1) {
const element = scene.elements[index];
const elements=getSelectedLayer().elements||[];
for (let index = elements.length - 1; index >= 0; index -= 1) {
const element = elements[index];
const bounds = transformedBounds(element);
if (
point.x >= bounds.x - 1
@@ -833,17 +906,17 @@ function hitTestText(point) {
function resizeHandleHit(point, element) {
const bounds = transformedBounds(element);
const x = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width));
const y = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height));
const x = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.x + bounds.width));
const y = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.y + bounds.height));
return Math.abs(point.x - x) <= 3.5 && Math.abs(point.y - y) <= 3.5;
}
function startTextGesture(event) {
if (interactionMode !== "text" || previewKind !== "scene") return;
if (interactionMode !== "text" || previewKind !== "scene" || (document.body.dataset.canvasEditorMode && document.body.dataset.canvasEditorMode!=="draw")) return;
const point = boardPoint(event, { round: false });
let element = getSelectedTextElement();
if (element && resizeHandleHit(point, element)) {
const distance = Math.hypot(point.x - element.x, point.y - element.y);
const distance = Math.hypot(point.x-element.x-getSelectedLayer().x,point.y-element.y-getSelectedLayer().y);
activeGesture = {
kind: "resize",
id: element.id,
@@ -885,7 +958,7 @@ function moveTextGesture(event) {
dy: point.y - activeGesture.startPoint.y,
};
} else {
const distance = Math.hypot(point.x - element.x, point.y - element.y);
const distance = Math.hypot(point.x-element.x-getSelectedLayer().x,point.y-element.y-getSelectedLayer().y);
const minimum = 1 / activeGesture.startSize;
const maximum = 64 / activeGesture.startSize;
activeTransform = {
@@ -928,7 +1001,7 @@ overlayCanvas.addEventListener("pointercancel", (event) => finishTextGesture(eve
export function setBoardWorkspace(workspaceId) {
const wasMonitorWorkspace = MONITOR_WORKSPACES.has(activeWorkspace);
activeWorkspace = workspaceId;
interactionMode = workspaceId === "canvas" ? "pixels" : workspaceId === "text" ? "text" : "readonly";
syncInteractionMode();
overlayCanvas.dataset.interaction = interactionMode;
const isMonitor = MONITOR_WORKSPACES.has(workspaceId);
const isAnimationWorkspace = workspaceId === "animations";
@@ -1142,7 +1215,7 @@ export async function applyComposition() {
export function exportCompositionPng() {
const state = layerState();
if (!state.ready || activeTransform) throw new Error("请等待文字图层完成后再导出");
if (!state.ready || activeTransform || layerMovePreview) throw new Error("请等待文字图层完成后再导出");
compositionCanvas.toBlob((blob) => {
if (!blob) {
announce("合成 PNG 生成失败", true);
@@ -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;
}
+121 -573
View File
@@ -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;}
@@ -905,6 +905,28 @@ h3 {
display: none;
}
.board-expanded .pixel-grid { background-size: calc(100% / 192) calc(100% / 192); }
.layer-panel { margin-block: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.layer-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: .75rem; }
.layer-actions button { flex: 1 1 7rem; }
.layer-list { display: grid; gap: .4rem; max-height: 18rem; overflow: auto; }
.layer-item { text-align: left; overflow-wrap: anywhere; }
.layer-item[aria-pressed="true"] { border-color: #72E0C5; background: #163832; }
.layer-move-controls { display: grid; gap: .35rem; }
.layer-move-controls[hidden], #brush-layer-tools[hidden], #text-layer-tools[hidden] { display: none; }
.layer-move-controls label { display: flex; align-items: center; gap: .5rem; }
.layer-move-controls input { min-width: 0; width: 100%; }
.offset-steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: .2rem; }
.offset-steps button { padding: .2rem; text-align: center; }
.layer-copy-dialog { width: min(36rem, calc(100vw - 2rem)); max-height: 85dvh; overflow: auto; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; padding: 1rem; box-shadow: var(--shadow); }
.layer-copy-dialog::backdrop { background: rgb(0 0 0 / 65%); }
.layer-copy-dialog select { width: 100%; }
#layer-copy-frame-list { display: grid; gap: .5rem; max-height: 45dvh; overflow: auto; }
#layer-copy-frame-list label { display: flex; align-items: center; gap: .6rem; min-width: 0; min-height: 44px; cursor: pointer; }
#layer-copy-frame-list label span { min-width: 0; overflow-wrap: anywhere; }
#layer-copy-frame-list input[type="checkbox"], .layer-background-toggle input[type="checkbox"] { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; margin: 0; accent-color: var(--accent); cursor: pointer; }
body.canvas-edit-active[data-canvas-editor-mode="layer"] .board-stage { cursor: move; }
.canvas-coordinate-guide {
display: none;
pointer-events: none;
@@ -1028,6 +1050,8 @@ body.canvas-edit-active[data-canvas-editor-mode="move"] .board-stage:active {
position: absolute;
top: -28px;
left: 38px;
max-height: var(--assistive-menu-height, min(85dvh, calc(100dvh - 32px)));
overflow-y: auto;
display: grid;
width: min(14rem, calc(100vw - 88px));
gap: 8px;
@@ -1919,8 +1943,15 @@ textarea {
.canvas-toolbar .color-field {
margin-top: 0;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.canvas-color-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; min-height: 44px; }
.layer-background-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }
.canvas-toolbar .color-trigger { width: 100%; min-width: 0; grid-template-columns: 34px minmax(0, 1fr); }
.segmented {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3121,3 +3152,8 @@ noscript {
transition-duration: 0.01ms !important;
}
}
#actual-screen-frame { position:absolute; inset:0; z-index:4; border:2px solid #FFCC66; pointer-events:none; }
#actual-screen-frame[hidden] { display:none; }
#actual-screen-frame span { position:absolute; top:2px; left:3px; font-size:11px; color:#FFCC66; background:#000b; padding:2px; white-space:nowrap; }
.board-expanded #actual-screen-frame { inset:33.333333%; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,7 @@
fflate 0.8.2, MIT.
Source: https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz
Tarball SHA-256: 61fd5061e2fc8e5e3e3129f7f2fec7bd78a313e1bf4becbf1cc1cc9998d141dc
Bundled offline PNG compression; no CDN or npm runtime dependency.
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { getSelectedLayer, boardExtent, editSnapshot, rememberEdit, editHistorySize, clearEditHistory, undoSceneEdit, setExpandedBoard, setMoveFrameVisible, moveSelectedLayer, previewLayerMove, cancelLayerPreview, clearPixelLayer, subscribeScene } from "../scene-board.js";
import {
announce,
createLocalState,
@@ -81,7 +82,13 @@ let suppressAssistiveClick = false;
let coordinateGuideFrame = null;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
const undoHistory = new PixelUndoHistory();
let expandedEnabled=Boolean(localState.load({}).expandedEnabled);
let layerDrag=null;
const expandedToggle=document.getElementById("toggle-expanded-board");
const moveLayerButton=document.getElementById("move-current-layer");
const exitLayerMoveButton=document.getElementById("exit-layer-move");
const moveControls=document.getElementById("layer-move-controls");
const offsetX=document.getElementById("layer-offset-x"),offsetY=document.getElementById("layer-offset-y");
paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
@@ -94,21 +101,19 @@ function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
function setPixel(x, y, rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return;
const offset = (y * 64 + x) * 3;
const pixels = getPixelBuffer();
pixels[offset] = rgb.r;
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
function setPixel(x,y,rgb) {
const layer=getSelectedLayer(),{origin,size}=boardExtent(),worldX=x-64+layer.x,worldY=y-64+layer.y;
if(x<0||x>=192||y<0||y>=192||worldX<origin||worldX>=origin+size||worldY<origin||worldY>=origin+size)return;
const i=(y*192+x)*4;
layer.pixels.set(tool==="eraser"?[0,0,0,0]:[rgb.r,rgb.g,rgb.b,255],i);
}
function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
return hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
brushStampPoints(centerX+64-getSelectedLayer().x, centerY+64-getSelectedLayer().y, currentBrushSize(), brushMode(),192,192)
.forEach((point) => setPixel(point.x, point.y, rgb));
}
@@ -127,24 +132,14 @@ function paintEvent(event) {
renderPixelDraft();
}
function pixelSnapshot() {
return Uint8Array.from(getPixelBuffer());
}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function pixelSnapshot() {return {scene:editSnapshot(),pixels:getSelectedLayer().pixels?.slice()};}
function updateUndoControl() {if(editHistorySize()>0)setControlReady(undoButton);else setControlUnavailable(undoButton,"还没有可以撤销的编辑操作。");}
function clearUndoHistory() {clearEditHistory();updateUndoControl();}
function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer());
updateUndoControl();
return recorded;
if(!before)return false;
const l=getSelectedLayer(),changed=before.pixels?.some((v,i)=>v!==l.pixels[i]);
if(changed)l.revision++;
if(before.scene!==editSnapshot()){rememberEdit(before.scene);updateUndoControl();return true;}return false;
}
function viewportBounds() {
@@ -191,8 +186,10 @@ function renderCoordinateGuide() {
const bottom = Math.min(height, rect.bottom);
if (right <= left || bottom <= top || rect.width <= 0) return;
const step = chooseCoordinateGuideStep(rect.width / 64);
const extent=boardExtent();
const step = chooseCoordinateGuideStep(rect.width / extent.size);
const xTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.left,
boardSize: rect.width,
visibleStart: left,
@@ -200,6 +197,7 @@ function renderCoordinateGuide() {
step,
});
const yTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.top,
boardSize: rect.height,
visibleStart: top,
@@ -296,6 +294,8 @@ function applyAssistivePosition(next = assistivePosition) {
});
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
const room=assistivePosition.y>window.innerHeight/2?assistivePosition.y:window.innerHeight-assistivePosition.y;
assistive.style.setProperty("--assistive-menu-height",`${Math.max(120,room+16)}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
}
@@ -327,17 +327,20 @@ function toggleAssistiveMenu() {
function updateEditorModeControls() {
const moving = editorMode === "move";
const textEditing = getSelectedLayer().type === "text";
moveLayerButton.setAttribute("aria-pressed",String(editorMode==="layer"));
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving
editorModeHint.textContent = editorMode==="layer"?"移动图层:拖动或使用偏移坐标;黄色框为实际屏幕":moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。";
: textEditing ? "文字编辑模式:拖动文字或使用缩放手柄。" : "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
if (editorMode === "layer") setControlReady(continueEditButton);
else setControlUnavailable(continueEditButton, textEditing ? "当前已经是文字编辑模式。" : "当前已经是绘画模式。");
}
updateUndoControl();
}
@@ -361,24 +364,36 @@ function resetViewGesture() {
function setEditorMode(mode) {
if (!editorActive) return;
if (mode === "move") finishDrawing();
editorMode = mode === "move" ? "move" : "draw";
const pointerIds = [...viewPointers.keys()];
if (layerDrag) {
pointerIds.push(layerDrag.id);
layerDrag = null;
cancelLayerPreview();
}
viewPointers.clear();
viewGesture = null;
for (const id of pointerIds) {
if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
}
if (mode === "move") finishDrawing();
editorMode = ["move","layer"].includes(mode)?mode:"draw";
if (editorMode === "draw") moveControls.hidden = true;
setMoveFrameVisible(editorMode==="layer" || !moveControls.hidden);
updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布");
: editorMode === "layer" ? "已进入移动当前图层模式:拖动图层或输入偏移" : "已继续编辑画布");
}
function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return;
if (editorActive || getInteractionMode() === "readonly") return;
editorActive = true;
editorMode = "draw";
canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active");
assistive.hidden = false;
setExpandedBoard(expandedEnabled);
closeAssistiveMenu();
updateEditorModeControls();
updateCoordinateGuideControl();
@@ -400,6 +415,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
moveControls.hidden=true;setMoveFrameVisible(false);setExpandedBoard(false);cancelLayerPreview();layerDrag=null;
hideCoordinateGuide();
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
@@ -417,17 +433,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function undoPixelOperation() {if(undoSceneEdit())announce("已撤销上一个编辑操作");updateUndoControl();}
function persistTools() {
localState.save({
@@ -438,6 +444,7 @@ function persistTools() {
normalBrushSize,
pixelBrushSize,
coordinateGuideEnabled,
expandedEnabled,
});
}
@@ -516,7 +523,10 @@ function selectTool(button) {
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return;
if (!editorActive || getInteractionMode() === "readonly") return;
if (editorMode === "layer") {
event.preventDefault();const l=getSelectedLayer();layerDrag={id:event.pointerId,start:boardPoint(event,{round:false}),x:l.x,y:l.y};canvas.setPointerCapture(event.pointerId);return;
}
if (editorMode === "move") {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -524,10 +534,11 @@ canvas.addEventListener("pointerdown", (event) => {
resetViewGesture();
return;
}
if (drawing) return;
if (getInteractionMode()!=="pixels" || drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3;
const l=getSelectedLayer();
const offset = ((point.y+64-l.y)*192+point.x+64-l.x)*4;
const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({
r: pixels[offset],
@@ -550,6 +561,7 @@ canvas.addEventListener("pointerdown", (event) => {
});
canvas.addEventListener("pointermove", (event) => {
if(layerDrag?.id===event.pointerId) {const p=boardPoint(event,{round:false});previewLayerMove(layerDrag.x+p.x-layerDrag.start.x,layerDrag.y+p.y-layerDrag.start.y);return;}
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -594,6 +606,12 @@ function finishDrawing() {
}
function finishCanvasPointer(event) {
if(layerDrag?.id===event.pointerId) {
const drag=layerDrag;layerDrag=null;
if(event.type==="pointercancel"||event.type==="lostpointercapture")cancelLayerPreview();
else {const p=boardPoint(event,{round:false});try{moveSelectedLayer(drag.x+p.x-drag.start.x,drag.y+p.y-drag.start.y);}catch(e){cancelLayerPreview();announce(e.message,true);}}
syncOffsets();return;
}
if (viewPointers.delete(event.pointerId)) {
resetViewGesture();
return;
@@ -625,13 +643,13 @@ document.getElementById("fill-canvas").addEventListener("click", () => {
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变");
announce("已开启当前画笔图层的背景");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]);
clearPixelLayer();
recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变");
announce("当前图层笔迹已清空,背景保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
@@ -639,6 +657,7 @@ undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
exitLayerMoveButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return;
@@ -728,3 +747,20 @@ registerWorkspace({
hideCoordinateGuide();
},
});
function syncOffsets(){const l=getSelectedLayer();offsetX.value=l.x;offsetY.value=l.y;}
function commitOffsets(){try{moveSelectedLayer(Number(offsetX.value),Number(offsetY.value));}catch(e){announce(e.message,true);}syncOffsets();}
expandedToggle.setAttribute("aria-pressed",String(expandedEnabled));
expandedToggle.textContent=expandedEnabled?"隐藏扩展画面":"显示扩展画面";
expandedToggle.addEventListener("click",()=>{finishDrawing();expandedEnabled=!expandedEnabled;setExpandedBoard(expandedEnabled);expandedToggle.setAttribute("aria-pressed",String(expandedEnabled));expandedToggle.textContent=expandedEnabled?"隐藏扩展画面":"显示扩展画面";persistTools();updatePixelGridVisibility();applyCanvasView();});
moveLayerButton.addEventListener("click",()=>{finishDrawing();moveControls.hidden=false;syncOffsets();setEditorMode("layer");openAssistiveMenu();});
for(const input of [offsetX,offsetY]){
input.addEventListener("change",commitOffsets);
input.addEventListener("keydown",e=>{if(e.key==="Enter"){e.preventDefault();input.blur();}});
}
for(const button of document.querySelectorAll("[data-offset]"))button.addEventListener("click",()=>{
const l=getSelectedLayer(),axis=button.parentElement.dataset.axis,delta=Number(button.dataset.offset);
try{moveSelectedLayer(l.x+(axis==="x"?delta:0),l.y+(axis==="y"?delta:0));}catch(e){announce(e.message,true);}syncOffsets();
});
document.addEventListener("matrix:edit-history",updateUndoControl);
subscribeScene(({reason})=>{if(["selection","layer-move","undo","move-cancel"].includes(reason)){syncOffsets();updatePixelGridVisibility();}});
@@ -0,0 +1,121 @@
import { announce, apiJson } from "../core.js";
import { runControlAction, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { layerToStorage, sceneToStorageValue } from "../scene-model.js";
import { getScene, getSelectedLayer, subscribeScene, selectLayer, changeLayer, setLayerBackground,
animationEditState, templateEditState, enterTemplateEdit, enterAnimationEdit, markTemplateSaved,
markAnimationFrameSaved } from "../scene-board.js";
import { initializeTextWorkspace } from "./text.js";
import { refreshTemplates } from "./templates.js";
import { refreshAnimations } from "./animations.js";
const byId=id=>document.getElementById(id);
const list=byId("layer-list"), background=byId("layer-background-enabled"), color=byId("background-color");
const dialog=byId("layer-copy-dialog"),target=byId("layer-copy-target"),feedback=byId("layer-copy-feedback");
const submit=byId("layer-copy-submit"),frames=byId("layer-copy-frame-list");
let source=null,targets=new Map(),targetDetail=null,loadSequence=0,busy=false;
function render({scene}) {
const selected=getSelectedLayer(),brush=selected.type==="brush";
byId("brush-layer-tools").hidden=!brush;byId("text-layer-tools").hidden=brush;
background.checked=Boolean(selected.background);
if(brush){color.value=selected.backgroundColor||selected.background||"#000000";color.dispatchEvent(new Event("input"));}
list.replaceChildren(...[...scene.layers].reverse().map(l=>{
const button=document.createElement("button");button.type="button";button.className="layer-item";
button.textContent=`${l.name} · ${l.type==="brush"?"画笔":"文字"}`;button.setAttribute("aria-pressed",String(l.id===selected.id));
button.addEventListener("click",()=>selectLayer(l.id));return button;
}));
const index=scene.layers.indexOf(selected);
for(const [id,allowed,reason] of [["delete-layer",!brush||scene.layers.filter(l=>l.type==="brush").length>1,"必须保留一个画笔图层。"],
["raise-layer",index<scene.layers.length-1,"已经在最上方。"],["lower-layer",index>0,"已经在最下方。"]]) {
if(allowed)setControlReady(byId(id));else setControlUnavailable(byId(id),reason);
}
if(!brush)initializeTextWorkspace().catch(e=>announce(e.message,true));
}
subscribeScene(render);
for(const [id,action,value] of [["new-brush-layer","create","brush"],["new-text-layer","create","text"],
["delete-layer","delete"],["raise-layer","up"],["lower-layer","down"]])byId(id).addEventListener("click",()=>changeLayer(action,value));
byId("rename-layer").addEventListener("click",()=>{
const name=window.prompt("图层名称(1..80个字符)",getSelectedLayer().name);
if(name!==null)try{changeLayer("rename",name);}catch(e){announce(e.message,true);}
});
background.addEventListener("change",()=>setLayerBackground(background.checked,color.value));
color.addEventListener("input",()=>{if(getSelectedLayer().type==="brush"&&getSelectedLayer().backgroundColor!==color.value)setLayerBackground(background.checked,color.value);});
function dirtyChoice(){
const d=document.createElement("dialog");d.className="layer-copy-dialog";
const title=document.createElement("h3");title.textContent="目标画面正在编辑,存在未保存修改";d.append(title);
for(const [value,label] of [["save","保存后复制"],["discard","放弃修改后复制"],["cancel","取消"]]){
const b=document.createElement("button");b.type="button";b.textContent=label;b.addEventListener("click",()=>d.close(value));d.append(b);
}
document.body.append(d);d.showModal();return new Promise(resolve=>d.addEventListener("close",()=>{const choice=d.returnValue||"cancel";d.remove();resolve(choice);},{once:true}));
}
async function loadFrames(){
const sequence=++loadSequence;targetDetail=null;frames.replaceChildren();byId("layer-copy-frames").hidden=true;
const ref=targets.get(target.value);
if(!ref){setControlReady(submit);return;}
setControlUnavailable(submit,"正在读取目标帧。");feedback.textContent="正在读取目标帧…";
try{
const detail=await apiJson(ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames`);if(sequence!==loadSequence)return;
targetDetail=detail;
if(ref.type==="template"){feedback.textContent="副本默认置顶。";setControlReady(submit);return;}
byId("layer-copy-frames").hidden=false;
for(const [i,frame] of detail.frames.entries()) {
const label=document.createElement("label"),input=document.createElement("input");input.type="checkbox";input.value=frame.id;
const name=document.createElement("span");name.textContent=`${i+1}. ${frame.name||"未命名帧"}`;
label.append(input,name);frames.append(label);
}
feedback.textContent=detail.frames.length?"选择一个或多个目标帧,复制后置顶。":"目标动图没有帧,请先添加帧。";
if(detail.frames.length)setControlReady(submit);else setControlUnavailable(submit,"目标动图没有帧。");
}catch(e){if(sequence===loadSequence){feedback.textContent=e.message;setControlUnavailable(submit,"读取失败,请重新选择目标。");}}
}
byId("copy-layer").addEventListener("click",async()=>{
const sequence=++loadSequence;
source=layerToStorage(getSelectedLayer());targets=new Map();target.replaceChildren(new Option("当前画板","current"));
feedback.textContent="正在读取可复制目标…";dialog.showModal();setControlUnavailable(submit,"正在读取目标。");
try{
const [templates,animations]=await Promise.all([apiJson("/api/templates"),apiJson("/api/animations")]);
if(sequence!==loadSequence)return;
for(const [type,items] of [["template",templates.templates],["animation",animations.animations]])for(const item of items.filter(i=>!i.is_demo&&!i.read_only)){
const key=`${type}:${item.id}`;targets.set(key,{...item,type});target.append(new Option(`${type==="template"?"静态":"动图"} · ${item.name}`,key));
}
feedback.textContent="副本默认置顶,不改变原图层。";await loadFrames();
}catch(e){feedback.textContent=e.message;setControlReady(submit);}
});
target.addEventListener("change",loadFrames);
for(const [id,checked] of [["layer-copy-all",true],["layer-copy-none",false]])byId(id).addEventListener("click",()=>frames.querySelectorAll("input").forEach(input=>input.checked=checked));
byId("layer-copy-cancel").addEventListener("click",()=>{if(!busy){loadSequence++;dialog.close();}});
dialog.addEventListener("cancel",e=>{if(busy)e.preventDefault();else loadSequence++;});
submit.addEventListener("click",()=>runControlAction(submit,{message:"正在复制图层…"},async()=>{
busy=true;target.disabled=true;frames.inert=true;
try{
if(target.value==="current"){changeLayer("duplicate");dialog.close();announce("图层已复制到当前画板最上方");return;}
const ref=targets.get(target.value);if(!ref)throw Error("请选择有效目标");
const edit=ref.type==="template"?templateEditState():animationEditState();
// An ordinary template parked behind animation editing is not the active board.
const editing=edit&&(ref.type==="template"?edit.id===ref.id&&!animationEditState():edit.animationId===ref.id);
let savedRevision=null;
if(editing&&edit.dirty){
const choice=await dirtyChoice();if(choice==="cancel")return;
if(choice==="save"){
const path=ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames/${edit.frameId}`;
const saved=await apiJson(path,{method:"PUT",headers:{"If-Match":`"${edit.revision}"`},body:JSON.stringify({scene:sceneToStorageValue(getScene())})});
if(ref.type==="template")markTemplateSaved(saved);else markAnimationFrameSaved(saved);
savedRevision=saved.revision;
}
}
const current=targetDetail;
if(!current)throw Error("目标状态已失效,请重新选择");
// Preserve the revision from frame selection: intervening writes must conflict.
const frameIds=Array.from(frames.querySelectorAll("input:checked"),input=>input.value);
if(ref.type==="animation"&&!frameIds.length)throw Error("请至少选择一个目标帧");
const revision=savedRevision||current.revision;
const result=await apiJson(ref.type==="template"?`/api/templates/${ref.id}/layers`:`/api/animations/${ref.id}/frames/layers`,{
method:"POST",headers:{"If-Match":`"${revision}"`},body:JSON.stringify({layer:source,...(ref.type==="animation"?{frame_ids:frameIds}:{})})});
if(editing){if(ref.type==="template")enterTemplateEdit(result);else{
const frame=await apiJson(`/api/animations/${ref.id}/frames/${edit.frameId}`);
enterAnimationEdit({...frame,animation_id:ref.id,animation_name:result.name,animation_revision:result.revision});
}}
dialog.close();announce("图层已复制到目标最上方");
await Promise.all([refreshTemplates(),refreshAnimations()]);
}catch(e){feedback.textContent=`复制失败:${e.message}`;announce(feedback.textContent,true);}
finally{busy=false;target.disabled=false;frames.inert=false;}
}));
@@ -528,7 +528,7 @@ export async function refreshTemplates() {
if (!userItems.length) {
const empty = document.createElement("p");
empty.className = "template-empty panel";
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
empty.textContent = "还没有模板。请在画图页保存当前统一画板。";
grid.appendChild(empty);
}
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
@@ -12,6 +12,7 @@ import {
deleteTextElement,
duplicateTextElement,
getScene,
getSelectedLayer,
getSelectedTextElement,
selectTextElement,
subscribeScene,
@@ -116,7 +117,7 @@ function displayName(element, index) {
}
function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => {
const items = (getSelectedLayer().elements||[]).map((element, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "text-element-item";
@@ -144,7 +145,7 @@ function renderSelection({ scene, selectedTextId, reason }) {
selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId
const shouldRefreshFields = reason === "selection" || reason === "undo" || reason === "move-cancel" || reason === "layer-move" || renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected);
}
@@ -169,12 +170,28 @@ function formPatch(element) {
form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return;
if (["text-x", "text-y"].includes(event.target.id)) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form");
const patch = formPatch(selected);
delete patch.x;
delete patch.y;
updateTextElement(selected.id, patch, "form");
});
for (const id of ["text-x", "text-y"]) {
field(id).addEventListener("change", () => {
const selected = getSelectedTextElement();
if (selected) updateTextElement(selected.id, {
x: numericValue("text-x", selected.x), y: numericValue("text-y", selected.y),
}, "gesture");
});
field(id).addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); field(id).blur(); }
});
}
document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({
text: "ok",
@@ -332,18 +349,10 @@ async function initializeTextWorkspace() {
}
}
if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1);
const topmost = getSelectedLayer().elements?.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
registerWorkspace({
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});
export { initializeTextWorkspace };
@@ -27,7 +27,7 @@ SCENE_WIDTH = 64
SCENE_HEIGHT = 64
RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * 3
NAME_LIMIT = 80
TEMPLATE_SCHEMA_VERSION = 1
TEMPLATE_SCHEMA_VERSION = 2
TEMPLATE_RECORD_FIELDS = frozenset({
"id",
"name",
@@ -79,7 +79,7 @@ def _migrate_template_v0_to_v1(document: dict[str, Any]) -> dict[str, Any]:
return dict(document)
TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1}
TEMPLATE_MIGRATIONS = {0: _migrate_template_v0_to_v1, 1: lambda document: dict(document)}
def _is_record(value: Any) -> bool:
@@ -102,7 +102,7 @@ def normalize_template_id(value: Any) -> str:
raise TemplateNotFoundError("template not found") from exc
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
def _validate_scene_v1(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
if not _is_record(value):
raise TemplateValidationError("scene must be an object")
if strict_schema:
@@ -190,6 +190,25 @@ def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]
}
def validate_scene(value: Any, *, strict_schema: bool = False) -> dict[str, Any]:
if strict_schema and isinstance(value, dict):
for field in ("version", "width", "height"):
if field in value and type(value[field]) is not int:
raise TemplateValidationError(f"scene {field} must be an integer")
if isinstance(value, dict) and value.get("version") == 2:
from app.scene_layers import validate_v2
try:
return validate_v2(value, _validate_scene_v1)
except (ValueError, TypeError, KeyError) as exc:
raise TemplateValidationError(str(exc)) from exc
return _validate_scene_v1(value, strict_schema=strict_schema)
def prepare_scene(value: Any) -> dict[str, Any]:
from app.scene_layers import upgrade_scene
return upgrade_scene(validate_scene(value, strict_schema=True))
def scene_digest(scene: dict[str, Any]) -> str:
canonical = json.dumps(scene, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@@ -241,6 +260,9 @@ def render_scene_image(
font_resolver: FontResolver | None = None,
) -> Image.Image:
checked = validate_scene(scene)
if checked["version"] == 2:
from app.scene_layers import render_v2
return render_v2(checked, font_resolver)
pixels = base64.b64decode(checked["pixelRgb"], validate=True)
image = Image.frombytes("RGB", (SCENE_WIDTH, SCENE_HEIGHT), pixels).convert("RGBA")
for element in checked["elements"]:
@@ -382,7 +404,7 @@ class TemplateStore:
name = normalize_name(document.get("name"))
scene = validate_scene(
document.get("scene"),
strict_schema=source_version == TEMPLATE_SCHEMA_VERSION,
strict_schema=True,
)
digest = scene_digest(scene)
if (
@@ -414,6 +436,12 @@ class TemplateStore:
raise TemplateValidationError(
f"template record does not match schema version {TEMPLATE_SCHEMA_VERSION}"
)
if source_version != TEMPLATE_SCHEMA_VERSION:
record["scene"] = prepare_scene(record["scene"])
record["digest"] = scene_digest(record["scene"])
record["thumbnail"] = f"{template_id}-{record['digest']}.png"
elif record["scene"]["version"] != 2:
raise TemplateValidationError("current template requires scene v2")
return record, source_version != TEMPLATE_SCHEMA_VERSION
def _load_record_path(self, path: Path) -> tuple[dict[str, Any], bool]:
@@ -519,7 +547,7 @@ class TemplateStore:
def create(self, name: Any, scene: Any) -> dict[str, Any]:
checked_name = normalize_name(name)
checked_scene = validate_scene(scene)
checked_scene = prepare_scene(scene)
with self._lock:
self._ensure_unique_name(checked_name)
template_id = str(uuid4())
@@ -550,7 +578,7 @@ class TemplateStore:
def update_scene(self, template_id: Any, scene: Any, expected_revision: str) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id)
checked_scene = validate_scene(scene)
checked_scene = prepare_scene(scene)
with self._lock:
current = self._read_record_path(self._record_path(normalized_id))
if current is None:
@@ -574,6 +602,18 @@ class TemplateStore:
self._reconcile()
return {**self._metadata(updated), "scene": updated["scene"]}
def append_layer(self, template_id: Any, layer: Any, expected_revision: str) -> dict[str, Any]:
from app.scene_layers import copy_layer, validate_layer
try:
checked = validate_layer(layer, _validate_scene_v1)
except (ValueError, TypeError, KeyError) as exc:
raise TemplateValidationError(str(exc)) from exc
with self._lock:
current = self.get(template_id)
scene = prepare_scene(current["scene"])
scene["layers"].append(copy_layer(checked, scene["layers"]))
return self.update_scene(template_id, scene, expected_revision)
def rename(self, template_id: Any, name: Any, expected_revision: str) -> dict[str, Any]:
normalized_id = normalize_template_id(template_id)
checked_name = normalize_name(name)
@@ -0,0 +1,155 @@
// Run against an isolated mock data root and a fresh Playwright browser context.
// The caller supplies its existing page; this script installs no browser tools.
import assert from "node:assert/strict";
export async function checkLayersBrowser(page, baseUrl) {
const errors = [], dialogs = [], policy = { value: "dismiss" };
const onError = error => errors.push(error.message);
const onDialog = async dialog => {
dialogs.push(dialog.message());
try {
if (policy.value === "dismiss") await dialog.dismiss();
else await dialog.accept(policy.value === "accept" ? undefined : policy.value);
} catch (error) { errors.push(error.message); }
};
const click = id => page.locator(`#${id}`).click();
const draft = async () => JSON.parse(await page.evaluate(() => localStorage.getItem("matrixController:scene")));
const ready = () => page.locator('#board-status[data-state="ready"]').waitFor();
page.on("pageerror", onError);
page.on("dialog", onDialog);
try {
await page.goto(`${baseUrl}/#canvas`);
await ready();
const backgroundBox = await page.locator("#layer-background-enabled").boundingBox();
assert.equal(backgroundBox.width, 20);
assert.equal(backgroundBox.height, 20);
const paintBox = await page.locator("#paint-color").boundingBox();
const colorBox = await page.locator("#background-color").boundingBox();
if (page.viewportSize().width >= 900) assert.ok(Math.abs(paintBox.y - colorBox.y) < 1);
assert.equal(await page.locator("#layer-list button").count(), 1);
assert.equal(await page.locator("#delete-layer").getAttribute("aria-disabled"), "true");
await click("new-text-layer");
await click("create-text");
await click("create-text");
await ready();
assert.equal(await page.locator("#text-element-list button").count(), 2);
await click("new-text-layer");
assert.equal(await page.locator("#text-element-list button").count(), 0);
await click("delete-layer");
assert.equal(await page.locator("#text-element-list button").count(), 2);
policy.value = "浏览器图层验收";
await click("rename-layer");
assert.match(await page.locator("#layer-list button").first().innerText(), /浏览器图层验收/);
await click("lower-layer");
await click("raise-layer");
await click("copy-layer");
assert.match(await page.locator("#layer-copy-dialog").evaluate(el => getComputedStyle(el).backgroundColor), /^rgb\(/);
await page.locator('#layer-copy-submit:not([aria-disabled])').waitFor();
await click("layer-copy-submit");
assert.equal(await page.locator("#layer-list button").count(), 3);
await click("delete-layer");
await page.locator("#text-element-list button").first().click();
await ready();
const before = await draft();
await page.locator("#text-x").fill("127");
assert.deepEqual(await draft(), before);
policy.value = "dismiss";
await page.locator("#text-x").press("Enter");
assert.equal(await page.locator("#text-x").inputValue(), "32");
policy.value = "accept";
await page.locator("#text-x").fill("127");
await page.locator("#text-x").press("Enter");
await ready();
const clip = (await draft()).layers[1].elements[0].clip;
assert.ok(clip);
await page.locator("#text-x").fill("32");
await page.locator("#text-x").press("Enter");
await ready();
assert.deepEqual((await draft()).layers[1].elements[0].clip, clip);
await page.locator("#text-y").fill("128");
await page.locator("#text-y").press("Enter");
assert.equal(await page.locator("#text-element-list button").count(), 1);
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
await click("undo-canvas-edit");
assert.equal(await page.locator("#text-element-list button").count(), 2);
await click("exit-canvas-edit");
await click("new-brush-layer");
await page.locator("#layer-background-enabled").check();
await click("clear-canvas");
assert.equal(await page.locator("#layer-background-enabled").isChecked(), true);
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
assert.equal(await page.locator("#toggle-expanded-board").getAttribute("aria-pressed"), "false");
await click("toggle-expanded-board");
await click("canvas-assistive-toggle");
const rect = await page.locator("#scene-overlay").boundingBox();
await page.mouse.click(rect.x + rect.width * 190 / 192, rect.y + rect.height * 80 / 192);
await click("canvas-assistive-toggle");
await click("move-current-layer");
const step = page.locator('.offset-steps[data-axis="x"] [data-offset="10"]');
policy.value = "dismiss";
const uncommitted = await draft();
await step.click();
assert.deepEqual(await draft(), uncommitted);
policy.value = "accept";
await step.click();
assert.equal(await page.locator("#layer-offset-x").inputValue(), "10");
await click("undo-canvas-edit");
assert.deepEqual(await draft(), uncommitted);
await click("move-canvas-view");
await click("canvas-assistive-toggle");
assert.equal(await page.locator("#layer-move-controls").isVisible(), true);
await click("canvas-assistive-toggle");
const viewBefore = await draft();
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await page.mouse.down();
await page.mouse.move(rect.x + rect.width / 2 + 20, rect.y + rect.height / 2 + 12);
await page.mouse.up();
await page.mouse.wheel(0, -80);
assert.deepEqual(await draft(), viewBefore);
await click("canvas-assistive-toggle");
const viewStyle = await page.locator("#board-stage").getAttribute("style");
const undoBefore = await page.locator("#undo-canvas-edit").getAttribute("aria-disabled");
await click("exit-layer-move");
assert.equal(await page.locator("body").getAttribute("data-canvas-editor-mode"), "draw");
assert.equal(await page.locator("body").evaluate(el => el.classList.contains("canvas-edit-active")), true);
assert.equal(await page.locator("#layer-move-controls").getAttribute("hidden"), "");
assert.equal(await page.locator("#board-stage").getAttribute("style"), viewStyle);
assert.deepEqual(await draft(), viewBefore);
assert.equal(await page.locator("#undo-canvas-edit").getAttribute("aria-disabled"), undoBefore);
await click("canvas-assistive-toggle");
await click("exit-canvas-edit");
const saved = await draft();
await page.reload();
await ready();
assert.deepEqual(await draft(), saved);
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
assert.equal(await page.locator("#toggle-expanded-board").getAttribute("aria-pressed"), "true");
await click("toggle-expanded-board");
assert.deepEqual(await draft(), saved);
await click("exit-canvas-edit");
await page.locator("#layer-list button").filter({ hasText: "浏览器图层验收" }).click();
await ready();
await click("start-canvas-edit");
await click("canvas-assistive-toggle");
await click("move-current-layer");
assert.equal(await page.locator("#continue-canvas-edit").getAttribute("aria-disabled"), null);
const textBeforeMove = await draft();
await page.locator('.offset-steps[data-axis="y"] [data-offset="1"]').click();
const textMoved = await draft();
await click("exit-layer-move");
assert.deepEqual(await draft(), textMoved);
assert.match(await page.locator("#canvas-edit-mode-hint").innerText(), /文字编辑模式/);
await click("canvas-assistive-toggle");
await click("undo-canvas-edit");
assert.deepEqual(await draft(), textBeforeMove);
await click("exit-canvas-edit");
assert.deepEqual(errors, []);
return { passed: true, nativeConfirmations: dialogs.length, layers: saved.layers.length, errors };
} finally {
page.off("pageerror", onError);
page.off("dialog", onDialog);
}
}
@@ -60,4 +60,4 @@ def test_fixture_generates_distinct_complete_animations(tmp_path):
document = json.loads(
(root / "animations" / item["id"] / "animation.json").read_text(encoding="utf-8")
)
assert document["schema_version"] == 2
assert document["schema_version"] == 3
@@ -188,7 +188,7 @@ def test_animation_crud_reorder_duration_thumbnail_and_persistence(tmp_path):
persisted = TestClient(restarted).get(f"/api/animations/{created['id']}").json()
assert persisted["frame_count"] == 2
document = json.loads((tmp_path / "data" / "animations" / created["id"] / "animation.json").read_text(encoding="utf-8"))
assert document["schema_version"] == 2
assert document["schema_version"] == 3
assert all("scene" not in frame and "scene_file" in frame for frame in document["frames"])
assert len(list((tmp_path / "data" / "animations" / created["id"] / "scenes").glob("*.json"))) == 2
@@ -231,8 +231,8 @@ test("canvas editor markup, guarded input, and checkpoint events stay wired", as
assert.match(style, /\.canvas-coordinate-guide\s*\{[^}]*pointer-events:\s*none/s);
assert.match(style, /body\.canvas-edit-active \.canvas-coordinate-guide:not\(\[hidden\]\)\s*\{[^}]*z-index:\s*1005/s);
assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s);
assert.match(canvasView, /if \(!editorActive \|\| getInteractionMode\(\) !== "pixels"\) return/);
assert.match(canvasView, /new PixelUndoHistory\(\)/);
assert.match(canvasView, /getInteractionMode\(\) === "readonly"/);
assert.match(canvasView, /undoSceneEdit\(\)/);
assert.match(canvasView, /createLocalState\("canvas-tools", 3\)/);
assert.match(canvasView, /coordinateGuide\.hidden = true/);
assert.match(sceneBoard, /matrix:scene-replaced/);
@@ -231,7 +231,7 @@ def test_v7_config_inserts_media_workspace_without_reordering_existing_items(tmp
migrated = ConfigStore(project_root=tmp_path)
assert migrated.config["workspace_order"] == [
"settings", "text", "templates", "media-import", "animations", "device", "canvas",
"settings", "templates", "media-import", "animations", "device", "canvas",
]
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == CONFIG_SCHEMA_VERSION
@@ -263,7 +263,7 @@ def test_v9_config_migrates_animation_preview_concurrency_without_losing_values(
assert migrated.config["performance_mode_enabled"] is True
assert migrated.config["animation_preview_max_concurrent"] == 2
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 10
assert json.loads(migrated.path.read_text(encoding="utf-8"))["schema_version"] == 11
def test_config_schema_field_sets_are_explicit_and_incremental():
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as legacy from "../app/static/scene-model-v1.js";
import { encodePixels, decodePixels } from "../app/static/pixel-png.js";
import { attachScene, blankScene, createLayer, cloneLayer, composeScene, serializeScene, deserializeScene,
clipBrushPixels, translatedOverflow, textClipAt, clipTextPixels, addTextElement, updateTextElement } from "../app/static/scene-model.js";
import { restoreSceneDraft,LEGACY_STORAGE_KEYS } from "../app/static/scene-model.js";
function pixel(layer,x,y,color){layer.pixels.set(color,((y+64)*192+x+64)*4);layer.revision++;}
test("old scene migration rejects unknown fields and duplicate text without filtering",()=>{
const raw=legacy.sceneToStorageValue(legacy.addTextElement(legacy.createScene(),{text:"preserved"}));
assert.ok(deserializeScene(raw));
assert.equal(deserializeScene({...raw,unknown:true}),null);
assert.equal(deserializeScene({...raw,elements:[...raw.elements,...raw.elements]}),null);
assert.equal(deserializeScene({...raw,elements:[{...raw.elements[0],x:999}]}),null);
const values=new Map([[LEGACY_STORAGE_KEYS.sceneV1,JSON.stringify({...raw,unknown:true})]]);
const storage={getItem:key=>values.get(key)??null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)};
const before=[...values];
assert.equal(restoreSceneDraft(storage).canPersist,false);
assert.deepEqual([...values],before);
});
test("PNG compression round trips sparse and full brush pixels without DOM",()=>{
const data=new Uint8ClampedArray(192*192*4);data.set([1,2,3,255],400);data.set([5,6,7,128],data.length-4);
const png=encodePixels(data);assert.ok(png.length<3000);assert.deepEqual(decodePixels(png),data);
const corrupt=png.slice(0,-8)+"AAAAAAAA";assert.throws(()=>decodePixels(corrupt));
});
test("blank and black are distinct; independent backgrounds follow layer order",()=>{
const bottom=createLayer("brush"),top=createLayer("brush",[bottom]);bottom.background="#FF0000";
let scene=attachScene([bottom,top]);assert.deepEqual([...composeScene(scene).slice(0,3)],[255,0,0]);
pixel(top,0,0,[0,0,0,255]);assert.deepEqual([...composeScene(scene).slice(0,6)],[0,0,0,255,0,0]);
top.background="#00FF00";assert.deepEqual([...composeScene(scene).slice(3,6)],[0,255,0]);
top.pixels.fill(0);top.background=null;assert.deepEqual([...composeScene(scene).slice(0,3)],[255,0,0]);
});
test("disabled background colors stay independent through storage and copy",()=>{
const bottom=createLayer("brush"),top=createLayer("brush",[bottom]);
bottom.backgroundColor="#FF0000"; top.backgroundColor="#0000FF";
const restored=deserializeScene(serializeScene(attachScene([bottom,top])));
assert.equal(restored.layers[0].background,null);
assert.equal(restored.layers[0].backgroundColor,"#FF0000");
assert.equal(restored.layers[1].backgroundColor,"#0000FF");
assert.equal(cloneLayer(restored.layers[0],restored.layers).backgroundColor,"#FF0000");
});
test("extended pixels survive storage but never enter the central output",()=>{
const scene=blankScene(),l=scene.layers[0];pixel(l,-64,-64,[255,255,255,255]);pixel(l,127,127,[0,255,0,255]);
const rgb=composeScene(scene);assert.equal(rgb.some(Boolean),false);
const restored=deserializeScene(serializeScene(scene));assert.ok(restored);assert.equal(composeScene(restored,new Map(),192)[0],255);
assert.equal(restored.layers[0].pixels.at(-3),255);
});
test("move preview does not mutate source; confirmed clipping removes only outside ink",()=>{
const scene=blankScene(),l=scene.layers[0];l.background="#123456";pixel(l,127,0,[0,0,0,255]);pixel(l,0,0,[255,0,0,255]);
const before=serializeScene(scene);composeScene(scene,new Map(),192,{layerId:l.id,x:1,y:0});assert.equal(serializeScene(scene),before);
assert.deepEqual(translatedOverflow(l.pixels,1,0),{outside:true,inside:true});
l.pixels=clipBrushPixels(l.pixels,1,0);l.revision++;assert.equal(l.background,"#123456");
assert.deepEqual(translatedOverflow(l.pixels,1,0),{outside:false,inside:true});
const saved=deserializeScene(serializeScene(scene));assert.equal(saved.layers[0].pixels[((64)*192+191)*4+3],0);
});
test("text clipping follows anchor and scale and is retained after edit/reload/copy",()=>{
let scene=addTextElement(blankScene(),{text:"two",x:126,y:0,size:10});const element=scene.elements[0];element.clip=textClipAt(element,1,0);
const rgba=new Uint8ClampedArray(192*192*4);rgba.set([255,0,0,255],(64*192+191)*4);
assert.equal(clipTextPixels(rgba,element)[(64*192+191)*4+3],0);
const moved=updateTextElement(scene,element.id,{x:0,size:20});assert.deepEqual(moved.elements[0].clip,element.clip);
const restored=deserializeScene(serializeScene(moved));assert.deepEqual(restored.elements[0].clip,element.clip);
const copy=cloneLayer(restored.layers[1],restored.layers);assert.notEqual(copy.id,restored.layers[1].id);assert.notEqual(copy.elements[0].id,element.id);
copy.elements[0].clip[0]-=1;assert.notDeepEqual(copy.elements[0].clip,element.clip);
});
test("multiple text groups isolate edits, copies and IDs",()=>{
const a=createLayer("text"),b=createLayer("text",[a]);let scene=attachScene([createLayer("brush"),a,b]);
scene=addTextElement(scene,{text:"A"},a.id);scene=addTextElement(scene,{text:"B"},b.id);scene=addTextElement(scene,{text:"A2"},a.id);
assert.deepEqual(scene.layers[1].elements.map(e=>e.text),["A","A2"]);assert.deepEqual(scene.layers[2].elements.map(e=>e.text),["B"]);
assert.equal(new Set(scene.elements.map(e=>e.id)).size,3);
});
test("strict v2 storage protects unknown data and the final brush invariant",()=>{
const raw=JSON.parse(serializeScene(blankScene()));raw.layers=[];assert.equal(deserializeScene(raw),null);
const text=createLayer("text");assert.equal(deserializeScene({version:2,width:64,height:64,layers:[text]}),null);
const original=JSON.parse(serializeScene(blankScene()));original.layers[0].surprise=true;assert.equal(deserializeScene(original),null);
});
@@ -0,0 +1,189 @@
import base64
import copy
import json
import subprocess
import shutil
from pathlib import Path
from io import BytesIO
import pytest
from PIL import Image
from fastapi.testclient import TestClient
from app.main import create_app
from app.scene_layers import encode_pixels, decode_pixels, upgrade_scene, render_v2, render_element
from app.templates.store import TemplateStore, TemplateValidationError, prepare_scene, render_scene_image, scene_digest
from app.animations.store import AnimationStore
def legacy_scene(text=False):
elements = [dict(id="text-1", type="text", text="Hello", font="default", size=12, x=32, y=26, align="center", color="#FFFFFF")] if text else []
return dict(version=1, width=64, height=64, pixelRgb=base64.b64encode(bytes([30,60,90])*4096).decode(), elements=elements)
def brush(color=(0,0,0,0)):
return dict(id="source",name="图层1",type="brush",x=0,y=0,background=None,pixelPng=encode_pixels(Image.new("RGBA",(192,192),color)))
def test_migration_preserves_pixels_text_and_multilayer_render():
for text in (False,True):
old=legacy_scene(text);new=prepare_scene(old)
assert new["version"]==2
assert len(new["layers"])==(2 if text else 1)
assert render_scene_image(old).tobytes()==render_scene_image(new).tobytes()
layer=brush();image=decode_pixels(layer["pixelPng"]);image.putpixel((64,64),(0,0,0,255));layer["pixelPng"]=encode_pixels(image)
new=prepare_scene(legacy_scene());new["layers"].append(layer)
assert render_scene_image(new).getpixel((0,0))==(0,0,0)
assert render_scene_image(new).getpixel((1,0))==(30,60,90)
def test_disabled_background_color_is_saved_copied_and_does_not_paint(tmp_path):
layer = brush()
layer["backgroundColor"] = "#FF0000"
scene = dict(version=2,width=64,height=64,layers=[layer])
assert render_scene_image(scene).getpixel((0,0)) == (0,0,0)
store = TemplateStore(tmp_path)
saved = store.create("disabled background", scene)
copied = store.append_layer(saved["id"], layer, saved["revision"])
assert [item["backgroundColor"] for item in copied["scene"]["layers"]] == ["#FF0000"]*2
assert TemplateStore(tmp_path).get(saved["id"])["scene"] == copied["scene"]
def test_template_schema_migration_validates_old_digest_and_preserves_bad_original(tmp_path):
store=TemplateStore(tmp_path);created=store.create("legacy",legacy_scene(True))
path=tmp_path/"templates"/"records"/f"{created['id']}.json"
record=json.loads(path.read_text(encoding="utf-8"));old=legacy_scene(True);record.update(schema_version=1,scene=old,digest=scene_digest(old),thumbnail=f"{created['id']}-{scene_digest(old)}.png")
path.write_text(json.dumps(record),encoding="utf-8")
migrated=TemplateStore(tmp_path).get(created["id"])
assert migrated["scene"]["version"]==2
assert json.loads(path.read_text(encoding="utf-8"))["schema_version"]==2
record["digest"]="0000000000000000";bad=json.dumps(record).encode();path.write_bytes(bad)
with pytest.raises(TemplateValidationError):TemplateStore(tmp_path)
assert path.read_bytes()==bad
def test_animation_v2_migration_preserves_frame_ids_order_duration(tmp_path):
store=AnimationStore(tmp_path);record=store.create("old")
record=store.add_frame(record["id"],legacy_scene(),record["revision"],duration_ms=120)
root=tmp_path/"animations"/record["id"];index=root/"animation.json";raw=json.loads(index.read_text(encoding="utf-8"));raw["schema_version"]=2
old=legacy_scene();digest=scene_digest(old);frame=raw["frames"][0]
frame.update(digest=digest,scene_file=f"{frame['id']}-{digest}.json",thumbnail=f"{frame['id']}-{digest}.png")
(root/"scenes"/frame["scene_file"]).write_text(json.dumps(old),encoding="utf-8");index.write_text(json.dumps(raw),encoding="utf-8")
migrated=AnimationStore(tmp_path).get(record["id"])
assert migrated["frames"][0]["id"]==frame["id"]
assert migrated["frames"][0]["duration_ms"]==120
assert json.loads(index.read_text(encoding="utf-8"))["schema_version"]==3
@pytest.mark.parametrize("version", [True, 3.0, 4])
def test_animation_invalid_or_future_version_preserves_original(tmp_path, version):
store = AnimationStore(tmp_path)
record = store.create("protected")
path = tmp_path / "animations" / record["id"] / "animation.json"
document = json.loads(path.read_text(encoding="utf-8"))
document["schema_version"] = version
payload = json.dumps(document).encode("utf-8")
path.write_bytes(payload)
with pytest.raises(TemplateValidationError):
AnimationStore(tmp_path)
assert path.read_bytes() == payload
def test_append_layers_conflict_batch_failure_and_restart(tmp_path,monkeypatch):
app=create_app(project_root=tmp_path,driver_kind="mock");client=TestClient(app)
template=client.post("/api/templates",json=dict(name="target",scene=legacy_scene())).json();path=f"/api/templates/{template['id']}/layers"
assert client.post(path,json=dict(layer=brush())).status_code==428
copied=client.post(path,headers={"If-Match":template["revision"]},json=dict(layer=brush())).json()
assert len(copied["scene"]["layers"])==2
assert copied["scene"]["layers"][-1]["id"]!="source"
assert client.post(path,headers={"If-Match":template["revision"]},json=dict(layer=brush())).status_code==409
animations=app.state.animation_store
record=animations.create("batch")
for _ in range(3):record=animations.add_frame(record["id"],legacy_scene(),record["revision"],duration_ms=250)
ids=[f["id"] for f in record["frames"]]
root=tmp_path/"data"/"animations"/record["id"];index=root/"animation.json";before=index.read_bytes()
original=animations._write_thumbnail;count=0
def fail_second(*args):
nonlocal count
count+=1
if count==2:raise OSError("simulated full disk")
return original(*args)
monkeypatch.setattr(animations,"_write_thumbnail",fail_second)
with pytest.raises(OSError):animations.append_layer(record["id"],brush(),ids,record["revision"])
assert index.read_bytes()==before
monkeypatch.setattr(animations,"_write_thumbnail",original)
AnimationStore(tmp_path/"data")
write_index = animations._write
def fail_index(*args):
raise OSError("simulated index write failure")
monkeypatch.setattr(animations, "_write", fail_index)
with pytest.raises(OSError):
animations.append_layer(record["id"], brush(), ids, record["revision"])
assert index.read_bytes() == before
assert all(len(frame["scene"]["layers"]) == 1
for frame in AnimationStore(tmp_path/"data").get(record["id"], scenes=True)["frames"])
monkeypatch.setattr(animations, "_write", write_index)
endpoint=f"/api/animations/{record['id']}/frames/layers"
result=client.post(endpoint,headers={"If-Match":record["revision"]},json=dict(layer=brush(),frame_ids=ids[:2]))
assert result.status_code==200
restarted=AnimationStore(tmp_path/"data");detail=restarted.get(record["id"],scenes=True)
assert [len(f["scene"]["layers"]) for f in detail["frames"]]==[2,2,1]
assert [f["duration_ms"] for f in detail["frames"]]==[250]*3
assert detail["frames"][0]["scene"]["layers"][-1]["id"]!=detail["frames"][1]["scene"]["layers"][-1]["id"]
def test_config_v10_migration_only_removes_text_navigation(tmp_path):
from app.config.store import ConfigStore
store = ConfigStore(tmp_path)
path = store.path
original = json.loads(path.read_text(encoding="utf-8"))
original.update(schema_version=10, workspace_order=["templates", "text", "canvas", "animations"], brightness=27)
path.write_text(json.dumps(original), encoding="utf-8")
ConfigStore(tmp_path)
actual = json.loads(path.read_text(encoding="utf-8"))
assert actual == {**original, "schema_version": 11, "workspace_order": ["templates", "canvas", "animations"]}
def test_config_text_only_navigation_migrates_to_registered_defaults(tmp_path):
from app.config.store import ConfigStore, DEFAULT_WORKSPACE_ORDER
store=ConfigStore(tmp_path)
original=json.loads(store.path.read_text(encoding="utf-8"))
original.update(schema_version=10,workspace_order=["text"])
store.path.write_text(json.dumps(original),encoding="utf-8")
actual=ConfigStore(tmp_path)
assert actual.config["workspace_order"]==list(DEFAULT_WORKSPACE_ORDER)
def test_text_extended_preview_default_size_and_persistent_clip(tmp_path):
app=create_app(project_root=tmp_path,driver_kind="mock");client=TestClient(app)
for size in (64,192):
result=client.post("/api/preview/text-layer",json=dict(text="hello",edit_size=size))
assert result.status_code==200
assert Image.open(BytesIO(result.content)).size==(size,size)
element=upgrade_scene(legacy_scene(True))["layers"][1]["elements"][0]
element["clip"]=[-1,0,0,2]
image=render_element(element)
assert image.getpixel((element["x"]+64,element["y"]+64))[3]==0
scene=prepare_scene(legacy_scene());scene["layers"].append(dict(id="text-layer",name="图层2",type="text",x=0,y=0,elements=[element]))
store=TemplateStore(tmp_path/"clip");saved=store.create("clipped",scene)
assert TemplateStore(tmp_path/"clip").get(saved["id"])["scene"]==scene
def test_browser_python_composition_and_png_are_identical(tmp_path):
node=shutil.which("node")
if not node:pytest.skip("Node is only required for local cross-language parity")
scene=prepare_scene(legacy_scene(True));top=brush();top.update(x=-10,y=10,background="#123456")
image=decode_pixels(top["pixelPng"])
for y in range(64):
for x in range(64):
image.putpixel((64+x,64+y), ((x*19)%256,(y*31)%256,(x+y)*2,(x*7+y*13)%256))
top["pixelPng"]=encode_pixels(image)
scene["layers"].insert(1,top);scene["layers"][2]["x"]=5
element=scene["layers"][2]["elements"][0];element["clip"]=[-1,0,1,2]
overlapping = copy.deepcopy(element)
overlapping.update(id="overlapping",color="#22BBFF",x=30,clip=None)
scene["layers"][2]["elements"].append(overlapping)
text = {e["id"]:base64.b64encode(render_element(e).tobytes()).decode() for e in (element,overlapping)}
fixture=tmp_path/"parity.json";fixture.write_text(json.dumps(dict(scene=scene,text=text)),encoding="utf-8")
module=(Path(__file__).parents[1]/"app/static/scene-model.js").as_uri()
code='import fs from "node:fs";import {deserializeScene,composeScene} from '+json.dumps(module)+';const f=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const m=new Map(Object.entries(f.text).map(([k,v])=>[k,new Uint8ClampedArray(Buffer.from(v,"base64"))]));console.log(Buffer.from(composeScene(deserializeScene(f.scene),m)).toString("base64"));'
result=subprocess.run([node,"--input-type=module","-e",code,str(fixture)],capture_output=True,text=True,encoding="utf-8",check=True)
assert base64.b64decode(result.stdout.strip())==render_v2(scene).tobytes()
@@ -1,3 +1,4 @@
import * as v1 from "../app/static/scene-model-v1.js";
import test from "node:test";
import assert from "node:assert/strict";
import {
@@ -78,23 +79,23 @@ test("serializes pixelRgb as base64 and round trips an independent scene", () =>
color: "#12abef",
});
const stored = sceneToStorageValue(original);
assert.equal(typeof stored.pixelRgb, "string");
assert.equal(typeof stored.layers[0].pixelPng, "string");
assert.equal("pixels" in stored, false);
assert.equal("pixelsRgb" in stored, false);
assert.equal("revision" in stored.elements[0], false);
assert.equal("revision" in stored.layers[1].elements[0], false);
const restored = deserializeScene(serializeScene(original));
assert.ok(restored);
assert.deepEqual(Array.from(restored.pixels), Array.from(original.pixels));
assert.deepEqual(restored.elements, original.elements);
assert.notStrictEqual(restored.pixels, original.pixels);
assert.notStrictEqual(restored.elements[0], original.elements[0]);
assert.equal(restored.elements[0].text, "你好 · مرحبا · नमस्ते · ไทย");
assert.notStrictEqual(restored.layers[1].elements[0], original.elements[0]);
assert.equal(restored.layers[1].elements[0].text, "你好 · مرحبا · नमस्ते · ไทย");
});
test("moves a valid v1 draft to the stable key before removing exact legacy keys", () => {
const original = addTextElement(createScene({ pixels: patternedPixels(23) }), { text: "keep me" });
const storage = memoryStorage({
[LEGACY_STORAGE_KEYS.sceneV1]: serializeScene(original),
[LEGACY_STORAGE_KEYS.sceneV1]: v1.serializeScene(v1.createScene({pixels:original.pixels,elements:original.elements})),
[LEGACY_STORAGE_KEYS.canvasV3]: JSON.stringify({ pixels: Array.from(patternedPixels(2)) }),
[LEGACY_STORAGE_KEYS.textV1]: JSON.stringify({ "text-value": "older text" }),
"unrelated:key": "keep",
@@ -117,7 +118,7 @@ test("moves a valid v1 draft to the stable key before removing exact legacy keys
});
test("keeps the v1 draft and all legacy keys when stable-key migration cannot write", () => {
const originalRaw = serializeScene(addTextElement(createScene(), { text: "do not delete" }));
const originalRaw = v1.serializeScene(v1.addTextElement(v1.createScene(), { text: "do not delete" }));
const storage = memoryStorage({
[LEGACY_STORAGE_KEYS.sceneV1]: originalRaw,
[LEGACY_STORAGE_KEYS.canvasV2]: JSON.stringify({ pixels: Array.from(patternedPixels(4)) }),
@@ -184,23 +185,14 @@ test("does not discard a damaged higher-priority legacy key during fallback migr
test("rejects damaged scene roots while skipping only damaged elements", () => {
const valid = sceneToStorageValue(addTextElement(createScene(), { text: "keep" }));
assert.equal(deserializeScene({ ...valid, version: 2 }), null);
assert.equal(deserializeScene({ ...valid, version: 3 }), null);
assert.equal(deserializeScene({ ...valid, width: 32 }), null);
assert.equal(deserializeScene({ ...valid, pixelRgb: "not base64" }), null);
assert.equal(deserializeScene("{broken"), null);
const recovered = deserializeScene({
...valid,
elements: [
valid.elements[0],
{ id: "bad-type", type: "image", text: "skip" },
{ id: "bad-text", type: "text", text: 123 },
{ ...valid.elements[0], text: "duplicate id" },
],
});
assert.ok(recovered);
assert.equal(recovered.elements.length, 1);
assert.equal(recovered.elements[0].text, "keep");
const invalid = {...valid,layers:valid.layers.map((l,i)=>i===1?{...l,elements:[...l.elements,{...l.elements[0]}]}:l)};
assert.equal(deserializeScene(invalid), null, "duplicate ids must not be silently dropped");
});
test("normalizes text fields and creates a centered lowercase ok element", () => {
@@ -235,6 +227,7 @@ test("normalizes text fields and creates a centered lowercase ok element", () =>
align: DEFAULT_TEXT_ELEMENT.align,
color: "#12ABEF",
revision: 0,
clip: null,
});
assert.equal("background" in normalized, false);
});
@@ -270,7 +270,7 @@ def test_template_preserves_multilingual_text(tmp_path):
assert created.status_code == 201
stored = client.get(f"/api/templates/{created.json()['id']}")
assert stored.status_code == 200
assert stored.json()["scene"]["elements"][0]["text"] == multilingual
assert stored.json()["scene"]["layers"][1]["elements"][0]["text"] == multilingual
def test_template_mutations_require_current_revision(tmp_path):
@@ -294,7 +294,7 @@ def test_template_mutations_require_current_revision(tmp_path):
assert client.delete(path, headers=stale_headers).status_code == 409
preserved = client.get(path).json()
assert preserved["name"] == "shared"
assert preserved["scene"]["elements"][0]["text"] == "first"
assert preserved["scene"]["layers"][1]["elements"][0]["text"] == "first"
assert preserved["revision"] == current["revision"]
@@ -467,7 +467,7 @@ def test_template_migration_write_failure_rolls_back_every_record(tmp_path, monk
[
(("scene", "version"), True),
(("scene", "width"), 64.0),
(("scene", "elements", 0, "size"), True),
(("scene", "layers", 1, "elements", 0, "size"), True),
],
)
def test_current_template_schema_rejects_noncanonical_json_types(
@@ -492,7 +492,7 @@ def test_current_template_schema_rejects_noncanonical_json_types(
original = (json.dumps(document, separators=(",", ":")) + "\n").encode("utf-8")
record_path.write_bytes(original)
with pytest.raises(TemplateValidationError, match="must be an integer"):
with pytest.raises(TemplateValidationError, match="integer|fields or dimensions"):
TemplateStore(data_dir)
assert record_path.read_bytes() == original
@@ -70,17 +70,18 @@ def test_generated_ui_copy_catalog_is_current_and_covers_static_and_dynamic_copy
"copy.ff765a69c8b49cee": "全白(高亮度会导致严重耗电)",
}
finalized_hidden = {
"copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.209c8a51007fadfb",
"copy.4a2ff24bc62e26ef", "copy.55db93bad76b7e74", "copy.56f1193ee4a7192d",
"copy.5e26a038197f59b8", "copy.62029c969ffb9c9d", "copy.670c95eddb9bac43",
"copy.0a2bacf6cf154355", "copy.18abf73b22c104c1", "copy.a44cae35bcb5eebe",
"copy.55db93bad76b7e74", "copy.56f1193ee4a7192d",
"copy.62029c969ffb9c9d", "copy.670c95eddb9bac43",
"copy.99050fd7437476f6", "copy.b0e7bd7e96cbe4d3", "copy.d9541ebf887002a5",
"copy.dynamic.board.composition_eyebrow", "copy.dynamic.board.composition_mode",
"copy.dynamic.board.composition_title", "copy.dynamic.board.monitor_eyebrow",
"copy.dynamic.board.monitor_mode", "copy.efdd13bfd5037544",
"copy.dynamic.board.monitor_mode",
}
assert {copy_id: catalog["items"][copy_id]["text"] for copy_id in finalized_text} == finalized_text
assert all(catalog["items"][copy_id]["render"].get("default_hidden") is True for copy_id in finalized_hidden)
assert len(finalized_text) + len(finalized_hidden) == 34
# The separate text workspace's title, eyebrow and angle were removed.
assert len(finalized_text) + len(finalized_hidden) == 31
def test_normal_build_has_no_editor_entrypoints(tmp_path):