Improve Android app UI and controller flows
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-10-09T10:26+08:00
|
||||
2026-10-09T16:12+08:00
|
||||
|
||||
+7
-1
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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%; }
|
||||
|
||||
+634
-258
File diff suppressed because it is too large
Load Diff
+21
@@ -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.
|
||||
+7
@@ -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.
|
||||
+2665
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):
|
||||
|
||||
Reference in New Issue
Block a user