feat(editor): harden photo editor security and interactions

This commit is contained in:
Alexandre Teixeira
2026-10-28 17:37:10 +00:00
parent 0b8176b11e
commit c237a1cb9c
68 changed files with 3630 additions and 426 deletions
+2
View File
@@ -53,6 +53,8 @@ services/data/
# Test capture artifacts (browser session dumps may contain personal content) # Test capture artifacts (browser session dumps may contain personal content)
.playwright-mcp/ .playwright-mcp/
test-results/
playwright-report/
# OS # OS
.DS_Store .DS_Store
+86 -19
View File
@@ -25,7 +25,7 @@ import re
import uuid import uuid
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from fastapi import APIRouter, Request from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from src import editor_style_guide as style_guide from src import editor_style_guide as style_guide
@@ -1173,12 +1173,35 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
return out return out
def _user(request: Request) -> Optional[str]: def _is_admin(request: Request) -> bool:
try: try:
from src.auth_helpers import get_current_user from core.middleware import require_admin
return get_current_user(request) require_admin(request)
return True
except Exception: except Exception:
return False
def _user(request: Request) -> Optional[str]:
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
if getattr(request.state, "api_token", False):
from src.auth_helpers import require_api_token_scope
return require_api_token_scope(request, "chat")
from src.auth_helpers import get_current_user, _auth_disabled
u = get_current_user(request)
if u:
return u
if _auth_disabled():
return None return None
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
raise HTTPException(401, "Not authenticated")
client = getattr(request, "client", None)
host = (client.host if client else "") or ""
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
return None
raise HTTPException(401, "Not authenticated")
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]: async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
@@ -1297,9 +1320,13 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception: except Exception:
raw = None raw = None
_used = editor_agent_model.get_last_model() _used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
parsed = _parse_plan(raw) parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
if isinstance(parsed, dict): if isinstance(parsed, dict):
parsed["agent_model"] = model_used parsed["agent_model"] = model_used
if mode != "check": if mode != "check":
@@ -1494,6 +1521,12 @@ def setup_editor_agent_routes() -> APIRouter:
overwritten. overwritten.
""" """
owner = _user(request) owner = _user(request)
from src.settings import get_setting
disabled = get_setting("disabled_tools", []) or []
if not isinstance(disabled, (list, tuple, set)):
disabled = []
if "plan_edit" in disabled:
raise HTTPException(403, "Editor tool is disabled")
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
@@ -1506,7 +1539,7 @@ def setup_editor_agent_routes() -> APIRouter:
model_choice = str(body.get("model") or body.get("image_model") or "").strip() model_choice = str(body.get("model") or body.get("image_model") or "").strip()
# Optional user-picked PLANNER model (the editor agent/planner model). # Optional user-picked PLANNER model (the editor agent/planner model).
agent_model = str(body.get("agent_model") or "").strip() agent_model = str(body.get("agent_model") or "").strip()
tools_offered = _scoped_editor_tools() tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
scoped = _scoped(session, owner) scoped = _scoped(session, owner)
trace: List[Dict[str, Any]] = [ trace: List[Dict[str, Any]] = [
{"type": "session", "session": scoped, "tools_offered": tools_offered}, {"type": "session", "session": scoped, "tools_offered": tools_offered},
@@ -1540,9 +1573,14 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception: except Exception:
raw = None raw = None
_used = editor_agent_model.get_last_model() _used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0] parsed = _parse_plan(raw)
else (agent_model or editor_agent_model.local_model())) if _used and _used[0]:
plan = _parse_plan(raw) or _fallback_plan(prompt) model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
plan = parsed or _fallback_plan(prompt)
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or [])) _strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
if _strengthened != plan.get("steps"): if _strengthened != plan.get("steps"):
plan = {**plan, "steps": _strengthened, plan = {**plan, "steps": _strengthened,
@@ -1618,6 +1656,12 @@ def setup_editor_agent_routes() -> APIRouter:
"results": [], "results": [],
"agent_model": model_used, "agent_model": model_used,
}) })
needed = {"plan_edit"}
for step in steps:
op = step.get("op")
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
if needed.intersection(disabled):
raise HTTPException(403, "Editor tool is disabled")
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for step in steps: for step in steps:
op = step.get("op") op = step.get("op")
@@ -1726,6 +1770,8 @@ def setup_editor_agent_routes() -> APIRouter:
``edit`` (editing/inpainting models) or ``generate`` (text-to-image). ``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
""" """
owner = _user(request) owner = _user(request)
from src.tool_security import owner_is_admin_or_single_user
is_admin = owner_is_admin_or_single_user(owner)
models: List[Dict[str, Any]] = [] models: List[Dict[str, Any]] = []
seen: set = set() seen: set = set()
try: try:
@@ -1748,22 +1794,25 @@ def setup_editor_agent_routes() -> APIRouter:
# recognisably image-capable — the same test the request # recognisably image-capable — the same test the request
# router uses. # router uses.
image_endpoint = getattr(ep, "model_type", None) == "image" image_endpoint = getattr(ep, "model_type", None) == "image"
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image" endpoint_id = getattr(ep, "id", None)
provider = getattr(ep, "name", None) or (f"Endpoint {endpoint_id}" if endpoint_id else "image")
for name in _endpoint_enabled_models(ep): for name in _endpoint_enabled_models(ep):
if not image_endpoint and not looks_like_image_generation_model(name): if not image_endpoint and not looks_like_image_generation_model(name):
continue continue
key = (provider, name) key = (str(endpoint_id), name)
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
models.append({ entry = {
"id": name, "id": name,
"label": name, "label": name,
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate", "kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
"provider": provider, "provider": provider,
"endpoint_id": getattr(ep, "id", None), "endpoint_id": endpoint_id,
"base_url": getattr(ep, "base_url", "") or "", }
}) if is_admin:
entry["base_url"] = getattr(ep, "base_url", "") or ""
models.append(entry)
finally: finally:
db.close() db.close()
except Exception: except Exception:
@@ -1774,21 +1823,39 @@ def setup_editor_agent_routes() -> APIRouter:
async def editor_agent_models(request: Request) -> JSONResponse: async def editor_agent_models(request: Request) -> JSONResponse:
"""List the models the editor agent/planner can run on. """List the models the editor agent/planner can run on.
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is Each entry is ``{id, label, source, vision}`` where ``id`` is
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
``source`` is ``local`` (the local Qwen3.5-9B vision server) or ``source`` is ``local`` (the local Qwen3.5-9B vision server) or
``hosted`` (the configured default/utility chat model that the planner ``hosted`` (the configured default/utility chat model that the planner
falls back to). falls back to).
For non-admin users, private URLs and internal probe status are omitted.
""" """
owner = _user(request) owner = _user(request)
from src.tool_security import owner_is_admin_or_single_user
is_admin = owner_is_admin_or_single_user(owner)
try: try:
models = await editor_agent_model.list_editor_agent_models(owner) models = await editor_agent_model.list_editor_agent_models(owner)
except Exception: except Exception:
logger.warning("editor agent-models lookup failed", exc_info=True) logger.warning("editor agent-models lookup failed", exc_info=True)
models = [] models = []
safe_models = []
for m in models:
entry = dict(m)
if not is_admin:
entry.pop("base_url", None)
safe_models.append(entry)
probe_info = {}
if is_admin:
probe_info = editor_agent_model.probe_status()
if not probe_info:
editor_agent_model.startup_probe()
probe_info = editor_agent_model.probe_status()
return JSONResponse({ return JSONResponse({
"models": models, "models": safe_models,
"probe": editor_agent_model.probe_status(), "probe": probe_info,
}) })
@router.post("/api/editor/self-check") @router.post("/api/editor/self-check")
+29 -15
View File
@@ -683,9 +683,15 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
configured.update(v for v in values if isinstance(v, str)) configured.update(v for v in values if isinstance(v, str))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if model not in configured or not looks_like_image_generation_model(model): if model:
continue if model not in configured or not looks_like_image_generation_model(model):
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target: continue
else:
if not any(looks_like_image_generation_model(v) for v in configured):
continue
ep_id = str(getattr(ep, "id", "") or "")
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
if owner and getattr(ep, "owner", None) == owner: if owner and getattr(ep, "owner", None) == owner:
return ep return ep
if fallback is None: if fallback is None:
@@ -712,7 +718,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
) )
if not ok: if not ok:
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}") raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
async with httpx.AsyncClient(timeout=60) as c2: async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
ir = await c2.get(url) ir = await c2.get(url)
if ir.status_code == 200: if ir.status_code == 200:
return base64.b64encode(ir.content).decode() return base64.b64encode(ir.content).decode()
@@ -1727,7 +1733,7 @@ def setup_gallery_routes() -> APIRouter:
requested_base = (body.pop("_endpoint", "") or "").rstrip("/") requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
# SSRF hardening: validate a client-supplied endpoint before any # SSRF hardening: validate a client-supplied endpoint before any
# outbound request (mirrors routes/embedding_routes.py). # outbound request (mirrors routes/embedding_routes.py).
if requested_base: if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url from src.url_safety import check_outbound_url
ok, reason = check_outbound_url( ok, reason = check_outbound_url(
requested_base, requested_base,
@@ -2073,7 +2079,7 @@ def setup_gallery_routes() -> APIRouter:
runs off the event loop (large images). runs off the event loop (large images).
""" """
requested_base = (body.pop("_endpoint", "") or "").rstrip("/") requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
if requested_base: if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url from src.url_safety import check_outbound_url
ok, reason = check_outbound_url( ok, reason = check_outbound_url(
requested_base, requested_base,
@@ -2166,26 +2172,34 @@ def setup_gallery_routes() -> APIRouter:
try: try:
ep = None ep = None
if endpoint_id: if endpoint_id:
ep = owner_filter( ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id), if ep is None:
ModelEndpoint, user, return {"available": False, "reason": "endpoint not found or disabled"}
).first() elif requested_base:
if ep is None and requested_base:
ep = _visible_image_endpoint_for_base(db, requested_base, user) ep = _visible_image_endpoint_for_base(db, requested_base, user)
if ep is None: if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
else:
ep = _first_visible_image_endpoint(db, user) ep = _first_visible_image_endpoint(db, user)
if ep is None: if ep is None:
return {"available": False, "reason": "no image endpoint configured"} return {"available": False, "reason": "no image endpoint configured"}
base = ep.base_url.rstrip("/") base = ep.base_url.rstrip("/")
finally: finally:
db.close() db.close()
from src.url_safety import check_outbound_url
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
ok, reason = check_outbound_url(base, block_private=block_private)
if not ok:
return {"available": False, "reason": f"endpoint URL rejected: {reason}"}
if not base.endswith("/v1"): if not base.endswith("/v1"):
base += "/v1" base += "/v1"
# Dynamic path segment (validated token above), so not routed through # Dynamic path segment (validated token above), so not routed through
# _join_checked_gallery_endpoint's exact-match whitelist. # _join_checked_gallery_endpoint's exact-match whitelist.
url = f"{base}/images/progress/{request_id}" url = f"{base}/images/progress/{request_id}"
try: try:
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
r = await client.get(url) r = await client.get(url)
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
@@ -2223,7 +2237,7 @@ def setup_gallery_routes() -> APIRouter:
# below, so validate it first (mirrors routes/embedding_routes.py). # below, so validate it first (mirrors routes/embedding_routes.py).
# Local-first means loopback/LAN is allowed by default; the cloud # Local-first means loopback/LAN is allowed by default; the cloud
# metadata range and non-HTTP(S) schemes are always rejected. # metadata range and non-HTTP(S) schemes are always rejected.
if requested_base: if requested_base and "://" in requested_base:
from src.url_safety import check_outbound_url from src.url_safety import check_outbound_url
ok, reason = check_outbound_url( ok, reason = check_outbound_url(
requested_base, requested_base,
@@ -2250,7 +2264,7 @@ def setup_gallery_routes() -> APIRouter:
# so the outbound URL never depends directly on request-body input. # so the outbound URL never depends directly on request-body input.
db = SessionLocal() db = SessionLocal()
try: try:
ep = _visible_image_endpoint_for_base(db, requested_base, user) ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
if not ep: if not ep:
raise HTTPException(403, "Choose a registered image endpoint") raise HTTPException(403, "Choose a registered image endpoint")
base = ep.base_url.rstrip("/") base = ep.base_url.rstrip("/")
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
Structured, format-aware fail-closed security:
Replaces naive regex-based replacement with structured JSON / HAR parsing,
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
safe source code masking, and strict fail-closed purge on corruption or error.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import urllib.parse
import zipfile
from typing import Any, Dict, List, Set
logger = logging.getLogger(__name__)
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
SENSITIVE_KEY_PATTERN = re.compile(
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
)
SENSITIVE_PARAM_NAMES = frozenset({
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
"signature", "client_secret", "credential"
})
SENSITIVE_HEADER_NAMES = frozenset({
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
"api-key", "x-auth-token", "x-access-token"
})
def sanitize_url(url: str) -> str:
"""Sanitize query parameters and authority credentials in URLs."""
if not isinstance(url, str) or not url.strip():
return url
try:
parts = urllib.parse.urlsplit(url)
except Exception:
return url
netloc = parts.netloc
if "@" in netloc:
userinfo, host = netloc.split("@", 1)
if ":" in userinfo:
user, _ = userinfo.split(":", 1)
netloc = f"{user}:[REDACTED]@{host}"
else:
netloc = f"[REDACTED]@{host}"
query = parts.query
if query:
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
sanitized_pairs = []
for k, v in q_pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
sanitized_pairs.append((k, "[REDACTED]"))
else:
sanitized_pairs.append((k, v))
query = urllib.parse.urlencode(sanitized_pairs)
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
def sanitize_body_text(text: str) -> str:
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
if not isinstance(text, str) or not text.strip():
return text
# 1. Try JSON
trimmed = text.strip()
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
try:
parsed = json.loads(trimmed)
sanitized = sanitize_structured_obj(parsed)
return json.dumps(sanitized, ensure_ascii=False)
except Exception:
pass
# 2. Try URL-encoded form data
if "=" in trimmed and not trimmed.startswith("<"):
try:
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
out_pairs = []
for k, v in pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
out_pairs.append((k, "[REDACTED]"))
else:
out_pairs.append((k, v))
return urllib.parse.urlencode(out_pairs)
except Exception:
pass
return text
def sanitize_structured_obj(obj: Any) -> Any:
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
name_val = obj.get("name")
val_val = obj.get("value")
if isinstance(name_val, str) and isinstance(val_val, str):
name_lower = name_val.lower().strip()
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
for k, v in obj.items():
if k == "value":
new_dict[k] = "[REDACTED]"
else:
new_dict[k] = sanitize_structured_obj(v)
return new_dict
# Process standard dictionary keys
for k, v in obj.items():
k_lower = str(k).lower().replace("-", "_")
# Action parameters in Playwright traces (e.g. page.fill, page.type)
if k == "params" and isinstance(v, dict):
sanitized_params = sanitize_structured_obj(v)
# If this action involves fill, type, or sensitive inputs, redact value/text
if "value" in sanitized_params:
sanitized_params["value"] = "[REDACTED]"
if "text" in sanitized_params:
sanitized_params["text"] = "[REDACTED]"
new_dict[k] = sanitized_params
continue
# DOM snapshot nodes
if k == "attributes" and isinstance(v, (dict, list)):
if isinstance(v, dict):
attr_dict = dict(v)
type_val = str(attr_dict.get("type") or "").lower()
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
if "value" in attr_dict:
attr_dict["value"] = "[REDACTED]"
new_dict[k] = attr_dict
continue
elif isinstance(v, list):
# List of attribute strings or pairs
new_list = []
is_pw = any("password" in str(x).lower() for x in v)
for x in v:
if is_pw and isinstance(x, str) and not x.startswith("type="):
new_list.append("[REDACTED]")
else:
new_list.append(sanitize_structured_obj(x))
new_dict[k] = new_list
continue
# Request postData / text
if k in ("postData", "postDataText") and isinstance(v, str):
new_dict[k] = sanitize_body_text(v)
continue
# URL fields
if k == "url" and isinstance(v, str):
new_dict[k] = sanitize_url(v)
continue
# Cookie arrays
if k == "cookies" and isinstance(v, list):
sanitized_cookies = []
for c in v:
if isinstance(c, dict):
c_copy = dict(c)
if "value" in c_copy:
c_copy["value"] = "[REDACTED]"
sanitized_cookies.append(c_copy)
else:
sanitized_cookies.append(sanitize_structured_obj(c))
new_dict[k] = sanitized_cookies
continue
# Direct sensitive key matches
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
new_dict[k] = "[REDACTED]"
continue
new_dict[k] = sanitize_structured_obj(v)
return new_dict
elif isinstance(obj, list):
return [sanitize_structured_obj(item) for item in obj]
elif isinstance(obj, str):
# Check for inline credentials/tokens
if obj.startswith("Bearer ") or obj.startswith("bearer "):
return "Bearer [REDACTED]"
if obj.startswith("Basic ") or obj.startswith("basic "):
return "Basic [REDACTED]"
return obj
return obj
def sanitize_jsonl_content(data: bytes) -> bytes:
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
lines = data.split(b"\n")
out_lines = []
for line in lines:
if not line.strip():
out_lines.append(line)
continue
try:
parsed = json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
# Not valid JSON, keep as-is
out_lines.append(line)
continue
sanitized = sanitize_structured_obj(parsed)
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
return b"\n".join(out_lines)
def sanitize_trace_archive(zp: str) -> bool:
"""Sanitize a single zip archive in a strict fail-closed manner.
If the archive is malformed, partial, unreadable, or encounters an exception
during processing, the archive is permanently deleted so that raw traces
cannot leak.
Returns True if successfully sanitized, False if deleted due to error.
"""
if not os.path.exists(zp):
return False
temp_zp = zp + ".tmp"
try:
if os.path.getsize(zp) == 0:
raise zipfile.BadZipFile("Empty archive")
with zipfile.ZipFile(zp, "r") as zin:
infolist = zin.infolist()
if not infolist:
raise zipfile.BadZipFile("Archive has no entries")
corrupt_file = zin.testzip()
if corrupt_file is not None:
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in infolist:
filename_lower = item.filename.lower()
# 1. Binary image screenshots/diagnostics: preserve as-is
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
content = zin.read(item.filename)
zout.writestr(item, content)
continue
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
# Mask safely without syntax corruption!
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
zout.writestr(item, safe_placeholder)
continue
# 3. JSON Lines and structured JSON/HAR traces
content = zin.read(item.filename)
if filename_lower.endswith((".trace", ".network", ".stacks")):
sanitized_content = sanitize_jsonl_content(content)
zout.writestr(item, sanitized_content)
elif filename_lower.endswith((".json", ".har")):
try:
parsed = json.loads(content.decode("utf-8"))
sanitized = sanitize_structured_obj(parsed)
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
except Exception:
# If invalid JSON, treat as text line-by-line
zout.writestr(item, sanitize_jsonl_content(content))
else:
# Unknown binary or text member: exclude to fail closed
continue
# Atomic replace
os.replace(temp_zp, zp)
return True
except Exception as exc:
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
for path_to_remove in (temp_zp, zp):
if os.path.exists(path_to_remove):
try:
os.remove(path_to_remove)
except OSError as remove_err:
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
return False
def sanitize_directory(results_dir: str) -> int:
"""Walk results_dir and sanitize every .zip archive fail-closed.
Returns the number of archives processed. Exits with 1 if any archive fails.
"""
if not os.path.exists(results_dir):
return 0
count = 0
all_ok = True
for root, _, files in os.walk(results_dir):
for f in files:
if f.endswith(".zip"):
zp = os.path.join(root, f)
ok = sanitize_trace_archive(zp)
if not ok:
all_ok = False
count += 1
if not all_ok:
sys.exit(1)
return count
def main() -> None:
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <results_directory>")
sys.exit(1)
target_dir = sys.argv[1]
sanitize_directory(target_dir)
if __name__ == "__main__":
main()
+233 -6
View File
@@ -339,14 +339,222 @@ async def self_check(
return out return out
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
"""Resolve an explicit agent_model override under server authority.
Guarantees:
- Client-provided raw URLs never determine arbitrary outbound network destinations.
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
- Shared endpoints (owner IS NULL) remain visible to authorized users.
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
- Admin privileges do not allow arbitrary unconfigured destinations.
"""
raw = str(override or "").strip()
if not raw:
return None
if "::" in raw:
target, requested_model = raw.split("::", 1)
target = target.strip()
requested_model = requested_model.strip()
else:
target = ""
requested_model = raw
# 1. Explicit local selection or matching a configured local base
if target == "local":
bases = local_bases()
if not bases:
return None
return {
"base": bases[0],
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
for lb in local_bases():
if target and target.rstrip("/") == lb.rstrip("/"):
return {
"base": lb,
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
# 2. Explicit hosted fallback selection
if target in ("default", "hosted"):
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model:
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
except Exception:
pass
return None
# 3. Bare model id without endpoint prefix
if not target:
if requested_model == "local":
bases = local_bases()
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
if requested_model in ("default", "hosted"):
return _resolve_override_candidate("default::", owner)
# Exact match with local model
if requested_model == local_model():
bases = local_bases()
if bases:
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
# Exact match with hosted default/utility
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model and model == requested_model:
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
except Exception:
pass
# Check registered database endpoints for this bare model
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
matching_eps = []
for ep in q.all():
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model in enabled_models and requested_model not in hidden_models:
matching_eps.append(ep)
# Avoid model ID collision across providers: only accept if unique
if len(matching_eps) == 1:
ep = matching_eps[0]
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": requested_model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
pass
return None
# 4. Target is an endpoint_id or a registered legacy endpoint URL
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
_first_chat_model,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
# Lookup by ID
ep = q.filter(ModelEndpoint.id == target).first()
# If not found by ID, match legacy registered base URL (strictly registered rows only)
if ep is None and ("://" in target or target.startswith("http")):
target_norm = target.rstrip("/")
for cand_ep in q.all():
cand_base = (cand_ep.base_url or "").rstrip("/")
if (
cand_base == target_norm
or cand_base + "/v1" == target_norm
or cand_base == target_norm + "/v1"
):
ep = cand_ep
break
if ep is None:
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
return None
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model:
if requested_model in hidden_models:
return None
if enabled_models and requested_model not in enabled_models:
# Model not served by this endpoint -> fail closed against forged model IDs
return None
model = requested_model
else:
model = _first_chat_model(enabled_models)
if not model:
return None
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
return None
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]: def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
cands: List[Dict[str, Any]] = [] cands: List[Dict[str, Any]] = []
if override:
base, model = _split_override(override)
if model:
cands.append(
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
)
for base in local_bases(): for base in local_bases():
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"}) cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
try: try:
@@ -362,6 +570,16 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
break break
except Exception: except Exception:
pass pass
if override:
cand = _resolve_override_candidate(override, owner)
if cand is not None:
cands.insert(0, cand)
return cands
# Unauthorized, invalid, foreign, disabled, or forged override:
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
return []
return cands return cands
@@ -433,9 +651,11 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
"source": "local", "source": "local",
"vision": True, "vision": True,
"base_url": base, "base_url": base,
"endpoint_id": "local",
}) })
try: try:
from src.endpoint_resolver import resolve_endpoint from src.endpoint_resolver import resolve_endpoint
from src.settings import get_user_setting, load_settings
for prefix in ("utility", "default"): for prefix in ("utility", "default"):
try: try:
@@ -446,12 +666,19 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
key = ("hosted", model) key = ("hosted", model)
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
ep_id = None
try:
settings = load_settings()
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
except Exception:
pass
models.append({ models.append({
"id": model, "id": model,
"label": model, "label": model,
"source": "hosted", "source": "hosted",
"vision": None, "vision": None,
"base_url": url, "base_url": url,
"endpoint_id": ep_id or prefix,
}) })
break break
except Exception: except Exception:
+99 -17
View File
@@ -111,7 +111,9 @@
flex: 0 0 auto; flex: 0 0 auto;
flex-wrap: nowrap; flex-wrap: nowrap;
row-gap: 4px; row-gap: 4px;
min-height: 40px; height: 44px;
min-height: 44px;
max-height: 44px;
padding: 4px 10px; padding: 4px 10px;
box-sizing: border-box; box-sizing: border-box;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@@ -206,9 +208,19 @@
pushed to the right edge. */ pushed to the right edge. */
flex: 1 1 auto; flex: 1 1 auto;
} }
/* When transform section is active in controls, suppress any underlying tool section
so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not
overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */
.ge-controls.ge-transform-active > div:not(#ge-transform-section),
.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section),
.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) {
display: none !important;
}
/* The Text tool's option set is too wide for the thin strip: let the bar grow /* The Text tool's option set is too wide for the thin strip: let the bar grow
and wrap onto extra rows instead of clipping the trailing fields. */ and wrap onto extra rows instead of clipping the trailing fields. */
.ge-options-bar.ge-options-tall { .ge-options-bar.ge-options-tall {
height: auto;
max-height: none;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
overflow-y: visible; overflow-y: visible;
@@ -254,7 +266,7 @@
right after the caret instead of leaving a wide gap. */ right after the caret instead of leaving a wide gap. */
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; } .ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
.ge-controls--in-options .ge-control-row { .ge-controls--in-options .ge-control-row {
margin: 0; margin: 0 !important;
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -488,8 +500,12 @@
} }
.ge-opt-empty { font-size: 11px; opacity: 0.5; } .ge-opt-empty { font-size: 11px; opacity: 0.5; }
.ge-options-empty .ge-options-sep { display: none; } .ge-options-empty .ge-options-sep { display: none; }
/* An editor opened at phone width keeps its tool controls in their own bottom
sheet, so the strip has nothing to show. One opened wider than 700px moves
the controls into this strip; hiding it then would strand every tool control
(tablet widths of 701-820px, or a window narrowed after opening). */
@media (max-width: 820px) { @media (max-width: 820px) {
.ge-options-bar { display: none; } .ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; }
} }
/* Document tab strip (Photoshop-style) — names the open document and /* Document tab strip (Photoshop-style) — names the open document and
@@ -4198,7 +4214,11 @@ body.ge-editor-open textarea.ge-direct-text-editor {
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.ge-save-state-icon { display: none; } .ge-save-state-icon { display: inline-block; vertical-align: middle; }
.ge-save-state-icon[hidden] { display: none !important; }
.ge-save-state-saved { color: var(--fg); }
.ge-save-state-dirty { color: var(--accent, var(--red)); }
.ge-save-state-error { color: var(--danger, #ef4444); }
.ge-save-label { white-space: nowrap; min-width: 0; } .ge-save-label { white-space: nowrap; min-width: 0; }
/* Save button: glyph on its own row, then the SAVE action label and the /* Save button: glyph on its own row, then the SAVE action label and the
live draft-status chip share the second row. */ live draft-status chip share the second row. */
@@ -6341,6 +6361,10 @@ button .spinner-whirlpool {
box-shadow: 0 0 0 1px rgba(0,0,0,0.72); box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
display: none; display: none;
box-sizing: border-box; box-sizing: border-box;
contain: strict;
will-change: transform;
top: 0;
left: 0;
} }
/* Pixel brush: a hard square footprint instead of a feathered circle. */ /* Pixel brush: a hard square footprint instead of a feathered circle. */
.ge-brush-cursor.ge-brush-cursor--pixel { .ge-brush-cursor.ge-brush-cursor--pixel {
@@ -6622,6 +6646,7 @@ button .spinner-whirlpool {
} }
.ge-right-panel.expanded { .ge-right-panel.expanded {
transform: translateY(0); transform: translateY(0);
z-index: 100;
} }
/* Minimized: panel slides almost fully off-screen — only a thin /* Minimized: panel slides almost fully off-screen — only a thin
handle strip remains visible so the user can swipe back up. */ handle strip remains visible so the user can swipe back up. */
@@ -8134,7 +8159,12 @@ input.ge-ctl-check:focus-visible {
} }
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } .ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
@media (max-width: 700px) { @media (max-width: 700px) {
/* Cover the phone viewport rather than the small, scrollable canvas area;
the layers sheet otherwise hides the checklist's lower controls. */
.ge-preflight { position: fixed; }
.ge-preflight-card { padding: 13px 14px; } .ge-preflight-card { padding: 13px 14px; }
.ge-preflight-foot { flex-wrap: wrap; }
.ge-preflight-start { white-space: nowrap; }
} }
/* ── Quick Edit — premium look ────────────────────────────────────────── */ /* ── Quick Edit — premium look ────────────────────────────────────────── */
@@ -8296,17 +8326,37 @@ input.ge-ctl-check:focus-visible {
gap: 6px; gap: 6px;
padding: 3px 6px; padding: 3px 6px;
overflow: hidden; overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
white-space: nowrap;
min-width: 0; min-width: 0;
} }
.ge-editor-footer::-webkit-scrollbar {
display: none;
}
.ge-editor-footer .ge-status-tool, .ge-editor-footer .ge-status-tool,
.ge-editor-footer .ge-status-model, .ge-editor-footer .ge-status-model,
.ge-editor-footer .ge-canvas-size { .ge-editor-footer .ge-canvas-size {
display: none; display: none;
} }
.ge-footer-history { margin-right: 2px; } .ge-footer-history {
.ge-footer-zoom { gap: 3px; min-width: 0; } margin-right: 2px;
flex-shrink: 0;
}
.ge-footer-zoom {
gap: 3px;
min-width: 0;
flex-shrink: 0;
margin-left: 0;
}
.ge-footer-zoom > .ge-btn, .ge-footer-zoom > .ge-btn,
.ge-footer-history > .ge-btn { min-width: 26px; } .ge-footer-history > .ge-btn { min-width: 26px; }
.ge-setup-btn {
flex-shrink: 0;
}
/* Float the footer above the peek/expanded layers sheet so its zoom/setup /* Float the footer above the peek/expanded layers sheet so its zoom/setup
controls stay tappable (position/left/right/bottom set in JS). */ controls stay tappable (position/left/right/bottom set in JS). */
.ge-editor-footer.ge-footer-floating { .ge-editor-footer.ge-footer-floating {
@@ -8317,13 +8367,42 @@ input.ge-ctl-check:focus-visible {
} }
} }
/* Phones: the floating zoom/Setup footer collides with the peek/expanded /* Mobile layers header layout */
layers sheet and the Quick Edit card, so remove it entirely on phones.
Undo/Redo/History already live in the layers-sheet header, and pinch-zoom
replaces the zoom buttons. */
@media (max-width: 700px) { @media (max-width: 700px) {
.ge-editor-footer { .ge-layers-header {
display: none !important; overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
white-space: nowrap;
gap: 4px;
padding-right: 8px;
}
.ge-layers-header::-webkit-scrollbar {
display: none;
}
.ge-layers-header .ge-footer-zoom {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.ge-layers-header .ge-footer-zoom > .ge-btn,
.ge-layers-header .ge-footer-history > .ge-btn {
min-width: 28px;
min-height: 28px;
}
.ge-layers-header .ge-setup-btn {
flex-shrink: 0;
min-height: 28px;
padding: 3px 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%);
color: var(--fg);
cursor: pointer;
} }
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above /* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
every other editor asset (sheet headers, tool rail, popups) while lifted. */ every other editor asset (sheet headers, tool rail, popups) while lifted. */
@@ -8579,6 +8658,7 @@ body.ge-editor-open .toast.toast-transform-hint {
/* Keep the floating editor footer from stacking on the Quick Edit card. The /* Keep the floating editor footer from stacking on the Quick Edit card. The
card owns the bottom of the canvas while it is open, so the footer (zoom / card owns the bottom of the canvas while it is open, so the footer (zoom /
Setup / AI settings) hides until it closes. */ Setup / AI settings) hides until it closes. */
body.ge-qe-open .ge-editor-footer.ge-footer-floating,
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating { body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
display: none; display: none;
} }
@@ -8657,16 +8737,14 @@ body.ge-editor-open .toast.toast-transform-hint {
} }
} }
/* Phone menu bar: tighten the Photoshop menus and keep the Save control /* Phone menu bar: tighten the Photoshop menus. The compact topbar already
icon-only so the twelve menus + Import + Save are not crowded. */ drops the Save action label; the draft status stays visible because it is
the save button's only announced (role="status") save state. */
@media (max-width: 700px) { @media (max-width: 700px) {
.ge-topbar-left .ge-stacked-btn { .ge-topbar-left .ge-stacked-btn {
font-size: 11px; font-size: 11px;
padding: 0 6px !important; padding: 0 6px !important;
} }
.ge-topbar.ge-topbar-compact #ge-draft-status {
display: none;
}
} }
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The /* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
@@ -9675,12 +9753,15 @@ body.ge-editor-open .tour-hint {
} }
/* The dropdowns clip overflow to draw the custom scroll thumb; release the /* The dropdowns clip overflow to draw the custom scroll thumb; release the
clip while a flyout is open/hovered so it can extend past the menu edge. */ clip while a flyout is open/hovered so it can extend past the menu edge. */
.ge-filter-menu.ge-flyout-open,
.ge-filter-menu:has(.ge-menu-parent.open), .ge-filter-menu:has(.ge-menu-parent.open),
.ge-filter-menu:has(.ge-menu-parent:hover), .ge-filter-menu:has(.ge-menu-parent:hover),
.ge-filter-menu:has(.ge-submenu:hover), .ge-filter-menu:has(.ge-submenu:hover),
.ge-image-menu.ge-flyout-open,
.ge-image-menu:has(.ge-menu-parent.open), .ge-image-menu:has(.ge-menu-parent.open),
.ge-image-menu:has(.ge-menu-parent:hover), .ge-image-menu:has(.ge-menu-parent:hover),
.ge-image-menu:has(.ge-submenu:hover), .ge-image-menu:has(.ge-submenu:hover),
.ge-selection-menu.ge-flyout-open,
.ge-selection-menu:has(.ge-menu-parent.open), .ge-selection-menu:has(.ge-menu-parent.open),
.ge-selection-menu:has(.ge-menu-parent:hover), .ge-selection-menu:has(.ge-menu-parent:hover),
.ge-selection-menu:has(.ge-submenu:hover) { .ge-selection-menu:has(.ge-submenu:hover) {
@@ -9689,6 +9770,7 @@ body.ge-editor-open .tour-hint {
mask-image: none; mask-image: none;
} }
/* The Window menu's Workspace flyout needs the same clip release. */ /* The Window menu's Workspace flyout needs the same clip release. */
.ge-window-menu.ge-flyout-open,
.ge-window-menu:has(.ge-menu-parent.open), .ge-window-menu:has(.ge-menu-parent.open),
.ge-window-menu:has(.ge-menu-parent:hover), .ge-window-menu:has(.ge-menu-parent:hover),
.ge-window-menu:has(.ge-submenu:hover) { .ge-window-menu:has(.ge-submenu:hover) {
+21 -7
View File
@@ -72,7 +72,12 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
// ── Storage ─────────────────────────────────────────────────────────── // ── Storage ───────────────────────────────────────────────────────────
function getRecents() { function getRecents() {
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); } try {
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
return Array.isArray(values)
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
: [];
}
catch { return []; } catch { return []; }
} }
@@ -85,17 +90,26 @@ function addRecent(hex) {
} }
// ── Suggestions based on current color (5 harmony swatches) ────────── // ── Suggestions based on current color (5 harmony swatches) ──────────
function computeSuggestions() { function suggestionsForHsv(h, s, v) {
// Complement, analogous ±30°, split-complement (+150), tone shift // Complement, analogous ±30°, split-complement (+150), tone shift
return [ return [
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' }, { hex: hsvToHex(h + 180, s, v), label: 'Complement' },
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' }, { hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' }, { hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' }, { hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' }, { hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
]; ];
} }
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex);
return suggestionsForHsv(h, s, v);
}
// ── Popover build ───────────────────────────────────────────────────── // ── Popover build ─────────────────────────────────────────────────────
function buildPopover() { function buildPopover() {
const p = document.createElement('div'); const p = document.createElement('div');
+68 -55
View File
@@ -82,10 +82,9 @@ export function resolveQuickEditAgentModel() {
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() }; return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
} }
// Quick Edit agent (planner) model options. Kept small + explicit; the local // Quick Edit agent (planner) model options. Kept small + explicit;
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>". // dynamically populated from /api/editor/agent-models.
export const QUICK_EDIT_AGENT_OPTIONS = [ export const QUICK_EDIT_AGENT_OPTIONS = [
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
{ value: 'default', label: 'Server default (hosted planner)' }, { value: 'default', label: 'Server default (hosted planner)' },
]; ];
@@ -167,12 +166,29 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model'); const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel()); if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel()); if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker is static for now (local VL default, // The Quick Edit agent (planner) picker: dynamically populates from
// server default option); restore/save its choice from localStorage. // /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = () => { const populateAgentSelect = async () => {
if (!aiCommandAgentSelect) return; if (!aiCommandAgentSelect) return;
const prevVal = aiCommandAgentSelect.value;
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
try {
const res = await fetch(`${apiBase}/api/editor/agent-models`);
if (res.ok) {
const data = await res.json();
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`;
const label = m.label || m.id;
if (!agentOptions.some(o => o.value === optVal)) {
agentOptions.push({ value: optVal, label });
}
}
}
} catch (_) {}
aiCommandAgentSelect.innerHTML = ''; aiCommandAgentSelect.innerHTML = '';
for (const opt of QUICK_EDIT_AGENT_OPTIONS) { for (const opt of agentOptions) {
const el = document.createElement('option'); const el = document.createElement('option');
el.value = opt.value; el.value = opt.value;
el.textContent = opt.label; el.textContent = opt.label;
@@ -182,6 +198,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const saved = localStorage.getItem('ge-ai-command-agent-model'); const saved = localStorage.getItem('ge-ai-command-agent-model');
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) { if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
aiCommandAgentSelect.value = saved; aiCommandAgentSelect.value = saved;
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
aiCommandAgentSelect.value = prevVal;
} }
} catch {} } catch {}
if (!aiCommandAgentSelect._agentWired) { if (!aiCommandAgentSelect._agentWired) {
@@ -196,16 +214,17 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
// select nor the inpaint select nor any per-tool select). // select nor the inpaint select nor any per-tool select).
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect && if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
!document.querySelector('select.ge-tool-model')) return; !document.querySelector('select.ge-tool-model')) return;
populateAgentSelect(); void populateAgentSelect();
async function loadAIModels(opts = {}) { async function loadAIModels(opts = {}) {
try { try {
const selectBaseUrl = opts.selectBaseUrl || ''; const selectBaseUrl = opts.selectBaseUrl || '';
const prevGenValue = aiGenSelect?.value || ''; const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || ''; const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/model-endpoints`); const res = await fetch(`${apiBase}/api/editor/image-models`);
if (!res.ok) throw new Error(`Could not load image models (${res.status})`); if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const endpoints = await res.json(); const data = await res.json();
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>'; if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>'; if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>'; if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
@@ -215,59 +234,52 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
let selectedGen = null; let selectedGen = null;
let selectedInpaint = null; let selectedInpaint = null;
const loaded = []; const loaded = [];
for (const ep of endpoints) { for (const m of modelsList) {
if (!ep.is_enabled) continue; const modelId = m.id || '';
const hasListedModels = Array.isArray(ep.models) && ep.models.length; const caps = modelCaps(modelId, m.provider, m.kind);
const models = hasListedModels ? sortModelIds(ep.models) : ['']; const canGen = Boolean(caps.gen || m.kind === 'generate');
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image'; const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
// Image/inpaint endpoints can be called by URL even when their if (!canGen && !canInpaint) continue;
// /models cache is still empty, so don't strand a freshly served
// Cookbook model as "(offline)" in the editor picker. // Use opaque endpoint_id if available; fallback to provider name or base_url for admin
const epUsable = !!ep.online || isImageEndpoint; const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || '');
for (const modelId of models) { const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type); const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
if (!caps.gen && !caps.inpaint) continue; const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
// Encode "<base_url>::<model_id>" so the value carries both pieces. const label = `${shortModel}${epHint}`;
const value = `${ep.base_url}::${modelId}`; const epUsable = true;
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url); loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : ''; if (canGen && aiGenSelect) {
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`; const opt = document.createElement('option');
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId }); opt.value = value;
if (caps.gen && aiGenSelect) { opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
}
if (canInpaint && aiInpaintSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
}
if (canInpaint || canGen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = value; opt.value = value;
opt.textContent = label; opt.textContent = label;
opt.disabled = !epUsable; opt.disabled = !epUsable;
aiGenSelect.appendChild(opt); ts.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
} }
if (caps.inpaint && aiInpaintSelect) { if (aiCommandSelect) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = value; opt.value = value;
opt.textContent = label; opt.textContent = label;
opt.disabled = !epUsable; opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt); aiCommandSelect.appendChild(opt);
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
}
// Per-tool selectors get every img2img-capable entry. Both
// caps.inpaint AND caps.gen models work for harmonize /
// style / upscale (anything that can do img2img).
if (caps.inpaint || caps.gen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
ts.appendChild(opt);
}
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiCommandSelect.appendChild(opt);
}
} }
} }
} }
@@ -374,7 +386,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
wireServe(aiInpaintSelect); wireServe(aiInpaintSelect);
wireServe(aiCommandSelect); wireServe(aiCommandSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe); document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
populateAgentSelect(); void populateAgentSelect();
updateFooterEditModel(); updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() }; _aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
@@ -387,6 +399,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
return; return;
} }
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' }); loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
void populateAgentSelect();
}; };
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated); window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown, // Re-fetch the model list when the user opens the inpaint dropdown,
+10 -7
View File
@@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// entries in a compact `<select>` overlay reachable from a "Panels" // entries in a compact `<select>` overlay reachable from a "Panels"
// button in the layers header, without touching the peek/expand model. // button in the layers header, without touching the peek/expand model.
let mobilePanels = null; let mobilePanels = null;
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) { if (Array.isArray(extraPanels) && extraPanels.length) {
const mDock = buildDock({ const mDock = buildDock({
entries: extraPanels, entries: extraPanels,
persistKey: 'ge-dock-active', persistKey: 'ge-dock-active',
@@ -218,7 +218,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
overlay.hidden = !overlay.hidden; overlay.hidden = !overlay.hidden;
}); });
hdr.appendChild(panelsBtn); hdr.appendChild(panelsBtn);
if (useDock) panelsBtn.style.display = 'none';
} }
if (useDock) overlay.style.display = 'none';
mobilePanels = overlay; mobilePanels = overlay;
} }
@@ -226,7 +228,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// The panels are only re-parented, so every existing selector and // The panels are only re-parented, so every existing selector and
// listener keeps working; switching tabs just toggles `hidden`. // listener keeps working; switching tabs just toggles `hidden`.
let dock = null; let dock = null;
if (useDock) { rightPanel.classList.add('ge-right-panel--dock'); if (Array.isArray(extraPanels) && extraPanels.length) {
dock = buildDock({ dock = buildDock({
entries: [ entries: [
...(Array.isArray(extraPanels) ? extraPanels : []), ...(Array.isArray(extraPanels) ? extraPanels : []),
@@ -239,11 +241,12 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
onCloseGroup, onCloseGroup,
}); });
rightPanel.appendChild(dock.dock); rightPanel.appendChild(dock.dock);
// Layers stays pinned below the dock so the layer list is always if (!useDock) dock.dock.style.display = 'none';
// visible while editing (Photoshop-style), regardless of the active
// dock tab. Only the tool options + studio panels live in tabs.
rightPanel.appendChild(layerPanel);
} }
if (useDock) {
rightPanel.classList.add('ge-right-panel--dock');
}
rightPanel.appendChild(layerPanel);
// Horizontal drag handle on the LEFT edge of the right panel — drag // Horizontal drag handle on the LEFT edge of the right panel — drag
// left to widen, right to narrow. Persists chosen width in // left to widen, right to narrow. Persists chosen width in
@@ -286,5 +289,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
} catch {} } catch {}
}); });
return { rightPanel, controls, layerPanel, panelResize, dock }; return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
} }
+7 -1
View File
@@ -242,7 +242,13 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
: soft >= 70 : soft >= 70
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>' ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>'; : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`); const icon = document.createElement('span');
icon.className = 'ge-tool-icon';
icon.innerHTML = presetIcon;
const label = document.createElement('span');
label.className = 'ge-tool-flyout-label';
label.textContent = preset.name;
presetItem.append(icon, label);
presetItem.addEventListener('click', (ev) => { presetItem.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
setInner(face, 'brush'); setInner(face, 'brush');
+1
View File
@@ -535,6 +535,7 @@ export function buildTopbar() {
<span class="ge-stacked-glyph"> <span class="ge-stacked-glyph">
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg> <svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> <svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</span> </span>
<span class="ge-stacked-label ge-save-action-label">SAVE</span> <span class="ge-stacked-label ge-save-action-label">SAVE</span>
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span> <span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
+22 -1
View File
@@ -8,8 +8,29 @@
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
* @returns {{x: number, y: number}} * @returns {{x: number, y: number}}
*/ */
import { state } from './state.js';
let _cachedCanvas = null;
let _cachedRect = null;
let _cachedRectTime = 0;
export function invalidateCanvasCoordsCache() {
_cachedCanvas = null;
_cachedRect = null;
_cachedRectTime = 0;
}
export function canvasCoords(e, canvas) { export function canvasCoords(e, canvas) {
const rect = canvas.getBoundingClientRect(); const now = performance.now();
let rect;
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
rect = _cachedRect;
} else {
rect = canvas.getBoundingClientRect();
_cachedCanvas = canvas;
_cachedRect = rect;
_cachedRectTime = now;
}
const scaleX = canvas.width / rect.width; const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height; const scaleY = canvas.height / rect.height;
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX; const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
+11 -1
View File
@@ -44,6 +44,7 @@
* }} ctx * }} ctx
*/ */
import { state } from './state.js'; import { state } from './state.js';
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
import { import {
applyCanvasPan, applyCanvasPan,
isDirectPanIntent, isDirectPanIntent,
@@ -58,6 +59,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
canvasWindowBindings?.abort(); canvasWindowBindings?.abort();
canvasWindowBindings = new AbortController(); canvasWindowBindings = new AbortController();
const { signal } = canvasWindowBindings; const { signal } = canvasWindowBindings;
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
let suppressMouseUntil = 0; let suppressMouseUntil = 0;
// Tool-aware right-click menu on the image and the surrounding area. // Tool-aware right-click menu on the image and the surrounding area.
wireCanvasContextMenu(canvasArea, { signal }); wireCanvasContextMenu(canvasArea, { signal });
@@ -90,7 +97,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
window.addEventListener('mousemove', (e) => { window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return; if (Date.now() < suppressMouseUntil) return;
// Only shield the raw-coordinate selection/crop rects from chrome. // Only shield the raw-coordinate selection/crop rects from chrome.
if (isUiChrome(e) && directGestureActive()) return; if (directGestureActive() && isUiChrome(e)) return;
continueDraw(e); continueDraw(e);
}, { signal }); }, { signal });
window.addEventListener('mouseup', (e) => { window.addEventListener('mouseup', (e) => {
@@ -240,11 +247,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio)); const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
if (Math.abs(newZoom - state.zoom) > 0.001) { if (Math.abs(newZoom - state.zoom) > 0.001) {
state.zoom = newZoom; state.zoom = newZoom;
// A pinch is an explicit zoom; layout refits must not undo it.
state.zoomViewMode = 'scale';
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px'; state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px'; state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
const label = state.container.querySelector('.ge-zoom-label'); const label = state.container.querySelector('.ge-zoom-label');
if (label) label.textContent = Math.round(state.zoom * 100) + '%'; if (label) label.textContent = Math.round(state.zoom * 100) + '%';
syncZoomControls?.(); syncZoomControls?.();
invalidateCanvasCoordsCache();
onViewportChange?.(); onViewportChange?.();
} }
const dx = info.cx - multiStartCenter.x; const dx = info.cx - multiStartCenter.x;
+2 -1
View File
@@ -77,7 +77,8 @@ export function wireClipboardAndDrop({
// inside the gallery modal's role="dialog" modal-content, so that would // inside the gallery modal's role="dialog" modal-content, so that would
// swallow every paste (including the internal Clipboard + Ctrl+V). // swallow every paste (including the internal Clipboard + Ctrl+V).
const target = e.target; const target = e.target;
const insideEditor = target && container && container.contains(target); // A paste dispatched at window has a non-Node target; contains() would throw.
const insideEditor = target instanceof Node && !!container && container.contains(target);
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return; if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/')); const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
+18 -5
View File
@@ -50,7 +50,7 @@ import {
import { drawHistogram } from './histogram.js'; import { drawHistogram } from './histogram.js';
import { parseCubeLut, generateCubeLut } from './cube-lut.js'; import { parseCubeLut, generateCubeLut } from './cube-lut.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) { export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
function suppressLayerGhostTap() { function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650; window.__geSuppressLayerTapUntil = Date.now() + 650;
} }
@@ -511,6 +511,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
composite(); composite();
renderLayerPanel(); renderLayerPanel();
closeAdjPopup(); closeAdjPopup();
schedulePersist?.();
}); });
} }
@@ -535,8 +536,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel]; if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel]; return p.channels[channel];
}; };
// Params come from project files and drafts, which only validate the
// adjustment type; coerce anything interpolated into markup below.
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'; const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => ` const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
const value = finite(rawValue);
return `
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}> <div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
<label>${label}</label> <label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" /> <input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
@@ -544,6 +551,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button> <button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
</div> </div>
`; `;
};
if (type === 'brightness-contrast') { if (type === 'brightness-contrast') {
const bSlider = Math.round((p.brightness - 1) * 100); const bSlider = Math.round((p.brightness - 1) * 100);
const cSlider = Math.round((p.contrast - 1) * 100); const cSlider = Math.round((p.contrast - 1) * 100);
@@ -692,7 +700,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
}); });
} else if (type === 'color-balance') { } else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what. // Color-tinted slider ends so the user sees what direction does what.
const cbRow = (key, leftCol, rightCol, label, value) => ` const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}"> <div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span> <span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" /> <input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
@@ -749,6 +757,9 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
buildAdjBody(layer, type, body, popEl); buildAdjBody(layer, type, body, popEl);
}); });
} else if (type === 'gradient-map') { } else if (type === 'gradient-map') {
const gradientDefaults = defaultAdjParams('gradient-map');
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
body.innerHTML = ` body.innerHTML = `
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div> <div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
<div class="ge-gradient-color-row"> <div class="ge-gradient-color-row">
@@ -789,7 +800,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
body.innerHTML = ` body.innerHTML = `
<div class="ge-adj-row"> <div class="ge-adj-row">
<label>Color</label> <label>Color</label>
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}"> <input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
</div> </div>
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')} ${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label> <label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
@@ -838,7 +849,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' }; const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
const strength = p.strength == null ? 100 : p.strength; const strength = p.strength == null ? 100 : p.strength;
const hasCube = !!(p.cube && p.cube.size && p.cube.data); const hasCube = !!(p.cube && p.cube.size && p.cube.data);
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded'; const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
body.innerHTML = ` body.innerHTML = `
<label class="ge-adj-channel-field">Lookup preset <label class="ge-adj-channel-field">Lookup preset
<select class="ge-adj-lut-select"> <select class="ge-adj-lut-select">
@@ -972,6 +983,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
p.strength = raw; display = raw + '%'; p.strength = raw; display = raw + '%';
} else if (type === 'white-balance') { } else if (type === 'white-balance') {
p[key] = raw; p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') { } else if (type === 'black-white') {
p[key] = raw; p[key] = raw;
} else if (type === 'shadows-highlights') { } else if (type === 'shadows-highlights') {
+2 -1
View File
@@ -1948,7 +1948,8 @@ export function createLayerPanelRenderer(deps) {
}); });
const sName = document.createElement('span'); const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name'; sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`; sName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
sName.lastElementChild.textContent = effectLabel(effect.type);
sName.title = 'Edit effect (drag onto another layer to copy)'; sName.title = 'Edit effect (drag onto another layer to copy)';
sName.addEventListener('click', event => { sName.addEventListener('click', event => {
event.stopPropagation(); event.stopPropagation();
+2 -2
View File
@@ -28,10 +28,10 @@ const GRADIENTS = [
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']], ['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
]; ];
function el(tag, className, html) { function el(tag, className, text) {
const node = document.createElement(tag); const node = document.createElement(tag);
if (className) node.className = className; if (className) node.className = className;
if (html != null) node.innerHTML = html; if (text != null) node.textContent = text;
return node; return node;
} }
+5 -1
View File
@@ -274,7 +274,11 @@ export function createStudioPanels({
return; return;
} }
shapeLayers.slice().reverse().forEach(layer => { shapeLayers.slice().reverse().forEach(layer => {
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`); const button = el('button', 'ge-channel-row');
const name = document.createElement('span');
name.className = 'ge-channel-name';
name.textContent = `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`;
button.appendChild(name);
button.type = 'button'; button.type = 'button';
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); }); button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
pathBody.appendChild(button); pathBody.appendChild(button);
+6 -1
View File
@@ -400,10 +400,15 @@ export const state = {
// Loading-overlay element (whirlpool + label). // Loading-overlay element (whirlpool + label).
editorLoadingEl: null, editorLoadingEl: null,
// ── Draft persistence ── // ── Draft persistence & revision tracking ──
draftId: null, draftId: null,
draftName: '', draftName: '',
documentId: null, documentId: null,
documentRevision: 0,
persistingRevision: 0,
savedRevision: 0,
persistRetryTimer: null,
persistRetryCount: 0,
persistTimer: null, persistTimer: null,
// Current PUT/POST promise so concurrent saves can chain. // Current PUT/POST promise so concurrent saves can chain.
persistInFlight: null, persistInFlight: null,
+8 -2
View File
@@ -12,15 +12,21 @@ function toolSettings(tool) {
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness }; return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
} }
let _lastColor = null;
let _lastTransparentColor = null;
function transparentColor(color) { function transparentColor(color) {
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = color;
if (color.startsWith('#')) { if (color.startsWith('#')) {
const raw = color.slice(1); const raw = color.slice(1);
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw; const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16)); const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
return `rgba(${rgb.join(',')},0)`; _lastTransparentColor = `rgba(${rgb.join(',')},0)`;
return _lastTransparentColor;
} }
const match = color.match(/rgba?\(([^)]+)\)/); const match = color.match(/rgba?\(([^)]+)\)/);
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)'; _lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
return _lastTransparentColor;
} }
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) { function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
+10
View File
@@ -10,6 +10,16 @@
* the Window > Workspace submenu (wire-extra-menus.js). * the Window > Workspace submenu (wire-extra-menus.js).
*/ */
export function wireSubmenuToggles(menu) { export function wireSubmenuToggles(menu) {
menu.addEventListener('mouseover', (e) => {
if (e.target.closest('.ge-menu-parent, .ge-submenu')) {
menu.classList.add('ge-flyout-open');
}
});
menu.addEventListener('mouseleave', () => {
if (!menu.querySelector('.ge-menu-parent.open')) {
menu.classList.remove('ge-flyout-open');
}
});
menu.addEventListener('click', (e) => { menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent'); const parent = e.target.closest('.ge-menu-parent');
if (!parent) return; if (!parent) return;
@@ -241,11 +241,13 @@ export function createTransformSession({
} }
if (state.transformPopup) { if (state.transformPopup) {
try { try {
state.transformPopup.closest('.ge-controls')?.classList.remove('ge-transform-active');
state.transformPopup.innerHTML = ''; state.transformPopup.innerHTML = '';
state.transformPopup.style.display = 'none'; state.transformPopup.style.display = 'none';
} catch {} } catch {}
state.transformPopup = null; state.transformPopup = null;
} }
document.querySelectorAll('.ge-controls.ge-transform-active').forEach(c => c.classList.remove('ge-transform-active'));
} }
let mobileBoundsHandler = null; let mobileBoundsHandler = null;
@@ -285,6 +287,11 @@ export function createTransformSession({
// bar) instead of a floating popup over the canvas. // bar) instead of a floating popup over the canvas.
const pop = document.getElementById('ge-transform-section'); const pop = document.getElementById('ge-transform-section');
if (!pop) return; if (!pop) return;
const parentControls = pop.closest('.ge-controls');
if (parentControls) {
parentControls.classList.add('ge-transform-active');
parentControls.classList.remove('dismissed');
}
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`; pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
const title = pop.querySelector('.ge-adj-title'); const title = pop.querySelector('.ge-adj-title');
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection'; if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
+2 -2
View File
@@ -127,8 +127,8 @@ export function wireTopbar(deps) {
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom(); (Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); } else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
}); });
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); }); document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); }); document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
// Export / Download / Project Save / Project Load. // Export / Download / Project Save / Project Load.
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery); document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
+366 -134
View File
@@ -7,7 +7,7 @@ import dragSortModule from './dragSort.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
import modalManager from './modalManager.js'; import modalManager from './modalManager.js';
import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js'; import { canvasCoords as _canvasCoords, invalidateCanvasCoordsCache } from './editor/canvas-coords.js';
import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js'; import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js';
import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js'; import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js'; import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
@@ -854,11 +854,13 @@ function _setAiProgress(frac, label) {
if (lbl) lbl.textContent = label || ''; if (lbl) lbl.textContent = label || '';
} }
function _startAiProgress(requestId) { function _startAiProgress(requestId) {
const endpointId = arguments[1] || '';
_stopAiProgress(); _stopAiProgress();
if (!requestId) return; if (!requestId) return;
const tick = async () => { const tick = async () => {
try { try {
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`, { credentials: 'same-origin' }); const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
if (!r.ok) return; if (!r.ok) return;
const p = await r.json(); const p = await r.json();
const st = p.stage || ''; const st = p.stage || '';
@@ -1113,6 +1115,68 @@ function _matchAiCommandSuggestions(query) {
.map(hit => hit.item); .map(hit => hit.item);
} }
// On phones the layers panel is a bottom sheet that can cover the editor
// footer (zoom / setup). Float the footer just above the sheet's top edge so
// its controls stay tappable; restore it to normal flow when the sheet is
// collapsed or on desktop.
let _lastFooterDelta = 0;
const _placeMobileFooter = () => {
const footer = document.querySelector('.ge-editor-footer');
if (!footer) return;
const area = document.querySelector('.ge-canvas-area');
const _clear = () => {
footer.classList.remove('ge-footer-floating');
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
footer.style.bottom = ''; footer.style.zIndex = '';
document.body.classList.remove('ge-footer-docked');
state._footerDocked = false;
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
_lastFooterDelta = 0;
// The sheet may have squeezed fit view to a few pixels. Restore fit
// after releasing that constraint, while retaining an explicit zoom.
if (state.zoomViewMode === 'fit') {
try { _fitZoom(); } catch (_) {}
}
}
};
if (window.innerWidth > 700) { _clear(); return; }
const sheet = document.querySelector('.ge-right-panel');
if (!sheet || sheet.classList.contains('minimized') || sheet.classList.contains('expanded')) { _clear(); return; }
const r = sheet.getBoundingClientRect();
const vh = window.innerHeight;
if (r.height <= 0 || r.top >= vh || r.top <= 140) { _clear(); return; }
footer.classList.add('ge-footer-floating');
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.zIndex = '60';
// Dock the footer flush to the sheet top: no seam / dead space between them.
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
document.body.classList.add('ge-footer-docked');
// Dock the canvas image flush too: with the dock padding off, the image
// fills the region so its bottom meets the footer top (no dead space).
state._footerDocked = true;
// Size the canvas region so it ends exactly where the footer begins:
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
// would otherwise keep it running under the footer and the sheet.) The
// ge-footer-docked class also hides the sheet grab handle.
if (area) {
const footerH = footer.getBoundingClientRect().height || 0;
const desiredBottom = Math.round(r.top - footerH);
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
if (want !== _lastFooterDelta) {
area.style.flex = want ? '0 0 auto' : '';
area.style.minHeight = want ? '0px' : '';
area.style.height = want ? want + 'px' : '';
_lastFooterDelta = want;
if (state.zoomViewMode === 'fit') {
try { _fitZoom(); } catch (_) {}
}
}
}
};
function _wireAiCommandBox() { function _wireAiCommandBox() {
const wrap = document.getElementById('ge-ai-command'); const wrap = document.getElementById('ge-ai-command');
const toggle = document.getElementById('ge-ai-command-toggle'); const toggle = document.getElementById('ge-ai-command-toggle');
@@ -1368,60 +1432,6 @@ function _wireAiCommandBox() {
input.addEventListener('click', _trackLift); input.addEventListener('click', _trackLift);
wrap.addEventListener('focusin', _trackLift); wrap.addEventListener('focusin', _trackLift);
requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit)); requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit));
// On phones the layers panel is a bottom sheet that can cover the editor
// footer (zoom / setup). Float the footer just above the sheet's top edge so
// its controls stay tappable; restore it to normal flow when the sheet is
// collapsed or on desktop.
let _lastFooterDelta = 0;
const _placeMobileFooter = () => {
const footer = document.querySelector('.ge-editor-footer');
if (!footer) return;
const area = document.querySelector('.ge-canvas-area');
const _clear = () => {
footer.classList.remove('ge-footer-floating');
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
footer.style.bottom = ''; footer.style.zIndex = '';
document.body.classList.remove('ge-footer-docked');
state._footerDocked = false;
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
_lastFooterDelta = 0;
}
};
if (window.innerWidth > 700) { _clear(); return; }
const sheet = document.querySelector('.ge-right-panel');
if (!sheet || sheet.classList.contains('minimized')) { _clear(); return; }
const r = sheet.getBoundingClientRect();
const vh = window.innerHeight;
if (r.height <= 0 || r.top >= vh) { _clear(); return; }
footer.classList.add('ge-footer-floating');
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.zIndex = '60';
// Dock the footer flush to the sheet top: no seam / dead space between them.
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
document.body.classList.add('ge-footer-docked');
// Dock the canvas image flush too: with the dock padding off, the image
// fills the region so its bottom meets the footer top (no dead space).
state._footerDocked = true;
// Size the canvas region so it ends exactly where the footer begins:
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
// would otherwise keep it running under the footer and the sheet.) The
// ge-footer-docked class also hides the sheet grab handle.
if (area) {
const footerH = footer.getBoundingClientRect().height || 0;
const desiredBottom = Math.round(r.top - footerH);
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
if (want !== _lastFooterDelta) {
area.style.flex = want ? '0 0 auto' : '';
area.style.minHeight = want ? '0px' : '';
area.style.height = want ? want + 'px' : '';
_lastFooterDelta = want;
try { _fitZoom(); } catch (_) {}
}
}
};
// The layers sheet expands/collapses (and its peek height grows as layers // The layers sheet expands/collapses (and its peek height grows as layers
// render) independently of the keyboard; watch it so the bar and the footer // render) independently of the keyboard; watch it so the bar and the footer
// keep clear of the sheet's top edge as it moves. // keep clear of the sheet's top edge as it moves.
@@ -2592,8 +2602,8 @@ function _wireAiCommandBox() {
// done vs retry. The caller enforces a single retry (max 1). // done vs retry. The caller enforces a single retry (max 1).
const _vlJudgeAfter = async (prompt, before, after) => { const _vlJudgeAfter = async (prompt, before, after) => {
if (!before || !after) return { done: true }; if (!before || !after) return { done: true };
let ep = 'http://100.69.120.65:8097/v1'; let ep = '';
let model = 'Qwen3.5-9B-MLX-8bit'; let model = 'Qwen3.5-9B';
try { try {
const info = _getSelectedQuickEditAgentModel(); const info = _getSelectedQuickEditAgentModel();
if (info && info.endpoint) ep = info.endpoint; if (info && info.endpoint) ep = info.endpoint;
@@ -2637,6 +2647,7 @@ function _wireAiCommandBox() {
{ type: 'image_url', image_url: { url: after } }, { type: 'image_url', image_url: { url: after } },
] }], ] }],
}; };
if (!ep) return { done: true };
try { try {
const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', { const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
@@ -3295,6 +3306,7 @@ const _adjPopupSystem = createAdjPopupSystem({
saveState: _saveState, saveState: _saveState,
renderLayerPanel: () => _renderLayerPanel(), renderLayerPanel: () => _renderLayerPanel(),
getAdjustmentSource: layer => _adjustmentSourceCanvas(layer), getAdjustmentSource: layer => _adjustmentSourceCanvas(layer),
schedulePersist: () => _schedulePersist(),
}); });
const _closeFxPopup = _adjPopupSystem.closeFxPopup; const _closeFxPopup = _adjPopupSystem.closeFxPopup;
const _ensureAdjustments = _adjPopupSystem.ensureAdjustments; const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
@@ -3948,13 +3960,13 @@ function _finishComposite(render, documentCanvas) {
if (state.transformActive) _drawTransformHandles(); if (state.transformActive) _drawTransformHandles();
else if (state.transformOverlay) state.transformOverlay.style.display = 'none'; else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay(); if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
_penTool.draw(); if (state.tool === 'pen' || state.penPoints?.length) _penTool.draw();
if (state.cropRect && !state.cropping) _drawCropOverlay(); if (state.cropRect && !state.cropping) _drawCropOverlay();
_syncCropFrame(); if (state.cropRect) _syncCropFrame();
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides(); if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
_precisionGuides?.drawDocumentOverlay(state.mainCtx); if (!state.drawing) _precisionGuides?.drawDocumentOverlay(state.mainCtx);
_refreshSelectionOverlay(); _refreshSelectionOverlay();
_syncToolClearIndicators(); if (!state.drawing) _syncToolClearIndicators();
} }
function composite() { function composite() {
@@ -4333,7 +4345,14 @@ function _saveState(label, options = {}) {
console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e); console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e);
} }
try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); } try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); }
try { _schedulePersist(); } catch (e) { console.error('[gallery] schedulePersist:', e); } try {
state.documentRevision = (state.documentRevision || 0) + 1;
state.persistDirty = true;
_setDraftStatus('Unsaved', 'dirty');
if (!_draftSavingDisabled()) {
_schedulePersist();
}
} catch (e) { console.error('[gallery] schedulePersist:', e); }
try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); } try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
} }
@@ -4367,20 +4386,47 @@ function _writeActiveEditorSession() {
function _setDraftStatus(label, stateName = '') { function _setDraftStatus(label, stateName = '') {
const el = document.getElementById('ge-draft-status'); const el = document.getElementById('ge-draft-status');
if (!el) return; if (!el) return;
const normalizedState = stateName === 'saved' ? 'saved' : 'dirty'; const validStates = ['saved', 'dirty', 'pending', 'saving', 'error', 'paused'];
const normalizedLabel = normalizedState === 'saved' ? 'Saved' : 'Unsaved'; let normalizedState = stateName;
if (!validStates.includes(normalizedState)) {
if (stateName === 'saved' || label === 'Saved') normalizedState = 'saved';
else if (stateName === 'error' || /fail|error/i.test(label || '')) normalizedState = 'error';
else if (stateName === 'paused' || /paused/i.test(label || '')) normalizedState = 'paused';
else if (stateName === 'saving' || label === 'Saving…' || label === 'Saving...') normalizedState = 'saving';
else if (stateName === 'pending' || /saving soon/i.test(label || '')) normalizedState = 'pending';
else normalizedState = 'dirty';
}
const defaultLabels = {
saved: 'Saved',
dirty: 'Unsaved',
pending: 'Saving soon…',
saving: 'Saving…',
error: 'Save failed',
paused: 'Save paused',
};
const normalizedLabel = label || defaultLabels[normalizedState] || 'Unsaved';
el.textContent = normalizedLabel; el.textContent = normalizedLabel;
el.dataset.state = stateName; el.dataset.state = normalizedState;
const iconHost = el.closest('#ge-save-menu-btn'); const iconHost = el.closest('#ge-save-menu-btn');
const iconKind = (normalizedState === 'error' || normalizedState === 'paused')
? 'error'
: (normalizedState === 'saved' ? 'saved' : 'dirty');
// The icons are <svg>; SVGElement has no `hidden` property, so set the attribute.
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => { iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
icon.hidden = !icon.classList.contains(`ge-save-state-${normalizedState}`); icon.toggleAttribute('hidden', !icon.classList.contains(`ge-save-state-${iconKind}`));
}); });
const saveButton = document.getElementById('ge-save-menu-btn'); const saveButton = document.getElementById('ge-save-menu-btn');
if (saveButton) { if (saveButton) {
saveButton.title = normalizedState === 'error' ? 'Draft autosave needs attention' : `Draft status: ${normalizedLabel}`; if (normalizedState === 'error' || normalizedState === 'paused') {
saveButton.setAttribute('aria-label', normalizedLabel); saveButton.title = state.persistErrorMessage || `Draft autosave failed: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', `Draft autosave failed: ${normalizedLabel}`);
} else {
saveButton.title = `Draft status: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', `Draft status: ${normalizedLabel}`);
}
} }
} }
try { window.__geSetDraftStatus = _setDraftStatus; } catch (_) {}
function _clearActiveEditorSession() { function _clearActiveEditorSession() {
try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {} try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {}
@@ -4403,11 +4449,20 @@ function _draftSavingDisabled() {
function _schedulePersist() { function _schedulePersist() {
if (_draftSavingDisabled()) return; if (_draftSavingDisabled()) return;
if (!state.editorOpen || !state.layers.length) return; if (!state.editorOpen || !state.layers.length) return;
if (state.persistTimer) clearTimeout(state.persistTimer); if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
_setDraftStatus('Saving soon…', 'pending'); if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
// The first save establishes the recovery anchor. Start it immediately // The first save establishes the recovery anchor. Start it immediately
// when no server draft exists; subsequent edits can stay debounced. // when no server draft exists; subsequent edits can stay debounced.
const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0; const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;
if (delay > 0) {
state.pendingStatusTimer = setTimeout(() => {
state.pendingStatusTimer = null;
if (state.editorOpen) _setDraftStatus('Saving soon…', 'pending');
}, Math.min(200, Math.floor(delay / 2)));
} else {
_setDraftStatus('Saving soon…', 'pending');
}
state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay); state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);
} }
@@ -4591,6 +4646,8 @@ async function _persistDraft() {
// Coalesce concurrent saves — if one's already in-flight, mark dirty // Coalesce concurrent saves — if one's already in-flight, mark dirty
// and let the running call kick off another when it returns. // and let the running call kick off another when it returns.
if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; } if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }
const currentRevision = state.documentRevision || 0;
state.persistingRevision = currentRevision;
const sessionToken = state.editorSessionToken; const sessionToken = state.editorSessionToken;
const draftIdAtStart = state.draftId || null; const draftIdAtStart = state.draftId || null;
const draftNameAtStart = state.draftName || 'Untitled'; const draftNameAtStart = state.draftName || 'Untitled';
@@ -4618,13 +4675,10 @@ async function _persistDraft() {
}; };
const serializedBody = JSON.stringify(body); const serializedBody = JSON.stringify(body);
if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) { if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {
const message = 'Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.'; _setDraftStatus('Save paused', 'error');
if (isCurrentSession()) { const err = new Error('Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.');
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 9000); err.status = 413;
state.persistErrorMessage = message; throw err;
_setDraftStatus('Save paused', 'error');
}
return;
} }
const responseError = async (res, method) => { const responseError = async (res, method) => {
let detail = ''; let detail = '';
@@ -4667,29 +4721,51 @@ async function _persistDraft() {
}; };
const savePromise = doSave(); const savePromise = doSave();
state.persistInFlight = savePromise state.persistInFlight = savePromise
.catch((e) => {
console.warn('[ge] draft save failed', e);
if (!isCurrentSession()) return;
const message = e?.status === 413
? 'Draft autosave paused: the document exceeds the configured storage limit.'
: `Draft autosave failed: ${e?.message || 'server error'}`;
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 7000);
state.persistErrorMessage = message;
_setDraftStatus('Save failed', 'error');
})
.then((out) => { .then((out) => {
if (isCurrentSession()) state.persistInFlight = null;
if (!isCurrentSession()) return; if (!isCurrentSession()) return;
state.savedRevision = Math.max(state.savedRevision || 0, currentRevision);
state.persistRetryCount = 0;
if (out && out.id) { if (out && out.id) {
state.draftId = out.id; state.draftId = out.id;
_writeActiveEditorSession(); _writeActiveEditorSession();
} }
state.persistErrorMessage = null; state.persistErrorMessage = null;
if (!state.persistErrorMessage) _setDraftStatus('Saved', 'saved'); if ((state.documentRevision || 0) > state.savedRevision || state.persistDirty) {
state.persistDirty = true;
_setDraftStatus('Unsaved', 'dirty');
} else {
_setDraftStatus('Saved', 'saved');
}
if (state.persistDirty) { if (state.persistDirty) {
state.persistDirty = false; state.persistDirty = false;
_schedulePersist(); _schedulePersist();
} }
})
.catch((e) => {
console.warn('[ge] draft save failed', e);
if (!isCurrentSession()) return;
state.persistDirty = true;
const isPaused = e?.status === 413;
const message = isPaused
? (e?.message || 'Draft autosave paused: the document exceeds the configured storage limit.')
: `Draft autosave failed: ${e?.message || 'server error'}`;
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, isPaused ? 9000 : 7000);
state.persistErrorMessage = message;
_setDraftStatus(isPaused ? 'Save paused' : 'Save failed', isPaused ? 'paused' : 'error');
if (!isPaused && state.editorOpen) {
state.persistRetryCount = Math.min((state.persistRetryCount || 0) + 1, 5);
const retryDelay = Math.min(1000 * Math.pow(2, state.persistRetryCount), 15000);
if (state.persistRetryTimer) clearTimeout(state.persistRetryTimer);
state.persistRetryTimer = setTimeout(() => {
state.persistRetryTimer = null;
if (state.editorOpen && (state.persistDirty || (state.documentRevision || 0) > (state.savedRevision || 0))) {
_persistDraft();
}
}, retryDelay);
}
})
.finally(() => {
if (isCurrentSession()) state.persistInFlight = null;
}); });
return state.persistInFlight; return state.persistInFlight;
} }
@@ -5381,7 +5457,24 @@ async function _confirmRasterizeLayer(layer) {
return true; return true;
} }
let _lastCursorRect = null;
let _lastCursorRectTime = 0;
function _invalidateCoordsCaches() {
invalidateCanvasCoordsCache();
_lastCursorRect = null;
_lastCursorRectTime = 0;
}
function _beginDraw(e) { function _beginDraw(e) {
if (state.mainCanvas) {
// Prime canvas rect and cursor rect once before stroke begins
// to avoid synchronous layout reads during pointermove.
_canvasCoords(e, state.mainCanvas);
const now = performance.now();
_lastCursorRect = state.mainCanvas.getBoundingClientRect();
_lastCursorRectTime = now;
}
// Move-tool auto-select (Photoshop parity): when the Auto-Select option is // Move-tool auto-select (Photoshop parity): when the Auto-Select option is
// on — or Ctrl/Cmd is held for a one-off — pick the topmost visible, // on — or Ctrl/Cmd is held for a one-off — pick the topmost visible,
// position-unlocked layer under the pointer before the drag. If the picked // position-unlocked layer under the pointer before the drag. If the picked
@@ -5428,13 +5521,11 @@ function _beginDraw(e) {
} }
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive && if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
['text', 'shape', 'placed'].includes(layer?.kind)) { ['text', 'shape', 'placed'].includes(layer?.kind)) {
// Paint tools need pixels: rasterize the text/shape/placed layer silently // Paint tools need pixels: prompt for confirmation before destructive rasterization.
// (it stays undoable) so the very first stroke or pour works, instead of // If user cancels, editable text/shape/placed layer is preserved intact.
// bouncing the user through a rasterize dialog that can end up behind the // If user accepts, layer is converted with an undo boundary so it can be reverted.
// canvas where it cannot be clicked. If rasterizing is not possible, fall void _confirmRasterizeLayer(layer);
// back to the prompt. return;
if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }
// fall through — `layer` is a pixel layer now, so the tool runs below.
} }
// Transform-tool drag (handle grab or move-fallback) — handler in // Transform-tool drag (handle grab or move-fallback) — handler in
// editor/tools/transform-drag.js. // editor/tools/transform-drag.js.
@@ -5480,6 +5571,13 @@ function _beginDraw(e) {
_strokeTool.tryBegin(e); _strokeTool.tryBegin(e);
} }
let _cursorRaf = null;
let _pendingCursorEvent = null;
const _cursorOverlayTools = new Set([
'eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal',
'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'
]);
function _continueDraw(e) { function _continueDraw(e) {
if (state.tool === 'pen') return _penTool.drag(e); if (state.tool === 'pen') return _penTool.drag(e);
if (state.tool === 'path') return _pathTool.drag(e); if (state.tool === 'path') return _pathTool.drag(e);
@@ -5496,10 +5594,40 @@ function _continueDraw(e) {
// when it's actually over the canvas, otherwise hide it. // when it's actually over the canvas, otherwise hide it.
const overCanvas = state.mainCanvas && e.target === state.mainCanvas; const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e); if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e);
if (['eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'].includes(state.tool) && state.mainCanvas) { if (_cursorOverlayTools.has(state.tool) && state.mainCanvas) {
if (overCanvas) _updateBrushCursor(e); const syncCursor = () => {
else if (state.cursorEl) state.cursorEl.style.display = 'none'; if (!overCanvas) {
if (state.cursorEl) state.cursorEl.style.display = 'none';
return;
}
if (state.drawing) {
_pendingCursorEvent = e;
if (!_cursorRaf) {
_cursorRaf = requestAnimationFrame(() => {
_cursorRaf = null;
if (_pendingCursorEvent) {
_updateBrushCursor(_pendingCursorEvent);
_pendingCursorEvent = null;
}
});
}
} else {
_updateBrushCursor(e);
}
};
// Mid-stroke, move the cursor only after the stroke has sampled the canvas
// rect: a style write first forces a synchronous style + layout pass of the
// whole editor on every pointer move, which made long strokes lag.
if (state.drawing) {
try { return _continueDrawTools(e); } finally { syncCursor(); }
}
syncCursor();
} }
return _continueDrawTools(e);
}
function _continueDrawTools(e) {
if (state.drawing) return _strokeTool.tryContinue(e);
// Transform-tool hover-cursor + handle drag — handler in // Transform-tool hover-cursor + handle drag — handler in
// editor/tools/transform-drag.js. Returns true when the drag is // editor/tools/transform-drag.js. Returns true when the drag is
// consuming the event (rotation / resize); the hover-cursor pass // consuming the event (rotation / resize); the hover-cursor pass
@@ -5514,17 +5642,13 @@ function _continueDraw(e) {
if (state.tool === 'quickselect') return _quickSelectTool.drag(e); if (state.tool === 'quickselect') return _quickSelectTool.drag(e);
if (state.tool === 'objectselect') return _objectSelectTool.drag(e); if (state.tool === 'objectselect') return _objectSelectTool.drag(e);
if (state.tool === 'patch') return _patchTool.drag(e); if (state.tool === 'patch') return _patchTool.drag(e);
if (!state.drawing) { if (state.moving) return _continueMove(e);
if (state.moving) return _continueMove(e); if (state.cropping || state.cropMoving) return _continueCrop(e);
if (state.cropping || state.cropMoving) return _continueCrop(e);
return;
}
// In-progress stroke (brush / eraser / inpaint / clone) — handler in
// editor/tools/stroke.js.
_strokeTool.tryContinue(e);
} }
function _endDraw(e) { function _endDraw(e) {
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
if (_pendingCursorEvent) { _updateBrushCursor(_pendingCursorEvent); _pendingCursorEvent = null; }
if (_cursorLoupeVisible) _setCursorLoupe(false); if (_cursorLoupeVisible) _setCursorLoupe(false);
if (state.tool === 'pen') return _penTool.end(); if (state.tool === 'pen') return _penTool.end();
if (state.tool === 'path') return _pathTool.end(e); if (state.tool === 'path') return _pathTool.end(e);
@@ -5543,6 +5667,7 @@ function _endDraw(e) {
// Stroke end (brush / eraser / inpaint / clone) — handler in // Stroke end (brush / eraser / inpaint / clone) — handler in
// editor/tools/stroke.js. // editor/tools/stroke.js.
_strokeTool.tryEnd(e); _strokeTool.tryEnd(e);
_invalidateCoordsCaches();
} }
// Floating popup that appears after an inpaint stroke so the user can // Floating popup that appears after an inpaint stroke so the user can
@@ -5579,19 +5704,33 @@ const _endStroke = _strokePipeline.endStroke;
// ── Brush cursor overlay ── // ── Brush cursor overlay ──
function _updateBrushCursor(e) { function _updateBrushCursor(e) {
if (!state.mainCanvas) return; if (!state.mainCanvas) return;
const rect = state.mainCanvas.getBoundingClientRect(); const now = performance.now();
let rect;
if (_lastCursorRect && (state.drawing || (now - _lastCursorRectTime < 32))) {
rect = _lastCursorRect;
} else {
rect = state.mainCanvas.getBoundingClientRect();
_lastCursorRect = rect;
_lastCursorRectTime = now;
}
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY; const clientY = e.touches ? e.touches[0].clientY : e.clientY;
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return; if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
if (!state.cursorEl) { if (!state.cursorEl) {
let layer = document.getElementById('ge-cursor-layer');
if (!layer) {
layer = document.createElement('div');
layer.id = 'ge-cursor-layer';
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;contain:strict;z-index:2147483647;';
document.body.appendChild(layer);
}
state.cursorEl = document.createElement('div'); state.cursorEl = document.createElement('div');
state.cursorEl.className = 'ge-brush-cursor'; state.cursorEl.className = 'ge-brush-cursor';
document.body.appendChild(state.cursorEl); layer.appendChild(state.cursorEl);
} }
// Lasso uses the feather radius (or a sensible default) so the circle // Lasso uses the feather radius (or a sensible default) so the circle
@@ -5613,14 +5752,23 @@ function _updateBrushCursor(e) {
// or responsive layout changes the canvas independently of nominal zoom. // or responsive layout changes the canvas independently of nominal zoom.
const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width)); const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width));
const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height)); const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height));
state.cursorEl.style.width = diameterX + 'px'; if (state._lastCursorDiameterX !== diameterX || state._lastCursorDiameterY !== diameterY) {
state.cursorEl.style.height = diameterY + 'px'; state._lastCursorDiameterX = diameterX;
state._lastCursorDiameterY = diameterY;
state.cursorEl.style.width = diameterX + 'px';
state.cursorEl.style.height = diameterY + 'px';
}
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush); state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
state.cursorEl.style.left = (clientX - diameterX / 2) + 'px'; state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
state.cursorEl.style.top = (clientY - diameterY / 2) + 'px'; if (state.cursorEl.style.display !== 'block') {
state.cursorEl.style.display = 'block'; state.cursorEl.style.display = 'block';
state.cursorEl.dataset.clientX = String(clientX); }
state.cursorEl.dataset.clientY = String(clientY); state.cursorClientX = clientX;
state.cursorClientY = clientY;
if (!state.drawing) {
state.cursorEl.dataset.clientX = String(clientX);
state.cursorEl.dataset.clientY = String(clientY);
}
if (state.tool === 'inpaint') { if (state.tool === 'inpaint') {
// Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also // Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
// flips the cursor so the user sees the effective mode before they // flips the cursor so the user sees the effective mode before they
@@ -5641,9 +5789,13 @@ function _updateBrushCursor(e) {
state.cursorEl.style.background = 'rgba(0,0,0,0.15)'; state.cursorEl.style.background = 'rgba(0,0,0,0.15)';
state.cursorEl.style.borderStyle = 'solid'; state.cursorEl.style.borderStyle = 'solid';
} else { } else {
state.cursorEl.style.borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color; const borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
state.cursorEl.style.background = 'transparent'; if (state._lastCursorBorderColor !== borderColor) {
state.cursorEl.style.borderStyle = 'solid'; state._lastCursorBorderColor = borderColor;
state.cursorEl.style.borderColor = borderColor;
state.cursorEl.style.background = 'transparent';
state.cursorEl.style.borderStyle = 'solid';
}
} }
} }
@@ -9476,6 +9628,7 @@ function _getFitZoom() {
} }
function _applyZoom({ resetPan = false } = {}) { function _applyZoom({ resetPan = false } = {}) {
_invalidateCoordsCaches();
if (!state.mainCanvas) return; if (!state.mainCanvas) return;
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px'; state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px'; state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
@@ -9485,6 +9638,9 @@ function _applyZoom({ resetPan = false } = {}) {
const area = state.container && state.container.querySelector('.ge-canvas-area'); const area = state.container && state.container.querySelector('.ge-canvas-area');
if (resetPan && area && area._resetPan) area._resetPan(); if (resetPan && area && area._resetPan) area._resetPan();
_syncSelectionOverlay(); _syncSelectionOverlay();
// The transform overlay is sized and placed for the zoom it was drawn at;
// a refit (e.g. the phone Layers sheet collapsing) must redraw it.
if (state.transformActive) _drawTransformHandles();
_precisionGuides?.redrawRulers(); _precisionGuides?.redrawRulers();
} }
@@ -10337,7 +10493,10 @@ function _buildEditor(container) {
if (e) _eyedropperTool.preview(e); if (e) _eyedropperTool.preview(e);
}, },
syncZoomControls: () => _syncZoomControls(), syncZoomControls: () => _syncZoomControls(),
onViewportChange: () => _precisionGuides?.redrawRulers(), onViewportChange: () => {
_precisionGuides?.redrawRulers();
if (state.transformActive) _drawTransformHandles();
},
onTwoFingerTap: () => undo(), onTwoFingerTap: () => undo(),
onThreeFingerTap: () => redo(), onThreeFingerTap: () => redo(),
}); });
@@ -10405,7 +10564,7 @@ function _buildEditor(container) {
composite(); composite();
}, },
}); });
const { rightPanel, controls, layerPanel, dock } = buildRightPanel({ const { rightPanel, controls, mobilePanels, layerPanel, dock } = buildRightPanel({
controlsHTML: _controlsHTML, controlsHTML: _controlsHTML,
layerPanelHTML: _layerPanelHTML, layerPanelHTML: _layerPanelHTML,
extraPanels: [ extraPanels: [
@@ -10434,6 +10593,7 @@ function _buildEditor(container) {
}); });
editorBody.appendChild(rightPanel); editorBody.appendChild(rightPanel);
panelVisibility.apply(); panelVisibility.apply();
panelVisibility.subscribe(_invalidateCoordsCaches);
window.gePanelVisibility = panelVisibility; window.gePanelVisibility = panelVisibility;
// Remember the workspace/window layout the user last chose and restore it on // Remember the workspace/window layout the user last chose and restore it on
// open, so the editor comes back the way they left it. A fresh session has no // open, so the editor comes back the way they left it. A fresh session has no
@@ -10502,16 +10662,72 @@ function _buildEditor(container) {
_preflight?.maybeOpen(); _preflight?.maybeOpen();
} catch (_) {} } catch (_) {}
try { _initControlKit(container); } catch (_) {} try { _initControlKit(container); } catch (_) {}
// Tool options live in the options bar (Photoshop), not the dock. On let _lastLayoutDesktop = null;
// desktop the controls panel is moved under the contextual strip so the const _syncResponsiveLayout = (force = false) => {
// dock is reserved for Color / Adjustments / Layers; on mobile _invalidateCoordsCaches();
// right-panel.js already re-parents it into a bottom sheet. const isDesktop = window.innerWidth > 700;
if (window.innerWidth > 700 && controls) { if (!force && isDesktop === _lastLayoutDesktop) return;
optionsBar.appendChild(controls); _lastLayoutDesktop = isDesktop;
controls.classList.add('ge-controls--in-options');
// The "..." overflow affordance is shown/hidden per tool inside const footer = container.querySelector('.ge-editor-footer');
// renderOptionsBar (it only reveals the secondary brush rows). const hdr = container.querySelector('.ge-layers-header');
} const fh = container.querySelector('.ge-footer-history');
const fz = container.querySelector('.ge-footer-zoom');
const setupBtn = document.getElementById('ge-setup-btn');
const aiBtn = document.getElementById('ge-ai-settings-btn');
const optionsBar = container.querySelector('.ge-options-bar');
if (isDesktop) {
// Reparent controls into optionsBar
if (controls && optionsBar && controls.parentElement !== optionsBar) {
optionsBar.appendChild(controls);
}
controls?.classList.add('ge-controls--in-options');
controls?.classList.remove('dismissed');
optionsBar?.classList.add('ge-options-bar--has-controls');
// Right panel dock mode
rightPanel?.classList.add('ge-right-panel--dock');
if (dock?.dock) dock.dock.style.display = '';
if (mobilePanels) mobilePanels.style.display = 'none';
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
if (panelsBtn) panelsBtn.style.display = 'none';
// Clear mobile footer docking on desktop
if (fh && footer && fh.parentElement !== footer) {
footer.insertBefore(fh, footer.firstChild);
}
_placeMobileFooter();
} else {
// Reparent controls into editor container (mobile sheet)
if (controls && controls.parentElement !== container) {
container.appendChild(controls);
}
controls?.classList.remove('ge-controls--in-options');
optionsBar?.classList.remove('ge-options-bar--has-controls');
// Right panel mobile sheet mode
rightPanel?.classList.remove('ge-right-panel--dock');
if (dock?.dock) dock.dock.style.display = 'none';
if (mobilePanels) mobilePanels.style.display = '';
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
if (panelsBtn) panelsBtn.style.display = '';
if (fh && hdr && fh.parentElement !== hdr) {
hdr.appendChild(fh);
}
// Dock footer flush to sheet on mobile
_placeMobileFooter();
}
};
_syncResponsiveLayout(true);
try {
const _mobileMql = window.matchMedia('(max-width: 700px)');
_mobileMql.addEventListener('change', () => _syncResponsiveLayout(true));
} catch (_) {}
window.addEventListener('resize', () => _syncResponsiveLayout(false), { passive: true });
// Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9). // Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9).
// Choosing one locks new crop drags to the ratio and refits an existing // Choosing one locks new crop drags to the ratio and refits an existing
// crop rect (centred, clamped to the canvas). Wired after the controls // crop rect (centred, clamped to the canvas). Wired after the controls
@@ -11931,7 +12147,13 @@ function _buildEditor(container) {
const flat = _aiEditSourceCanvas(); const flat = _aiEditSourceCanvas();
const image = flat.toDataURL('image/png').split(',')[1]; const image = flat.toDataURL('image/png').split(',')[1];
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
_startAiProgress(requestId); const qeModel = _getSelectedQuickEditModel();
const endpointId = payload._endpoint || qeModel.endpoint || '';
if (endpointId) {
_startAiProgress(requestId, endpointId);
} else {
_startAiProgress(requestId);
}
const body = JSON.stringify({ image, request_id: requestId, ...payload }); const body = JSON.stringify({ image, request_id: requestId, ...payload });
// The image servers occasionally reject a request transiently (503 while a // The image servers occasionally reject a request transiently (503 while a
// model swaps, 502/504 gateways, dropped connections). Retry those a couple // model swaps, 502/504 gateways, dropped connections). Retry those a couple
@@ -13497,6 +13719,11 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled'); state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
state.editorSessionToken += 1; state.editorSessionToken += 1;
state.editorOpen = true; state.editorOpen = true;
state.documentRevision = 0;
state.persistingRevision = 0;
state.savedRevision = 0;
state.persistRetryCount = 0;
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
_writeActiveEditorSession(); _writeActiveEditorSession();
state.layers = []; state.layers = [];
state.selectedLayerIds = []; state.selectedLayerIds = [];
@@ -13547,6 +13774,8 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
_stopSelectionAnimation(); _stopSelectionAnimation();
window.__galleryEditLive = true; window.__galleryEditLive = true;
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; } if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
state.persistDirty = false; state.persistDirty = false;
state.persistErrorMessage = null; state.persistErrorMessage = null;
state.persistInFlight = null; state.persistInFlight = null;
@@ -13793,6 +14022,8 @@ export function closeEditor(options = {}) {
// the editor mid-stroke doesn't lose work. The call is fire-and-forget; // the editor mid-stroke doesn't lose work. The call is fire-and-forget;
// the server commit lands shortly after the modal hides. // the server commit lands shortly after the modal hides.
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; } if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
if (state.layers.length) { if (state.layers.length) {
try { _persistDraft(); } catch {} try { _persistDraft(); } catch {}
} }
@@ -13831,6 +14062,7 @@ export function closeEditor(options = {}) {
try { document.removeEventListener('click', h); } catch {} try { document.removeEventListener('click', h); } catch {}
} }
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; } if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
// Tear down all floating popups + the dock so closing the editor // Tear down all floating popups + the dock so closing the editor
// doesn't leave stale chips/panels behind on top of the gallery. // doesn't leave stale chips/panels behind on top of the gallery.
try { _closeFxMenu(); } catch {} try { _closeFxMenu(); } catch {}
+1 -1
View File
@@ -1,4 +1,4 @@
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop'; export const TOOL_WINDOW_SELECTOR = 'body > .modal:not(#styled-confirm-overlay), body > .research-overlay, body > .notes-pane-backdrop';
export function topToolWindowZ(options = {}) { export function topToolWindowZ(options = {}) {
const { const {
+3 -1
View File
@@ -8,7 +8,7 @@ import themeModule from './theme.js?v=20260911organsrain1';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js'; import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js'; import { nextToolWindowZ, topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
let toastEl = null; let toastEl = null;
let autoScrollEnabled = true; let autoScrollEnabled = true;
@@ -793,10 +793,12 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
const _prevFocus = document.activeElement; const _prevFocus = document.activeElement;
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
overlay.style.display = ''; overlay.style.display = '';
overlay.style.zIndex = String(topPortalZ() + 1000);
function cleanup(result) { function cleanup(result) {
overlay.classList.add('hidden'); overlay.classList.add('hidden');
overlay.style.display = 'none'; overlay.style.display = 'none';
overlay.style.zIndex = '';
okBtn.removeEventListener('click', onOk); okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel); cancelBtn.removeEventListener('click', onCancel);
altBtn.removeEventListener('click', onAlt); altBtn.removeEventListener('click', onAlt);
+23 -3
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh. // - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached. // - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes. // Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache'; const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them // KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting. // gives offline math fallback glyphs instead of proper typesetting.
@@ -74,6 +74,8 @@ const PRECACHE = [
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1', '/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
'/static/js/voiceRecorder.js', '/static/js/voiceRecorder.js',
'/static/js/actionMenuOrder.js', '/static/js/actionMenuOrder.js',
'/static/js/platform.js',
'/static/js/modelSort.js',
'/static/js/models.js', '/static/js/models.js',
'/static/js/rag.js', '/static/js/rag.js',
'/static/js/presets.js?v=20260908personaname1', '/static/js/presets.js?v=20260908personaname1',
@@ -364,11 +366,29 @@ self.addEventListener('activate', (e) => {
); );
}); });
// Offline fallback for /static/ JS/CSS. /static is a public, unauthenticated
// mount whose bytes do not depend on the request, but CORS adds `Vary: Origin`
// and module imports send an Origin header that install-time fetches omit, so a
// strict match hides the precached copy. Ignore Vary only for entries in this
// worker's own cache whose sole Vary header is Origin.
async function cachedStaticFile(request) {
const cache = await caches.open(CACHE_NAME);
const exact = await cache.match(request);
if (exact) return exact;
const variesOnlyOnOrigin = response => (response.headers.get('Vary') || '')
.split(',').map(name => name.trim().toLowerCase()).filter(Boolean)
.every(name => name === 'origin');
const candidates = await cache.matchAll(request, { ignoreVary: true });
return candidates.find(variesOnlyOnOrigin) || Response.error();
}
self.addEventListener('fetch', (e) => { self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url); const url = new URL(e.request.url);
// Never touch API calls or non-GET. // Never touch API calls or non-GET.
if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return; if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;
// Only this origin's own files are cached; anything else goes to the network.
if (url.origin !== self.location.origin) return;
// HTML navigation: network-first app shell — but ONLY for the SPA root. // HTML navigation: network-first app shell — but ONLY for the SPA root.
// Other navigations (e.g. a deep-linked /static/*.html page) must go to the // Other navigations (e.g. a deep-linked /static/*.html page) must go to the
@@ -394,10 +414,10 @@ self.addEventListener('fetch', (e) => {
fetch(e.request).then(res => { fetch(e.request).then(res => {
if (res && res.ok) { if (res && res.ok) {
const copy = res.clone(); const copy = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy)); caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy)).catch(() => {});
} }
return res; return res;
}).catch(() => caches.match(e.request)) }).catch(() => cachedStaticFile(e.request))
); );
return; return;
} }
+7 -2
View File
@@ -1,7 +1,7 @@
# Browser End-to-End Tests # Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled The photo editor release gate launches an isolated Odysseus server on port
Odysseus server on port `7013` with a temporary SQLite database. `7013` with a temporary SQLite database and disposable authenticated account.
```bash ```bash
npm install npm install
@@ -13,3 +13,8 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable `.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout. when using a different environment layout.
The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js'); const { chooseFilterAction, selectTool } = require('./helpers.js');
const { const {
dragOnCanvas, dragOnCanvas,
encodedImagePixelDigest, encodedImagePixelDigest,
@@ -11,7 +11,7 @@ const {
} = require('./helpers.js'); } = require('./helpers.js');
async function addAdjustment(page, type) { async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-fx').click();
const menu = page.locator('.ge-add-layer-menu'); const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible(); await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click(); await menu.locator(`[data-adjustment-type="${type}"]`).click();
@@ -252,8 +252,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 }); await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page); const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'effect-blur-gaussian');
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14'); await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click(); await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -282,8 +281,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => { test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E'); await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'effect-preset-crisp-detail');
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35'); expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75'); await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
@@ -308,16 +306,14 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140); await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up(); await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'effect-color-overlay');
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699'); await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35'); await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click(); await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click(); await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'effect-drop-shadow');
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18'); await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click(); await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -348,8 +344,7 @@ test('retained Stroke uses layer alpha and persists its controls', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E'); await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await selectTool(page, 'brush'); await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 }); await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'effect-stroke');
await page.locator('[data-filter-action="effect-stroke"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9'); await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click(); await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -1,5 +1,9 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
// These tests borrow /login as a lightweight page. A signed-in session would
// make the login page redirect to the app mid-test, so run them signed out.
test.use({ storageState: { cookies: [], origins: [] } });
async function addAppStyles(page) { async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs'); const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) { for (const url of await stylesheetUrls()) {
+11 -3
View File
@@ -54,7 +54,8 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 }); await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up(); await page.mouse.up();
expect(Date.now() - startedAt).toBeLessThan(5000); const strokeDuration = Date.now() - startedAt;
expect(strokeDuration).toBeLessThan(5000);
const after = await flattenedPixelDigest(page); const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before); expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68'); await expect(thumb).toHaveAttribute('width', '68');
@@ -78,14 +79,17 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
await selectTool(page, 'eyedropper'); await selectTool(page, 'eyedropper');
await expect(page.locator('#ge-eyedropper-section')).toBeVisible(); await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite'); await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y); // The options strip height differs per tool, which re-centres the canvas a
// few pixels; hover the same image point that was painted.
const painted = await canvasPoint(page, 0.3, 0.3);
await page.mouse.move(painted.x, painted.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55'); await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85'); await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%'); await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/); await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible(); await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255); expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y); await page.mouse.click(painted.x, painted.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55'); await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) { for (const tool of ['dodge', 'burn']) {
@@ -178,7 +182,11 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data]; return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
}); });
await selectTool(page, 'smudge'); await selectTool(page, 'smudge');
// Smudge strength lives in the brush-family "More" menu with the other
// per-tool stroke modifiers.
await page.locator('#ge-brush-more').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible(); await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.keyboard.press('Escape');
await page.mouse.move(source.x, source.y); await page.mouse.move(source.x, source.y);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 }); await page.mouse.move(target.x, target.y, { steps: 20 });
@@ -1,6 +1,6 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js'); const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js'); const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => { test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation'); await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
@@ -75,13 +75,15 @@ test('touch crop and selection gestures complete through the shared lifecycle',
try { try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures'); await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page); const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', { const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type, type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }], touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
}); });
await selectTool(page, 'crop'); await selectTool(page, 'crop');
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
// aim at where the canvas settles, as a finger would.
const canvasBox = await settledCanvasBox(page);
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30); await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145); await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145); await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
@@ -113,18 +115,24 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
try { try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush'); await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page); const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', { const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type, type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }], touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
}); });
await selectTool(page, 'brush'); await selectTool(page, 'brush');
// Re-tapping the active tool collapses its controls sheet (canvas-first
// phone editing), which also uncovers the Layers sheet grab handle.
await selectTool(page, 'brush');
const canvasBox = await settledCanvasBox(page);
const before = await flattenedPixelDigest(page); const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5); await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5); await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5); await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page); const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before); expect(after).not.toEqual(before);
// History buttons live in the Layers sheet, which the tool pick minimized.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click(); await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before); expect(await flattenedPixelDigest(page)).toEqual(before);
} finally { } finally {
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
} }
async function addAdjustment(page, type) { async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-fx').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible(); await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click(); await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible(); await expect(page.locator('.ge-adj-popup')).toBeVisible();
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity'); await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page); await seedGradient(page);
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click(); await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35'); await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input'); await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
+3 -5
View File
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js'); const { chooseFilterAction, selectTool } = require('./helpers.js');
const fs = require('node:fs'); const fs = require('node:fs');
const { const {
compareExportPixels, compareExportPixels,
@@ -38,8 +38,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
const groupId = (await editorState(page)).groups[0].id; const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`); const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click(); await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14'); await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click(); await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -84,8 +83,7 @@ test('group retained effects export the visible composite and survive reopen', a
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] }); await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click(); await page.locator('#ge-group-selected').click();
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click(); await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699'); await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40'); await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
+1 -2
View File
@@ -10,7 +10,6 @@ test('group drag reorders the complete subtree with undo and server persistence'
const originalOrder = current.layers.map(layer => layer.id); const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page); current = await editorState(page);
const looseId = current.activeLayerId; const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]); expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
@@ -66,7 +65,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
const page = await context.newPage(); const page = await context.newPage();
try { try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder'); await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click(); await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/); await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page); const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom. // State is bottom-to-top; the panel is top-to-bottom.
+60 -37
View File
@@ -1,40 +1,22 @@
const { expect } = require('@playwright/test'); const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') { async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null; await page.goto('/', { waitUntil: 'domcontentloaded' });
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) { // Workflow tests exercise the canvas, not the optional setup overlay.
try { await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 }); await page.evaluate(async () => {
await page.evaluate(async () => { window.__photoEditorImport = path => import(path);
window.__photoEditorImport = async path => { const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
let lastImportError = null; gallery.openGallery();
for (let attempt = 0; attempt < 4; attempt += 1) { });
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`; await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
try { return await import(url); } catch (error) { lastImportError = error; } await page.locator('#gallery-editor-tab').click();
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1))); await page.evaluate(async ({ size: nextSize, name: nextName }) => {
} const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
throw lastImportError; editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}; }, { size, name });
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4'); await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await expect.poll(async () => page.evaluate(async () => { await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js'); const { state } = await import('/static/js/editor/state.js');
return state.layers.length; return state.layers.length;
@@ -368,12 +350,51 @@ async function selectTool(page, toolId) {
await face.first().click(); await face.first().click();
return; return;
} }
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a // Open the flyout via its corner triangle, wait for item visibility, and click realistically
// click directly so the helper does not depend on hover/flyout visibility. const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click'); const tri = slot.locator('.ge-tool-flyout-tri');
if (await tri.count() > 0) {
await tri.click();
}
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
await expect(item).toBeVisible({ timeout: 3000 });
await item.click();
}
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
// Wait for the canvas to stop moving before aiming at it.
async function settledCanvasBox(page) {
let box = null;
await expect.poll(async () => {
const previous = box;
box = await page.locator('.ge-main-canvas').boundingBox();
return JSON.stringify(previous) === JSON.stringify(box);
}).toBe(true);
return box;
}
// Rare text/shape options live in the options strip's "More" popover, which
// closes on any outside press. Open it (idempotently) before using them.
async function openOptionsMore(page, kind) {
const menu = page.locator(`#ge-${kind}-more`);
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
await expect(menu).toBeVisible();
}
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
// Open the menu, then the item's parent row, as a user would.
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
await page.locator(opener).click();
const menu = page.locator('#ge-filter-menu');
await expect(menu).toBeVisible();
const item = menu.locator(`[data-filter-action="${action}"]`);
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
await item.click();
} }
module.exports = { module.exports = {
chooseFilterAction,
compareExportPixels, compareExportPixels,
dragOnCanvas, dragOnCanvas,
encodedImagePixelDigest, encodedImagePixelDigest,
@@ -381,7 +402,9 @@ module.exports = {
flattenedPixelDigest, flattenedPixelDigest,
openBlankEditor, openBlankEditor,
openExportDialog, openExportDialog,
openOptionsMore,
reopenDraft, reopenDraft,
selectTool, selectTool,
settledCanvasBox,
waitForDraft, waitForDraft,
}; };
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers'); const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// The isolated editor server has no logged-in notification owner. // The isolated editor server has no logged-in notification owner.
@@ -35,6 +35,96 @@ test('top toolbar stays on one row and its menus remain clickable at narrow widt
} }
}); });
test('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
// Autosave can settle within a frame, so record the rendered icon at every
// status change instead of racing it with polling assertions.
await page.evaluate(() => {
const status = document.getElementById('ge-draft-status');
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
.filter(icon => icon.getBoundingClientRect().width > 0)
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
window.__saveStates = [];
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
.observe(status, { childList: true, characterData: true, subtree: true });
});
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
await waitForDraft(page);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
const states = await page.evaluate(() => window.__saveStates);
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
for (const entry of states) {
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
}
});
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
const cases = [
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
];
for (const c of cases) {
await page.evaluate(({ label, state }) => {
window.__geSetDraftStatus(label, state);
}, c);
const statusEl = page.locator('#ge-draft-status');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(statusEl).toHaveText(c.expectedText);
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
const visibleIcon = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
const visible = icons.filter(i => !i.hasAttribute('hidden'));
return visible.map(i => i.className.baseVal || i.className);
});
expect(visibleIcon.length).toBe(1);
expect(visibleIcon[0]).toContain(c.iconClass);
if (c.isError) {
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
} else {
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
}
}
});
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Database error' }),
}));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
const statusEl = page.locator('#ge-draft-status');
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
await expect(statusEl).toHaveAttribute('data-state', 'error');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
const visibleIcons = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
});
expect(visibleIcons).toHaveLength(1);
expect(visibleIcons[0]).toContain('ge-save-state-error');
});
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => { test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }); await openBlankEditor(page, { width: 320, height: 240 });
const activeId = (await editorState(page)).activeLayerId; const activeId = (await editorState(page)).activeLayerId;
@@ -55,8 +145,7 @@ test('filter Escape restores pixels and preserves redo history', async ({ page }
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 }); await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
await page.keyboard.press('Control+z'); await page.keyboard.press('Control+z');
const before = await editorState(page); const before = await editorState(page);
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'blur-gaussian');
await page.locator('[data-filter-action="blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await expect(page.locator('.ge-filter-modal')).toHaveCount(0); await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
@@ -75,7 +164,10 @@ test('focused fields own undo, selection and clipboard without changing layers',
await input.fill('Editable text'); await input.fill('Editable text');
await input.press('Control+Enter'); await input.press('Control+Enter');
// A tool's ordinary text input must also retain native clipboard ownership. // A tool's ordinary text input must also retain native clipboard ownership.
// Frame width lives in the text options' collapsed "More" section.
await page.locator('#ge-text-more-toggle').click();
await page.locator('#ge-text-frame-width').focus(); await page.locator('#ge-text-frame-width').focus();
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
const before = await editorState(page); const before = await editorState(page);
await page.keyboard.press('Control+z'); await page.keyboard.press('Control+z');
await page.keyboard.press('Control+j'); await page.keyboard.press('Control+j');
@@ -247,3 +339,76 @@ test('switching away from a held brush ends one stroke and desktop reselect keep
await page.keyboard.press('Control+z'); await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers); expect((await editorState(page)).layers).toEqual(before.layers);
}); });
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
let delayedRouteResolve = null;
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
let saveCallCount = 0;
await page.route(/\/api\/editor-drafts/, async route => {
saveCallCount += 1;
if (saveCallCount === 1) {
await delayedRoutePromise;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
});
});
// Edit 1
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
// Wait until save is in flight
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
// Edit 2 while save 1 is in flight
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
const revsDuringFlight = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return {
docRev: state.documentRevision,
persistingRev: state.persistingRevision,
dirty: state.persistDirty,
};
});
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
expect(revsDuringFlight.dirty).toBe(true);
// Now let save 1 complete
delayedRouteResolve();
// Wait for the chained second save to complete
await expect.poll(async () => {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.savedRevision >= state.documentRevision && !state.persistDirty;
});
}, { timeout: 15_000 }).toBe(true);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
});
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
const dirtyAfterFailure = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.persistDirty;
});
expect(dirtyAfterFailure).toBe(true);
});
@@ -3,8 +3,6 @@ const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js'); const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => { test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
// Notification polling requires login even in the auth-disabled test server.
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection'); await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
const id = await page.evaluate(async () => { const id = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js'); const { state } = await import('/static/js/editor/state.js');
@@ -32,7 +30,6 @@ test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden
test('selected layers align to the canvas and undo as one operation', async ({ page }) => { test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E'); await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
await page.evaluate(async () => { await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js'); const { state } = await import('/static/js/editor/state.js');
const edit = state.layers.find(layer => layer.name === 'Edit'); const edit = state.layers.find(layer => layer.name === 'Edit');
@@ -111,7 +108,6 @@ test('group rows can be selected with the keyboard', async ({ page }) => {
test('Delete removes the selected layer and undo restores it', async ({ page }) => { test('Delete removes the selected layer and undo restores it', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E'); await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId; const addedId = (await editorState(page)).activeLayerId;
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click(); await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
await page.keyboard.press('Backspace'); await page.keyboard.press('Backspace');
@@ -193,7 +189,6 @@ test('multi-selected layers move together and support history-backed bulk action
await editRow.click(); await editRow.click();
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId; const addedId = (await editorState(page)).activeLayerId;
await editRow.click({ modifiers: ['Control'] }); await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2); await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
@@ -278,7 +273,6 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
const innerId = current.groups[0].id; const innerId = current.groups[0].id;
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page); current = await editorState(page);
const looseId = current.activeLayerId; const looseId = current.activeLayerId;
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click(); await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js'); const { chooseFilterAction, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) { async function seedVisibleLayers(page) {
await page.evaluate(async () => { await page.evaluate(async () => {
@@ -18,14 +18,13 @@ test('Merge all preserves retained effects and adjustment output', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E'); await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
await seedVisibleLayers(page); await seedVisibleLayers(page);
await page.locator('#ge-filter-menu-btn').click(); await chooseFilterAction(page, 'effect-color-overlay');
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible(); await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a'); await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35'); await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click(); await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('#ge-add-layer').click(); await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click(); await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible(); await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45'); await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const canvas = page.locator('.ge-main-canvas');
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
const manualZoom = await zoom();
await page.locator('.ge-layers-title').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(zoom).toBe(manualZoom);
});
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
await openBlankEditor(page, { width: 320, height: 240 });
const state = () => page.evaluate(async () => {
const { state: editor } = await import('/static/js/editor/state.js');
return { zoom: editor.zoom, mode: editor.zoomViewMode };
});
const fitted = await state();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const cdp = await context.newCDPSession(page);
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
for (const spread of [30, 40, 50]) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const pinched = await state();
expect(pinched.mode).toBe('scale');
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
});
@@ -121,6 +121,10 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
const painted = await editorState(page); const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length); const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before); expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
// History controls live in the layers header, which is hidden while the
// sheet is minimized for painting. Reopen it as a phone user would.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click(); await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash) expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before); .toBe(before);
@@ -207,6 +211,8 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
expect(movedLayer.offset).toEqual(beforeLayer.offset); expect(movedLayer.offset).toEqual(beforeLayer.offset);
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x); expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y); expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click(); await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset) expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset); .toEqual(beforeLayer.masks[0].offset);
@@ -0,0 +1,87 @@
const { test, expect } = require('@playwright/test');
const path = require('node:path');
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
serviceWorkers: 'allow',
});
const page = await context.newPage();
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
try {
// The app's worker intentionally controls /static/, not the SPA root.
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
await previous.put(pickerUrl, new Response('stale picker'));
await previous.put(cssUrl, new Response('stale css'));
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
}
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
}, { pickerUrl, cssUrl });
expect(installed).toEqual({ scope: '/static/', root: false });
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
}
// Simulate stale entries in the active version, then verify network-first
// refresh updates both JS and CSS before testing the offline fallback.
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(pickerUrl, new Response('stale picker'));
await cache.put(cssUrl, new Response('stale css'));
for (const url of [pickerUrl, cssUrl]) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
}
}, { currentCache, pickerUrl, cssUrl });
for (const url of [pickerUrl, cssUrl]) {
await expect.poll(() => page.evaluate(async value => {
const response = await caches.match(value);
return response ? !(await response.text()).startsWith('stale ') : false;
}, url)).toBe(true);
}
// Origin is the only Vary header the offline fallback may ignore. An entry
// that varies on anything else (as a per-user response would) must miss.
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
);
}, { currentCache, varyProbeUrl });
await context.setOffline(true);
for (let attempt = 0; attempt < 2; attempt += 1) {
const offline = await context.newPage();
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
const editor = await import(editorUrl);
const picker = await import(pickerUrl);
const css = await fetch(cssUrl);
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
return {
editor: typeof editor.openEditor,
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
cssOk: css.ok,
privateProbe,
};
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
await offline.close();
}
} finally { await context.close(); }
});
@@ -0,0 +1,95 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
const attack = '<img src=x onerror="window.__panelXss=true">';
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.name = attack;
layer.kind = 'shape';
layer.shape = { type: attack };
const colors = createColorPanels({ state });
const studio = createStudioPanels({ state });
document.body.append(colors.channels, studio.paths);
colors.refresh();
studio.refresh();
return {
attack,
channel: colors.channels.querySelector('.ge-channel-name').textContent,
path: studio.paths.querySelector('.ge-channel-name').textContent,
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
executed: !!window.__panelXss,
};
});
expect(result.channel).toBe(result.attack);
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
expect(result.images).toBe(0);
expect(result.executed).toBe(false);
});
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '<img src=x onerror="window.__effectXss=true">';
const id = await page.evaluate(async value => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
return layer.id;
}, attack);
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
const list = page.locator('#ge-layers-list');
await expect(list).toContainText('Gaussian Blur');
await expect(list).not.toContainText(attack);
await expect(list.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
});
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
const attack = '<img src=x onerror="window.__brushXss=true">';
await page.addInitScript(value => {
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
]));
}, attack);
await openBlankEditor(page, { width: 64, height: 64 });
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
await expect(preset.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
});
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '"><img src=x onerror="window.__adjXss=true">';
const cases = [
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
];
for (const item of cases) {
await page.locator('#ge-add-fx').click();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
// Project files and drafts reach the editor through the codec's
// normalizer, which validates the type but not every parameter.
await page.evaluate(async ({ type, params }) => {
const { state } = await import('/static/js/editor/state.js');
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
layer.adjustment = normalizeAdjustmentData({ type, params });
}, item);
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
const popup = page.locator('.ge-adj-popup');
await expect(popup).toBeVisible();
await expect(popup.locator('img')).toHaveCount(0);
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
await popup.locator('[data-adj-action="cancel"]').click();
}
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
});
@@ -38,3 +38,45 @@ test('mobile footer docks flush between the canvas and the layers sheet', async
expect(g.footerLeft).toBeLessThanOrEqual(1); expect(g.footerLeft).toBeLessThanOrEqual(1);
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1); expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
}); });
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const footer = page.locator('.ge-editor-footer');
await expect(footer).toBeVisible();
const setupBtn = page.locator('#ge-setup-btn');
const aiBtn = page.locator('#ge-ai-settings-btn');
const zoomIn = page.locator('#ge-zoom-in');
const zoomOut = page.locator('#ge-zoom-out');
const zoomFit = page.locator('#ge-zoom-100');
await expect(setupBtn).toBeVisible();
await expect(aiBtn).toBeVisible();
await expect(zoomIn).toBeVisible();
await expect(zoomOut).toBeVisible();
await expect(zoomFit).toBeVisible();
// Test zoom controls via normal clicks
const zoomLabel = page.locator('.ge-zoom-label');
const initialZoom = await zoomLabel.textContent();
await zoomIn.click();
await expect(zoomLabel).not.toHaveText(initialZoom);
await zoomFit.click();
await expect(zoomLabel).toHaveText(/%/);
// Test Setup modal accessibility via normal click
await setupBtn.click();
const preflight = page.locator('.ge-preflight');
await expect(preflight).toBeVisible();
await page.locator('.ge-preflight-start').click();
await expect(preflight).toBeHidden();
// Test AI Settings modal accessibility via normal click
await aiBtn.click();
const aiSettings = page.locator('.ge-ai-settings');
await expect(aiSettings).toBeVisible();
await page.locator('.ge-ai-settings-done').click();
await expect(aiSettings).toBeHidden();
});
@@ -0,0 +1,17 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
await openBlankEditor(page);
await page.locator('#ge-setup-btn').click();
const checklist = page.locator('.ge-preflight');
const start = checklist.locator('[data-preflight-start]');
await expect(checklist).toBeVisible();
await start.scrollIntoViewIfNeeded();
await expect(start).toHaveCSS('white-space', 'nowrap');
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
await start.click();
await expect(checklist).toBeHidden();
});
@@ -1,8 +1,7 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js'); const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
const { openBlankEditor, editorState } = require('./helpers.js');
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => { test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
await openBlankEditor(page); await openBlankEditor(page);
await selectTool(page, 'text'); await selectTool(page, 'text');
const canvas = await page.locator('.ge-main-canvas').boundingBox(); const canvas = await page.locator('.ge-main-canvas').boundingBox();
@@ -11,19 +10,39 @@ test('paint tools offer rasterization, cancel preserves text, Enter accepts and
await text.fill('Keep editable'); await text.fill('Keep editable');
await text.press('Control+Enter'); await text.press('Control+Enter');
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text'); const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
expect(retained).toBeDefined();
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
// Tool selection alone does not prompt and does not rasterize
for (const tool of ['brush', 'eraser']) {
await selectTool(page, tool);
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect(await kind()).toBe('text');
}
// Attempting to draw triggers confirmation
await selectTool(page, 'brush'); await selectTool(page, 'brush');
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize'); await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await page.keyboard.press('Escape'); await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
// Cancelling keeps text editable with no changes
await page.locator('#styled-confirm-cancel').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden(); await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text'); expect(await kind()).toBe('text');
await selectTool(page, 'brush');
await expect(page.locator('#styled-confirm-ok')).toBeFocused(); // Second stroke prompt confirmed -> rasterizes layer
await page.keyboard.press('Enter'); await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
await page.locator('#styled-confirm-ok').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden(); await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster'); expect(await kind()).toBe('raster');
// Subsequent strokes on the raster layer draw immediately without prompting
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
// Undo restores editable text
await page.keyboard.press('Control+z'); await page.keyboard.press('Control+z');
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text'); await page.keyboard.press('Control+z');
await selectTool(page, 'eraser'); expect(await kind()).toBe('text');
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
await page.keyboard.press('Escape');
}); });
@@ -106,12 +106,15 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
await page.waitForTimeout(250); await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click(); await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click(); await page.locator('[data-selection-action="transform"]').click();
await expect(page.locator('#ge-transform-section')).toBeVisible();
const mobileSection = await page.locator('#ge-transform-section').boundingBox(); const mobileSection = await page.locator('#ge-transform-section').boundingBox();
expect(mobileSection.x).toBeGreaterThanOrEqual(0); expect(mobileSection.x).toBeGreaterThanOrEqual(0);
expect(mobileSection.y).toBeGreaterThanOrEqual(0); expect(mobileSection.y).toBeGreaterThanOrEqual(0);
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390); expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844); expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
await page.locator('#ge-transform-cancel-btn').click(); await page.locator('#ge-transform-cancel-btn').click();
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
}); });
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => { test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
@@ -0,0 +1,46 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
// Editors opened wider than 700px move the tool controls into the options
// strip. At tablet widths (701-820px) that strip must stay visible, or every
// tool control is stranded in a hidden container.
for (const width of [768, 820]) {
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
await page.setViewportSize({ width, height: 1024 });
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
const inViewport = locator => locator.evaluate(el => {
const box = el.getBoundingClientRect();
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
});
await selectTool(page, 'brush');
await expect(page.locator('#ge-options-bar')).toBeVisible();
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('200');
await page.locator('#ge-transform-w').dispatchEvent('input');
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
await page.locator('#ge-transform-cancel-btn').click();
});
}
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
// Desktop -> Phone transition
await page.setViewportSize({ width: 1024, height: 768 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
// Resize across 700px to phone
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
// Resize back to desktop
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
});
+13 -1
View File
@@ -1,10 +1,11 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js'); const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js'); const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => { test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page); await openBlankEditor(page);
await selectTool(page, 'text'); await selectTool(page, 'text');
await openOptionsMore(page, 'text');
await page.locator('#ge-text-frame-width').fill('260'); await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox(); const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2); await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
@@ -14,11 +15,13 @@ test('text edits directly on canvas and remains retained after reopen', async ({
await editor.press('Control+Enter'); await editor.press('Control+Enter');
await expect(editor).toHaveCount(0); await expect(editor).toHaveCount(0);
await openOptionsMore(page, 'text');
await page.locator('#ge-text-letter-spacing').fill('3.5'); await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4'); await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia'); await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click(); await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked(); await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-width')).toBeDisabled(); await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180'); await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom'); await page.locator('#ge-text-vertical-align').selectOption('bottom');
@@ -47,8 +50,13 @@ test('text paragraph controls remain usable in a narrow phone viewport', async (
const section = page.locator('#ge-text-section'); const section = page.locator('#ge-text-section');
await expect(section).toBeVisible(); await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true); expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-height')).toBeVisible(); await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible(); await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
expect(await page.locator('#ge-text-more').evaluate(el => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth + 1;
})).toBe(true);
}); });
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => { test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
@@ -71,6 +79,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
let current = await editorState(page); let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape'); const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types); expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-sides').fill('7'); await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6'); await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14'); await page.locator('#ge-shape-radius').fill('14');
@@ -96,6 +105,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
test('gradient shape fill remains editable after reopening the project', async ({ page }) => { test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page); await openBlankEditor(page);
await selectTool(page, 'shape'); await selectTool(page, 'shape');
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden(); await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 }); await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => { await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
@@ -110,6 +120,7 @@ test('gradient shape fill remains editable after reopening the project', async (
angle.value = '35'; angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true })); angle.dispatchEvent(new Event('input', { bubbles: true }));
}); });
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible(); await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page); const before = await editorState(page);
@@ -143,6 +154,7 @@ test('shape gradients retain added stops through editing and reopen', async ({ p
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E'); await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await selectTool(page, 'shape'); await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 }); await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient'); await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => { await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) { for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
+50 -3
View File
@@ -223,15 +223,23 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
const handlePoint = id => page.evaluate(async handleId => { const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js'); const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js'); const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({ const frame = {
centerX: state.transformCenter.x, centerX: state.transformCenter.x,
centerY: state.transformCenter.y, centerY: state.transformCenter.y,
width: state.transformPendingW, width: state.transformPendingW,
height: state.transformPendingH, height: state.transformPendingH,
rotation: state.transformPendingRot, rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' }); };
const handle = geometry.handles.find(item => item.id === handleId);
const rect = state.mainCanvas.getBoundingClientRect(); const rect = state.mainCanvas.getBoundingClientRect();
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
const x = rect.left + outside.x * rect.width / state.mainCanvas.width;
const y = rect.top + outside.y * rect.height / state.mainCanvas.height;
const area = state.container.querySelector('.ge-canvas-area').getBoundingClientRect();
const popup = state.transformPopup?.getBoundingClientRect();
const rotationInside = y < area.top + 6 || x < area.left + 6 || x > area.right - 6 ||
!!(popup && x >= popup.left && x <= popup.right && y >= popup.top && y <= popup.bottom);
const geometry = transformFrameGeometry(frame, { zoom: state.zoom, rotationInside });
const handle = geometry.handles.find(item => item.id === handleId);
return { return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width, x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height, y: rect.top + handle.y * rect.height / state.mainCanvas.height,
@@ -366,3 +374,42 @@ test('transform handle keeps tracking when the pointer crosses the right panel',
}); });
expect(after).toBeGreaterThan(setup.width); expect(after).toBeGreaterThan(setup.width);
}); });
test('phone transform overlay follows the canvas through refits and zoom', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Phone transform overlay');
const fitBefore = await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const alignment = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const overlay = state.transformOverlay.getBoundingClientRect();
const canvas = state.mainCanvas.getBoundingClientRect();
return {
zoom: state.zoom,
overlayScale: overlay.width / state.transformOverlay.width,
covers: overlay.left <= canvas.left + 1 && overlay.top <= canvas.top + 1
&& overlay.right >= canvas.right - 1 && overlay.bottom >= canvas.bottom - 1,
};
});
// Selecting Transform collapses the Layers sheet, which refits the canvas.
await expect.poll(async () => (await alignment()).zoom).not.toBe(fitBefore);
let current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
expect(current.covers).toBe(true);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(async () => (await alignment()).zoom).toBeGreaterThan(current.zoom);
current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
} finally {
await context.close();
}
});
+15 -4
View File
@@ -10,10 +10,12 @@ const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe') ? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python'); : path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python'); const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-')); || fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db'); || path.join(dataDirectory, 'app.db');
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath; process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory; process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
@@ -22,6 +24,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`); throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
} }
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({ module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'), testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'), outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
@@ -29,18 +34,24 @@ module.exports = defineConfig({
expect: { timeout: 10_000 }, expect: { timeout: 10_000 },
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line', reporter: isCI ? 'line' : 'line',
globalSetup: require.resolve('./setup.js'), globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'), globalTeardown: require.resolve('./teardown.js'),
use: { use: {
baseURL, baseURL,
browserName, browserName,
headless: true, headless: true,
launchOptions: {
args: ['--disable-dev-shm-usage', '--no-sandbox'],
},
viewport: { width: 1440, height: 960 }, viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' }, extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block', serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
trace: 'retain-on-failure', // Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: traceOptIn ? 'retain-on-failure' : 'off',
}, },
webServer: { webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`, command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -50,7 +61,7 @@ module.exports = defineConfig({
reuseExistingServer: false, reuseExistingServer: false,
env: { env: {
...process.env, ...process.env,
AUTH_ENABLED: 'false', AUTH_ENABLED: authenticated ? 'true' : 'false',
DATABASE_URL: `sqlite:///${databasePath}`, DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory, ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0', ODYSSEUS_STARTUP_WARMUPS: '0',
+16
View File
@@ -1,4 +1,6 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path');
const { request } = require('@playwright/test');
module.exports = async () => { module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR; const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -7,4 +9,18 @@ module.exports = async () => {
throw new Error('Photo editor E2E data paths were not configured'); throw new Error('Photo editor E2E data paths were not configured');
} }
fs.mkdirSync(dataDirectory, { recursive: true }); fs.mkdirSync(dataDirectory, { recursive: true });
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
try {
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
const setup = await client.post('/api/auth/setup', { data: credentials });
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
const login = await client.post('/api/auth/login', { data: credentials });
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
} finally {
await client.dispose();
}
}
}; };
+81 -2
View File
@@ -1,7 +1,86 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path');
const { execSync } = require('node:child_process');
module.exports = async () => { module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR; const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return; if (dataDirectory) {
fs.rmSync(dataDirectory, { recursive: true, force: true }); try {
fs.rmSync(dataDirectory, { recursive: true, force: true });
} catch (_) {}
}
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
// When traces are not opted-in, purge all archives unconditionally.
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
const resultsDir = path.join(__dirname, '..', '..', 'test-results');
const reportDir = path.join(__dirname, '..', '..', 'playwright-report');
if (fs.existsSync(reportDir)) {
try {
fs.rmSync(reportDir, { recursive: true, force: true });
} catch (_) {}
}
if (fs.existsSync(resultsDir)) {
const purgeSensitiveArtifacts = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeSensitiveArtifacts(fullPath);
} else if (entry.isFile() && (
entry.name.endsWith('.zip') ||
entry.name.endsWith('.tmp') ||
entry.name.endsWith('.png') ||
entry.name.endsWith('.har')
)) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const purgeZipAndTmp = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeZipAndTmp(fullPath);
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir);
} else {
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
const sanitizerScript = path.join(__dirname, '..', '..', 'scripts', 'sanitize_trace_artifacts.py');
let sanitizerSucceeded = false;
try {
execSync(`${JSON.stringify(python)} ${JSON.stringify(sanitizerScript)} ${JSON.stringify(resultsDir)}`, {
stdio: 'ignore',
timeout: 30_000,
});
sanitizerSucceeded = true;
} catch (_) {
sanitizerSucceeded = false;
}
if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives
purgeZipAndTmp(resultsDir);
}
}
}
}; };
+2 -2
View File
@@ -1043,8 +1043,8 @@ def test_editor_agent_model_override_is_first_candidate(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"]) monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"])
monkeypatch.setattr(eam, "local_model", lambda: "LMODEL") monkeypatch.setattr(eam, "local_model", lambda: "LMODEL")
cands = eam._candidates(None, "http://ovr::OVERRIDE") cands = eam._candidates(None, "http://local1::OVERRIDE")
assert cands[0] == {"base": "http://ovr", "model": "OVERRIDE", "headers": None, assert cands[0] == {"base": "http://local1", "model": "OVERRIDE", "headers": None,
"source": "override"} "source": "override"}
assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands) assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands)
+23 -1
View File
@@ -1,4 +1,5 @@
import json import json
import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@@ -27,10 +28,22 @@ def test_action_names_and_unknown_steps_are_literal_text():
const panel = createActionsPanel(); const panel = createActionsPanel();
document.body.append(panel.element); document.body.append(panel.element);
panel.setSteps([attack]); panel.setSteps([attack]);
const input = panel.element.querySelector('.ge-actions-name');
input.value = attack + '<svg onload="window.__actionsXss=true">';
input.parentElement.querySelector('button').click();
const savedName = input.value;
const savedButton = Array.from(panel.element.querySelectorAll('.ge-actions-saved-row button'))
.find(button => button.textContent === savedName);
savedButton.click();
return { return {
images: panel.element.querySelectorAll('img').length, images: panel.element.querySelectorAll('img').length,
svgs: panel.element.querySelectorAll('svg').length,
saved: panel.element.querySelector('.ge-actions-saved-row button').textContent, saved: panel.element.querySelector('.ge-actions-saved-row button').textContent,
step: panel.element.querySelector('.ge-actions-step-label').textContent, step: panel.element.querySelector('.ge-actions-step-label').textContent,
option: Array.from(panel.element.querySelector('.ge-actions-batch-select').options)
.find(option => option.value === savedName)?.textContent,
persisted: JSON.parse(localStorage.getItem('ge-actions'))[savedName],
savedName,
executed: Boolean(window.__actionsXss), executed: Boolean(window.__actionsXss),
attack, attack,
}; };
@@ -39,12 +52,21 @@ def test_action_names_and_unknown_steps_are_literal_text():
} finally { await browser.close(); } } finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); }); })().catch(error => { console.error(error); process.exit(1); });
''' '''
env = os.environ.copy()
if 'PLAYWRIGHT_BROWSERS_PATH' not in env:
for candidate in [Path.home() / '.cache/ms-playwright', Path('/home/alteixeira20/.cache/ms-playwright')]:
if candidate.exists():
env['PLAYWRIGHT_BROWSERS_PATH'] = str(candidate)
break
result = subprocess.run( result = subprocess.run(
['node', '-e', script, str(ROOT / 'static/js/editor/actions-panel.js')], ['node', '-e', script, str(ROOT / 'static/js/editor/actions-panel.js')],
cwd=ROOT, capture_output=True, text=True, timeout=30, cwd=ROOT, capture_output=True, text=True, timeout=30, env=env,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
observed = json.loads(result.stdout) observed = json.loads(result.stdout)
assert observed['images'] == 0 assert observed['images'] == 0
assert observed['svgs'] == 0
assert observed['saved'] == observed['step'] == observed['attack'] assert observed['saved'] == observed['step'] == observed['attack']
assert observed['option'] == observed['savedName']
assert observed['persisted'] == [observed['attack']]
assert not observed['executed'] assert not observed['executed']
@@ -0,0 +1,43 @@
"""Every adjustment popup that renders slider rows must store their values.
``buildAdjBody`` renders ``sliderRow`` controls per adjustment type, and
``onAdjSliderInput`` maps a moved slider onto ``layer._stagedAdj.params``. A
type with rows but no input branch shows a working slider whose value is
silently dropped on OK (Vibrance regressed exactly this way). The browser
behavior is covered by tests/e2e/photo-editor/adjustment-layers.spec.js.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
POPUP = (ROOT / "static/js/editor/fx/adj-popup.js").read_text(encoding="utf-8")
def _function_body(name: str, next_name: str) -> str:
start = POPUP.index(f"function {name}(")
return POPUP[start:POPUP.index(f"function {next_name}(", start)]
def _types_with_slider_rows() -> set[str]:
builder = _function_body("buildAdjBody", "onAdjSliderInput")
branches = re.split(r"\}\s*else if \(|if \(type === ", builder)
types = set()
for branch in branches:
names = re.findall(r"type === '([a-z-]+)'", "type === " + branch.split(")", 1)[0])
if names and "sliderRow(" in branch:
types.update(names)
return types
def test_every_slider_adjustment_type_writes_its_params():
rendered = _types_with_slider_rows()
assert {"exposure", "white-balance", "vibrance", "black-white"} <= rendered, rendered
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
handled = set(re.findall(r"type === '([a-z-]+)'", handler))
missing = sorted(rendered - handled)
assert not missing, f"slider rows whose input is never stored: {missing}"
def test_vibrance_slider_stores_the_raw_value():
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
assert re.search(r"else if \(type === 'vibrance'\) \{\s*p\[key\] = raw;", handler)
+3 -14
View File
@@ -102,23 +102,12 @@ def test_bucket_tool_is_registered_and_wired():
assert "bucketTolerance: 32" in state assert "bucketTolerance: 32" in state
def test_bucket_rasterizes_a_pixel_layer_in_one_click(): def test_paint_tools_require_confirmation_before_rasterizing():
# A text/shape/placed layer used to bounce the user through a rasterize
# dialog that could then sit behind the canvas, so the bucket looked broken.
# Now the layer is rasterized silently (undoable) and filled in one click.
editor = (ROOT / "static/js/galleryEditor.js").read_text() editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "function _rasterizeLayerNow(layer) {" in editor assert "async function _confirmRasterizeLayer(layer) {" in editor
assert '_saveState(`Rasterize "${layer.name}"`);' in editor assert '_saveState(`Rasterize "${layer.name}"`);' in editor
assert "if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }" in editor
assert "if (!_pixelPaintTools.has(toolId)) void _offerRasterizeForTool(toolId);" in editor
def test_all_paint_tools_rasterize_silently_instead_of_prompting():
# Clone/heal/brush/etc. hit the same rasterize prompt on text/shape/placed
# layers, so every paint tool now rasterizes silently and continues.
editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "// fall through — `layer` is a pixel layer now, so the tool runs below." in editor
assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor
assert "void _confirmRasterizeLayer(layer);" in editor
def test_smudge_and_dodge_icons_are_distinct(): def test_smudge_and_dodge_icons_are_distinct():
+3 -2
View File
@@ -20,8 +20,9 @@ def test_editor_paste_owns_clipboard_images_while_editor_open():
""" """
src = SRC.read_text(encoding="utf-8") src = SRC.read_text(encoding="utf-8")
assert "wireClipboardAndDrop" in src assert "wireClipboardAndDrop" in src
# The bail-out must be scoped to targets inside the editor. # The bail-out must be scoped to targets inside the editor, and must not
assert "const insideEditor = target && container && container.contains(target);" in src # throw for a paste whose target is not a Node (e.g. dispatched at window).
assert "const insideEditor = target instanceof Node && !!container && container.contains(target);" in src
assert ( assert (
"if (insideEditor && (target.isContentEditable || " "if (insideEditor && (target.isContentEditable || "
"target.closest?.('input, textarea, select'))) return;" "target.closest?.('input, textarea, select'))) return;"
@@ -0,0 +1,29 @@
from pathlib import Path
import subprocess
def test_shared_picker_exports_editor_color_contract():
root = Path(__file__).resolve().parents[1]
script = r'''
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
const store = new Map();
globalThis.localStorage = { getItem: key => store.get(key), setItem: (key, value) => store.set(key, value) };
globalThis.HTMLInputElement = class {};
const picker = await import(pathToFileURL(process.argv[1]));
store.set('odysseus-recent-colors', JSON.stringify(['<img src=x onerror=alert(1)>', 123, '#ABCDEF']));
assert.deepEqual(picker.getRecentColors(), ['#ABCDEF']);
picker.recordRecentColor('#abcdef');
picker.recordRecentColor('#ff0000');
assert.deepEqual(picker.getRecentColors(), ['#ff0000', '#abcdef']);
const suggestions = picker.suggestColorsFor('#ff0000');
assert.equal(suggestions.length, 5);
assert.equal(suggestions[0].hex, '#00ffff');
assert.ok(suggestions.every(s => /^#[0-9a-f]{6}$/.test(s.hex)));
store.set('odysseus-recent-colors', '{}');
assert.deepEqual(picker.getRecentColors(), []);
'''
result = subprocess.run(['node', '--input-type=module', '-e', script,
str(root / 'static/js/colorPicker.js')],
cwd=root, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
+4 -15
View File
@@ -18,21 +18,10 @@ CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8") EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8") SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
MOBILE_BLOCK = """@media (max-width: 700px) { def test_mobile_footer_is_docked_flush_and_accessible():
.ge-editor-footer { assert ".ge-editor-footer" in CSS
display: none !important; assert "overflow-x: auto;" in CSS
} assert ".ge-editor-footer {\n display: none !important;" not in CSS
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
every other editor asset (sheet headers, tool rail, popups) while lifted. */
.ge-ai-command {
z-index: 90;
}
}"""
def test_mobile_footer_is_removed():
assert MOBILE_BLOCK in CSS
assert ".ge-editor-footer {\n display: none !important;" in CSS
def test_quick_edit_stacks_above_other_assets(): def test_quick_edit_stacks_above_other_assets():
+5 -2
View File
@@ -19,8 +19,11 @@ def test_phone_menu_bar_compaction_rules_present():
assert ".ge-topbar-left .ge-stacked-btn {" in CSS assert ".ge-topbar-left .ge-stacked-btn {" in CSS
assert "font-size: 11px;" in CSS assert "font-size: 11px;" in CSS
assert "padding: 0 6px !important;" in CSS assert "padding: 0 6px !important;" in CSS
assert ".ge-topbar.ge-topbar-compact #ge-draft-status {" in CSS # Compaction drops the Save action label only. The draft status is the
assert "display: none;" in CSS # button's role="status" live region (its icon is aria-hidden), so it must
# stay rendered; interaction-contract.spec.js checks it at 390px.
assert ".ge-topbar.ge-topbar-compact #ge-save-menu-btn .ge-save-action-label { display: none !important; }" in CSS
assert not re.search(r"ge-topbar-compact\s+#ge-draft-status\s*\{[^}]*display:\s*none", CSS)
def test_sw_cache_name_is_version_agnostic_pattern(): def test_sw_cache_name_is_version_agnostic_pattern():
+163
View File
@@ -0,0 +1,163 @@
import json
import pytest
from unittest.mock import MagicMock, patch
from starlette.requests import Request
from starlette.datastructures import Headers
from routes.editor_agent_routes import setup_editor_agent_routes
from routes.gallery.gallery_routes import _visible_image_endpoint_for_base
def create_fake_request(user: str | None = None, is_admin: bool = False):
scope = {
"type": "http",
"method": "GET",
"path": "/api/editor/image-models",
"headers": [],
"app": MagicMock(),
}
req = Request(scope)
# Mock auth_manager on app state
auth_mgr = MagicMock()
auth_mgr.is_admin.return_value = is_admin
auth_mgr.is_configured = True
req.app.state.auth_manager = auth_mgr
return req
class FakeEndpoint:
def __init__(self, id, name, base_url, model_type="image", is_enabled=True, owner=None, cached_models=None):
self.id = id
self.name = name
self.base_url = base_url
self.model_type = model_type
self.is_enabled = is_enabled
self.owner = owner
self.cached_models = cached_models or json.dumps(["qwen-image-edit", "qwen-generate"])
self.pinned_models = None
@pytest.mark.asyncio
async def test_editor_image_models_non_admin_redacts_urls():
router = setup_editor_agent_routes()
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
fake_ep = FakeEndpoint(
id=42,
name="private-gpu-cluster",
base_url="http://10.0.0.50:8000/v1",
owner="testuser",
)
fake_db = MagicMock()
fake_query = MagicMock()
fake_db.query.return_value = fake_query
fake_query.filter.return_value = fake_query
fake_query.all.return_value = [fake_ep]
req = create_fake_request(user="testuser", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="testuser"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
patch("core.database.SessionLocal", return_value=fake_db), \
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
resp = await route.endpoint(req)
data = json.loads(resp.body.decode())
assert "models" in data
assert len(data["models"]) == 1
m = data["models"][0]
assert m["id"] == "qwen-image-edit"
assert m["endpoint_id"] == 42
# Non-admin must NOT see internal URL!
assert "base_url" not in m
assert "10.0.0.50" not in json.dumps(data)
@pytest.mark.asyncio
async def test_editor_image_models_admin_includes_urls():
router = setup_editor_agent_routes()
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
fake_ep = FakeEndpoint(
id=42,
name="private-gpu-cluster",
base_url="http://10.0.0.50:8000/v1",
owner="adminuser",
)
fake_db = MagicMock()
fake_query = MagicMock()
fake_db.query.return_value = fake_query
fake_query.filter.return_value = fake_query
fake_query.all.return_value = [fake_ep]
req = create_fake_request(user="adminuser", is_admin=True)
with patch("routes.editor_agent_routes._user", return_value="adminuser"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True), \
patch("core.database.SessionLocal", return_value=fake_db), \
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
resp = await route.endpoint(req)
data = json.loads(resp.body.decode())
assert len(data["models"]) == 1
m = data["models"][0]
assert m["endpoint_id"] == 42
# Admin is allowed to see the endpoint base URL
assert m.get("base_url") == "http://10.0.0.50:8000/v1"
@pytest.mark.asyncio
async def test_editor_agent_models_non_admin_redacts_urls_and_probe():
router = setup_editor_agent_routes()
route = next(r for r in router.routes if r.path == "/api/editor/agent-models")
raw_models = [
{"id": "Qwen/Qwen3.5-9B", "label": "Qwen3.5-9B · local", "source": "local", "vision": True, "base_url": "http://100.69.120.65:8097/v1"},
{"id": "hosted-default", "label": "hosted-default", "source": "hosted", "vision": None, "base_url": "https://api.hosted.ai/v1"},
]
req = create_fake_request(user="regularuser", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="regularuser"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
patch("src.editor_agent_model.list_editor_agent_models", return_value=raw_models):
resp = await route.endpoint(req)
data = json.loads(resp.body.decode())
assert data.get("probe") == {}
assert len(data["models"]) == 2
for m in data["models"]:
assert "base_url" not in m
assert "100.69.120.65" not in json.dumps(data)
def test_visible_image_endpoint_for_base_supports_id_and_url():
fake_ep = FakeEndpoint(
id=7,
name="my-diffusion",
base_url="http://internal-host:8080/v1",
owner="alice",
)
fake_db = MagicMock()
fake_query = MagicMock()
fake_db.query.return_value = fake_query
fake_query.filter.return_value = fake_query
fake_query.all.return_value = [fake_ep]
with patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q):
# 1. Lookup by numeric string ID
res_id = _visible_image_endpoint_for_base(fake_db, "7", "alice", "qwen-image-edit")
assert res_id == fake_ep
# 2. Lookup by URL
res_url = _visible_image_endpoint_for_base(fake_db, "http://internal-host:8080", "alice", "qwen-image-edit")
assert res_url == fake_ep
# 3. Lookup non-existent
res_none = _visible_image_endpoint_for_base(fake_db, "999", "alice", "qwen-image-edit")
assert res_none is None
@@ -0,0 +1,458 @@
import asyncio
import http.server
import json
import threading
from typing import List, Tuple
from unittest.mock import MagicMock, patch
import pytest
from starlette.requests import Request
from starlette.testclient import TestClient
from core.database import SessionLocal, ModelEndpoint
from routes.editor_agent_routes import setup_editor_agent_routes
import src.editor_agent_model as eam
class TrackingRequestHandler(http.server.BaseHTTPRequestHandler):
received_requests: List[Tuple[str, str, dict]] = []
def do_POST(self):
length = int(self.headers.get("content-length", 0))
body = self.rfile.read(length) if length > 0 else b""
try:
body_json = json.loads(body.decode("utf-8"))
except Exception:
body_json = {}
TrackingRequestHandler.received_requests.append((self.command, self.path, body_json))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"choices": [{"message": {"content": "ATTACKER_PLAN"}}]}')
def log_message(self, format, *args):
pass
@pytest.fixture
def local_listener():
TrackingRequestHandler.received_requests = []
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingRequestHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}/v1"
server.shutdown()
server.server_close()
def create_mock_request(path: str, body: dict, user: str = "testuser", is_admin: bool = False):
scope = {
"type": "http",
"method": "POST",
"path": path,
"headers": [(b"content-type", b"application/json")],
"app": MagicMock(),
}
req = Request(scope)
auth_mgr = MagicMock()
auth_mgr.is_admin.return_value = is_admin
auth_mgr.is_configured = True
req.app.state.auth_manager = auth_mgr
async def mock_json():
return body
req.json = mock_json
return req
SYNTHETIC_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
def test_ssrf_arbitrary_listener_receives_zero_requests(local_listener, monkeypatch):
"""Prove attacker-supplied agent_model URL receives 0 requests on planner routes."""
router = setup_editor_agent_routes()
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
agent_route = next(r for r in router.routes if r.path == "/api/editor/agent")
async def _fake_wait(fut, timeout=None, **kwargs):
return {"ok": True, "result": {"width": 100, "height": 100}}
monkeypatch.setattr("routes.editor_agent_routes.asyncio.wait_for", _fake_wait)
payload = {
"prompt": "make it brighter",
"screenshot": SYNTHETIC_SCREENSHOT,
"agent_model": f"{local_listener}::attacker-model",
}
# Test /api/editor/plan
req_plan = create_mock_request("/api/editor/plan", payload, user="alice", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="alice"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
resp_plan = asyncio.run(plan_route.endpoint(req_plan))
assert resp_plan.status_code == 200
assert len(TrackingRequestHandler.received_requests) == 0
# Test /api/editor/agent
req_agent = create_mock_request("/api/editor/agent", payload, user="alice", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="alice"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
resp_agent = asyncio.run(agent_route.endpoint(req_agent))
assert resp_agent.status_code == 200
assert len(TrackingRequestHandler.received_requests) == 0
def test_ssrf_admin_cannot_target_arbitrary_url(local_listener):
"""Admin access must not grant arbitrary outbound network destinations."""
payload = {
"prompt": "make it brighter",
"screenshot": SYNTHETIC_SCREENSHOT,
"agent_model": f"{local_listener}::attacker-model",
}
router = setup_editor_agent_routes()
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
req = create_mock_request("/api/editor/plan", payload, user="admin_user", is_admin=True)
with patch("routes.editor_agent_routes._user", return_value="admin_user"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True):
resp = asyncio.run(plan_route.endpoint(req))
assert resp.status_code == 200
assert len(TrackingRequestHandler.received_requests) == 0
def test_candidates_cloud_metadata_rejected():
"""Cloud metadata destination is rejected fail-closed."""
cands = eam._candidates("user1", "http://169.254.169.254/latest/meta-data::cloud-meta")
assert cands == []
def test_candidates_foreign_owned_endpoint_rejected():
"""Foreign-owned endpoint ID fails closed for a different user."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-foreign-test",
name="Foreign Cluster",
base_url="http://192.168.1.100:8000/v1",
owner="victim_user",
is_enabled=True,
cached_models=json.dumps(["victim-model"]),
)
db.merge(ep)
db.commit()
# Attacker user query
cands = eam._candidates("attacker_user", "ep-foreign-test::victim-model")
assert cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-foreign-test").delete()
db.commit()
db.close()
def test_candidates_disabled_endpoint_rejected():
"""Disabled endpoint ID fails closed."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-disabled-test",
name="Disabled Cluster",
base_url="http://192.168.1.100:8000/v1",
owner="user1",
is_enabled=False,
cached_models=json.dumps(["some-model"]),
)
db.merge(ep)
db.commit()
cands = eam._candidates("user1", "ep-disabled-test::some-model")
assert cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-disabled-test").delete()
db.commit()
db.close()
def test_candidates_nonexistent_endpoint_rejected():
"""Nonexistent endpoint ID fails closed."""
cands = eam._candidates("user1", "ep-nonexistent-9999::model1")
assert cands == []
def test_candidates_forged_model_id_rejected():
"""Forged model ID on an authorized endpoint fails closed."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-auth-test",
name="User Cluster",
base_url="http://127.0.0.1:9099/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["real-model"]),
)
db.merge(ep)
db.commit()
# Legitimate model passes
valid_cands = eam._candidates("user1", "ep-auth-test::real-model")
assert len(valid_cands) >= 1
assert valid_cands[0]["model"] == "real-model"
# Forged model fails closed
forged_cands = eam._candidates("user1", "ep-auth-test::forged-attacker-model")
assert forged_cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-auth-test").delete()
db.commit()
db.close()
def test_candidates_duplicate_model_ids_scoped_by_endpoint():
"""Duplicate model IDs across different endpoints do not collide or cross providers."""
db = SessionLocal()
try:
ep1 = ModelEndpoint(
id="ep-provider-1",
name="Provider 1",
base_url="http://127.0.0.1:9001/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["shared-model-name"]),
)
ep2 = ModelEndpoint(
id="ep-provider-2",
name="Provider 2",
base_url="http://127.0.0.1:9002/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["shared-model-name"]),
)
db.merge(ep1)
db.merge(ep2)
db.commit()
cands1 = eam._candidates("user1", "ep-provider-1::shared-model-name")
assert len(cands1) >= 1
assert cands1[0]["endpoint_id"] == "ep-provider-1"
assert "9001" in cands1[0]["base"]
cands2 = eam._candidates("user1", "ep-provider-2::shared-model-name")
assert len(cands2) >= 1
assert cands2[0]["endpoint_id"] == "ep-provider-2"
assert "9002" in cands2[0]["base"]
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(["ep-provider-1", "ep-provider-2"])).delete()
db.commit()
db.close()
def test_candidates_authorized_shared_endpoint():
"""Shared endpoint (owner is NULL) is accessible to authenticated user."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-shared-test",
name="Shared Org Cluster",
base_url="http://127.0.0.1:9005/v1",
owner=None,
is_enabled=True,
cached_models=json.dumps(["org-planner-model"]),
)
db.merge(ep)
db.commit()
cands = eam._candidates("any_user", "ep-shared-test::org-planner-model")
assert len(cands) >= 1
assert cands[0]["endpoint_id"] == "ep-shared-test"
assert cands[0]["model"] == "org-planner-model"
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-shared-test").delete()
db.commit()
db.close()
def test_candidates_legacy_raw_url_override_verified_against_registered_db():
"""Legacy raw URL format resolves only when matching a registered DB endpoint."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-legacy-test",
name="Legacy Registered",
base_url="http://registered-server.internal:8000/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["legacy-model"]),
)
db.merge(ep)
db.commit()
# Matching registered row resolves
cands = eam._candidates("user1", "http://registered-server.internal:8000/v1::legacy-model")
assert len(cands) >= 1
assert cands[0]["endpoint_id"] == "ep-legacy-test"
# Unregistered raw URL fails closed
unreg_cands = eam._candidates("user1", "http://unregistered-server.internal:8000/v1::legacy-model")
assert unreg_cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-legacy-test").delete()
db.commit()
db.close()
def test_candidates_unauthorized_override_does_not_silently_fallback(monkeypatch):
"""Unauthorized override fails closed rather than silently falling back to defaults."""
monkeypatch.setattr(eam, "local_bases", lambda: ["http://default-local:8000/v1"])
monkeypatch.setattr(eam, "local_model", lambda: "default-local-model")
# Override is unauthorized/attacker -> MUST NOT fallback to default-local
cands = eam._candidates("user1", "http://attacker-listener.com:9999/v1::attacker-model")
assert cands == []
def test_editor_routes_unauthenticated_returns_401_when_auth_configured():
"""Unauthenticated requests must return 401 when authentication is enabled."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
router = setup_editor_agent_routes()
app.include_router(router)
auth_mgr = MagicMock()
auth_mgr.is_configured = True
app.state.auth_manager = auth_mgr
with patch("src.auth_helpers._auth_disabled", return_value=False), \
patch("src.auth_helpers.get_current_user", return_value=None):
client = TestClient(app, raise_server_exceptions=False)
resp_plan = client.post("/api/editor/plan", json={"prompt": "draw cat"})
assert resp_plan.status_code == 401
assert resp_plan.json().get("detail") == "Not authenticated"
resp_agent = client.post("/api/editor/agent", json={"prompt": "draw cat"})
assert resp_agent.status_code == 401
assert resp_agent.json().get("detail") == "Not authenticated"
def test_candidates_empty_owner_cannot_query_foreign_private_endpoint():
"""Empty owner in multi-user mode cannot access private endpoints of other users."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-private-victim",
name="Victim Private Cluster",
base_url="http://192.168.1.105:8000/v1",
owner="victim_user",
is_enabled=True,
cached_models=json.dumps(["victim-secret-model"]),
)
db.merge(ep)
db.commit()
with patch("src.auth_helpers._auth_disabled", return_value=False):
# None owner fails closed
cands_none = eam._candidates(None, "ep-private-victim::victim-secret-model")
assert cands_none == []
# Empty string owner fails closed
cands_empty = eam._candidates("", "ep-private-victim::victim-secret-model")
assert cands_empty == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-private-victim").delete()
db.commit()
db.close()
def test_editor_routes_api_token_without_chat_scope_returns_403():
"""Bearer API token missing 'chat' scope is rejected with 403."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def inject_token_middleware(request, call_next):
request.state.api_token = True
request.state.api_token_scopes = ["image", "read"] # Missing "chat"
request.state.api_token_owner = "alice"
return await call_next(request)
app.include_router(setup_editor_agent_routes())
client = TestClient(app, raise_server_exceptions=False)
resp_plan = client.post("/api/editor/plan", json={"prompt": "make it pop"})
assert resp_plan.status_code == 403
assert "chat" in resp_plan.json().get("detail", "")
resp_agent = client.post("/api/editor/agent", json={"prompt": "make it pop"})
assert resp_agent.status_code == 403
assert "chat" in resp_agent.json().get("detail", "")
def test_editor_routes_api_token_with_chat_scope_resolves_owner_endpoint():
"""Bearer API token with 'chat' scope resolves the token owner's endpoints."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
db = SessionLocal()
try:
alice_ep = ModelEndpoint(
id="ep-alice-token",
name="Alice Cluster",
base_url="http://127.0.0.1:9098/v1",
owner="alice",
is_enabled=True,
cached_models=json.dumps(["alice-model"]),
)
bob_ep = ModelEndpoint(
id="ep-bob-token",
name="Bob Cluster",
base_url="http://127.0.0.1:9099/v1",
owner="bob",
is_enabled=True,
cached_models=json.dumps(["bob-model"]),
)
db.merge(alice_ep)
db.merge(bob_ep)
db.commit()
app = FastAPI()
@app.middleware("http")
async def inject_token_middleware(request, call_next):
request.state.api_token = True
request.state.api_token_scopes = ["chat"]
request.state.api_token_owner = "alice"
return await call_next(request)
app.include_router(setup_editor_agent_routes())
client = TestClient(app, raise_server_exceptions=False)
# Alice's token querying Alice's endpoint resolves
cands = eam._candidates("alice", "ep-alice-token::alice-model")
assert len(cands) == 1
assert cands[0]["endpoint_id"] == "ep-alice-token"
# Alice's token querying Bob's endpoint fails closed
cands_bob = eam._candidates("alice", "ep-bob-token::bob-model")
assert cands_bob == []
# Route-level check: calling /api/editor/plan with Alice's token targeting Bob's endpoint
# falls back and does NOT route to Bob
resp = client.post(
"/api/editor/plan",
json={"prompt": "enhance", "agent_model": "ep-bob-token::bob-model"}
)
assert resp.status_code == 200
# Should report fallback or default, never bob's endpoint
assert resp.json().get("agent_model") != "ep-bob-token::bob-model"
finally:
db.query(ModelEndpoint).filter(
ModelEndpoint.id.in_(["ep-alice-token", "ep-bob-token"])
).delete()
db.commit()
db.close()
+5 -3
View File
@@ -388,11 +388,13 @@ def test_quick_edit_has_an_agent_model_picker():
assert "Recommended, not required" in AI_SETTINGS assert "Recommended, not required" in AI_SETTINGS
assert "function _getSelectedQuickEditAgentModel()" in EDITOR assert "function _getSelectedQuickEditAgentModel()" in EDITOR
assert "getAgentModel: () => _getSelectedQuickEditAgentModel()," in EDITOR assert "getAgentModel: () => _getSelectedQuickEditAgentModel()," in EDITOR
# Local Qwen3.5-9B VL is the default; server default is the alternative. # Local/hosted agent models are dynamically discovered via /api/editor/agent-models;
# private Tailscale/CGNAT IPs must not be hardcoded in frontend source.
assert "export function resolveQuickEditAgentModel()" in models assert "export function resolveQuickEditAgentModel()" in models
assert "export const QUICK_EDIT_AGENT_OPTIONS" in models assert "export const QUICK_EDIT_AGENT_OPTIONS" in models
assert "Qwen3.5-9B VL · local" in models assert "Server default (hosted planner)" in models
assert "http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B" in models assert "100.69.120.65" not in models
assert "/api/editor/agent-models" in models
assert "localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value)" in models assert "localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value)" in models
assert "localStorage.getItem('ge-ai-command-agent-model')" in models assert "localStorage.getItem('ge-ai-command-agent-model')" in models
# The request carries the agent model. # The request carries the agent model.
+4 -1
View File
@@ -40,7 +40,10 @@ def test_flyout_triangle_css_is_visible():
def test_brush_preset_rows_have_a_tip_icon(): def test_brush_preset_rows_have_a_tip_icon():
assert "const soft = typeof preset.softness === 'number' ? preset.softness : 100;" in TOOLBAR assert "const soft = typeof preset.softness === 'number' ? preset.softness : 100;" in TOOLBAR
assert "const presetIcon = preset.pixel" in TOOLBAR assert "const presetIcon = preset.pixel" in TOOLBAR
assert '<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>' in TOOLBAR assert "icon.className = 'ge-tool-icon';" in TOOLBAR
assert 'icon.innerHTML = presetIcon;' in TOOLBAR
assert "label.className = 'ge-tool-flyout-label';" in TOOLBAR
assert 'label.textContent = preset.name;' in TOOLBAR
def test_flyout_is_clamped_inside_the_viewport(): def test_flyout_is_clamped_inside_the_viewport():
+327
View File
@@ -0,0 +1,327 @@
import asyncio
import http.server
import json
import threading
from typing import List, Tuple
from unittest.mock import MagicMock, patch
import pytest
from starlette.requests import Request
from starlette.exceptions import HTTPException
from core.database import SessionLocal, ModelEndpoint
from routes.gallery.gallery_routes import setup_gallery_routes, _visible_image_endpoint_for_base
class TrackingPollHandler(http.server.BaseHTTPRequestHandler):
requests_received: List[str] = []
def do_GET(self):
TrackingPollHandler.requests_received.append(self.path)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"available": true, "progress": 50}')
def do_POST(self):
TrackingPollHandler.requests_received.append(self.path)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"images": ["data:image/png;base64,AAAA"]}')
def log_message(self, format, *args):
pass
@pytest.fixture
def mock_provider_listener():
TrackingPollHandler.requests_received = []
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingPollHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
server.server_close()
def create_gallery_request(method: str, path: str, query_params: dict = None, body: dict = None, user: str = "alice"):
scope = {
"type": "http",
"method": method,
"path": path,
"headers": [(b"content-type", b"application/json")],
"query_string": "&".join(f"{k}={v}" for k, v in (query_params or {}).items()).encode("utf-8"),
"app": MagicMock(),
}
req = Request(scope)
async def mock_json():
return body or {}
req.json = mock_json
return req
def test_edit_progress_disabled_endpoint_causes_no_outbound_polling(mock_provider_listener):
"""GET /api/image/edit-progress with disabled endpoint causes zero outbound requests."""
db = SessionLocal()
ep_id = "ep-test-disabled-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Disabled Endpoint",
base_url=mock_provider_listener,
model_type="image",
is_enabled=False,
owner="alice",
cached_models=json.dumps(["test-diff"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-123", "endpoint_id": ep_id},
user="alice",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert resp.get("reason") == "endpoint not found or disabled"
# Zero outbound polling to the provider!
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_edit_progress_foreign_owned_endpoint_causes_no_outbound_polling(mock_provider_listener):
"""GET /api/image/edit-progress with foreign-owned endpoint causes zero outbound requests."""
db = SessionLocal()
ep_id = "ep-test-foreign-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Victim Endpoint",
base_url=mock_provider_listener,
model_type="image",
is_enabled=True,
owner="victim_user",
cached_models=json.dumps(["test-diff"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
# Attacker user 'bob' requests victim's endpoint
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-456", "endpoint_id": ep_id},
user="bob",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="bob"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert resp.get("reason") == "endpoint not found or disabled"
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_edit_progress_rejects_chat_only_endpoint_causes_no_polling(mock_provider_listener):
"""GET /api/image/edit-progress rejects chat-only endpoint ID with zero outbound requests."""
db = SessionLocal()
ep_id = "ep-test-chat-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Chat Endpoint",
base_url=mock_provider_listener,
model_type="llm", # chat only!
is_enabled=True,
owner="alice",
cached_models=json.dumps(["llama-3-8b"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-789", "endpoint_id": ep_id},
user="alice",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert resp.get("reason") == "endpoint not found or disabled"
# Zero outbound polling to the chat provider!
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_edit_progress_blocks_private_ip_when_policy_enabled(mock_provider_listener, monkeypatch):
"""GET /api/image/edit-progress enforces IMAGE_BLOCK_PRIVATE_IPS policy."""
monkeypatch.setenv("IMAGE_BLOCK_PRIVATE_IPS", "true")
db = SessionLocal()
ep_id = "ep-test-private-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Private Image Endpoint",
base_url=mock_provider_listener, # 127.0.0.1 loopback
model_type="image",
is_enabled=True,
owner="alice",
cached_models=json.dumps(["sd-v1"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-priv", "endpoint_id": ep_id},
user="alice",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert "rejected" in str(resp.get("reason"))
# Zero outbound polling because private IP was blocked by policy!
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_harmonize_rejects_chat_only_endpoint(mock_provider_listener):
"""POST /api/image/harmonize rejects a chat-only endpoint that has no image capability."""
db = SessionLocal()
ep_id = "ep-test-chat-only"
try:
ep = ModelEndpoint(
id=ep_id,
name="Chat Only Llama",
base_url=mock_provider_listener,
model_type="llm", # chat only
is_enabled=True,
owner="alice",
cached_models=json.dumps(["meta-llama/Llama-3-8B-Instruct", "mistral-7b"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/harmonize")
payload = {
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"_endpoint": ep_id,
"_model": "meta-llama/Llama-3-8B-Instruct",
}
req = create_gallery_request("POST", "/api/image/harmonize", body=payload, user="alice")
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
with pytest.raises(HTTPException) as exc_info:
asyncio.run(route.endpoint(req))
assert exc_info.value.status_code == 403
assert "Choose a registered image endpoint" in exc_info.value.detail
# Zero outbound requests to chat provider
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_harmonize_accepts_eligible_image_model_on_mixed_endpoint(mock_provider_listener):
"""POST /api/image/harmonize accepts a mixed endpoint when an image-capable model is selected."""
db = SessionLocal()
ep_id = "ep-test-mixed-endpoint"
try:
ep = ModelEndpoint(
id=ep_id,
name="Mixed Gateway",
base_url=mock_provider_listener,
model_type="llm", # mixed gateway
is_enabled=True,
owner="alice",
cached_models=json.dumps(["gpt-4o", "qwen-image-edit"]),
)
db.merge(ep)
db.commit()
# Testing capability helper directly with image model
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "qwen-image-edit")
assert ep_resolved is not None
assert ep_resolved.id == ep_id
# Testing with chat model fails
ep_chat = _visible_image_endpoint_for_base(db, ep_id, "alice", "gpt-4o")
assert ep_chat is None
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_harmonize_preserves_legitimate_image_capable_endpoint(mock_provider_listener):
"""Legitimate image endpoint (model_type == 'image') resolves and works properly."""
db = SessionLocal()
ep_id = "ep-test-sd-diffusion"
try:
ep = ModelEndpoint(
id=ep_id,
name="Forge SD WebUI",
base_url=mock_provider_listener,
model_type="image",
is_enabled=True,
owner="alice",
cached_models=json.dumps(["sd_xl_base_1.0.safetensors"]),
)
db.merge(ep)
db.commit()
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "sd_xl_base_1.0.safetensors")
assert ep_resolved is not None
assert ep_resolved.id == ep_id
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
+99
View File
@@ -0,0 +1,99 @@
"""Service-worker cache policy for the /static/ scope.
The worker is registered at /static/sw.js without a scope, so it only controls
/static/ clients. The SPA root is outside that scope, which means an ordinary
page load never populates the worker's cache: anything the lazy editor graph
imports must be in the precache lists, shared modules included. The real
browser upgrade/offline proof lives in
tests/e2e/photo-editor/offline-upgrade.spec.js.
"""
import os
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
JS_DIR = STATIC / "js"
SW = (STATIC / "sw.js").read_text(encoding="utf-8")
_STATIC_IMPORT = re.compile(
r"""(?:^|\n)\s*(?:import\s+(?:[^;'"()]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)['"]([^'"]+)['"]"""
)
def _list(name: str) -> set[str]:
block = re.search(rf"const {name} = \[(.*?)\];", SW, re.S)
assert block, f"{name} not found in static/sw.js"
return set(re.findall(r"'([^']+)'", block.group(1)))
def _fetch_handler() -> str:
start = SW.index("self.addEventListener('fetch'")
return SW[start:]
def _lazy_editor_graph() -> set[str]:
panels = (JS_DIR / "panels.js").read_text(encoding="utf-8")
entry = re.search(r"import\(['\"]\./(galleryEditor\.js(?:\?[^'\"]+)?)['\"]\)", panels)
assert entry, "panels.js must lazily import galleryEditor.js"
seen: set[str] = set()
stack = [entry.group(1)]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
source = (JS_DIR / current.split("?")[0]).read_text(encoding="utf-8")
for spec in _STATIC_IMPORT.findall(source):
if not spec.startswith("."):
continue
path, _, query = spec.partition("?")
target = os.path.normpath(os.path.join(os.path.dirname(current.split("?")[0]), path))
stack.append(f"{target}?{query}" if query else target)
return seen
def test_every_module_in_the_lazy_editor_graph_is_precached_with_its_exact_url():
graph = _lazy_editor_graph()
assert len(graph) > 100, len(graph)
precached = _list("PRECACHE") | _list("PANEL_PRECACHE")
missing = sorted(f"/static/js/{module}" for module in graph if f"/static/js/{module}" not in precached)
assert not missing, (
"the editor imports these modules, but the /static/-scoped worker never "
f"sees the SPA page load that would cache them: {missing}"
)
def test_worker_keeps_the_static_scope():
index = (STATIC / "index.html").read_text(encoding="utf-8")
registrations = re.findall(r"serviceWorker\.register\(([^)]*)\)", index)
assert registrations == ["'/static/sw.js?v=20260916autoscroll1'"]
assert "Service-Worker-Allowed" not in (ROOT / "app.py").read_text(encoding="utf-8")
def test_fetch_handler_ignores_cross_origin_api_and_non_get_requests():
handler = _fetch_handler()
api_guard = handler.index("url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;")
origin_guard = handler.index("if (url.origin !== self.location.origin) return;")
first_respond = handler.index("e.respondWith(")
assert api_guard < origin_guard < first_respond
def test_offline_fallback_ignores_only_an_origin_vary_inside_the_current_cache():
helper = SW[SW.index("async function cachedStaticFile(request)"):SW.index("self.addEventListener('fetch'")]
assert "const cache = await caches.open(CACHE_NAME);" in helper
assert "await cache.match(request);" in helper
assert "cache.matchAll(request, { ignoreVary: true })" in helper
assert ".every(name => name === 'origin')" in helper
assert "|| Response.error();" in helper
# No other lookup may relax Vary or search caches left by other versions.
assert SW.count("ignoreVary") == 1
assert "caches.match(" not in _fetch_handler()
assert ".catch(() => cachedStaticFile(e.request))" in _fetch_handler()
def test_activation_removes_every_other_cache_version():
assert re.search(r"const CACHE_NAME = 'odysseus-v\d+-[a-z0-9-]+';", SW)
activate = SW[SW.index("self.addEventListener('activate'"):SW.index("async function cachedStaticFile")]
assert "keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))" in activate
assert "self.clients.claim()" in activate
+244
View File
@@ -0,0 +1,244 @@
import json
import os
import shutil
import tempfile
import zipfile
import pytest
from scripts.sanitize_trace_artifacts import (
sanitize_structured_obj,
sanitize_jsonl_content,
sanitize_url,
sanitize_body_text,
sanitize_trace_archive,
sanitize_directory,
)
def test_sanitize_headers_and_inverted_keys():
"""Inverted key order and non-Bearer schemes are safely redacted."""
# Standard order
h1 = {"name": "Authorization", "value": "Bearer my_super_secret_jwt_12345"}
assert sanitize_structured_obj(h1)["value"] == "[REDACTED]"
# Inverted order {"value": ..., "name": ...}
h2 = {"value": "Digest username=admin, nonce=abc, response=secret123", "name": "Authorization"}
res2 = sanitize_structured_obj(h2)
assert res2["value"] == "[REDACTED]"
# ApiKey scheme
h3 = {"name": "Authorization", "value": "ApiKey secret_api_key_xyz"}
assert sanitize_structured_obj(h3)["value"] == "[REDACTED]"
# Custom token header
h4 = {"name": "X-Auth-Token", "value": "auth_token_999"}
assert sanitize_structured_obj(h4)["value"] == "[REDACTED]"
def test_sanitize_cookies_arbitrary_names():
"""Arbitrary cookie names and values are completely redacted."""
cookie_entry = {
"cookies": [
{"name": "odysseus_session", "value": "secret_sess_abc"},
{"name": "remember_me_custom", "value": "remember_secret_val"},
{"name": "sso_auth_id", "value": "sso_uuid_12345"},
]
}
sanitized = sanitize_structured_obj(cookie_entry)
for c in sanitized["cookies"]:
assert c["value"] == "[REDACTED]"
def test_sanitize_dom_snapshots_and_nested_action_params():
"""DOM snapshot trees and deeply nested action params are redacted."""
# 1. Action with nested options before value
action_record = {
"type": "action",
"name": "fill",
"params": {
"selector": "#login-password",
"options": {"force": True, "timeout": 5000},
"value": "escaped\"quote\"password123!",
},
}
res_action = sanitize_structured_obj(action_record)
assert res_action["params"]["value"] == "[REDACTED]"
# 2. DOM snapshot input element
dom_record = {
"nodeName": "INPUT",
"attributes": {
"type": "password",
"name": "user_password",
"value": "super_secret_in_dom",
},
}
res_dom = sanitize_structured_obj(dom_record)
assert res_dom["attributes"]["value"] == "[REDACTED]"
def test_sanitize_url_tokens_and_authority():
"""URL query parameters and userinfo credentials are scrubbed."""
# Authority credentials
url1 = "https://admin:my_secret_pw@example.com/api/v1/resource"
assert sanitize_url(url1) == "https://admin:[REDACTED]@example.com/api/v1/resource"
# Query tokens
url2 = "https://example.com/oauth/callback?code=auth_code_secret&state=xyz&token=access_token_123"
sanitized2 = sanitize_url(url2)
assert "auth_code_secret" not in sanitized2
assert "access_token_123" not in sanitized2
assert "code=%5BREDACTED%5D" in sanitized2 or "code=[REDACTED]" in sanitized2
assert "token=%5BREDACTED%5D" in sanitized2 or "token=[REDACTED]" in sanitized2
assert "state=xyz" in sanitized2
def test_sanitize_request_bodies():
"""JSON and urlencoded form request bodies are scrubbed."""
# JSON body
json_body = '{"username": "admin", "password": "superSecretPassword123!", "api_key": "sk-123456789"}'
s_json = sanitize_body_text(json_body)
assert "superSecretPassword123!" not in s_json
assert "sk-123456789" not in s_json
assert '"password": "[REDACTED]"' in s_json
assert '"api_key": "[REDACTED]"' in s_json
assert '"username": "admin"' in s_json
# Form-encoded body
form_body = "username=alice&password=mypassword999&token=abcde&action=login"
s_form = sanitize_body_text(form_body)
assert "mypassword999" not in s_form
assert "abcde" not in s_form
assert "password=%5BREDACTED%5D" in s_form or "password=[REDACTED]" in s_form
assert "token=%5BREDACTED%5D" in s_form or "token=[REDACTED]" in s_form
assert "username=alice" in s_form
def test_sanitize_valid_archive(tmp_path):
"""Structured archive sanitization cleans HAR, trace, and masks source code without corruption."""
zp = str(tmp_path / "valid_trace.zip")
with zipfile.ZipFile(zp, "w") as z:
# JSON trace record
trace_record = {
"type": "action",
"name": "fill",
"params": {"selector": "#password", "value": 'secret"quote"pass'},
}
z.writestr("0-trace.trace", json.dumps(trace_record).encode() + b"\n")
# Network record with inverted header keys and URL query
net_record = {
"type": "request",
"request": {
"url": "https://localhost:7013/api/auth/login?token=token_abc",
"headers": [{"value": "Digest secret_digest", "name": "Authorization"}],
"postData": '{"password": "my_request_pw"}',
},
}
z.writestr("0-trace.network", json.dumps(net_record).encode() + b"\n")
# Embedded test source file
z.writestr("resources/src@test_auth.js", b'const password = "admin_password";\n')
# Binary image screenshot
z.writestr("screenshot.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic")
ok = sanitize_trace_archive(zp)
assert ok is True
assert os.path.exists(zp)
with zipfile.ZipFile(zp, "r") as z:
# Verify trace record
trace_data = json.loads(z.read("0-trace.trace").decode().strip())
assert trace_data["params"]["value"] == "[REDACTED]"
# Verify network record
net_data = json.loads(z.read("0-trace.network").decode().strip())
assert "token_abc" not in net_data["request"]["url"]
assert net_data["request"]["headers"][0]["value"] == "[REDACTED]"
assert "my_request_pw" not in net_data["request"]["postData"]
# Verify embedded source code was masked safely
src_content = z.read("resources/src@test_auth.js").decode()
assert "admin_password" not in src_content
assert "[SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]" in src_content
assert "const password: [REDACTED]" not in src_content # No syntax error!
# Verify screenshot is unmodified
png = z.read("screenshot.png")
assert png == b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic"
def test_fail_closed_malformed_zip(tmp_path):
zp = str(tmp_path / "corrupt.zip")
with open(zp, "wb") as f:
f.write(b"this is completely invalid zip data PK\x03\x04 corrupt")
ok = sanitize_trace_archive(zp)
assert ok is False
# Fail-closed: corrupted zip must be deleted immediately!
assert not os.path.exists(zp)
def test_fail_closed_partial_archive(tmp_path):
zp = str(tmp_path / "partial.zip")
with open(zp, "wb") as f:
f.write(b"PK\x03\x04\x14\x00\x00\x00")
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
def test_fail_closed_empty_archive(tmp_path):
zp = str(tmp_path / "empty.zip")
with open(zp, "wb") as f:
pass
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
def test_fail_closed_on_unhandled_exception(tmp_path, monkeypatch):
zp = str(tmp_path / "valid.zip")
with zipfile.ZipFile(zp, "w") as z:
z.writestr("test.trace", b'{"name": "fill"}\n')
def broken_sanitize(obj):
raise RuntimeError("Simulated crash")
monkeypatch.setattr("scripts.sanitize_trace_artifacts.sanitize_structured_obj", broken_sanitize)
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
def test_multiple_trace_archives_in_directory(tmp_path):
sub1 = tmp_path / "test1"
sub2 = tmp_path / "test2"
sub1.mkdir()
sub2.mkdir()
z1 = str(sub1 / "trace.zip")
z2 = str(sub2 / "trace.zip")
with zipfile.ZipFile(z1, "w") as z:
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret1"}}\n')
with zipfile.ZipFile(z2, "w") as z:
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret2"}}\n')
count = sanitize_directory(str(tmp_path))
assert count == 2
assert os.path.exists(z1)
with zipfile.ZipFile(z1, "r") as z:
content = json.loads(z.read("0.trace").decode().strip())
assert content["params"]["value"] == "[REDACTED]"
assert os.path.exists(z2)
with zipfile.ZipFile(z2, "r") as z:
content = json.loads(z.read("0.trace").decode().strip())
assert content["params"]["value"] == "[REDACTED]"