Add floating canvas panels, theme settings, and SVG line-spacing analysis.

Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
This commit is contained in:
2026-07-13 22:01:01 +08:00
parent d5d8caef2f
commit f8a907e7c5
33 changed files with 4434 additions and 455 deletions
+1
View File
@@ -4,6 +4,7 @@ python-multipart>=0.0.27
pydantic>=2.10.0 pydantic>=2.10.0
pillow>=10.0.0 pillow>=10.0.0
numpy>=2.0.0 numpy>=2.0.0
scipy>=1.11.0
matplotlib>=3.10.0 matplotlib>=3.10.0
pandas>=2.0.0 pandas>=2.0.0
openpyxl>=3.1.0 openpyxl>=3.1.0
+43
View File
@@ -8,6 +8,7 @@ import re
import shutil import shutil
import sqlite3 import sqlite3
import threading import threading
import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -21,6 +22,7 @@ from PIL import Image, ImageDraw, ImageFont
from core import config as wc_config from core import config as wc_config
from core.fonts import get_cached_font from core.fonts import get_cached_font
from .job_manager import JobManager from .job_manager import JobManager
from .line_spacing import analyze_svg_line_spacing_file
from .log_config import get_logger from .log_config import get_logger
from .runner import JobRunner from .runner import JobRunner
from .schemas import ( from .schemas import (
@@ -32,6 +34,8 @@ from .schemas import (
JobLocationSearchResult, JobLocationSearchResult,
JobResult, JobResult,
JobStatus, JobStatus,
LineSpacingAnalysisRequest,
LineSpacingAnalysisSummary,
Project, Project,
ProjectSummary, ProjectSummary,
Template, Template,
@@ -973,6 +977,45 @@ def download_asset(asset_id: str):
return FileResponse(path, media_type=media, filename=f"{meta['name']}{ext}") return FileResponse(path, media_type=media, filename=f"{meta['name']}{ext}")
@app.post("/api/assets/{asset_id}/line-spacing", response_model=LineSpacingAnalysisSummary)
def analyze_asset_line_spacing(
asset_id: str,
request: LineSpacingAnalysisRequest,
) -> LineSpacingAnalysisSummary:
d = _asset_dir(asset_id)
meta = _read_asset_meta(d)
if not meta:
raise HTTPException(status_code=404, detail="asset not found")
if meta.get("mime_type") != "image/svg+xml":
raise HTTPException(status_code=400, detail="line spacing analysis only supports SVG assets")
path = d / "asset.svg"
if not path.exists():
raise HTTPException(status_code=404, detail="asset file missing on disk")
started = time.perf_counter()
try:
result = analyze_svg_line_spacing_file(
path,
percentile=request.percentile,
element_width=request.elementWidth,
element_height=request.elementHeight,
sample_step=request.sampleStep,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
elapsed = time.perf_counter() - started
log.info(
"[API] line-spacing asset=%s percentile=%s curves=%s segments=%s elapsed=%.2fs",
asset_id,
result.percentile,
result.curveCount,
result.segmentCount,
elapsed,
)
return LineSpacingAnalysisSummary(**result.as_dict())
@app.delete("/api/assets/{asset_id}", status_code=204) @app.delete("/api/assets/{asset_id}", status_code=204)
def delete_asset(asset_id: str) -> None: def delete_asset(asset_id: str) -> None:
d = _asset_dir(asset_id) d = _asset_dir(asset_id)
+937
View File
@@ -0,0 +1,937 @@
from __future__ import annotations
import math
import os
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
Point = tuple[float, float]
Matrix = tuple[float, float, float, float, float, float]
Segment = tuple[int, float, float, float, float, float, float, float, float]
SVG_NS_RE = re.compile(r"\{[^}]+\}")
PATH_TOKEN_RE = re.compile(
r"[AaCcHhLlMmQqSsTtVvZz]|[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?"
)
TRANSFORM_RE = re.compile(r"([a-zA-Z]+)\(([^)]*)\)")
NUMBER_RE = re.compile(r"[-+]?(?:(?:\d*\.\d+)|(?:\d+\.?))(?:[eE][-+]?\d+)?")
PATH_PARAM_COUNTS = {
"a": 7,
"c": 6,
"h": 1,
"l": 2,
"m": 2,
"q": 4,
"s": 4,
"t": 2,
"v": 1,
}
DPI = 96
MM_PER_INCH = 25.4
IDENTITY: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
@dataclass(frozen=True)
class SvgLineSpacingResult:
percentile: float
spacingPx: float
spacingMm: float
minSpacingPx: float
minSpacingMm: float
sampleStep: float
curveCount: int
segmentCount: int
nearestCount: int
sourceWidth: float
sourceHeight: float
elementWidth: float
elementHeight: float
computedAt: str
closestPoints: dict[str, float] | None = None
def as_dict(self) -> dict:
return {
"percentile": self.percentile,
"spacingPx": self.spacingPx,
"spacingMm": self.spacingMm,
"minSpacingPx": self.minSpacingPx,
"minSpacingMm": self.minSpacingMm,
"sampleStep": self.sampleStep,
"curveCount": self.curveCount,
"segmentCount": self.segmentCount,
"nearestCount": self.nearestCount,
"sourceWidth": self.sourceWidth,
"sourceHeight": self.sourceHeight,
"elementWidth": self.elementWidth,
"elementHeight": self.elementHeight,
"computedAt": self.computedAt,
"closestPoints": self.closestPoints,
}
def analyze_svg_line_spacing_file(
svg_path: Path,
*,
percentile: float,
element_width: float,
element_height: float,
sample_step: float = 2.0,
) -> SvgLineSpacingResult:
svg_text = svg_path.read_text(encoding="utf-8", errors="replace")
return analyze_svg_line_spacing(
svg_text,
percentile=percentile,
element_width=element_width,
element_height=element_height,
sample_step=sample_step,
)
def analyze_svg_line_spacing(
svg_text: str,
*,
percentile: float,
element_width: float,
element_height: float,
sample_step: float = 2.0,
) -> SvgLineSpacingResult:
if element_width <= 0 or element_height <= 0:
raise ValueError("element size must be positive")
percentile = max(0.0, min(100.0, float(percentile)))
sample_step = max(0.5, float(sample_step or 2.0))
try:
root = ET.fromstring(svg_text)
except ET.ParseError as exc:
raise ValueError("无法解析 SVG") from exc
source_width, source_height = read_svg_size(root)
segments, curve_count = collect_segments(root, sample_step)
if len(segments) < 2:
raise ValueError("SVG 中可分析的轮廓线太少")
nearest_distances, min_distance, closest_points = compute_nearest_spacing(
segments,
max(8.0, sample_step * 8.0),
)
if not nearest_distances or not math.isfinite(min_distance):
raise ValueError("没有找到可比较的不同轮廓曲线")
nearest_distances.sort()
percentile_distance = percentile_value(nearest_distances, percentile)
scale = min(element_width / source_width, element_height / source_height)
spacing_px = percentile_distance * scale
min_spacing_px = min_distance * scale
return SvgLineSpacingResult(
percentile=percentile,
spacingPx=spacing_px,
spacingMm=px_to_mm(spacing_px),
minSpacingPx=min_spacing_px,
minSpacingMm=px_to_mm(min_spacing_px),
sampleStep=sample_step,
curveCount=curve_count,
segmentCount=len(segments),
nearestCount=len(nearest_distances),
sourceWidth=source_width,
sourceHeight=source_height,
elementWidth=element_width,
elementHeight=element_height,
computedAt=datetime.now(timezone.utc).isoformat(),
closestPoints=scale_closest_points(closest_points, scale),
)
def read_svg_size(root: ET.Element) -> tuple[float, float]:
view_box = root.attrib.get("viewBox") or root.attrib.get("viewbox")
if view_box:
values = [parse_float(item) for item in re.split(r"[\s,]+", view_box.strip()) if item]
if len(values) >= 4 and all(math.isfinite(v) for v in values[:4]) and values[2] > 0 and values[3] > 0:
return values[2], values[3]
width = parse_svg_length(root.attrib.get("width"))
height = parse_svg_length(root.attrib.get("height"))
if width > 0 and height > 0:
return width, height
return 1000.0, 1000.0
def collect_segments(root: ET.Element, sample_step: float) -> tuple[list[Segment], int]:
segments: list[Segment] = []
next_curve_id = 0
def visit(node: ET.Element, matrix: Matrix, hidden: bool) -> None:
nonlocal next_curve_id
hidden = hidden or element_hidden(node)
node_matrix = multiply_matrix(matrix, parse_transform(node.attrib.get("transform", "")))
tag = strip_ns(node.tag)
if not hidden and drawable_geometry(node):
if tag == "path":
next_curve_id = append_path_segments(
node.attrib.get("d", ""),
next_curve_id,
sample_step,
node_matrix,
segments,
)
elif tag == "line":
next_curve_id = append_line_element(node, next_curve_id, sample_step, node_matrix, segments)
elif tag in {"polyline", "polygon"}:
next_curve_id = append_poly_element(node, next_curve_id, sample_step, node_matrix, segments, tag == "polygon")
elif tag == "rect":
next_curve_id = append_rect_element(node, next_curve_id, sample_step, node_matrix, segments)
elif tag in {"circle", "ellipse"}:
next_curve_id = append_ellipse_element(node, next_curve_id, sample_step, node_matrix, segments, tag)
if tag not in {"defs", "clipPath", "mask", "pattern", "symbol"}:
for child in list(node):
visit(child, node_matrix, hidden)
visit(root, IDENTITY, False)
return segments, next_curve_id
def strip_ns(tag: str) -> str:
return SVG_NS_RE.sub("", tag)
def element_hidden(node: ET.Element) -> bool:
style = parse_style(node.attrib.get("style", ""))
display = (node.attrib.get("display") or style.get("display") or "").strip().lower()
visibility = (node.attrib.get("visibility") or style.get("visibility") or "").strip().lower()
return display == "none" or visibility == "hidden"
def drawable_geometry(node: ET.Element) -> bool:
tag = strip_ns(node.tag)
if tag not in {"path", "line", "polyline", "polygon", "rect", "circle", "ellipse"}:
return False
style = parse_style(node.attrib.get("style", ""))
stroke = (node.attrib.get("stroke") or style.get("stroke") or "").strip().lower()
fill = (node.attrib.get("fill") or style.get("fill") or "").strip().lower()
if stroke and stroke not in {"none", "transparent"}:
return True
if not fill or fill not in {"none", "transparent"}:
return True
return False
def parse_style(raw: str) -> dict[str, str]:
result: dict[str, str] = {}
for part in raw.split(";"):
if ":" not in part:
continue
key, value = part.split(":", 1)
result[key.strip().lower()] = value.strip()
return result
def append_path_segments(
path_data: str,
curve_id: int,
sample_step: float,
matrix: Matrix,
output: list[Segment],
) -> int:
tokens = tokenize_path(path_data)
if not tokens:
return curve_id
index = 0
command = ""
current: Point = (0.0, 0.0)
subpath_start: Point = (0.0, 0.0)
active_curve_id = curve_id - 1
last_cubic_control: Point | None = None
last_quad_control: Point | None = None
previous_command = ""
while index < len(tokens):
token = tokens[index]
if isinstance(token, str):
command = token
index += 1
if not command:
break
lower = command.lower()
if lower == "z":
if active_curve_id >= curve_id:
append_sampled_line(current, subpath_start, active_curve_id, sample_step, matrix, output)
current = subpath_start
last_cubic_control = None
last_quad_control = None
previous_command = command
command = ""
continue
param_count = PATH_PARAM_COUNTS.get(lower)
if not param_count:
break
first_move = lower == "m"
while has_number_run(tokens, index, param_count):
values = [float(tokens[index + offset]) for offset in range(param_count)]
index += param_count
if lower == "m":
point = absolute_point(command, current, values[0], values[1])
if first_move:
active_curve_id = curve_id
curve_id += 1
subpath_start = point
current = point
first_move = False
else:
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = None
last_quad_control = None
elif lower == "l":
point = absolute_point(command, current, values[0], values[1])
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = None
last_quad_control = None
elif lower == "h":
x = current[0] + values[0] if command.islower() else values[0]
point = (x, current[1])
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = None
last_quad_control = None
elif lower == "v":
y = current[1] + values[0] if command.islower() else values[0]
point = (current[0], y)
append_sampled_line(current, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = None
last_quad_control = None
elif lower == "c":
p1 = absolute_point(command, current, values[0], values[1])
p2 = absolute_point(command, current, values[2], values[3])
point = absolute_point(command, current, values[4], values[5])
append_cubic(current, p1, p2, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = p2
last_quad_control = None
elif lower == "s":
p1 = reflect_point(current, last_cubic_control) if previous_command.lower() in {"c", "s"} else current
p2 = absolute_point(command, current, values[0], values[1])
point = absolute_point(command, current, values[2], values[3])
append_cubic(current, p1, p2, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = p2
last_quad_control = None
elif lower == "q":
p1 = absolute_point(command, current, values[0], values[1])
point = absolute_point(command, current, values[2], values[3])
append_quadratic(current, p1, point, active_curve_id, sample_step, matrix, output)
current = point
last_quad_control = p1
last_cubic_control = None
elif lower == "t":
p1 = reflect_point(current, last_quad_control) if previous_command.lower() in {"q", "t"} else current
point = absolute_point(command, current, values[0], values[1])
append_quadratic(current, p1, point, active_curve_id, sample_step, matrix, output)
current = point
last_quad_control = p1
last_cubic_control = None
elif lower == "a":
rx, ry, angle, large_arc, sweep, x, y = values
point = absolute_point(command, current, x, y)
append_arc(current, rx, ry, angle, large_arc, sweep, point, active_curve_id, sample_step, matrix, output)
current = point
last_cubic_control = None
last_quad_control = None
previous_command = command
if index < len(tokens) and isinstance(tokens[index], str):
break
return curve_id
def tokenize_path(path_data: str) -> list[str | float]:
tokens: list[str | float] = []
for match in PATH_TOKEN_RE.finditer(path_data):
raw = match.group(0)
if re.fullmatch(r"[AaCcHhLlMmQqSsTtVvZz]", raw):
tokens.append(raw)
else:
tokens.append(float(raw))
return tokens
def has_number_run(tokens: list[str | float], index: int, count: int) -> bool:
if index + count > len(tokens):
return False
return all(not isinstance(tokens[index + offset], str) for offset in range(count))
def absolute_point(command: str, current: Point, x: float, y: float) -> Point:
if command.islower():
return current[0] + x, current[1] + y
return x, y
def reflect_point(origin: Point, point: Point | None) -> Point:
if point is None:
return origin
return 2 * origin[0] - point[0], 2 * origin[1] - point[1]
def append_line_element(node: ET.Element, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> int:
p1 = (parse_svg_length(node.attrib.get("x1")), parse_svg_length(node.attrib.get("y1")))
p2 = (parse_svg_length(node.attrib.get("x2")), parse_svg_length(node.attrib.get("y2")))
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
return curve_id + 1
def append_poly_element(
node: ET.Element,
curve_id: int,
sample_step: float,
matrix: Matrix,
output: list[Segment],
close: bool,
) -> int:
points = parse_points(node.attrib.get("points", ""))
if len(points) < 2:
return curve_id
for p1, p2 in zip(points, points[1:]):
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
if close:
append_sampled_line(points[-1], points[0], curve_id, sample_step, matrix, output)
return curve_id + 1
def append_rect_element(node: ET.Element, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> int:
x = parse_svg_length(node.attrib.get("x"))
y = parse_svg_length(node.attrib.get("y"))
width = parse_svg_length(node.attrib.get("width"))
height = parse_svg_length(node.attrib.get("height"))
if width <= 0 or height <= 0:
return curve_id
points = [(x, y), (x + width, y), (x + width, y + height), (x, y + height)]
for p1, p2 in zip(points, points[1:] + points[:1]):
append_sampled_line(p1, p2, curve_id, sample_step, matrix, output)
return curve_id + 1
def append_ellipse_element(
node: ET.Element,
curve_id: int,
sample_step: float,
matrix: Matrix,
output: list[Segment],
tag: str,
) -> int:
if tag == "circle":
cx = parse_svg_length(node.attrib.get("cx"))
cy = parse_svg_length(node.attrib.get("cy"))
rx = ry = parse_svg_length(node.attrib.get("r"))
else:
cx = parse_svg_length(node.attrib.get("cx"))
cy = parse_svg_length(node.attrib.get("cy"))
rx = parse_svg_length(node.attrib.get("rx"))
ry = parse_svg_length(node.attrib.get("ry"))
if rx <= 0 or ry <= 0:
return curve_id
circumference = math.pi * (3 * (rx + ry) - math.sqrt((3 * rx + ry) * (rx + 3 * ry)))
steps = max(24, int(math.ceil(circumference / sample_step)))
raw_points = [
(cx + math.cos((math.tau * i) / steps) * rx, cy + math.sin((math.tau * i) / steps) * ry)
for i in range(steps + 1)
]
append_points(raw_points, curve_id, matrix, output)
return curve_id + 1
def append_sampled_line(p1: Point, p2: Point, curve_id: int, sample_step: float, matrix: Matrix, output: list[Segment]) -> None:
if curve_id < 0:
return
tp1 = transform_point(matrix, p1)
tp2 = transform_point(matrix, p2)
distance = point_distance(tp1, tp2)
steps = max(1, int(math.ceil(distance / sample_step)))
points = [lerp_point(p1, p2, i / steps) for i in range(steps + 1)]
append_points(points, curve_id, matrix, output)
def append_quadratic(
p0: Point,
p1: Point,
p2: Point,
curve_id: int,
sample_step: float,
matrix: Matrix,
output: list[Segment],
) -> None:
tp = [transform_point(matrix, p) for p in (p0, p1, p2)]
control_length = point_distance(tp[0], tp[1]) + point_distance(tp[1], tp[2])
steps = max(4, int(math.ceil(control_length / sample_step)))
points = []
for i in range(steps + 1):
t = i / steps
mt = 1 - t
points.append((
mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0],
mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1],
))
append_points(points, curve_id, matrix, output)
def append_cubic(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
curve_id: int,
sample_step: float,
matrix: Matrix,
output: list[Segment],
) -> None:
tp = [transform_point(matrix, p) for p in (p0, p1, p2, p3)]
control_length = point_distance(tp[0], tp[1]) + point_distance(tp[1], tp[2]) + point_distance(tp[2], tp[3])
steps = max(6, int(math.ceil(control_length / sample_step)))
points = []
for i in range(steps + 1):
t = i / steps
mt = 1 - t
points.append((
mt**3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t**3 * p3[0],
mt**3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t**3 * p3[1],
))
append_points(points, curve_id, matrix, output)
def append_arc(
start: Point,
rx: float,
ry: float,
angle_degrees: float,
large_arc: float,
sweep: float,
end: Point,
curve_id: int,
sample_step: float,
matrix: Matrix,
output: list[Segment],
) -> None:
if rx == 0 or ry == 0 or start == end:
append_sampled_line(start, end, curve_id, sample_step, matrix, output)
return
rx = abs(rx)
ry = abs(ry)
phi = math.radians(angle_degrees % 360)
cos_phi = math.cos(phi)
sin_phi = math.sin(phi)
dx = (start[0] - end[0]) / 2
dy = (start[1] - end[1]) / 2
x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy
radii_check = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
if radii_check > 1:
scale = math.sqrt(radii_check)
rx *= scale
ry *= scale
sign = -1 if bool(large_arc) == bool(sweep) else 1
numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p
factor = sign * math.sqrt(max(0.0, numerator / denominator)) if denominator else 0.0
cxp = factor * (rx * y1p / ry)
cyp = factor * (-ry * x1p / rx)
cx = cos_phi * cxp - sin_phi * cyp + (start[0] + end[0]) / 2
cy = sin_phi * cxp + cos_phi * cyp + (start[1] + end[1]) / 2
theta1 = vector_angle((1, 0), ((x1p - cxp) / rx, (y1p - cyp) / ry))
delta = vector_angle(
((x1p - cxp) / rx, (y1p - cyp) / ry),
((-x1p - cxp) / rx, (-y1p - cyp) / ry),
)
if not sweep and delta > 0:
delta -= math.tau
elif sweep and delta < 0:
delta += math.tau
arc_length = abs(delta) * max(rx, ry)
steps = max(4, int(math.ceil(arc_length / sample_step)))
points = []
for i in range(steps + 1):
theta = theta1 + delta * (i / steps)
x = cx + cos_phi * rx * math.cos(theta) - sin_phi * ry * math.sin(theta)
y = cy + sin_phi * rx * math.cos(theta) + cos_phi * ry * math.sin(theta)
points.append((x, y))
append_points(points, curve_id, matrix, output)
def vector_angle(u: Point, v: Point) -> float:
dot_value = u[0] * v[0] + u[1] * v[1]
det_value = u[0] * v[1] - u[1] * v[0]
return math.atan2(det_value, dot_value)
def append_points(points: list[Point], curve_id: int, matrix: Matrix, output: list[Segment]) -> None:
transformed = [transform_point(matrix, point) for point in points]
for a, b in zip(transformed, transformed[1:]):
length = point_distance(a, b)
if length <= 0.0001:
continue
ax, ay = a
bx, by = b
output.append((
curve_id,
ax,
ay,
bx,
by,
min(ax, bx),
min(ay, by),
max(ax, bx),
max(ay, by),
))
def compute_nearest_spacing(
segments: list[Segment],
cell_size: float,
) -> tuple[list[float], float, dict[str, float] | None]:
try:
return compute_nearest_spacing_kdtree(segments)
except ImportError:
return compute_nearest_spacing_grid(segments, cell_size)
def compute_nearest_spacing_kdtree(segments: list[Segment]) -> tuple[list[float], float, dict[str, float] | None]:
try:
import numpy as np
from scipy.spatial import cKDTree
except ImportError:
scipy_site = os.environ.get("WORDCLOUD_SCIPY_SITE", "")
if scipy_site and scipy_site not in sys.path:
sys.path.append(scipy_site)
import numpy as np
from scipy.spatial import cKDTree
points = sample_points_from_segments(segments)
if len(points) < 2:
return [], math.inf, None
coordinates = np.asarray([(point[1], point[2]) for point in points], dtype=np.float64)
curve_ids = np.asarray([point[0] for point in points], dtype=np.int32)
tree = cKDTree(coordinates)
k = min(128, len(points))
chunk_size = 50000
nearest_distances: list[float] = []
min_distance = math.inf
closest_points: dict[str, float] | None = None
for start in range(0, len(points), chunk_size):
stop = min(start + chunk_size, len(points))
try:
distances, indices = tree.query(coordinates[start:stop], k=k, workers=-1)
except TypeError:
distances, indices = tree.query(coordinates[start:stop], k=k)
if k == 1:
distances = distances[:, np.newaxis]
indices = indices[:, np.newaxis]
candidate_curve_ids = curve_ids[indices]
current_curve_ids = curve_ids[start:stop, np.newaxis]
valid = candidate_curve_ids != current_curve_ids
found = valid.any(axis=1)
if not found.any():
continue
first_valid = valid.argmax(axis=1)
row_numbers = np.arange(stop - start)
found_rows = row_numbers[found]
found_columns = first_valid[found]
values = distances[found_rows, found_columns]
finite = np.isfinite(values)
if not finite.any():
continue
values = values[finite]
found_rows = found_rows[finite]
found_columns = found_columns[finite]
nearest_distances.extend(values.tolist())
local_min_index = int(np.argmin(values))
local_min = float(values[local_min_index])
if local_min < min_distance:
source_index = start + int(found_rows[local_min_index])
target_index = int(indices[found_rows[local_min_index], found_columns[local_min_index]])
min_distance = local_min
closest_points = {
"ax": float(coordinates[source_index, 0]),
"ay": float(coordinates[source_index, 1]),
"bx": float(coordinates[target_index, 0]),
"by": float(coordinates[target_index, 1]),
}
return nearest_distances, min_distance, closest_points
def compute_nearest_spacing_grid(
segments: list[Segment],
cell_size: float,
) -> tuple[list[float], float, dict[str, float] | None]:
points = sample_points_from_segments(segments)
grid: dict[tuple[int, int], list[int]] = {}
min_grid_x = min_grid_y = math.inf
max_grid_x = max_grid_y = -math.inf
for index, (_, x, y) in enumerate(points):
cx = math.floor(x / cell_size)
cy = math.floor(y / cell_size)
min_grid_x = min(min_grid_x, cx)
min_grid_y = min(min_grid_y, cy)
max_grid_x = max(max_grid_x, cx)
max_grid_y = max(max_grid_y, cy)
grid.setdefault((cx, cy), []).append(index)
max_ring = int(max(max_grid_x - min_grid_x, max_grid_y - min_grid_y) + 2) if math.isfinite(min_grid_x) else 0
nearest_distances: list[float] = []
min_distance = math.inf
closest_points: dict[str, float] | None = None
for index, point in enumerate(points):
curve_id, x, y = point
base_cx = math.floor(x / cell_size)
base_cy = math.floor(y / cell_size)
seen: set[int] = set()
nearest_sq = math.inf
for ring in range(max_ring + 1):
for cx in range(base_cx - ring, base_cx + ring + 1):
for cy in range(base_cy - ring, base_cy + ring + 1):
if ring > 0 and base_cx - ring < cx < base_cx + ring and base_cy - ring < cy < base_cy + ring:
continue
bucket = grid.get((cx, cy))
if not bucket:
continue
for candidate_index in bucket:
if candidate_index == index or candidate_index in seen:
continue
seen.add(candidate_index)
candidate_curve_id, candidate_x, candidate_y = points[candidate_index]
if candidate_curve_id == curve_id:
continue
dx = x - candidate_x
dy = y - candidate_y
distance_sq = dx * dx + dy * dy
if distance_sq < nearest_sq:
nearest_sq = distance_sq
if distance_sq < min_distance * min_distance:
distance = math.sqrt(distance_sq)
min_distance = distance
closest_points = {
"ax": x,
"ay": y,
"bx": candidate_x,
"by": candidate_y,
}
if math.isfinite(nearest_sq) and ring * cell_size > math.sqrt(nearest_sq) + cell_size * 2:
break
if math.isfinite(nearest_sq):
nearest_distances.append(math.sqrt(nearest_sq))
return nearest_distances, min_distance, closest_points
def sample_points_from_segments(segments: list[Segment]) -> list[tuple[int, float, float]]:
points: list[tuple[int, float, float]] = []
for index, segment in enumerate(segments):
curve_id = int(segment[0])
points.append((curve_id, segment[1], segment[2]))
next_segment = segments[index + 1] if index + 1 < len(segments) else None
if next_segment is None or int(next_segment[0]) != curve_id:
points.append((curve_id, segment[3], segment[4]))
return points
def cell_bounds(segment: Segment, cell_size: float) -> tuple[int, int, int, int]:
return (
math.floor(segment[5] / cell_size),
math.floor(segment[6] / cell_size),
math.floor(segment[7] / cell_size),
math.floor(segment[8] / cell_size),
)
def segment_distance(a: Segment, b: Segment) -> tuple[float, Point, Point]:
pa, pb = closest_segment_points((a[1], a[2]), (a[3], a[4]), (b[1], b[2]), (b[3], b[4]))
return point_distance(pa, pb), pa, pb
def closest_segment_points(p1: Point, q1: Point, p2: Point, q2: Point) -> tuple[Point, Point]:
d1 = sub(q1, p1)
d2 = sub(q2, p2)
r = sub(p1, p2)
a = dot(d1, d1)
e = dot(d2, d2)
f = dot(d2, r)
s = 0.0
t = 0.0
epsilon = 1e-9
if a <= epsilon and e <= epsilon:
return p1, p2
if a <= epsilon:
t = clamp01(f / e)
else:
c = dot(d1, r)
if e <= epsilon:
s = clamp01(-c / a)
else:
b = dot(d1, d2)
denom = a * e - b * b
s = clamp01((b * f - c * e) / denom) if denom != 0 else 0.0
t_nom = b * s + f
if t_nom < 0:
t = 0.0
s = clamp01(-c / a)
elif t_nom > e:
t = 1.0
s = clamp01((b - c) / a)
else:
t = t_nom / e
return add(p1, mul(d1, s)), add(p2, mul(d2, t))
def parse_transform(raw: str) -> Matrix:
matrix = IDENTITY
for name, args_raw in TRANSFORM_RE.findall(raw):
args = [parse_float(item) for item in NUMBER_RE.findall(args_raw)]
name = name.lower()
next_matrix = IDENTITY
if name == "matrix" and len(args) >= 6:
next_matrix = (args[0], args[1], args[2], args[3], args[4], args[5])
elif name == "translate" and args:
next_matrix = (1.0, 0.0, 0.0, 1.0, args[0], args[1] if len(args) > 1 else 0.0)
elif name == "scale" and args:
sx = args[0]
sy = args[1] if len(args) > 1 else sx
next_matrix = (sx, 0.0, 0.0, sy, 0.0, 0.0)
elif name == "rotate" and args:
angle = math.radians(args[0])
cos_a = math.cos(angle)
sin_a = math.sin(angle)
rotate = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
if len(args) >= 3:
next_matrix = multiply_matrix(
multiply_matrix((1.0, 0.0, 0.0, 1.0, args[1], args[2]), rotate),
(1.0, 0.0, 0.0, 1.0, -args[1], -args[2]),
)
else:
next_matrix = rotate
elif name == "skewx" and args:
next_matrix = (1.0, 0.0, math.tan(math.radians(args[0])), 1.0, 0.0, 0.0)
elif name == "skewy" and args:
next_matrix = (1.0, math.tan(math.radians(args[0])), 0.0, 1.0, 0.0, 0.0)
matrix = multiply_matrix(matrix, next_matrix)
return matrix
def multiply_matrix(left: Matrix, right: Matrix) -> Matrix:
a1, b1, c1, d1, e1, f1 = left
a2, b2, c2, d2, e2, f2 = right
return (
a1 * a2 + c1 * b2,
b1 * a2 + d1 * b2,
a1 * c2 + c1 * d2,
b1 * c2 + d1 * d2,
a1 * e2 + c1 * f2 + e1,
b1 * e2 + d1 * f2 + f1,
)
def transform_point(matrix: Matrix, point: Point) -> Point:
a, b, c, d, e, f = matrix
x, y = point
return a * x + c * y + e, b * x + d * y + f
def parse_points(raw: str) -> list[Point]:
values = [parse_float(item) for item in NUMBER_RE.findall(raw)]
return [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)]
def parse_svg_length(raw: str | None) -> float:
if not raw:
return 0.0
return parse_float(raw)
def parse_float(raw: str | float) -> float:
if isinstance(raw, float):
return raw
try:
return float(raw)
except ValueError:
match = NUMBER_RE.search(raw)
return float(match.group(0)) if match else 0.0
def percentile_value(sorted_values: list[float], percentile: float) -> float:
if not sorted_values:
return math.inf
if percentile <= 0:
return sorted_values[0]
index = math.floor((percentile / 100.0) * (len(sorted_values) - 1))
return sorted_values[max(0, min(len(sorted_values) - 1, index))]
def px_to_mm(px: float) -> float:
return (px / DPI) * MM_PER_INCH
def scale_closest_points(points: dict[str, float] | None, scale: float) -> dict[str, float] | None:
if not points:
return None
return {key: value * scale for key, value in points.items()}
def point_distance(a: Point, b: Point) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def lerp_point(a: Point, b: Point, t: float) -> Point:
return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
def sub(a: Point, b: Point) -> Point:
return a[0] - b[0], a[1] - b[1]
def add(a: Point, b: Point) -> Point:
return a[0] + b[0], a[1] + b[1]
def mul(a: Point, value: float) -> Point:
return a[0] * value, a[1] * value
def dot(a: Point, b: Point) -> float:
return a[0] * b[0] + a[1] * b[1]
def clamp01(value: float) -> float:
return max(0.0, min(1.0, value))
+32
View File
@@ -100,6 +100,38 @@ class Asset(BaseModel):
created_at: datetime created_at: datetime
class LineSpacingAnalysisRequest(BaseModel):
percentile: float = Field(default=3, ge=0, le=100)
elementWidth: float = Field(gt=0)
elementHeight: float = Field(gt=0)
sampleStep: float = Field(default=2, gt=0)
class LineSpacingClosestPoints(BaseModel):
ax: float
ay: float
bx: float
by: float
class LineSpacingAnalysisSummary(BaseModel):
percentile: float
spacingPx: float
spacingMm: float
minSpacingPx: float
minSpacingMm: float
sampleStep: float
curveCount: int
segmentCount: int
nearestCount: int
sourceWidth: float
sourceHeight: float
elementWidth: float
elementHeight: float
computedAt: str
closestPoints: LineSpacingClosestPoints | None = None
class DesignTemplate(BaseModel): class DesignTemplate(BaseModel):
template_id: str template_id: str
name: str name: str
@@ -0,0 +1,90 @@
{
"template_id": "tmpl_38311b4587e04821966231f4eb4ce9d7",
"name": "陪你度过漫长岁月",
"description": "",
"document": {
"width": 559.3700787401575,
"height": 793.7007874015749,
"background": "#ffffff",
"layers": [
{
"id": "layer-default",
"name": "图层 1",
"visible": true,
"locked": false
},
{
"id": "7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
"name": "原图遮罩",
"visible": true,
"locked": false,
"folderId": "3c987a17-de95-472d-8755-1a1f539675e2"
},
{
"id": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
"name": "词云",
"visible": true,
"locked": false,
"folderId": "3c987a17-de95-472d-8755-1a1f539675e2"
}
],
"layerFolders": [
{
"id": "3c987a17-de95-472d-8755-1a1f539675e2",
"name": "词云文件夹 22:13",
"layerIds": [
"7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
"833f7ef1-74ec-4848-b1f6-60e6885ec6a4"
],
"collapsed": false
}
],
"elements": [
{
"id": "b0080484-2f6a-4289-b7f3-958ad0543b1d",
"type": "sticker",
"assetId": "asset_26fccdb1f8c74e8689a1db2f3e320e91",
"layerId": "7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
"groupId": "8b2d6bad-e0d4-4243-b079-e0bb7aa0abde",
"x": -176,
"y": 13,
"width": 931,
"height": 588,
"rotation": 0,
"opacity": 0.34
},
{
"id": "bac6086f-7b64-4e81-b78d-ec57f08379e9",
"type": "sticker",
"assetId": "asset_66d7cf53d2d64d3baf770f292d3bedd1",
"layerId": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
"groupId": "8b2d6bad-e0d4-4243-b079-e0bb7aa0abde",
"x": -176,
"y": 13,
"width": 931,
"height": 588,
"rotation": 0,
"opacity": 1
},
{
"id": "a3e8d52c-50a9-4985-bce4-e10be96e9a63",
"type": "sticker",
"assetId": "asset_fcbb587f2be14fc698b3876855124ec3",
"layerId": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
"x": 77,
"y": 435,
"width": 511,
"height": 326,
"rotation": 0,
"opacity": 1
}
]
},
"reference_asset_ids": [
"asset_9e14a0b2832b4599a2cb008f39bc7730",
"asset_6b39fabb1d474c67a3aea7c439e129f1"
],
"cover_asset_id": "asset_9e14a0b2832b4599a2cb008f39bc7730",
"created_at": "2026-07-06T14:18:21.214896+00:00",
"updated_at": "2026-07-06T14:18:21.214896+00:00"
}
@@ -0,0 +1,122 @@
{
"template_id": "tmpl_e935a04991b047979cf1e5f825e2e440",
"name": "校长笔盒背面",
"description": "",
"document": {
"width": 627.4015748031496,
"height": 177.63779527559055,
"background": "transparent",
"layers": [
{
"id": "layer-default",
"name": "背景图案层",
"visible": true,
"locked": false
},
{
"id": "e6b2559b-760b-49bd-bd57-84ab44ab28a4",
"name": "图层 2",
"visible": true,
"locked": false
},
{
"id": "46fdb3ff-f6cb-4d29-960d-0255a7facb1b",
"name": "原图遮罩",
"visible": true,
"locked": false,
"folderId": "1ca14ff0-39ad-4d52-9c3f-6404d02ab7f8"
},
{
"id": "f7bb6607-1204-4c7c-8670-8948ee585394",
"name": "词云",
"visible": true,
"locked": false,
"folderId": "1ca14ff0-39ad-4d52-9c3f-6404d02ab7f8"
}
],
"layerFolders": [
{
"id": "088e2a5c-2bbe-4b0b-9154-fe48ded8dee9",
"name": "词云文件夹 20:51",
"layerIds": [],
"collapsed": false
},
{
"id": "7bc060a6-d16a-4e5d-a4b2-7c05835ba964",
"name": "词云文件夹 20:57",
"layerIds": [],
"collapsed": false
},
{
"id": "1ca14ff0-39ad-4d52-9c3f-6404d02ab7f8",
"name": "词云文件夹 21:07",
"layerIds": [
"46fdb3ff-f6cb-4d29-960d-0255a7facb1b",
"f7bb6607-1204-4c7c-8670-8948ee585394"
],
"collapsed": false
}
],
"elements": [
{
"id": "b0e05ca7-f406-4640-ae60-4bc217d2ce12",
"type": "sticker",
"assetId": "asset_33b4fb3f0d3e49fd96bb492dc3855cb4",
"layerId": "layer-default",
"x": 1.0015748031496066,
"y": 1,
"width": 104,
"height": 177.63779527559055,
"rotation": 0,
"opacity": 1
},
{
"id": "e0aa1ccc-89dc-4ba6-a39d-1d11f8e6f262",
"type": "sticker",
"assetId": "asset_f1cc6323e9954feeba80c9b1ce052877",
"layerId": "e6b2559b-760b-49bd-bd57-84ab44ab28a4",
"x": 503,
"y": -5,
"width": 126,
"height": 195,
"rotation": 0,
"opacity": 1
},
{
"id": "e2aa5272-c51e-402f-af5f-2f6d0b20488c",
"type": "sticker",
"assetId": "asset_fa68b37898924f2b92cf88512a2394be",
"layerId": "46fdb3ff-f6cb-4d29-960d-0255a7facb1b",
"groupId": "418d1744-4db6-43d5-99d2-c0de196d433b",
"x": 92,
"y": 7,
"width": 440,
"height": 179,
"rotation": 0,
"opacity": 0.34
},
{
"id": "eaeaac4a-ae18-43d1-bcde-8f9f42d85590",
"type": "sticker",
"assetId": "asset_ecb3c2725f0c4fca8d3ab6d7dede3c29",
"layerId": "f7bb6607-1204-4c7c-8670-8948ee585394",
"groupId": "418d1744-4db6-43d5-99d2-c0de196d433b",
"x": 92,
"y": 7,
"width": 440,
"height": 179,
"rotation": 0,
"opacity": 1
}
]
},
"reference_asset_ids": [
"asset_a0453900f607497b93c65270189d18c0",
"asset_269859f7f6614ffc9688242552043aee",
"asset_47c7d9afd78644b5a9b9f30a0e035ecc",
"asset_87a2c3c9f9d94929bc83f3a616df3f42"
],
"cover_asset_id": "asset_a0453900f607497b93c65270189d18c0",
"created_at": "2026-07-06T13:27:21.987091+00:00",
"updated_at": "2026-07-06T13:27:21.987091+00:00"
}
+27
View File
@@ -57,6 +57,32 @@ if [[ "$DEPS_OK" == "0" ]]; then
"$PYTHON" -m pip install -q fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib "$PYTHON" -m pip install -q fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib
fi fi
# SciPy is an optional accelerator for line-spacing analysis. The production
# requirements include it, but local dev environments may already have it in
# the base interpreter while the project venv does not. Reuse that copy when
# the Python ABI matches so startup does not require a network install.
EXTRA_SCIPY_SITE=""
if ! "$PYTHON" -c 'import scipy' >/dev/null 2>&1; then
PYTHON_ABI=$("$PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
for cmd in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do
if command -v "$cmd" >/dev/null 2>&1; then
SCIPY_SITE=$("$cmd" -c '
import pathlib
import scipy
import sys
expected = "'"$PYTHON_ABI"'"
current = f"{sys.version_info.major}.{sys.version_info.minor}"
print(pathlib.Path(scipy.__file__).parents[1] if current == expected else "")
' 2>/dev/null || true)
if [[ -n "$SCIPY_SITE" ]]; then
EXTRA_SCIPY_SITE="$SCIPY_SITE"
echo "[OK] 使用外部 SciPy 加速线距计算: $SCIPY_SITE"
break
fi
fi
done
fi
# ── C++ extension ──────────────────────────────────────── # ── C++ extension ────────────────────────────────────────
EXT=$($PYTHON -c 'import importlib.machinery; print(importlib.machinery.EXTENSION_SUFFIXES[0])') EXT=$($PYTHON -c 'import importlib.machinery; print(importlib.machinery.EXTENSION_SUFFIXES[0])')
EWC_SO="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/ewc_core${EXT}" EWC_SO="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/ewc_core${EXT}"
@@ -83,6 +109,7 @@ fi
# ── start ──────────────────────────────────────────────── # ── start ────────────────────────────────────────────────
echo "[INFO] 启动后端 http://0.0.0.0:${BACKEND_PORT}" echo "[INFO] 启动后端 http://0.0.0.0:${BACKEND_PORT}"
WORDCLOUD_SCIPY_SITE="$EXTRA_SCIPY_SITE" \
PYTHONPATH="$ROOT_DIR/EfficientWordCloud" \ PYTHONPATH="$ROOT_DIR/EfficientWordCloud" \
"$PYTHON" -m uvicorn service.app:app \ "$PYTHON" -m uvicorn service.app:app \
--app-dir "$ROOT_DIR" \ --app-dir "$ROOT_DIR" \
+45 -5
View File
@@ -1,12 +1,13 @@
import { useLayoutEffect, useState } from 'react'; import { useEffect, useLayoutEffect, useState } from 'react';
import type { ThemeMode } from './components/AppSettingsWindow';
import CanvasStudio from './pages/CanvasStudio'; import CanvasStudio from './pages/CanvasStudio';
import HelpPage from './pages/HelpPage';
import TemplateHome from './pages/TemplateHome'; import TemplateHome from './pages/TemplateHome';
import TestWorkbench from './pages/TestWorkbench'; import TestWorkbench from './pages/TestWorkbench';
import { CanvasDocument, WordcloudStickerPayload } from './types'; import { CanvasDocument, WordcloudStickerPayload } from './types';
import { createDefaultDocument } from './lib/canvasDocument'; import { createDefaultDocument } from './lib/canvasDocument';
type AppPage = 'home' | 'canvas' | 'wordcloud'; type AppPage = 'home' | 'canvas' | 'wordcloud' | 'help';
type ThemeMode = 'light' | 'dark' | 'system';
const getStoredTheme = (): ThemeMode => { const getStoredTheme = (): ThemeMode => {
const stored = window.localStorage.getItem('wordcloud-theme'); const stored = window.localStorage.getItem('wordcloud-theme');
@@ -18,22 +19,40 @@ const getSystemTheme = () =>
export default function App() { export default function App() {
const [page, setPage] = useState<AppPage>('home'); const [page, setPage] = useState<AppPage>('home');
const [themeMode] = useState<ThemeMode>(getStoredTheme); const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
const [systemTheme] = useState<'light' | 'dark'>(getSystemTheme); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null); const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null);
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null); const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null);
const openHelp = () => setPage('help');
useLayoutEffect(() => { useLayoutEffect(() => {
const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode; const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode;
document.documentElement.dataset.theme = resolvedTheme; document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themeMode = themeMode; document.documentElement.dataset.themeMode = themeMode;
document.documentElement.style.colorScheme = resolvedTheme; document.documentElement.style.colorScheme = resolvedTheme;
window.localStorage.setItem('wordcloud-theme', themeMode);
}, [themeMode, systemTheme]); }, [themeMode, systemTheme]);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setSystemTheme(event.matches ? 'dark' : 'light');
};
setSystemTheme(media.matches ? 'dark' : 'light');
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, []);
if (page === 'wordcloud') { if (page === 'wordcloud') {
return ( return (
<TestWorkbench <TestWorkbench
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenCanvas={() => setPage('canvas')} onOpenCanvas={() => setPage('canvas')}
onOpenHelp={openHelp}
onImportWordcloudSticker={(payload) => { onImportWordcloudSticker={(payload) => {
setPendingWordcloudSticker(payload); setPendingWordcloudSticker(payload);
setPage('canvas'); setPage('canvas');
@@ -45,8 +64,12 @@ export default function App() {
if (page === 'canvas') { if (page === 'canvas') {
return ( return (
<CanvasStudio <CanvasStudio
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')} onOpenHome={() => setPage('home')}
onOpenWordcloud={() => setPage('wordcloud')} onOpenWordcloud={() => setPage('wordcloud')}
onOpenHelp={openHelp}
initialDocument={initialDocument} initialDocument={initialDocument}
onConsumeInitialDocument={() => setInitialDocument(null)} onConsumeInitialDocument={() => setInitialDocument(null)}
pendingWordcloudSticker={pendingWordcloudSticker} pendingWordcloudSticker={pendingWordcloudSticker}
@@ -55,8 +78,24 @@ export default function App() {
); );
} }
if (page === 'help') {
return (
<HelpPage
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')}
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
/>
);
}
return ( return (
<TemplateHome <TemplateHome
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onCreateBlank={() => { onCreateBlank={() => {
setInitialDocument(createDefaultDocument()); setInitialDocument(createDefaultDocument());
setPage('canvas'); setPage('canvas');
@@ -67,6 +106,7 @@ export default function App() {
}} }}
onOpenCanvas={() => setPage('canvas')} onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')} onOpenWordcloud={() => setPage('wordcloud')}
onOpenHelp={openHelp}
/> />
); );
} }
+4 -1
View File
@@ -3,14 +3,17 @@ import { JobParams } from '../types';
interface AdvancedPanelProps { interface AdvancedPanelProps {
params: JobParams; params: JobParams;
onParamsChange: (partial: Partial<JobParams>) => void; onParamsChange: (partial: Partial<JobParams>) => void;
embedded?: boolean;
} }
export default function AdvancedPanel({ params, onParamsChange }: AdvancedPanelProps) { export default function AdvancedPanel({ params, onParamsChange, embedded = false }: AdvancedPanelProps) {
return ( return (
<> <>
{!embedded && (
<div className="panel-header"> <div className="panel-header">
<div className="panel-title"></div> <div className="panel-title"></div>
</div> </div>
)}
<div className="panel-body"> <div className="panel-body">
{/* SEED */} {/* SEED */}
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react';
import { IconClose, IconSettings } from './Icons';
export type ThemeMode = 'light' | 'dark' | 'system';
const THEME_OPTIONS: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'system', label: '跟随系统' },
];
interface AppSettingsWindowProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onResetLayout?: () => void;
resetLayoutLabel?: string;
}
export default function AppSettingsWindow({
themeMode,
systemTheme,
onThemeModeChange,
onResetLayout,
resetLayoutLabel = '重置当前页面布局',
}: AppSettingsWindowProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
return (
<div className="app-settings" ref={rootRef}>
<button
className="btn btn-secondary btn-sm app-settings-trigger"
type="button"
aria-expanded={open}
onClick={() => setOpen(prev => !prev)}
>
<IconSettings />
</button>
{open && (
<div className="settings-window" role="dialog" aria-label="设置">
<div className="settings-window-header">
<div>
<div className="settings-window-title"></div>
<div className="settings-window-subtitle"></div>
</div>
<button
className="icon-btn"
type="button"
title="关闭设置"
onClick={() => setOpen(false)}
>
<IconClose />
</button>
</div>
<div className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-segmented" role="group" aria-label="外观模式">
{THEME_OPTIONS.map(option => (
<button
key={option.id}
type="button"
className={`settings-segment${themeMode === option.id ? ' active' : ''}`}
title={option.id === 'system' ? `跟随系统,当前${systemTheme === 'dark' ? '深色' : '浅色'}` : option.label}
aria-pressed={themeMode === option.id}
onClick={() => onThemeModeChange(option.id)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="settings-section">
<div className="settings-section-title"></div>
{onResetLayout ? (
<button
className="btn btn-secondary btn-block"
type="button"
onClick={() => {
onResetLayout();
setOpen(false);
}}
>
{resetLayoutLabel}
</button>
) : (
<div className="settings-note"></div>
)}
</div>
</div>
)}
</div>
);
}
+4 -8
View File
@@ -1,18 +1,18 @@
import { useRef, useEffect, useState } from 'react'; import { useRef, useEffect, useState } from 'react';
import { NameLocation, JobResult } from '../types'; import { NameLocation, JobResult } from '../types';
import { apiUrl } from '../lib/api';
import { IconCloudy } from './Icons'; import { IconCloudy } from './Icons';
interface CanvasAreaProps { interface CanvasAreaProps {
maskFile: File | null; maskFile: File | null;
jobResult: JobResult | null; jobResult: JobResult | null;
apiBase: string;
viewMode: '2d' | '3d'; viewMode: '2d' | '3d';
zoom: number; zoom: number;
highlightLocation: NameLocation | null; highlightLocation: NameLocation | null;
} }
export default function CanvasArea({ export default function CanvasArea({
maskFile, jobResult, apiBase, viewMode, zoom, highlightLocation maskFile, jobResult, viewMode, zoom, highlightLocation
}: CanvasAreaProps) { }: CanvasAreaProps) {
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null); const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
@@ -24,16 +24,12 @@ export default function CanvasArea({
return () => URL.revokeObjectURL(url); return () => URL.revokeObjectURL(url);
}, [maskFile]); }, [maskFile]);
// 图片 URL 处理: // 图片 URL 可能来自后端相对路径,也可能是本地预览地址。
// - 若已是完整 http URL 直接使用
// - 若是 /api/... 路径则拼接 apiBase
// - 否则回退到 /api/jobs/{id}/files/png
const resolveImageUrl = (): string | null => { const resolveImageUrl = (): string | null => {
if (!jobResult) return maskPreviewUrl; if (!jobResult) return maskPreviewUrl;
const raw = jobResult.image_url; const raw = jobResult.image_url;
if (!raw || !jobResult.job_id) return maskPreviewUrl; if (!raw || !jobResult.job_id) return maskPreviewUrl;
if (raw.startsWith('http')) return raw; return apiUrl(raw);
return `${apiBase}${raw}`;
}; };
const displayUrl = resolveImageUrl(); const displayUrl = resolveImageUrl();
+104
View File
@@ -0,0 +1,104 @@
import { useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import type { DockSide } from '../hooks/useFloatingPanels';
import { DOCK_WIDTH, DOCK_TOP_RESERVED } from '../hooks/useFloatingPanels';
import { UNDOCK_THRESHOLD } from '../hooks/usePanelDocking';
import { IconClose } from './Icons';
export interface DockTabItem {
id: string;
title: string;
icon?: ReactNode;
}
interface DockTabBarProps {
side: DockSide;
panels: DockTabItem[];
activeId: string | null;
workspaceWidth: number;
/** Current dock column width (user-resizable). Falls back to DOCK_WIDTH. */
columnWidth?: number;
onSelect: (id: string) => void;
onClose: (id: string) => void;
/** Drag a tab away from the bar to free its panel or dock it into another side. */
onUndockTab?: (id: string, point: { clientX: number; clientY: number }) => void;
}
export default function DockTabBar({
side,
panels,
activeId,
workspaceWidth,
columnWidth,
onSelect,
onClose,
onUndockTab,
}: DockTabBarProps) {
if (panels.length < 1) return null;
const width = columnWidth || DOCK_WIDTH;
const x = side === 'left' ? 0 : Math.max(0, workspaceWidth - width);
// Per-tab drag state so each tab can be grabbed and pulled out.
const dragRef = useRef<{ id: string; startX: number; startY: number; moved: boolean } | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const handleTabPointerDown = (event: ReactPointerEvent, id: string) => {
event.stopPropagation();
onSelect(id);
if (!onUndockTab) return;
dragRef.current = { id, startX: event.clientX, startY: event.clientY, moved: false };
setDraggingId(id);
const onMove = (ev: PointerEvent) => {
if (!dragRef.current) return;
const dx = ev.clientX - dragRef.current.startX;
const dy = ev.clientY - dragRef.current.startY;
if (Math.hypot(dx, dy) > UNDOCK_THRESHOLD) dragRef.current.moved = true;
};
const onUp = (ev: PointerEvent) => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const drag = dragRef.current;
dragRef.current = null;
setDraggingId(null);
if (drag && drag.moved && onUndockTab) {
onUndockTab(drag.id, { clientX: ev.clientX, clientY: ev.clientY });
}
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
return (
<div
className="dock-tab-bar"
data-side={side}
style={{ left: x, top: DOCK_TOP_RESERVED, width }}
>
{panels.map(panel => (
<button
key={panel.id}
type="button"
className={`dock-tab${panel.id === activeId ? ' active' : ''}${panel.id === draggingId ? ' dragging' : ''}`}
onClick={() => onSelect(panel.id)}
onPointerDown={event => handleTabPointerDown(event, panel.id)}
>
{panel.icon && <span className="dock-tab-icon">{panel.icon}</span>}
<span className="dock-tab-label">{panel.title}</span>
<span
className="dock-tab-close"
role="button"
aria-label="关闭"
onPointerDown={event => event.stopPropagation()}
onClick={event => {
event.stopPropagation();
onClose(panel.id);
}}
>
<IconClose />
</span>
</button>
))}
</div>
);
}
+4 -1
View File
@@ -5,9 +5,10 @@ import { IconFolder } from './Icons';
interface EditPanelProps { interface EditPanelProps {
entries: NameEntry[]; entries: NameEntry[];
onEntriesChange: (entries: NameEntry[]) => void; onEntriesChange: (entries: NameEntry[]) => void;
embedded?: boolean;
} }
export default function EditPanel({ entries, onEntriesChange }: EditPanelProps) { export default function EditPanel({ entries, onEntriesChange, embedded = false }: EditPanelProps) {
const [filterCol, setFilterCol] = useState(''); const [filterCol, setFilterCol] = useState('');
const [filterVal, setFilterVal] = useState(''); const [filterVal, setFilterVal] = useState('');
@@ -40,9 +41,11 @@ export default function EditPanel({ entries, onEntriesChange }: EditPanelProps)
return ( return (
<> <>
{!embedded && (
<div className="panel-header"> <div className="panel-header">
<div className="panel-title"></div> <div className="panel-title"></div>
</div> </div>
)}
<div className="panel-body" style={{ padding: '10px 10px 0' }}> <div className="panel-body" style={{ padding: '10px 10px 0' }}>
{/* Filter bar */} {/* Filter bar */}
<div className="flex-row" style={{ gap: 4, marginBottom: 8 }}> <div className="flex-row" style={{ gap: 4, marginBottom: 8 }}>
+9 -7
View File
@@ -1,14 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types'; import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
import { apiUrl, ensureOk } from '../lib/api';
interface ExportPanelProps { interface ExportPanelProps {
jobId: string | null; jobId: string | null;
apiBase: string;
svgUrl?: string; svgUrl?: string;
imageUrl?: string; imageUrl?: string;
onOpenCanvas?: () => void; onOpenCanvas?: () => void;
maskSource?: WordcloudMaskSource | null; maskSource?: WordcloudMaskSource | null;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void; onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
embedded?: boolean;
} }
type Format = 'jpg' | 'png'; type Format = 'jpg' | 'png';
@@ -16,12 +17,12 @@ type FillMode = 'fill' | 'dot' | 'line' | 'ring';
export default function ExportPanel({ export default function ExportPanel({
jobId, jobId,
apiBase,
svgUrl, svgUrl,
imageUrl, imageUrl,
onOpenCanvas, onOpenCanvas,
maskSource, maskSource,
onImportWordcloudSticker, onImportWordcloudSticker,
embedded = false,
}: ExportPanelProps) { }: ExportPanelProps) {
const [bmpFormat, setBmpFormat] = useState<Format>('png'); const [bmpFormat, setBmpFormat] = useState<Format>('png');
const [exportW, setExportW] = useState('1920'); const [exportW, setExportW] = useState('1920');
@@ -40,8 +41,8 @@ export default function ExportPanel({
const [ringSpacing, setRingSpacing] = useState(8); const [ringSpacing, setRingSpacing] = useState(8);
const resolveUrl = (field: string | undefined, kind: string) => { const resolveUrl = (field: string | undefined, kind: string) => {
if (field) return field.startsWith('http') ? field : `${apiBase}${field}`; if (field) return apiUrl(field);
if (jobId) return `${apiBase}/api/jobs/${jobId}/files/${kind}`; if (jobId) return apiUrl(`/api/jobs/${jobId}/files/${kind}`);
return null; return null;
}; };
@@ -64,7 +65,7 @@ export default function ExportPanel({
params.set('ring_width', String(ringWidth)); params.set('ring_width', String(ringWidth));
params.set('ring_spacing', String(ringSpacing)); params.set('ring_spacing', String(ringSpacing));
} }
return `${apiBase}/api/jobs/${jobId}/custom.svg?${params}`; return apiUrl(`/api/jobs/${jobId}/custom.svg?${params}`);
}; };
const handleExportSvg = () => { const handleExportSvg = () => {
@@ -79,8 +80,7 @@ export default function ExportPanel({
setIsSavingSticker(true); setIsSavingSticker(true);
try { try {
const res = await fetch(url); const res = await ensureOk(await fetch(url), '读取 SVG 失败');
if (!res.ok) throw new Error(`读取 SVG 失败 (${res.status})`);
const svg = await res.text(); const svg = await res.text();
if (onImportWordcloudSticker) { if (onImportWordcloudSticker) {
onImportWordcloudSticker({ svg, mask: maskSource || undefined }); onImportWordcloudSticker({ svg, mask: maskSource || undefined });
@@ -114,9 +114,11 @@ export default function ExportPanel({
return ( return (
<> <>
{!embedded && (
<div className="panel-header"> <div className="panel-header">
<div className="panel-title"></div> <div className="panel-title"></div>
</div> </div>
)}
<div className="panel-body"> <div className="panel-body">
{/* ── SVG 导出 ── */} {/* ── SVG 导出 ── */}
+7 -5
View File
@@ -1,13 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { NameLocation } from '../types'; import { NameLocation } from '../types';
import { apiUrl, ensureOk } from '../lib/api';
interface FindPanelProps { interface FindPanelProps {
jobId: string | null; jobId: string | null;
apiBase: string;
onLocate: (location: NameLocation) => void; onLocate: (location: NameLocation) => void;
embedded?: boolean;
} }
export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps) { export default function FindPanel({ jobId, onLocate, embedded = false }: FindPanelProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<NameLocation[]>([]); const [results, setResults] = useState<NameLocation[]>([]);
const [currentIdx, setCurrentIdx] = useState(-1); const [currentIdx, setCurrentIdx] = useState(-1);
@@ -19,9 +20,8 @@ export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps)
setLoading(true); setLoading(true);
setSearched(false); setSearched(false);
try { try {
const url = `${apiBase}/api/jobs/${jobId}/locations?name=${encodeURIComponent(query.trim())}`; const url = apiUrl(`/api/jobs/${jobId}/locations?name=${encodeURIComponent(query.trim())}`);
const res = await fetch(url); const res = await ensureOk(await fetch(url), '查找失败');
if (!res.ok) throw new Error('请求失败');
const data = await res.json(); const data = await res.json();
const matches: NameLocation[] = (data.matches ?? []).map((m: any) => ({ const matches: NameLocation[] = (data.matches ?? []).map((m: any) => ({
...m, ...m,
@@ -54,9 +54,11 @@ export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps)
return ( return (
<> <>
{!embedded && (
<div className="panel-header"> <div className="panel-header">
<div className="panel-title"></div> <div className="panel-title"></div>
</div> </div>
)}
<div className="panel-body"> <div className="panel-body">
<div className="form-group"> <div className="form-group">
<label className="form-label" style={{ fontWeight: 600, fontSize: 12 }}></label> <label className="form-label" style={{ fontWeight: 600, fontSize: 12 }}></label>
+285
View File
@@ -0,0 +1,285 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { DockSide, FloatingPanelFrame, FloatingPanelLayout } from '../hooks/useFloatingPanels';
import { computeEdgeHighlight, SnapPreview, WorkspaceSize } from '../hooks/usePanelDocking';
import { IconClose } from './Icons';
type ResizeEdge = 'n' | 'e' | 's' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
interface FloatingPanelProps {
id: string;
title: string;
icon?: ReactNode;
frame: FloatingPanelFrame;
minWidth?: number;
minHeight?: number;
onFrameChange: (partial: Partial<FloatingPanelFrame>) => void;
onFocus: () => void;
onClose?: () => void;
children: ReactNode;
/** Snap computation injected by the page (returns preview geometry or null). */
computeSnap?: (clientX: number, clientY: number) => SnapPreview | null;
/** Called when a drag ends with an active snap preview. */
onDock?: (side: DockSide) => void;
/** Called when a docked panel is dragged away from its dock. */
onUndock?: () => void;
/** Hide panel body (used when a stacked sibling is the active tab). */
hidden?: boolean;
/** True when this docked panel has at least one OTHER open, same-side docked
* panel — i.e. the top DockTabBar is rendering. Hides the per-panel titlebar
* so the tab group shares one header. Computed by the page so it matches the
* DockTabBar's own (open-panels-only) accounting. */
hasTabSiblings?: boolean;
/** Workspace rect + frames, used for the early edge highlight (虚影). */
workspaceSize?: WorkspaceSize | null;
frames?: FloatingPanelLayout<string>;
/** Ref to the workspace node, used to compute cursor-relative localX for the highlight. */
workspaceNodeRef?: MutableRefObject<HTMLElement | null>;
}
interface DragState {
type: 'move' | 'resize';
edge?: ResizeEdge;
startX: number;
startY: number;
frame: FloatingPanelFrame;
pendingSnap: SnapPreview | null;
movedAway: boolean;
}
export default function FloatingPanel({
id,
title,
icon,
frame,
minWidth = 240,
minHeight = 180,
onFrameChange,
onFocus,
onClose,
children,
computeSnap,
onDock,
onUndock,
hidden = false,
hasTabSiblings: hasTabSiblingsProp = false,
workspaceSize = null,
frames,
workspaceNodeRef,
}: FloatingPanelProps) {
const [dragState, setDragState] = useState<DragState | null>(null);
const [snapPreview, setSnapPreview] = useState<SnapPreview | null>(null);
const [edgeHighlight, setEdgeHighlight] = useState<SnapPreview | null>(null);
const frameRef = useRef(frame);
const workspaceRef = useRef<WorkspaceSize | null>(workspaceSize);
const framesRef = useRef<FloatingPanelLayout<string> | undefined>(frames);
useEffect(() => { frameRef.current = frame; }, [frame]);
useEffect(() => { workspaceRef.current = workspaceSize; }, [workspaceSize]);
useEffect(() => { framesRef.current = frames; }, [frames]);
const startMove = (event: ReactPointerEvent) => {
event.preventDefault();
onFocus();
setSnapPreview(null);
setEdgeHighlight(null);
setDragState({
type: 'move',
startX: event.clientX,
startY: event.clientY,
frame: frameRef.current,
pendingSnap: null,
movedAway: false,
});
};
const startResize = (event: ReactPointerEvent, edge: ResizeEdge) => {
event.preventDefault();
event.stopPropagation();
onFocus();
setDragState({
type: 'resize',
edge,
startX: event.clientX,
startY: event.clientY,
frame: frameRef.current,
pendingSnap: null,
movedAway: false,
});
};
const handleMove = useCallback((event: PointerEvent) => {
if (!dragState) return;
const dx = event.clientX - dragState.startX;
const dy = event.clientY - dragState.startY;
if (dragState.type === 'move') {
const nextX = Math.max(8, dragState.frame.x + dx);
const nextY = Math.max(8, dragState.frame.y + dy);
onFrameChange({ x: nextX, y: nextY });
if (computeSnap) {
const preview = computeSnap(event.clientX, event.clientY);
const hasSnap = !!(preview && preview.side);
setSnapPreview(hasSnap ? preview : null);
const movedAway = !!(dragState.frame.docked && preview && preview.side === null);
setDragState(prev => (prev ? { ...prev, pendingSnap: preview, movedAway } : prev));
// While the cursor is in the edge band but not yet snapping, show the
// slim edge highlight so the user sees the dock target early.
if (hasSnap) {
setEdgeHighlight(null);
} else {
const rect = workspaceNodeRef?.current?.getBoundingClientRect();
const localX = rect ? event.clientX - rect.left : 0;
const highlight = computeEdgeHighlight(
localX,
frameRef.current,
id,
workspaceRef.current,
framesRef.current ?? {},
);
setEdgeHighlight(highlight);
}
return;
}
setEdgeHighlight(null);
return;
}
// resize branch
const edge = dragState.edge || 'se';
let x = dragState.frame.x;
let y = dragState.frame.y;
let width = dragState.frame.width;
let height = dragState.frame.height;
if (edge.includes('e')) width = Math.max(minWidth, dragState.frame.width + dx);
if (edge.includes('s')) height = Math.max(minHeight, dragState.frame.height + dy);
if (edge.includes('w')) {
width = Math.max(minWidth, dragState.frame.width - dx);
x = dragState.frame.x + (dragState.frame.width - width);
}
if (edge.includes('n')) {
height = Math.max(minHeight, dragState.frame.height - dy);
y = dragState.frame.y + (dragState.frame.height - height);
}
onFrameChange({ x: Math.max(8, x), y: Math.max(8, y), width, height });
}, [dragState, minHeight, minWidth, onFrameChange, computeSnap, id]);
useEffect(() => {
if (!dragState) return;
document.body.classList.add(dragState.type === 'move' ? 'floating-panel-moving' : 'resizing');
const stop = () => {
if (dragState.type === 'move') {
if (dragState.pendingSnap && dragState.pendingSnap.side && onDock) {
onDock(dragState.pendingSnap.side);
} else if (dragState.movedAway && onUndock) {
onUndock();
}
}
setSnapPreview(null);
setEdgeHighlight(null);
setDragState(null);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
return () => {
document.body.classList.remove('floating-panel-moving');
document.body.classList.remove('resizing');
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
};
}, [dragState, handleMove, onDock, onUndock]);
// Edge highlight takes precedence in rendering only when there is no full snap preview.
const highlight = edgeHighlight && edgeHighlight.side && !snapPreview ? edgeHighlight : null;
// Determine whether to hide the per-panel titlebar. Prefer the page-supplied
// prop (which matches the DockTabBar's open-panels-only accounting); fall back
// to a frames-only check so a stale persisted layout can't leave a docked
// panel with no header if the page forgot to pass the prop.
const hasTabSiblings = hasTabSiblingsProp || !!(frame.docked && frames && (Object.keys(frames) as string[])
.filter(otherId => otherId !== id)
.some(otherId => frames[otherId]?.docked === frame.docked
// Exclude panels with zero geometry (e.g. a closed-but-persisted leftover)
&& (frames[otherId]?.width || 0) > 0 && (frames[otherId]?.height || 0) > 0));
return (
<section
className="floating-panel"
data-panel-id={id}
data-docked={frame.docked ?? 'none'}
data-hidden={hidden ? 'true' : 'false'}
data-active-tab={frame.docked && !hidden ? 'true' : 'false'}
style={{
left: frame.x,
top: frame.y,
width: frame.width,
height: frame.height,
zIndex: frame.zIndex,
display: hidden ? 'none' : undefined,
}}
onPointerDown={onFocus}
>
{/* Docked panels always share the top dock-tab-bar, including a single
docked panel, so the panel content can fill the whole dock column. */}
{frame.docked ? null : (
<div className="floating-panel-titlebar" onPointerDown={startMove}>
<div className="floating-panel-title">
{icon && <span className="floating-panel-icon">{icon}</span>}
<span>{title}</span>
</div>
{onClose && (
<button
className="floating-panel-close"
type="button"
title="关闭面板"
onPointerDown={event => event.stopPropagation()}
onClick={onClose}
>
<IconClose />
</button>
)}
</div>
)}
<div className="floating-panel-content">{children}</div>
{(['n', 'e', 's', 'w', 'ne', 'nw', 'se', 'sw'] as ResizeEdge[]).map(edge => {
// When docked, the column geometry is managed by the restack logic.
// Allow horizontal resize (e/w) so the user can widen/narrow the dock
// column, but hide vertical and corner handles — height is full-column.
const hideEdge = !!frame.docked && !(edge === 'e' || edge === 'w');
return (
<span
key={edge}
className={`floating-resize floating-resize-${edge}`}
style={hideEdge ? { display: 'none' } : undefined}
onPointerDown={event => startResize(event, edge)}
/>
);
})}
{highlight && (
<div
className="floating-panel-edge-highlight"
style={{
left: highlight.x,
top: highlight.y,
width: highlight.width,
height: highlight.height,
}}
/>
)}
{snapPreview && snapPreview.side && (
<div
className="floating-panel-snap-guide"
style={{
left: snapPreview.x,
top: snapPreview.y,
width: snapPreview.width,
height: snapPreview.height,
}}
/>
)}
</section>
);
}
+10
View File
@@ -258,3 +258,13 @@ export function IconDownload() {
</Icon> </Icon>
); );
} }
export function IconHelp() {
return (
<Icon>
<circle cx="8" cy="8" r="7" />
<path d="M6 6a2 2 0 1 1 3.2 1.6c-.8.5-1.2 1-1.2 2" />
<path d="M8 13h.01" />
</Icon>
);
}
+4
View File
@@ -15,6 +15,7 @@ interface ImportPanelProps {
onFontUpload: (file: File) => void; onFontUpload: (file: File) => void;
onFontDelete: (fontId: string) => void; onFontDelete: (fontId: string) => void;
onFontSelect: (fontId: string) => void; onFontSelect: (fontId: string) => void;
embedded?: boolean;
} }
const TEMPLATE_URL = '#'; const TEMPLATE_URL = '#';
@@ -23,6 +24,7 @@ export default function ImportPanel({
maskFile, namesFile, params, fonts, selectedFontId, maskFile, namesFile, params, fonts, selectedFontId,
onMaskChange, onNamesChange, onParamsChange, onMaskChange, onNamesChange, onParamsChange,
onFontUpload, onFontDelete, onFontSelect, onFontUpload, onFontDelete, onFontSelect,
embedded = false,
}: ImportPanelProps) { }: ImportPanelProps) {
const fontInputRef = useRef<HTMLInputElement>(null); const fontInputRef = useRef<HTMLInputElement>(null);
@@ -36,9 +38,11 @@ export default function ImportPanel({
return ( return (
<> <>
{!embedded && (
<div className="panel-header"> <div className="panel-header">
<div className="panel-title"></div> <div className="panel-title"></div>
</div> </div>
)}
<div className="panel-body"> <div className="panel-body">
{/* 底图导入 */} {/* 底图导入 */}
+5 -2
View File
@@ -1,6 +1,9 @@
import type { CSSProperties } from 'react';
interface ViewControlsProps { interface ViewControlsProps {
zoom: number; zoom: number;
viewMode: '2d' | '3d'; viewMode: '2d' | '3d';
style?: CSSProperties;
onZoomIn: () => void; onZoomIn: () => void;
onZoomOut: () => void; onZoomOut: () => void;
onZoomReset: () => void; onZoomReset: () => void;
@@ -8,10 +11,10 @@ interface ViewControlsProps {
} }
export default function ViewControls({ export default function ViewControls({
zoom, viewMode, onZoomIn, onZoomOut, onZoomReset, onToggleView zoom, viewMode, style, onZoomIn, onZoomOut, onZoomReset, onToggleView
}: ViewControlsProps) { }: ViewControlsProps) {
return ( return (
<div className="view-controls"> <div className="view-controls" style={style}>
<button className="view-btn" title="缩小" onClick={onZoomOut}></button> <button className="view-btn" title="缩小" onClick={onZoomOut}></button>
<button className="view-btn" title="重置缩放" onClick={onZoomReset} style={{ fontSize: 10, width: 'auto', padding: '0 4px' }}> <button className="view-btn" title="重置缩放" onClick={onZoomReset} style={{ fontSize: 10, width: 'auto', padding: '0 4px' }}>
{Math.round(zoom * 100)}% {Math.round(zoom * 100)}%
+224
View File
@@ -0,0 +1,224 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export type DockSide = 'left' | 'right';
export const DOCK_WIDTH = 280;
export const FREE_WIDTH = 320;
export const TAB_BAR_HEIGHT = 30;
/** Inset at the top of a dock column. Set to 0 so tabs/columns flush with the
* top edge of the workspace — no gap between the dock and the screen top. */
export const DOCK_TOP_RESERVED = 0;
export interface FloatingPanelFrame {
x: number;
y: number;
width: number;
height: number;
zIndex: number;
docked: DockSide | null;
}
export type FloatingPanelLayout<T extends string> = Record<T, FloatingPanelFrame>;
interface PersistedLayout {
frames: Partial<FloatingPanelLayout<string>>;
activeTab: Partial<Record<DockSide, string | null>>;
}
export function useFloatingPanels<T extends string>(
storageKey: string,
defaultLayout: FloatingPanelLayout<T>,
) {
const defaults = useMemo(() => defaultLayout, [defaultLayout]);
const dockOrder = useRef<string[]>([]);
const ensureDocked = (frame: FloatingPanelFrame): FloatingPanelFrame => ({
...frame,
docked: frame.docked === 'left' || frame.docked === 'right' ? frame.docked : null,
});
const mergeWithDefaults = (storedFrames: Partial<FloatingPanelLayout<T>> = {}) =>
Object.keys(defaults).reduce((acc, key) => {
const id = key as T;
acc[id] = ensureDocked({ ...defaults[id], ...(storedFrames[id] || {}) });
return acc;
}, {} as FloatingPanelLayout<T>);
const [frames, setFrames] = useState<FloatingPanelLayout<T>>(() => {
try {
const stored = window.localStorage.getItem(storageKey);
if (!stored) return defaults;
const parsed = JSON.parse(stored) as Partial<PersistedLayout> | Partial<FloatingPanelLayout<T>>;
// support both new ({frames, activeTab}) and legacy (raw frames) payloads
const storedFrames = parsed && 'frames' in parsed && parsed.frames
? parsed.frames as Partial<FloatingPanelLayout<T>>
: parsed as Partial<FloatingPanelLayout<T>>;
return mergeWithDefaults(storedFrames);
} catch {
return defaults;
}
});
const firstDockedPanel = (layout: FloatingPanelLayout<T>, side: DockSide): string | null => {
const id = (Object.keys(layout) as T[]).find(panelId => layout[panelId]?.docked === side);
return id ? (id as unknown as string) : null;
};
const [activeTab, setActiveTabState] = useState<Record<DockSide, string | null>>(() => {
try {
const stored = window.localStorage.getItem(storageKey);
if (!stored) {
return {
left: firstDockedPanel(defaults, 'left'),
right: firstDockedPanel(defaults, 'right'),
};
}
const parsed = JSON.parse(stored) as Partial<PersistedLayout>;
if (!parsed || !('frames' in parsed)) {
return {
left: firstDockedPanel(defaults, 'left'),
right: firstDockedPanel(defaults, 'right'),
};
}
const storedFrames = parsed.frames as Partial<FloatingPanelLayout<T>> | undefined;
const layout = mergeWithDefaults(storedFrames);
const storedLeft = parsed.activeTab?.left ?? null;
const storedRight = parsed.activeTab?.right ?? null;
return {
left: storedLeft && layout[storedLeft as T]?.docked === 'left' ? storedLeft : firstDockedPanel(layout, 'left'),
right: storedRight && layout[storedRight as T]?.docked === 'right' ? storedRight : firstDockedPanel(layout, 'right'),
};
} catch {
return { left: null, right: null };
}
});
useEffect(() => {
setActiveTabState(prev => {
const next = { ...prev };
let changed = false;
(['left', 'right'] as const).forEach(side => {
const currentId = next[side] as T | null;
if (currentId && frames[currentId]?.docked === side) return;
const fallback = firstDockedPanel(frames, side);
if (fallback !== next[side]) {
next[side] = fallback;
changed = true;
}
});
return changed ? next : prev;
});
}, [frames]);
// Maintain dock order for stacking computation (insert on first dock).
useEffect(() => {
const ids = Object.keys(frames) as string[];
ids.forEach(id => {
const frame = (frames as FloatingPanelLayout<string>)[id];
if (frame.docked && !dockOrder.current.includes(id)) dockOrder.current.push(id);
});
dockOrder.current = dockOrder.current.filter(id => ids.includes(id) && (frames as FloatingPanelLayout<string>)[id].docked);
}, [frames]);
useEffect(() => {
const payload: PersistedLayout = {
frames: frames as unknown as Partial<FloatingPanelLayout<string>>,
activeTab,
};
window.localStorage.setItem(storageKey, JSON.stringify(payload));
}, [frames, activeTab, storageKey]);
const updateFrame = useCallback((id: T, partial: Partial<FloatingPanelFrame>) => {
setFrames(prev => ({
...prev,
[id]: { ...prev[id], ...partial },
}));
}, []);
const focusPanel = useCallback((id: T) => {
setFrames(prev => {
const values = Object.values(prev) as FloatingPanelFrame[];
const maxZ = Math.max(...values.map(frame => frame.zIndex));
if (prev[id].zIndex === maxZ) return prev;
return {
...prev,
[id]: { ...prev[id], zIndex: maxZ + 1 },
};
});
}, []);
const dockedPanelsSorted = (prev: FloatingPanelLayout<T>, side: DockSide, excludeId: T) =>
(Object.keys(prev) as T[])
.filter(id => id !== excludeId && prev[id].docked === side)
.sort((a, b) => prev[a].y - prev[b].y);
const dockPanel = useCallback((id: T, side: DockSide, workspaceSize: { width: number; height: number }) => {
setFrames(prev => {
const others = dockedPanelsSorted(prev, side, id);
// Tab mode: every docked panel on this side shares the SAME full-column
// geometry. Only the active tab is visible (others are display:none via
// the `hidden` prop), so we do NOT split height or stack vertically.
const topInset = DOCK_TOP_RESERVED + TAB_BAR_HEIGHT;
const availHeight = Math.max(120, workspaceSize.height - topInset);
const currentWidth = others[0] ? prev[others[0]].width : prev[id].docked === side ? prev[id].width : DOCK_WIDTH;
const columnWidth = Math.max(220, Math.min(560, currentWidth || DOCK_WIDTH));
const x = side === 'left' ? 0 : Math.max(0, workspaceSize.width - columnWidth);
const y = topInset;
const height = availHeight;
const next = { ...prev } as FloatingPanelLayout<T>;
others.forEach(otherId => {
next[otherId] = { ...next[otherId], x, y, width: columnWidth, height };
});
next[id] = { ...next[id], docked: side, x, y, width: columnWidth, height };
return next;
});
// Newly docked panel becomes the active tab so it's immediately visible.
setActiveTabState(prev => ({ ...prev, [side]: id as unknown as string }));
}, []);
const undockPanel = useCallback((id: T) => {
setFrames(prev => {
if (!prev[id].docked) return prev;
const side = prev[id].docked;
const next = {
...prev,
[id]: { ...prev[id], docked: null, width: Math.max(prev[id].width, FREE_WIDTH) },
} as FloatingPanelLayout<T>;
// Promote another docked sibling on the same side to active tab.
const sibling = (Object.keys(next) as T[]).find(otherId => next[otherId].docked === side);
setActiveTabState(prevTabs => ({
...prevTabs,
[side]: sibling ? (sibling as unknown as string) : null,
}));
return next;
});
}, []);
const setActiveTab = useCallback((side: DockSide, id: string | null) => {
setActiveTabState(prev => ({ ...prev, [side]: id }));
}, []);
// Resize the whole dock column at once: every docked panel on `side` adopts
// the new width (and left-dock clamps x to 0, right-dock tracks the new width
// against the workspace's right edge). Called from the e/w resize handles.
const resizeDockColumn = useCallback((side: DockSide, width: number, workspaceSizeRef: { width: number }) => {
setFrames(prev => {
const ids = (Object.keys(prev) as T[]).filter(id => prev[id].docked === side);
if (ids.length === 0) return prev;
const clampedWidth = Math.max(220, Math.min(560, Math.round(width)));
const x = side === 'left' ? 0 : Math.max(0, workspaceSizeRef.width - clampedWidth);
const next = { ...prev } as FloatingPanelLayout<T>;
ids.forEach(id => {
next[id] = { ...next[id], x, width: clampedWidth };
});
return next;
});
}, []);
const resetFrames = useCallback(() => {
dockOrder.current = [];
setFrames(defaults);
setActiveTabState({ left: null, right: null });
}, [defaults]);
return { frames, updateFrame, focusPanel, resetFrames, dockPanel, undockPanel, activeTab, setActiveTab, resizeDockColumn };
}
+109
View File
@@ -0,0 +1,109 @@
import type { DockSide, FloatingPanelFrame, FloatingPanelLayout } from './useFloatingPanels';
import { DOCK_WIDTH, DOCK_TOP_RESERVED, TAB_BAR_HEIGHT } from './useFloatingPanels';
export const SNAP_THRESHOLD = 12;
export const UNDOCK_THRESHOLD = 24;
export interface SnapPreview {
side: DockSide | null; // null means undock signal
x: number;
y: number;
width: number;
height: number;
}
export interface WorkspaceSize {
width: number;
height: number;
}
/**
* Shared docking/snap math. Pure functions so both FloatingPanel (during drag)
* and the page (to render the guide) can use them.
*/
export function dockedStackOffset<T extends string>(
frames: FloatingPanelLayout<T>,
side: DockSide,
excludeId: T,
): number {
let offset = 0;
(Object.keys(frames) as T[])
.filter(id => id !== excludeId && frames[id].docked === side)
.sort((a, b) => frames[a].y - frames[b].y)
.forEach(id => {
offset += frames[id].height;
});
return offset;
}
export function computeSnap<T extends string>(
clientX: number,
localX: number,
frame: FloatingPanelFrame,
panelId: T,
workspaceSize: WorkspaceSize | null,
frames: FloatingPanelLayout<T>,
): SnapPreview | null {
if (!workspaceSize) return null;
// Undock check: currently docked and dragged inward past threshold.
if (frame.docked) {
const dockX = frame.x;
if (Math.abs(localX - dockX) > UNDOCK_THRESHOLD && Math.abs(localX) > SNAP_THRESHOLD * 2) {
return { side: null, x: 0, y: 0, width: 0, height: 0 };
}
return null;
}
let side: DockSide | null = null;
if (localX < SNAP_THRESHOLD) side = 'left';
else if (localX + frame.width > workspaceSize.width - SNAP_THRESHOLD) side = 'right';
if (!side) return null;
const topInset = DOCK_TOP_RESERVED + TAB_BAR_HEIGHT;
const availHeight = Math.max(120, workspaceSize.height - topInset);
const dockedSibling = (Object.keys(frames) as T[])
.find(id => id !== panelId && frames[id].docked === side);
const columnWidth = dockedSibling
? Math.max(220, Math.min(560, frames[dockedSibling].width || DOCK_WIDTH))
: DOCK_WIDTH;
// Tab mode: snapped panels overlay the same full-column geometry; the
// preview should match so releasing drops the panel exactly in place.
const x = side === 'left' ? 0 : Math.max(0, workspaceSize.width - columnWidth);
return { side, x, y: topInset, width: columnWidth, height: availHeight };
}
/**
* A lightweight edge highlight shown while dragging near a dockable edge,
* before the full snap preview kicks in. It's a slim vertical strip hugging
* the side, full column height — the "虚影" the user asked for.
*
* `localX` is the cursor's x relative to the workspace left edge (caller
* computes this from the workspace rect, same convention as computeSnap).
*/
export function computeEdgeHighlight<T extends string>(
localX: number,
frame: FloatingPanelFrame,
panelId: T,
workspaceSize: WorkspaceSize | null,
frames: FloatingPanelLayout<T>,
): SnapPreview | null {
if (!workspaceSize || frame.docked) return null;
// Wider band than SNAP_THRESHOLD so the highlight appears early.
const EDGE_BAND = 60;
let side: DockSide | null = null;
const xEnd = localX + frame.width;
if (localX < EDGE_BAND) side = 'left';
else if (xEnd > workspaceSize.width - EDGE_BAND) side = 'right';
if (!side) return null;
const topInset = DOCK_TOP_RESERVED + TAB_BAR_HEIGHT;
const dockedSibling = (Object.keys(frames) as T[])
.find(id => id !== panelId && frames[id].docked === side);
const columnWidth = dockedSibling
? Math.max(220, Math.min(560, frames[dockedSibling].width || DOCK_WIDTH))
: DOCK_WIDTH;
const x = side === 'left' ? 0 : Math.max(0, workspaceSize.width - columnWidth);
const height = Math.max(120, workspaceSize.height - topInset);
return { side, x, y: topInset, width: columnWidth, height };
}
+33
View File
@@ -0,0 +1,33 @@
const rawApiBase = (import.meta.env.VITE_API_BASE ?? '').trim();
export const API_BASE = rawApiBase.replace(/\/+$/, '');
export function apiUrl(path: string) {
if (!path) return API_BASE || '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:') || path.startsWith('blob:')) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE}${normalizedPath}`;
}
export function apiEventSource(path: string) {
return new EventSource(apiUrl(path));
}
export async function readApiError(res: Response) {
const text = await res.text().catch(() => '');
if (!text) return `${res.status} ${res.statusText}`.trim();
try {
const parsed = JSON.parse(text);
return parsed.detail || parsed.message || text;
} catch {
return text;
}
}
export async function ensureOk(res: Response, fallback: string) {
if (res.ok) return res;
const detail = await readApiError(res);
throw new Error(`${fallback} (${res.status})${detail ? `: ${detail}` : ''}`);
}
+15 -2
View File
@@ -8,9 +8,10 @@ import {
export const DPI = 96; export const DPI = 96;
export const MM_PER_INCH = 25.4; export const MM_PER_INCH = 25.4;
export const DEFAULT_LAYER_ID = 'layer-default'; export const DEFAULT_LAYER_ID = 'layer-default';
export const TRANSPARENT_BACKGROUND = 'transparent';
export function mmToPx(mm: number) { export function mmToPx(mm: number) {
return Math.max(1, Math.round((mm / MM_PER_INCH) * DPI)); return Math.max(1, (mm / MM_PER_INCH) * DPI);
} }
export function pxToMm(px: number) { export function pxToMm(px: number) {
@@ -21,6 +22,18 @@ export function formatMm(px: number) {
return pxToMm(px).toFixed(1); return pxToMm(px).toFixed(1);
} }
export function hasCanvasBackground(background?: string | null) {
const value = typeof background === 'string' ? background.trim().toLowerCase() : '';
return value !== '' && value !== TRANSPARENT_BACKGROUND && value !== 'none' && value !== 'rgba(0,0,0,0)' && value !== 'rgba(0, 0, 0, 0)';
}
export function normalizeCanvasBackground(background?: string | null) {
if (typeof background !== 'string') return '#ffffff';
const value = background.trim();
if (!value) return '#ffffff';
return hasCanvasBackground(value) ? value : TRANSPARENT_BACKGROUND;
}
export function makeId(prefix: string) { export function makeId(prefix: string) {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -95,7 +108,7 @@ export function normalizeDocument(input: CanvasDocument): CanvasDocument {
return { return {
width: Number.isFinite(input.width) ? input.width : 1600, width: Number.isFinite(input.width) ? input.width : 1600,
height: Number.isFinite(input.height) ? input.height : 1000, height: Number.isFinite(input.height) ? input.height : 1000,
background: input.background || '#ffffff', background: normalizeCanvasBackground(input.background),
layers: nextLayers, layers: nextLayers,
layerFolders, layerFolders,
elements, elements,
+8 -7
View File
@@ -1,4 +1,5 @@
import { StickerAsset } from '../types'; import { StickerAsset } from '../types';
import { apiUrl, ensureOk } from './api';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Backend-based sticker library // Backend-based sticker library
@@ -76,8 +77,7 @@ interface BackendAsset {
} }
async function apiListAssets(): Promise<BackendAsset[]> { async function apiListAssets(): Promise<BackendAsset[]> {
const res = await fetch('/api/assets?type=sticker'); const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败');
if (!res.ok) throw new Error(`list assets failed: ${res.status}`);
return res.json(); return res.json();
} }
@@ -90,14 +90,15 @@ async function apiUploadAsset(
form.append('file', blob, filename); form.append('file', blob, filename);
form.append('name', name); form.append('name', name);
form.append('type', 'sticker'); form.append('type', 'sticker');
const res = await fetch('/api/assets', { method: 'POST', body: form }); const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: form }), '上传贴纸失败');
if (!res.ok) throw new Error(`upload asset failed: ${res.status}`);
return res.json(); return res.json();
} }
async function apiDeleteAsset(assetId: string): Promise<void> { async function apiDeleteAsset(assetId: string): Promise<void> {
const res = await fetch(`/api/assets/${assetId}`, { method: 'DELETE' }); const res = await fetch(apiUrl(`/api/assets/${assetId}`), { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`delete asset failed: ${res.status}`); if (!res.ok && res.status !== 404) {
await ensureOk(res, '删除贴纸失败');
}
} }
// ── Public API ───────────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
@@ -152,5 +153,5 @@ export function svgToDataUrl(svg: string) {
export function assetToDataUrl(asset: StickerAsset) { export function assetToDataUrl(asset: StickerAsset) {
// source is now a backend URL; return it directly // source is now a backend URL; return it directly
return asset.source; return apiUrl(asset.source);
} }
+165 -17
View File
@@ -1,5 +1,6 @@
import { CanvasDocument, StickerAsset } from '../types'; import { CanvasDocument, StickerAsset } from '../types';
import { normalizeDocument, pxToMm } from './canvasDocument'; import { apiUrl } from './api';
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
import { createZip } from './zip'; import { createZip } from './zip';
export interface SerializeOptions { export interface SerializeOptions {
@@ -8,7 +9,7 @@ export interface SerializeOptions {
} }
async function fetchBlobAsDataUrl(url: string): Promise<string> { async function fetchBlobAsDataUrl(url: string): Promise<string> {
const res = await fetch(url); const res = await fetch(apiUrl(url));
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`); if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
const blob = await res.blob(); const blob = await res.blob();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -19,16 +20,30 @@ async function fetchBlobAsDataUrl(url: string): Promise<string> {
}); });
} }
async function resolveStickerHref(asset: StickerAsset): Promise<string> { async function fetchText(url: string): Promise<string> {
// Legacy inline content (still supported for imported files / tests) const res = await fetch(apiUrl(url));
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) { if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`; return res.text();
}
async function resolveStickerSvg(asset: StickerAsset): Promise<string | null> {
if (asset.type !== 'svg') return null;
const source = asset.source.trim();
if (!source) return null;
if (source.startsWith('<') || source.startsWith('<?xml')) {
return source;
} }
if (asset.source.startsWith('data:')) { if (source.startsWith('data:')) {
return asset.source; return decodeDataUrl(source);
} }
// Backend URL: fetch and inline so the exported SVG is self-contained return fetchText(source);
return fetchBlobAsDataUrl(asset.source); }
async function resolveStickerImageHref(asset: StickerAsset): Promise<string | null> {
const source = asset.source.trim();
if (!source) return null;
if (source.startsWith('data:')) return source;
return fetchBlobAsDataUrl(source);
} }
export async function serializeDocument( export async function serializeDocument(
@@ -45,7 +60,7 @@ export async function serializeDocument(
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`, `<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
]; ];
if (options.includeBackground !== false) { if (options.includeBackground !== false && hasCanvasBackground(doc.background)) {
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`); parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
} }
@@ -60,7 +75,14 @@ export async function serializeDocument(
if (element.type === 'sticker') { if (element.type === 'sticker') {
const asset = stickerById.get(element.assetId); const asset = stickerById.get(element.assetId);
if (!asset) continue; if (!asset) continue;
const href = await resolveStickerHref(asset); const svg = await resolveStickerSvg(asset);
if (svg) {
const inline = serializeInlineSvgSticker(svg, element.width, element.height, transform, opacity, asset.tint === 'gray');
if (inline) parts.push(inline);
continue;
}
const href = await resolveStickerImageHref(asset);
if (!href) continue;
const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : ''; const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : '';
parts.push(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`); parts.push(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`);
continue; continue;
@@ -100,6 +122,13 @@ export async function createLayerExportZip(
const files: { name: string; content: string }[] = []; const files: { name: string; content: string }[] = [];
const used = new Map<string, number>(); const used = new Map<string, number>();
if (hasCanvasBackground(doc.background)) {
files.push({
name: uniqueSvgName('背景', used),
content: serializeBackgroundLayer(doc),
});
}
for (const layerId of selectedLayerIds) { for (const layerId of selectedLayerIds) {
const layer = doc.layers?.find(item => item.id === layerId); const layer = doc.layers?.find(item => item.id === layerId);
if (!layer) continue; if (!layer) continue;
@@ -118,14 +147,133 @@ export async function createLayerExportZip(
}); });
} }
files.push({
name: uniqueSvgName('总效果', used),
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
});
return createZip(files); return createZip(files);
} }
function serializeBackgroundLayer(documentModel: CanvasDocument) {
const doc = normalizeDocument(documentModel);
const widthMm = pxToMm(doc.width).toFixed(1);
const heightMm = pxToMm(doc.height).toFixed(1);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
'</svg>',
].join('\n');
}
function serializeInlineSvgSticker(
svgText: string,
targetWidth: number,
targetHeight: number,
transform: string,
opacity: number,
grayscale: boolean,
) {
const parsed = parseInlineSvg(svgText);
if (!parsed || !parsed.content.trim()) return null;
const scale = Math.min(targetWidth / parsed.width, targetHeight / parsed.height);
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const offsetX = (targetWidth - parsed.width * safeScale) / 2;
const offsetY = (targetHeight - parsed.height * safeScale) / 2;
const contentTransform = [
transform,
`translate(${formatSvgNumber(offsetX)} ${formatSvgNumber(offsetY)})`,
`scale(${formatSvgNumber(safeScale)})`,
`translate(${formatSvgNumber(-parsed.minX)} ${formatSvgNumber(-parsed.minY)})`,
].join(' ');
const filter = grayscale ? ' style="filter: grayscale(1)"' : '';
return `<g opacity="${formatSvgNumber(opacity)}" transform="${contentTransform}"${filter}>\n${parsed.content}\n</g>`;
}
function parseInlineSvg(svgText: string) {
const parser = new DOMParser();
const xml = parser.parseFromString(svgText, 'image/svg+xml');
if (xml.querySelector('parsererror')) return null;
const root = xml.documentElement;
if (!root || root.localName.toLowerCase() !== 'svg') return null;
removeEmptyReferences(root);
const viewBox = parseViewBox(root.getAttribute('viewBox') || root.getAttribute('viewbox'));
const sourceWidth = viewBox?.width || parseSvgLength(root.getAttribute('width'));
const sourceHeight = viewBox?.height || parseSvgLength(root.getAttribute('height'));
const width = sourceWidth && sourceWidth > 0 ? sourceWidth : viewBox?.width || 1;
const height = sourceHeight && sourceHeight > 0 ? sourceHeight : viewBox?.height || 1;
const minX = viewBox?.minX || 0;
const minY = viewBox?.minY || 0;
const serializer = new XMLSerializer();
const body = Array.from(root.childNodes)
.map(node => serializer.serializeToString(node))
.join('\n');
const rootAttributes = serializeRootPresentationAttributes(root);
const content = rootAttributes ? `<g ${rootAttributes}>\n${body}\n</g>` : body;
return { content, minX, minY, width, height };
}
function removeEmptyReferences(root: Element) {
Array.from(root.querySelectorAll('*')).forEach(element => {
const hrefAttributes = Array.from(element.attributes).filter(attr => attr.localName === 'href' || attr.name === 'href' || attr.name.endsWith(':href'));
hrefAttributes.forEach(attr => {
if (!attr.value.trim()) element.removeAttribute(attr.name);
});
if (element.localName.toLowerCase() === 'image') {
const href = element.getAttribute('href') || element.getAttribute('xlink:href') || element.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (!href || !href.trim()) element.remove();
}
});
}
function serializeRootPresentationAttributes(root: Element) {
const excluded = new Set([
'height',
'id',
'preserveAspectRatio',
'version',
'viewBox',
'viewbox',
'width',
'x',
'y',
]);
return Array.from(root.attributes)
.filter(attr => !excluded.has(attr.name) && !attr.name.startsWith('xmlns'))
.map(attr => `${attr.name}="${escapeXml(attr.value)}"`)
.join(' ');
}
function parseViewBox(value: string | null) {
if (!value) return null;
const parts = value.trim().split(/[\s,]+/).map(Number);
if (parts.length !== 4 || parts.some(part => !Number.isFinite(part))) return null;
const [minX, minY, width, height] = parts;
if (width <= 0 || height <= 0) return null;
return { minX, minY, width, height };
}
function parseSvgLength(value: string | null) {
if (!value) return null;
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const parsed = Number.parseFloat(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function decodeDataUrl(dataUrl: string) {
const commaIndex = dataUrl.indexOf(',');
if (commaIndex < 0) return null;
const header = dataUrl.slice(0, commaIndex);
const payload = dataUrl.slice(commaIndex + 1);
if (!/image\/svg\+xml/i.test(header)) return null;
try {
return /;base64/i.test(header) ? atob(payload) : decodeURIComponent(payload);
} catch {
return null;
}
}
function formatSvgNumber(value: number) {
return Number.isFinite(value) ? Number.parseFloat(value.toFixed(4)).toString() : '0';
}
function uniqueSvgName(name: string, used: Map<string, number>) { function uniqueSvgName(name: string, used: Map<string, number>) {
const base = sanitizeFileName(name || '未命名'); const base = sanitizeFileName(name || '未命名');
const count = used.get(base) || 0; const count = used.get(base) || 0;
+8 -15
View File
@@ -1,8 +1,7 @@
import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types'; import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types';
import { apiUrl, ensureOk } from './api';
import { cloneDocument, normalizeDocument } from './canvasDocument'; import { cloneDocument, normalizeDocument } from './canvasDocument';
const API_BASE = '';
export function templateId(template: CanvasTemplate) { export function templateId(template: CanvasTemplate) {
return template.template_id || template.id || ''; return template.template_id || template.id || '';
} }
@@ -24,8 +23,7 @@ export function templateCoverId(template: CanvasTemplate) {
} }
export async function listDesignTemplates(): Promise<CanvasTemplate[]> { export async function listDesignTemplates(): Promise<CanvasTemplate[]> {
const res = await fetch(`${API_BASE}/api/design-templates`); const res = await ensureOk(await fetch(apiUrl('/api/design-templates')), '读取模板库失败');
if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`);
const items = (await res.json()) as CanvasTemplate[]; const items = (await res.json()) as CanvasTemplate[];
return items.map(item => ({ ...item, document: normalizeDocument(item.document) })); return items.map(item => ({ ...item, document: normalizeDocument(item.document) }));
} }
@@ -43,8 +41,7 @@ export async function createCanvasTemplate(input: {
fd.append('document', JSON.stringify(normalizeDocument(input.document))); fd.append('document', JSON.stringify(normalizeDocument(input.document)));
fd.append('reference_asset_ids', JSON.stringify(input.referenceAssetIds || [])); fd.append('reference_asset_ids', JSON.stringify(input.referenceAssetIds || []));
fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || ''); fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || '');
const res = await fetch(`${API_BASE}/api/design-templates`, { method: 'POST', body: fd }); const res = await ensureOk(await fetch(apiUrl('/api/design-templates'), { method: 'POST', body: fd }), '保存模板失败');
if (!res.ok) throw new Error(`保存模板失败 (${res.status})`);
const template = (await res.json()) as CanvasTemplate; const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) }; return { ...template, document: normalizeDocument(template.document) };
} }
@@ -65,21 +62,18 @@ export async function updateCanvasTemplate(
if (partial.document) fd.append('document', JSON.stringify(normalizeDocument(partial.document))); if (partial.document) fd.append('document', JSON.stringify(normalizeDocument(partial.document)));
if (partial.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds)); if (partial.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds));
if (partial.coverAssetId !== undefined) fd.append('cover_asset_id', partial.coverAssetId); if (partial.coverAssetId !== undefined) fd.append('cover_asset_id', partial.coverAssetId);
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'PATCH', body: fd }); const res = await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'PATCH', body: fd }), '更新模板失败');
if (!res.ok) throw new Error(`更新模板失败 (${res.status})`);
const template = (await res.json()) as CanvasTemplate; const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) }; return { ...template, document: normalizeDocument(template.document) };
} }
export async function deleteCanvasTemplate(id: string) { export async function deleteCanvasTemplate(id: string) {
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'DELETE' }); await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'DELETE' }), '删除模板失败');
if (!res.ok) throw new Error(`删除模板失败 (${res.status})`);
} }
export async function listAssets(type = ''): Promise<BackendAsset[]> { export async function listAssets(type = ''): Promise<BackendAsset[]> {
const query = type ? `?type=${encodeURIComponent(type)}` : ''; const query = type ? `?type=${encodeURIComponent(type)}` : '';
const res = await fetch(`${API_BASE}/api/assets${query}`); const res = await ensureOk(await fetch(apiUrl(`/api/assets${query}`)), '读取素材失败');
if (!res.ok) throw new Error(`读取素材失败 (${res.status})`);
return (await res.json()) as BackendAsset[]; return (await res.json()) as BackendAsset[];
} }
@@ -88,8 +82,7 @@ export async function uploadAsset(file: File, type = 'reference'): Promise<Backe
fd.append('file', file); fd.append('file', file);
fd.append('name', file.name.replace(/\.(svg|png|jpe?g)$/i, '')); fd.append('name', file.name.replace(/\.(svg|png|jpe?g)$/i, ''));
fd.append('type', type); fd.append('type', type);
const res = await fetch(`${API_BASE}/api/assets`, { method: 'POST', body: fd }); const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: fd }), '上传参考图失败');
if (!res.ok) throw new Error(`上传参考图失败 (${res.status})`);
return (await res.json()) as BackendAsset; return (await res.json()) as BackendAsset;
} }
@@ -97,7 +90,7 @@ export function assetUrl(assetOrPath?: BackendAsset | string) {
if (!assetOrPath) return ''; if (!assetOrPath) return '';
const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url; const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url;
if (!raw) return ''; if (!raw) return '';
return raw.startsWith('http') ? raw : `${API_BASE}${raw}`; return apiUrl(raw);
} }
export function duplicateDocument(document: CanvasDocument): CanvasDocument { export function duplicateDocument(document: CanvasDocument): CanvasDocument {
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import {
IconCanvas,
IconCloud,
IconGrid,
IconHelp,
} from '../components/Icons';
interface HelpPageProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenHome: () => void;
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
}
const quickSteps = [
['1', '进入词云', '上传底图和 .xlsx 名单,确认名字列、表头行和字体。'],
['2', '生成结果', '点击生成词云,等待进度完成后可查找姓名或导出 SVG。'],
['3', '进入画布', '把词云作为贴纸加入画布,再叠加文字、形状、图层和模板。'],
['4', '保存复用', '导出总图、分层打包,或把当前画布保存为模板。'],
];
const wordcloudParams = [
['底图 mask_image', 'PNG、JPG 或 SVG。上传 SVG 时前端会转成后端可识别的 PNG。'],
['名单 name_list', '仅支持 .xlsx。默认读取第 2 列作为名字。'],
['DATA_COL_INDEX', '名字列索引,从 0 开始。默认 1 表示 Excel 第 B 列。'],
['表头行号', '只影响前端预览,表示第几行为表头,数据从下一行开始。'],
['WEIGHT_COL_INDEX', '可选权重列索引。留空时使用自动权重或笔画权重。'],
['SEED', '随机种子。填入固定数字后,同样数据会生成可复现布局。'],
['字体颜色', '十六进制颜色,例如 #2563eb。留空时后端可使用默认配色。'],
['词语重复填充次数', '名单较短时提高填充率,建议 1-10,过大可能让重复感变强。'],
['笔画复杂度权重', '开启后复杂汉字会获得更高权重,关闭则按均等权重排布。'],
];
const exportParams = [
['描边', '为 SVG 输出增加轮廓,适合雕刻、描线和透明背景应用。'],
['填充 fill', '完整填充轮廓,是最通用的矢量输出。'],
['点阵 dot', '用点阵构成轮廓,可调间距和点半径。'],
['横线 line', '用线条构成轮廓,可调线距、线宽和角度。'],
['空心圆 ring', '用环形单元构成轮廓,可调半径、环宽和间距。'],
['保存为画布贴纸', '把当前词云和可选遮罩一起送入画布设计台,自动分组便于同步移动缩放。'],
];
const canvasTools = [
['图层', '新建、命名、排序、隐藏、锁定图层,并可创建文件夹组织复杂设计。'],
['贴纸', '导入 SVG 贴纸,点击即可添加到画布;后端素材库会持久保存。'],
['文字', '添加可编辑文字,支持字号、颜色、字体、位置、旋转和透明度。'],
['形状', '添加矩形、椭圆和线条,支持填充色、描边色和描边宽度。'],
['属性', '选中元素后精确调整 X/Y、宽高、旋转、透明度、图层和元素组。'],
['画布', '设置画布尺寸、背景色,导出总图 SVG,或按图层/文件夹打包。'],
['模板库', '把当前画布和参考图保存成模板,在首页快速复用。'],
];
export default function HelpPage({
themeMode,
systemTheme,
onThemeModeChange,
onOpenHome,
onOpenCanvas,
onOpenWordcloud,
}: HelpPageProps) {
return (
<div className="help-page">
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconHelp /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions" />
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenHome}>
<span className="nav-btn-icon"><IconGrid /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenCanvas}>
<span className="nav-btn-icon"><IconCanvas /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenWordcloud}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<AppSettingsWindow
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={onThemeModeChange}
/>
</div>
</nav>
<main className="help-main">
<section className="help-hero">
<div>
<p className="help-kicker">Wordcloud Studio</p>
<h1>稿</h1>
<p>
</p>
</div>
<div className="help-shortcuts">
<button className="btn btn-primary" onClick={onOpenWordcloud}></button>
<button className="btn btn-secondary" onClick={onOpenCanvas}></button>
</div>
</section>
<section className="help-section">
<h2></h2>
<div className="help-steps">
{quickSteps.map(([num, title, body]) => (
<div className="help-step" key={num}>
<span>{num}</span>
<strong>{title}</strong>
<p>{body}</p>
</div>
))}
</div>
</section>
<section className="help-section">
<h2></h2>
<div className="help-grid two">
<HelpItem title="拖动" body="按住面板标题栏移动位置。点击面板会自动置顶。" />
<HelpItem title="缩放" body="拖动四条边或四个角调整大小。布局会自动保存到浏览器本地。" />
<HelpItem title="重置布局" body="右上角设置窗口里的布局设置会恢复默认面板位置,适合窗口乱掉时快速整理。" />
<HelpItem title="多面板并行" body="导航按钮用于显示或隐藏面板,可以同时打开导入、导出、名单、高级和查找。" />
</div>
</section>
<HelpTable title="词云参数" rows={wordcloudParams} />
<HelpTable title="导出参数" rows={exportParams} />
<HelpTable title="画布功能" rows={canvasTools} />
<section className="help-section">
<h2></h2>
<div className="help-grid two">
<HelpItem title="无法连接后端" body="开发环境默认通过 Vite 代理访问 http://localhost:8000;部署时可设置 VITE_API_BASE。" />
<HelpItem title="名单为空" body="检查 DATA_COL_INDEX 是否指向名字列,并确认表头行号之后还有数据。" />
<HelpItem title="IMAGE 模式报错" body="上传底图后会走 IMAGE 模式;底图必须是 PNG、JPG、JPEGSVG 会在前端转换。" />
<HelpItem title="找不到姓名" body="查找依赖已生成任务的数据库,请先生成词云,再输入完整或部分姓名搜索。" />
</div>
</section>
</main>
</div>
);
}
function HelpTable({ title, rows }: { title: string; rows: string[][] }) {
return (
<section className="help-section">
<h2>{title}</h2>
<div className="help-table">
{rows.map(([name, body]) => (
<div className="help-row" key={name}>
<strong>{name}</strong>
<p>{body}</p>
</div>
))}
</div>
</section>
);
}
function HelpItem({ title, body }: { title: string; body: string }) {
return (
<div className="help-item">
<strong>{title}</strong>
<p>{body}</p>
</div>
);
}
+21 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { BackendAsset, CanvasTemplate } from '../types'; import { BackendAsset, CanvasTemplate } from '../types';
import { formatMm, normalizeDocument } from '../lib/canvasDocument'; import { formatMm, normalizeDocument } from '../lib/canvasDocument';
import { import {
@@ -18,20 +19,29 @@ import {
IconCloud, IconCloud,
IconRefresh, IconRefresh,
IconCanvas, IconCanvas,
IconHelp,
} from '../components/Icons'; } from '../components/Icons';
interface TemplateHomeProps { interface TemplateHomeProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onCreateBlank: () => void; onCreateBlank: () => void;
onUseTemplate: (template: CanvasTemplate) => void; onUseTemplate: (template: CanvasTemplate) => void;
onOpenCanvas: () => void; onOpenCanvas: () => void;
onOpenWordcloud: () => void; onOpenWordcloud: () => void;
onOpenHelp: () => void;
} }
export default function TemplateHome({ export default function TemplateHome({
themeMode,
systemTheme,
onThemeModeChange,
onCreateBlank, onCreateBlank,
onUseTemplate, onUseTemplate,
onOpenCanvas, onOpenCanvas,
onOpenWordcloud, onOpenWordcloud,
onOpenHelp,
}: TemplateHomeProps) { }: TemplateHomeProps) {
const [templates, setTemplates] = useState<CanvasTemplate[]>([]); const [templates, setTemplates] = useState<CanvasTemplate[]>([]);
const [assets, setAssets] = useState<BackendAsset[]>([]); const [assets, setAssets] = useState<BackendAsset[]>([]);
@@ -94,6 +104,8 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconRefresh /></span> <span className="nav-btn-icon"><IconRefresh /></span>
<span className="nav-btn-label"></span> <span className="nav-btn-label"></span>
</button> </button>
</div>
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenCanvas}> <button className="nav-btn" onClick={onOpenCanvas}>
<span className="nav-btn-icon"><IconCanvas /></span> <span className="nav-btn-icon"><IconCanvas /></span>
<span className="nav-btn-label"></span> <span className="nav-btn-label"></span>
@@ -102,9 +114,16 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconCloud /></span> <span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span> <span className="nav-btn-label"></span>
</button> </button>
</div> <button className="nav-btn" onClick={onOpenHelp}>
<div className="navbar-end"> <span className="nav-btn-icon"><IconHelp /></span>
<span className="nav-btn-label"></span>
</button>
<button className="btn btn-primary btn-sm" onClick={onCreateBlank}></button> <button className="btn btn-primary btn-sm" onClick={onCreateBlank}></button>
<AppSettingsWindow
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={onThemeModeChange}
/>
</div> </div>
</nav> </nav>
+323 -131
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { import {
NameEntry, NameEntry,
JobParams, JobParams,
@@ -19,7 +20,11 @@ import AdvancedPanel from '../components/AdvancedPanel';
import ProgressPanel from '../components/ProgressPanel'; import ProgressPanel from '../components/ProgressPanel';
import CanvasArea from '../components/CanvasArea'; import CanvasArea from '../components/CanvasArea';
import ViewControls from '../components/ViewControls'; import ViewControls from '../components/ViewControls';
import { useResizablePanel } from '../hooks/useResizablePanel'; import FloatingPanel from '../components/FloatingPanel';
import DockTabBar, { DockTabItem } from '../components/DockTabBar';
import { DOCK_WIDTH, DOCK_TOP_RESERVED, FloatingPanelLayout, TAB_BAR_HEIGHT, useFloatingPanels } from '../hooks/useFloatingPanels';
import { computeSnap, type WorkspaceSize } from '../hooks/usePanelDocking';
import { apiEventSource, apiUrl, ensureOk, readApiError } from '../lib/api';
import { import {
IconImport, IconImport,
IconExport, IconExport,
@@ -27,10 +32,9 @@ import {
IconFind, IconFind,
IconSettings, IconSettings,
IconCloud, IconCloud,
IconHelp,
} from '../components/Icons'; } from '../components/Icons';
const API_BASE = '';
const DEFAULT_PARAMS: JobParams = { const DEFAULT_PARAMS: JobParams = {
seed: 42, seed: 42,
dataColIndex: 1, // 0-based,默认第2列(B列) dataColIndex: 1, // 0-based,默认第2列(B列)
@@ -42,12 +46,12 @@ const DEFAULT_PARAMS: JobParams = {
}; };
type NavItem = { type NavItem = {
id: NonNullable<PanelType>; id: WorkbenchPanelId;
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
}; };
type ThemeMode = 'light' | 'dark' | 'system'; type WorkbenchPanelId = NonNullable<PanelType>;
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ id: 'import', label: '导入', icon: <IconImport /> }, { id: 'import', label: '导入', icon: <IconImport /> },
@@ -57,27 +61,31 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'advanced', label: '高级', icon: <IconSettings /> }, { id: 'advanced', label: '高级', icon: <IconSettings /> },
]; ];
const THEME_OPTIONS: { id: ThemeMode; label: string }[] = [ const WORKBENCH_PANEL_LAYOUT: FloatingPanelLayout<WorkbenchPanelId> = {
{ id: 'light', label: '浅色' }, import: { x: 18, y: 18, width: 320, height: 580, zIndex: 4, docked: null },
{ id: 'dark', label: '深色' }, export: { x: 710, y: 18, width: 360, height: 560, zIndex: 3, docked: null },
{ id: 'system', label: '系统' }, edit: { x: 356, y: 392, width: 430, height: 250, zIndex: 2, docked: null },
]; find: { x: 804, y: 392, width: 320, height: 230, zIndex: 5, docked: null },
advanced: { x: 356, y: 18, width: 340, height: 360, zIndex: 1, docked: null },
interface TestWorkbenchProps {
onOpenCanvas?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
}
const getStoredTheme = (): ThemeMode => {
const stored = window.localStorage.getItem('wordcloud-theme');
return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
}; };
const getSystemTheme = () => interface TestWorkbenchProps {
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenCanvas?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
onOpenHelp?: () => void;
}
export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }: TestWorkbenchProps) { export default function TestWorkbench({
const { width: panelWidth, handleRef } = useResizablePanel('wb-panel-width', 240, 180, 400, 'left'); themeMode,
systemTheme,
onThemeModeChange,
onOpenCanvas,
onImportWordcloudSticker,
onOpenHelp,
}: TestWorkbenchProps) {
const [maskFile, setMaskFile] = useState<File | null>(null); const [maskFile, setMaskFile] = useState<File | null>(null);
const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null); const [maskSubmitFile, setMaskSubmitFile] = useState<File | null>(null);
const [maskSource, setMaskSource] = useState<WordcloudMaskSource | null>(null); const [maskSource, setMaskSource] = useState<WordcloudMaskSource | null>(null);
@@ -88,39 +96,73 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
const [jobResult, setJobResult] = useState<JobResult | null>(null); const [jobResult, setJobResult] = useState<JobResult | null>(null);
const [progress, setProgress] = useState<SSEProgress | null>(null); const [progress, setProgress] = useState<SSEProgress | null>(null);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [activePanel, setActivePanel] = useState<PanelType>('import'); const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d'); const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d');
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null); const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
const [fonts, setFonts] = useState<Font[]>([]); const [fonts, setFonts] = useState<Font[]>([]);
const [selectedFontId, setSelectedFontId] = useState<string>('__default__'); const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const sseRef = useRef<EventSource | null>(null); const sseRef = useRef<EventSource | null>(null);
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
const workspaceRef = useRef<HTMLDivElement>(null);
const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 });
const [workspaceSize, setWorkspaceSize] = useState<WorkspaceSize>({ width: 1200, height: 700 });
useLayoutEffect(() => { const measureWorkspace = useCallback(() => {
const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode; const node = workspaceRef.current;
document.documentElement.dataset.theme = resolvedTheme; if (!node) return;
document.documentElement.dataset.themeMode = themeMode; const rect = node.getBoundingClientRect();
document.documentElement.style.colorScheme = resolvedTheme; const size = { width: rect.width, height: rect.height };
window.localStorage.setItem('wordcloud-theme', themeMode); workspaceSizeRef.current = size;
}, [themeMode, systemTheme]); setWorkspaceSize(size);
}, []);
useEffect(() => { useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)'); measureWorkspace();
const handleChange = (event: MediaQueryListEvent) => { if (typeof ResizeObserver === 'undefined') return;
setSystemTheme(event.matches ? 'dark' : 'light'); const node = workspaceRef.current;
}; if (!node) return;
const observer = new ResizeObserver(() => measureWorkspace());
observer.observe(node);
return () => observer.disconnect();
}, [measureWorkspace]);
setSystemTheme(media.matches ? 'dark' : 'light'); // Re-stack docked panels when the workspace size or dock membership changes
media.addEventListener('change', handleChange); // (dock/undock/close can shift the tab-bar inset and stack offsets). Width is
return () => media.removeEventListener('change', handleChange); // user-adjustable via the e/w resize handles — preserve the side's current
}, []); // width here rather than resetting to DOCK_WIDTH on every restack.
const dockedSignature = (Object.keys(floatingPanels.frames) as WorkbenchPanelId[])
.filter(id => floatingPanels.frames[id]?.docked)
.sort()
.join(',');
useEffect(() => {
if (!dockedSignature) return;
const size = workspaceSizeRef.current;
(['left', 'right'] as const).forEach(side => {
const idsForSide = (Object.keys(floatingPanels.frames) as WorkbenchPanelId[])
.filter(id => floatingPanels.frames[id]?.docked === side)
.sort((a, b) => floatingPanels.frames[a].y - floatingPanels.frames[b].y);
if (idsForSide.length === 0) return;
const topInset = DOCK_TOP_RESERVED + TAB_BAR_HEIGHT;
const availHeight = Math.max(120, size.height - topInset);
const y = topInset;
const height = availHeight;
// Adopt the side's existing (possibly user-resized) width; clamp to a
// safe range so a stale persisted width can't collapse or dominate.
const currentWidth = floatingPanels.frames[idsForSide[0]].width || DOCK_WIDTH;
const clampedWidth = Math.max(220, Math.min(560, currentWidth));
const x = side === 'left' ? 0 : Math.max(0, size.width - clampedWidth);
idsForSide.forEach(id => {
floatingPanels.updateFrame(id, { x, y, width: clampedWidth, height });
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSize.height, workspaceSize.width, dockedSignature]);
// ─── 字体列表加载 ───────────────────────────────────────────────────────── // ─── 字体列表加载 ─────────────────────────────────────────────────────────
const fetchFonts = useCallback(async () => { const fetchFonts = useCallback(async () => {
try { try {
const res = await fetch(`${API_BASE}/api/fonts`); const res = await fetch(apiUrl('/api/fonts'));
if (res.ok) { if (res.ok) {
const data: Font[] = await res.json(); const data: Font[] = await res.json();
setFonts(data); setFonts(data);
@@ -135,7 +177,7 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
fd.append('file', file); fd.append('file', file);
fd.append('name', file.name.replace(/\.(ttf|ttc|otf)$/i, '')); fd.append('name', file.name.replace(/\.(ttf|ttc|otf)$/i, ''));
try { try {
const res = await fetch(`${API_BASE}/api/fonts`, { method: 'POST', body: fd }); const res = await fetch(apiUrl('/api/fonts'), { method: 'POST', body: fd });
if (res.ok) { if (res.ok) {
const font: Font = await res.json(); const font: Font = await res.json();
setFonts(prev => [font, ...prev]); setFonts(prev => [font, ...prev]);
@@ -146,7 +188,7 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
const handleFontDelete = useCallback(async (fontId: string) => { const handleFontDelete = useCallback(async (fontId: string) => {
try { try {
await fetch(`${API_BASE}/api/fonts/${fontId}`, { method: 'DELETE' }); await fetch(apiUrl(`/api/fonts/${fontId}`), { method: 'DELETE' });
setFonts(prev => prev.filter(f => f.font_id !== fontId)); setFonts(prev => prev.filter(f => f.font_id !== fontId));
setSelectedFontId('__default__'); setSelectedFontId('__default__');
} catch (e) { console.error('font delete error:', e); } } catch (e) { console.error('font delete error:', e); }
@@ -240,8 +282,17 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
}); });
}, [namesFile, parseExcel]); }, [namesFile, parseExcel]);
const handleNavClick = (panel: PanelType) => { const handleNavClick = (panel: WorkbenchPanelId) => {
setActivePanel(prev => prev === panel ? null : panel); const isOpen = openPanels.includes(panel);
if (isOpen && floatingPanels.frames[panel]?.docked) {
floatingPanels.undockPanel(panel);
}
setOpenPanels(prev => (
isOpen
? prev.filter(item => item !== panel)
: [...prev, panel]
));
floatingPanels.focusPanel(panel);
}; };
// ─── 生成任务提交 ───────────────────────────────────────────────────────── // ─── 生成任务提交 ─────────────────────────────────────────────────────────
@@ -258,16 +309,23 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
setHighlightLocation(null); setHighlightLocation(null);
try { try {
const usingEditedEntries = nameEntries.length > 0;
const submitNamesFile = usingEditedEntries
? buildNamesWorkbookFile(nameEntries, namesFile)
: namesFile;
// ── 组装 params JSON(对应后端 config 别名键)────────────────────── // ── 组装 params JSON(对应后端 config 别名键)──────────────────────
// 参见 README 4.8.3 / 4.8.10 // 参见 README 4.8.3 / 4.8.10
const paramsObj: Record<string, unknown> = { const paramsObj: Record<string, unknown> = {
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT', MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
DATA_COL_INDEX: params.dataColIndex, DATA_COL_INDEX: usingEditedEntries ? 1 : params.dataColIndex,
}; };
if (params.seed !== null) { if (params.seed !== null) {
paramsObj.SEED = params.seed; // 全局随机种子 paramsObj.SEED = params.seed; // 全局随机种子
} }
if (params.weightColIndex !== null) { if (usingEditedEntries) {
paramsObj.WEIGHT_COL_INDEX = 2;
} else if (params.weightColIndex !== null) {
paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列 paramsObj.WEIGHT_COL_INDEX = params.weightColIndex; // 0-based 权重列
} }
if (params.fontColor) { if (params.fontColor) {
@@ -287,20 +345,16 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
// params : JSON 字符串 // params : JSON 字符串
const formData = new FormData(); const formData = new FormData();
formData.append('name_list', namesFile); formData.append('name_list', submitNamesFile);
if (maskSubmitFile) { if (maskSubmitFile) {
formData.append('mask_image', maskSubmitFile); formData.append('mask_image', maskSubmitFile);
} }
if (selectedFontId) { if (selectedFontId && selectedFontId !== '__default__') {
formData.append('font_id', selectedFontId); formData.append('font_id', selectedFontId);
} }
formData.append('params', JSON.stringify(paramsObj)); formData.append('params', JSON.stringify(paramsObj));
const res = await fetch(`${API_BASE}/api/jobs`, { method: 'POST', body: formData }); const res = await ensureOk(await fetch(apiUrl('/api/jobs'), { method: 'POST', body: formData }), '提交失败');
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new Error(`提交失败 (${res.status}): ${errText}`);
}
const data = await res.json(); const data = await res.json();
// README 6.5 响应包含 job_id // README 6.5 响应包含 job_id
@@ -311,7 +365,7 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
// ── SSE 监听进度 ─────────────────────────────────────────────────── // ── SSE 监听进度 ───────────────────────────────────────────────────
// GET /api/jobs/{job_id}/events // GET /api/jobs/{job_id}/events
sseRef.current?.close(); sseRef.current?.close();
const sse = new EventSource(`${API_BASE}/api/jobs/${id}/events`); const sse = apiEventSource(`/api/jobs/${id}/events`);
sseRef.current = sse; sseRef.current = sse;
sse.onmessage = (e) => { sse.onmessage = (e) => {
@@ -346,18 +400,19 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
// GET /api/jobs/{job_id}/result // GET /api/jobs/{job_id}/result
const fetchResult = async (id: string) => { const fetchResult = async (id: string) => {
try { try {
const res = await fetch(`${API_BASE}/api/jobs/${id}/result`); const res = await ensureOk(await fetch(apiUrl(`/api/jobs/${id}/result`)), '获取结果失败');
if (!res.ok) throw new Error(`获取结果失败 (${res.status})`);
const data: JobResult = await res.json(); const data: JobResult = await res.json();
if (data.status === 'failed') { if (data.status === 'failed') {
// 尝试从 /detail 拿更详细的错误信息 // 尝试从 /detail 拿更详细的错误信息
let detail = ''; let detail = '';
try { try {
const dr = await fetch(`${API_BASE}/api/jobs/${id}/detail`); const dr = await fetch(apiUrl(`/api/jobs/${id}/detail`));
if (dr.ok) { if (dr.ok) {
const dd = await dr.json(); const dd = await dr.json();
detail = dd.error ?? dd.message ?? ''; detail = dd.error ?? dd.message ?? '';
} else {
detail = await readApiError(dr);
} }
} catch { /* ignore */ } } catch { /* ignore */ }
setProgress({ setProgress({
@@ -391,61 +446,118 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
setTimeout(() => setHighlightLocation(null), 3000); setTimeout(() => setHighlightLocation(null), 3000);
}; };
const panelOpen = activePanel !== null; const closePanel = (panel: WorkbenchPanelId) => {
// If it was docked, also clear dock state so reopening isn't stuck hidden.
if (floatingPanels.frames[panel]?.docked) floatingPanels.undockPanel(panel);
setOpenPanels(prev => prev.filter(item => item !== panel));
};
const openDockedPanelsForSide = (side: 'left' | 'right') =>
(Object.keys(floatingPanels.frames) as WorkbenchPanelId[])
.filter(id => openPanels.includes(id) && floatingPanels.frames[id]?.docked === side)
.sort((a, b) => floatingPanels.frames[a].y - floatingPanels.frames[b].y);
const activeDockPanelForSide = (side: 'left' | 'right') => {
const panelIds = openDockedPanelsForSide(side);
const active = floatingPanels.activeTab[side] as WorkbenchPanelId | null;
return active && panelIds.includes(active) ? active : panelIds[0] ?? null;
};
const rightDockFrames = openDockedPanelsForSide('right');
const rightDockWidth = rightDockFrames.length
? floatingPanels.frames[rightDockFrames[0]].width
: 0;
const handleUndockTab = (panel: WorkbenchPanelId, point: { clientX: number; clientY: number }) => {
const rect = workspaceRef.current?.getBoundingClientRect();
const size = workspaceSizeRef.current;
if (rect) {
const localX = point.clientX - rect.left;
if (localX <= 72) {
floatingPanels.dockPanel(panel, 'left', size);
floatingPanels.focusPanel(panel);
return;
}
if (localX >= rect.width - 72) {
floatingPanels.dockPanel(panel, 'right', size);
floatingPanels.focusPanel(panel);
return;
}
}
const width = Math.max(320, floatingPanels.frames[panel]?.width || 320);
const height = Math.max(260, Math.min(420, floatingPanels.frames[panel]?.height || 360));
const x = rect
? Math.min(Math.max(8, point.clientX - rect.left - width / 2), Math.max(8, rect.width - width - 8))
: Math.max(8, Math.round((size.width - width) / 2));
const y = rect
? Math.min(Math.max(8, point.clientY - rect.top - TAB_BAR_HEIGHT / 2), Math.max(8, rect.height - height - 8))
: Math.max(8, Math.round((size.height - height) / 2));
floatingPanels.undockPanel(panel);
floatingPanels.updateFrame(panel, { x, y, width, height });
floatingPanels.focusPanel(panel);
};
const renderPanel = (panel: WorkbenchPanelId) => {
const navItem = NAV_ITEMS.find(item => item.id === panel);
const frame = floatingPanels.frames[panel];
const docked = frame.docked;
const isActiveTab = docked ? activeDockPanelForSide(docked) === panel : false;
const hidden = docked ? !isActiveTab : false;
// Whether this docked panel shares the top DockTabBar — same open+docked
// accounting the DockTabBar uses, so a persisted-but-closed leftover can't
// blank out the header.
const tabSiblings = docked
? (Object.keys(floatingPanels.frames) as WorkbenchPanelId[])
.filter(id => id !== panel
&& openPanels.includes(id)
&& floatingPanels.frames[id]?.docked === docked)
.length > 0
: false;
const computeSnapForPanel = (clientX: number, clientY: number) => {
const rect = workspaceRef.current?.getBoundingClientRect();
if (!rect) return null;
const localX = clientX - rect.left;
return computeSnap(clientX, localX, frame, panel, { width: rect.width, height: rect.height }, floatingPanels.frames);
};
// A docked column only exposes e/w resize handles. When one fires, widen
// the whole side at once so tab siblings + the DockTabBar track it.
const handleFrameChange = (partial: Partial<typeof frame>) => {
if (docked && (partial.width !== undefined || partial.x !== undefined)) {
const size = workspaceSizeRef.current;
const baseWidth = partial.width !== undefined ? partial.width : frame.width;
floatingPanels.resizeDockColumn(docked, baseWidth, size);
return;
}
floatingPanels.updateFrame(panel, partial);
};
const commonProps = {
id: panel,
title: navItem?.label || '',
icon: navItem?.icon,
frame,
computeSnap: computeSnapForPanel,
workspaceSize,
frames: floatingPanels.frames,
workspaceNodeRef: workspaceRef,
onDock: (side: 'left' | 'right') => floatingPanels.dockPanel(panel, side, workspaceSizeRef.current),
onUndock: () => floatingPanels.undockPanel(panel),
onFrameChange: handleFrameChange,
onFocus: () => {
floatingPanels.focusPanel(panel);
if (docked) floatingPanels.setActiveTab(docked, panel);
},
onClose: () => closePanel(panel),
hidden,
hasTabSiblings: tabSiblings,
};
return ( return (
<div className="app-layout"> <FloatingPanel key={panel} {...commonProps}>
{/* ===== NAVBAR ===== */} {panel === 'import' && (
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions">
{NAV_ITEMS.map(item => (
<button
key={item.id}
className={`nav-btn${activePanel === item.id ? ' active' : ''}`}
onClick={() => handleNavClick(item.id as PanelType)}
>
<span className="nav-btn-icon">{item.icon}</span>
<span className="nav-btn-label">{item.label}</span>
</button>
))}
</div>
<div className="navbar-end">
{onOpenCanvas && (
<button className="btn btn-secondary btn-sm" onClick={onOpenCanvas}></button>
)}
<div className="theme-switch" aria-label="主题模式">
{THEME_OPTIONS.map(option => (
<button
key={option.id}
type="button"
className={`theme-btn${themeMode === option.id ? ' active' : ''}`}
title={
option.id === 'system'
? `跟随系统(当前${systemTheme === 'dark' ? '深色' : '浅色'}`
: `${option.label}模式`
}
aria-pressed={themeMode === option.id}
onClick={() => setThemeMode(option.id)}
>
{option.label}
</button>
))}
</div>
</div>
</nav>
{/* ===== MAIN ===== */}
<div className="main-content">
{/* ===== SIDE PANEL ===== */}
<aside className={`side-panel${panelOpen ? '' : ' collapsed'}`} style={panelOpen ? { width: panelWidth } : undefined}>
<div className="side-panel-inner">
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{activePanel === 'import' && (
<ImportPanel <ImportPanel
embedded
maskFile={maskFile} maskFile={maskFile}
namesFile={namesFile} namesFile={namesFile}
params={params} params={params}
@@ -459,10 +571,10 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
onFontSelect={setSelectedFontId} onFontSelect={setSelectedFontId}
/> />
)} )}
{activePanel === 'export' && ( {panel === 'export' && (
<ExportPanel <ExportPanel
embedded
jobId={jobId} jobId={jobId}
apiBase={API_BASE}
svgUrl={jobResult?.svg_url} svgUrl={jobResult?.svg_url}
imageUrl={jobResult?.image_url} imageUrl={jobResult?.image_url}
onOpenCanvas={onOpenCanvas} onOpenCanvas={onOpenCanvas}
@@ -470,49 +582,80 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
onImportWordcloudSticker={onImportWordcloudSticker} onImportWordcloudSticker={onImportWordcloudSticker}
/> />
)} )}
{activePanel === 'edit' && ( {panel === 'edit' && (
<EditPanel <EditPanel
embedded
entries={nameEntries} entries={nameEntries}
onEntriesChange={setNameEntries} onEntriesChange={setNameEntries}
/> />
)} )}
{activePanel === 'find' && ( {panel === 'find' && (
<FindPanel <FindPanel
embedded
jobId={jobId} jobId={jobId}
apiBase={API_BASE}
onLocate={handleLocate} onLocate={handleLocate}
/> />
)} )}
{activePanel === 'advanced' && ( {panel === 'advanced' && (
<AdvancedPanel <AdvancedPanel
embedded
params={params} params={params}
onParamsChange={handleParamsChange} onParamsChange={handleParamsChange}
/> />
)} )}
</div> </FloatingPanel>
);
};
{/* 生成按钮固定在面板底部 */} return (
<div className="panel-footer"> <div className="app-layout">
{/* ===== NAVBAR ===== */}
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions">
{NAV_ITEMS.map(item => (
<button <button
className="btn-generate" key={item.id}
className={`nav-btn${openPanels.includes(item.id) ? ' active' : ''}`}
onClick={() => handleNavClick(item.id)}
>
<span className="nav-btn-icon">{item.icon}</span>
<span className="nav-btn-label">{item.label}</span>
</button>
))}
</div>
<div className="navbar-end">
{onOpenCanvas && (
<button className="btn btn-secondary btn-sm" onClick={onOpenCanvas}></button>
)}
<button
className="btn btn-primary btn-sm"
onClick={handleGenerate} onClick={handleGenerate}
disabled={isGenerating} disabled={isGenerating}
> >
{isGenerating {isGenerating ? <><span className="spinner" /></> : '生成词云'}
? <><span className="spinner" /></>
: '生成'}
</button> </button>
{onOpenHelp && (
<button className="btn btn-secondary btn-sm" onClick={onOpenHelp}><IconHelp /> </button>
)}
<AppSettingsWindow
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={onThemeModeChange}
onResetLayout={floatingPanels.resetFrames}
/>
</div> </div>
</div> </nav>
<div className="panel-resize-handle" ref={handleRef} />
</aside>
{/* ===== CANVAS ===== */} {/* ===== MAIN ===== */}
<main className="canvas-area"> <div className="main-content workspace-shell" ref={workspaceRef}>
<main className="canvas-area workspace-canvas">
<CanvasArea <CanvasArea
maskFile={maskFile} maskFile={maskFile}
jobResult={jobResult} jobResult={jobResult}
apiBase={API_BASE}
viewMode={viewMode} viewMode={viewMode}
zoom={zoom} zoom={zoom}
highlightLocation={highlightLocation} highlightLocation={highlightLocation}
@@ -523,12 +666,40 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
<ViewControls <ViewControls
zoom={zoom} zoom={zoom}
viewMode={viewMode} viewMode={viewMode}
style={{ right: rightDockWidth ? rightDockWidth + 16 : 16 }}
onZoomIn={() => setZoom(z => Math.min(5, +(z + 0.1).toFixed(1)))} onZoomIn={() => setZoom(z => Math.min(5, +(z + 0.1).toFixed(1)))}
onZoomOut={() => setZoom(z => Math.max(0.1, +(z - 0.1).toFixed(1)))} onZoomOut={() => setZoom(z => Math.max(0.1, +(z - 0.1).toFixed(1)))}
onZoomReset={() => setZoom(1)} onZoomReset={() => setZoom(1)}
onToggleView={() => setViewMode(v => v === '2d' ? '3d' : '2d')} onToggleView={() => setViewMode(v => v === '2d' ? '3d' : '2d')}
/> />
</main> </main>
{(['left', 'right'] as const).map(side => {
const panels: DockTabItem[] = openDockedPanelsForSide(side)
.sort((a, b) => floatingPanels.frames[a].y - floatingPanels.frames[b].y)
.map(id => {
const navItem = NAV_ITEMS.find(item => item.id === id)!;
return { id, title: navItem.label, icon: navItem.icon };
});
if (panels.length < 1) return null;
const columnWidth = floatingPanels.frames[panels[0].id as WorkbenchPanelId]?.width || DOCK_WIDTH;
return (
<DockTabBar
key={`dock-${side}`}
side={side}
panels={panels}
activeId={activeDockPanelForSide(side)}
workspaceWidth={workspaceSize.width}
columnWidth={columnWidth}
onSelect={id => {
floatingPanels.setActiveTab(side, id);
floatingPanels.focusPanel(id as WorkbenchPanelId);
}}
onClose={id => closePanel(id as WorkbenchPanelId)}
onUndockTab={(id, point) => handleUndockTab(id as WorkbenchPanelId, point)}
/>
);
})}
{openPanels.map(renderPanel)}
</div> </div>
</div> </div>
); );
@@ -584,3 +755,24 @@ function parseSvgNumber(svg: string, attr: 'width' | 'height') {
const match = svg.match(new RegExp(`${attr}=["']([0-9.]+)`)); const match = svg.match(new RegExp(`${attr}=["']([0-9.]+)`));
return match ? Math.max(1, Math.round(parseFloat(match[1]))) : 0; return match ? Math.max(1, Math.round(parseFloat(match[1]))) : 0;
} }
function buildNamesWorkbookFile(entries: NameEntry[], sourceFile: File | null) {
const rows = [
['编号', '名字', '权重'],
...entries.map(entry => [
entry.group,
entry.name,
Number.isFinite(entry.weight) ? Math.max(1, entry.weight) : 1,
]),
];
const sheet = XLSX.utils.aoa_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, '名单');
const data = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }) as ArrayBuffer;
const baseName = sourceFile?.name.replace(/\.xlsx$/i, '') || 'names';
return new File(
[data],
`${baseName}-edited.xlsx`,
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
);
}
+721 -57
View File
@@ -8,27 +8,28 @@
:root { :root {
color-scheme: light; color-scheme: light;
--bg: #f5f4f0; --bg: #f4f6f8;
--workspace-bg: #eef1f5;
--bg-panel: #ffffff; --bg-panel: #ffffff;
--bg-panel-alt: #fafaf8; --bg-panel-alt: #f7f9fb;
--border: #e2e0da; --border: #d8dde5;
--border-focus: #8b7355; --border-focus: #3b82f6;
--text-primary: #1a1814; --text-primary: #171a1f;
--text-secondary: #6b6560; --text-secondary: #4c5563;
--text-muted: #a09890; --text-muted: #7f8a99;
--accent: #6b4f2e; --accent: #2563eb;
--accent-light: #f0ebe3; --accent-light: #e8f1ff;
--accent-hover: #5a4025; --accent-hover: #1d4ed8;
--on-accent: #ffffff; --on-accent: #ffffff;
--danger: #c0392b; --danger: #d83a3a;
--danger-light: #fdf0ef; --danger-light: #fff0f0;
--on-danger: #ffffff; --on-danger: #ffffff;
--success: #2d7a4f; --success: #0f8f62;
--success-light: #edf7f1; --success-light: #e8f7f0;
--warn: #b8860b; --warn: #b7791f;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06); --shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 16px rgba(0,0,0,0.08); --shadow-md: 0 10px 28px rgba(17,24,39,0.10);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12); --shadow-lg: 0 22px 60px rgba(17,24,39,0.18);
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 12px; --radius-lg: 12px;
@@ -41,23 +42,24 @@
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--bg: #000000; --bg: #101114;
--bg-panel: #211e19; --workspace-bg: #15181d;
--bg-panel-alt: #2a251f; --bg-panel: #1c1f24;
--border: #3a332b; --bg-panel-alt: #23272e;
--border-focus: #b99a70; --border: #363c46;
--text-primary: #f1ece4; --border-focus: #76a9ff;
--text-secondary: #c9bfb2; --text-primary: #f2f5f8;
--text-muted: #8f8579; --text-secondary: #c4ccd6;
--accent: #c3a174; --text-muted: #8994a3;
--accent-light: #3a3024; --accent: #6ea8ff;
--accent-hover: #d5b98f; --accent-light: #24364f;
--on-accent: #1c1711; --accent-hover: #91bdff;
--danger: #ef7569; --on-accent: #09111f;
--danger-light: #3b2220; --danger: #ff7a7a;
--on-danger: #1c1711; --danger-light: #3b2428;
--success: #6ec794; --on-danger: #1c1011;
--success-light: #1f3328; --success: #58d69c;
--success-light: #1d3329;
--warn: #e4b95a; --warn: #e4b95a;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.36); --shadow-sm: 0 1px 3px rgba(0,0,0,0.36);
--shadow-md: 0 8px 22px rgba(0,0,0,0.34); --shadow-md: 0 8px 22px rgba(0,0,0,0.34);
@@ -126,7 +128,7 @@ body {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
letter-spacing: -0.02em; letter-spacing: 0;
} }
.navbar-actions { .navbar-actions {
@@ -148,7 +150,7 @@ body {
font-family: var(--font-main); font-family: var(--font-main);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.01em; letter-spacing: 0;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: all var(--transition); transition: all var(--transition);
position: relative; position: relative;
@@ -182,6 +184,114 @@ body {
gap: 8px; gap: 8px;
} }
.navbar-end .nav-btn {
height: var(--nav-height);
}
.app-settings {
position: relative;
display: inline-flex;
align-items: center;
}
.app-settings-trigger {
white-space: nowrap;
}
.settings-window {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 310px;
z-index: 500;
display: grid;
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
color: var(--text-primary);
}
.settings-window-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.settings-window-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.settings-window-subtitle {
margin-top: 2px;
font-size: 11px;
color: var(--text-muted);
}
.settings-section {
display: grid;
gap: 8px;
}
.settings-section-title {
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
}
.settings-segmented {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 3px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-panel-alt);
}
.settings-segment {
min-width: 0;
height: 30px;
padding: 0 6px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-family: var(--font-main);
font-size: 12px;
cursor: pointer;
}
.settings-segment:hover {
color: var(--text-primary);
background: var(--bg);
}
.settings-segment.active {
background: var(--accent);
color: var(--on-accent);
box-shadow: var(--shadow-sm);
}
.background-mode-control {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-note {
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.theme-switch { .theme-switch {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -225,6 +335,277 @@ body {
overflow: hidden; overflow: hidden;
} }
.workspace-shell {
position: relative;
isolation: isolate;
background:
linear-gradient(color-mix(in srgb, var(--border) 56%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--border) 56%, transparent) 1px, transparent 1px),
var(--workspace-bg);
background-size: 24px 24px;
}
.workspace-canvas {
width: 100%;
}
.floating-panel {
position: absolute;
max-width: calc(100% - 16px);
max-height: calc(100% - 16px);
min-width: 220px;
min-height: 160px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
color: var(--text-primary);
}
.floating-panel-titlebar {
height: 34px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 0 10px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel-alt);
cursor: move;
user-select: none;
}
.floating-panel-title {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.floating-panel-title span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.floating-panel-icon {
display: inline-flex;
color: var(--accent);
font-size: 14px;
flex-shrink: 0;
}
.floating-panel-close {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
}
.floating-panel-close:hover {
background: var(--danger-light);
color: var(--danger);
}
.floating-panel-content {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.floating-panel .panel-body {
padding: 10px;
}
.floating-panel .table-empty {
min-height: 120px;
}
.floating-resize {
position: absolute;
z-index: 2;
}
.floating-resize-n,
.floating-resize-s {
left: 12px;
right: 12px;
height: 8px;
cursor: ns-resize;
}
.floating-resize-n { top: -4px; }
.floating-resize-s { bottom: -4px; }
.floating-resize-e,
.floating-resize-w {
top: 12px;
bottom: 12px;
width: 8px;
cursor: ew-resize;
}
.floating-resize-e { right: -4px; }
.floating-resize-w { left: -4px; }
.floating-resize-ne,
.floating-resize-nw,
.floating-resize-se,
.floating-resize-sw {
width: 16px;
height: 16px;
}
.floating-resize-ne { top: -4px; right: -4px; cursor: nesw-resize; }
.floating-resize-nw { top: -4px; left: -4px; cursor: nwse-resize; }
.floating-resize-se { right: -4px; bottom: -4px; cursor: nwse-resize; }
.floating-resize-sw { left: -4px; bottom: -4px; cursor: nesw-resize; }
body.floating-panel-moving,
body.resizing {
user-select: none;
}
/* ===== DOCK SNAP / STACK TABS ===== */
/* Docked columns flush against the screen edges — strip every corner radius
so the column reads as a continuous slab instead of a clipped card. */
.floating-panel[data-docked="left"],
.floating-panel[data-docked="right"] {
border-radius: 0;
}
.floating-panel[data-hidden="true"] {
display: none;
}
.floating-panel-snap-guide {
position: absolute;
pointer-events: none;
border: 2px dashed var(--accent, #6c63ff);
background: rgba(108, 99, 255, 0.12);
border-radius: var(--radius-md);
z-index: 9999;
}
/* Early edge "虚影" — a slim translucent strip hugging the side, shown while
dragging near a dockable edge but before the snap commits. Lighter than the
full snap guide so the two states read as distinct. */
.floating-panel-edge-highlight {
position: absolute;
pointer-events: none;
border: 1px dashed var(--accent, #6c63ff);
background: rgba(108, 99, 255, 0.06);
border-radius: var(--radius-md);
z-index: 9998;
animation: floating-panel-edge-pulse 1.4s ease-in-out infinite;
}
@keyframes floating-panel-edge-pulse {
0%, 100% { background: rgba(108, 99, 255, 0.05); }
50% { background: rgba(108, 99, 255, 0.13); }
}
/* When a dock column has a stacked tab bar, the active panel should sit
*below* the tab bar so it doesn't cover the tabs. Tab bar height = 30px. */
.workspace-shell[data-has-dock-tabs="true"] .floating-panel[data-active-tab="true"] {
margin-top: 0;
}
.dock-tab-bar {
position: absolute;
z-index: 100;
display: flex;
align-items: stretch;
height: 30px;
overflow: hidden;
border-bottom: 1px solid var(--border);
background: var(--bg-panel-alt);
/* Docked tab dock-bar sits flush against the screen edge — no rounding. */
border-radius: 0;
}
.dock-tab {
flex: 1 1 0;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.dock-tab:last-child {
border-right: none;
}
.dock-tab:hover {
background: rgba(0, 0, 0, 0.04);
}
.dock-tab.active {
background: var(--bg-panel);
color: var(--text-primary);
border-bottom: 2px solid var(--accent, #6c63ff);
}
.dock-tab.dragging {
cursor: grabbing;
opacity: 0.65;
box-shadow: inset 0 0 0 1px var(--accent, #6c63ff);
}
.dock-tab-icon {
display: inline-flex;
color: var(--accent, #6c63ff);
font-size: 13px;
flex-shrink: 0;
}
.dock-tab-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dock-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--radius-sm);
flex-shrink: 0;
cursor: pointer;
}
.dock-tab-close:hover {
background: var(--danger-light, rgba(220, 53, 69, 0.12));
color: var(--danger, #dc3545);
}
/* ===== SIDE PANEL ===== */ /* ===== SIDE PANEL ===== */
.side-panel { .side-panel {
width: var(--panel-width); width: var(--panel-width);
@@ -258,7 +639,7 @@ body {
.panel-title { .panel-title {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em; letter-spacing: 0;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -423,7 +804,7 @@ body {
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
letter-spacing: 0.01em; letter-spacing: 0;
} }
.form-input { .form-input {
@@ -558,7 +939,7 @@ body {
font-family: var(--font-main); font-family: var(--font-main);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0;
transition: all var(--transition); transition: all var(--transition);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -666,7 +1047,7 @@ body {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
letter-spacing: 0.04em; letter-spacing: 0;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 4px; margin-bottom: 4px;
margin-top: 2px; margin-top: 2px;
@@ -725,7 +1106,7 @@ body {
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0;
} }
.note-text { .note-text {
@@ -757,7 +1138,7 @@ body {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
letter-spacing: 0.04em; letter-spacing: 0;
text-transform: uppercase; text-transform: uppercase;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
@@ -902,6 +1283,80 @@ body {
flex: 1; flex: 1;
} }
.wordcloud-spacing-panel {
display: grid;
gap: 8px;
}
.metric-box {
min-height: 28px;
display: flex;
align-items: center;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
}
.spacing-result,
.spacing-batch-list {
display: grid;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.spacing-result.stale {
border-color: var(--warn);
}
.spacing-result-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--text-secondary);
font-size: 12px;
}
.spacing-result-row span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spacing-result-row strong {
flex-shrink: 0;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
}
.spacing-result-meta,
.spacing-warning,
.spacing-error {
font-size: 11px;
line-height: 1.5;
}
.spacing-result-meta {
color: var(--text-muted);
}
.spacing-warning {
color: var(--warn);
}
.spacing-error {
color: var(--danger);
}
/* ===== LINK ===== */ /* ===== LINK ===== */
.text-link { .text-link {
color: var(--danger); color: var(--danger);
@@ -1023,19 +1478,6 @@ body {
background-position: 0 0, 0 14px, 14px -14px, -14px 0; background-position: 0 0, 0 14px, 14px -14px, -14px 0;
} }
.studio-topbar {
position: absolute;
top: 14px;
left: 14px;
right: 14px;
height: 38px;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}
.canvas-size-pill, .canvas-size-pill,
.zoom-controls { .zoom-controls {
display: flex; display: flex;
@@ -1065,7 +1507,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 84px; padding: 32px 16px 80px;
} }
.studio-stage { .studio-stage {
@@ -1076,6 +1518,23 @@ body {
overflow: hidden; overflow: hidden;
} }
.studio-bottombar {
position: absolute;
bottom: 14px;
height: 38px;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
pointer-events: none;
}
.studio-bottombar .canvas-size-pill,
.studio-bottombar .zoom-controls {
pointer-events: auto;
}
.studio-element { .studio-element {
position: absolute; position: absolute;
transform-origin: center center; transform-origin: center center;
@@ -1405,6 +1864,158 @@ body {
padding: 3px; padding: 3px;
} }
/* ===== HELP PAGE ===== */
.help-page {
height: 100vh;
background: var(--bg);
color: var(--text-primary);
overflow: hidden;
}
.help-main {
height: calc(100vh - var(--nav-height));
overflow: auto;
padding: 24px;
}
.help-hero {
min-height: 220px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 24px;
padding: 28px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-panel);
box-shadow: var(--shadow-sm);
}
.help-kicker {
margin-bottom: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--accent);
}
.help-hero h1 {
margin-bottom: 10px;
font-size: clamp(28px, 4vw, 48px);
line-height: 1;
letter-spacing: 0;
}
.help-hero p {
max-width: 720px;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
.help-shortcuts {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.help-section {
margin-top: 22px;
}
.help-section h2 {
margin-bottom: 12px;
font-size: 18px;
letter-spacing: 0;
}
.help-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.help-step,
.help-item,
.help-row {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-panel);
box-shadow: var(--shadow-sm);
}
.help-step {
display: grid;
gap: 8px;
padding: 14px;
}
.help-step span {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--accent);
color: var(--on-accent);
font-family: var(--font-mono);
font-size: 12px;
}
.help-step strong,
.help-item strong,
.help-row strong {
font-size: 13px;
color: var(--text-primary);
}
.help-step p,
.help-item p,
.help-row p {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.65;
}
.help-grid {
display: grid;
gap: 12px;
}
.help-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.help-item {
padding: 14px;
}
.help-table {
display: grid;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-panel);
box-shadow: var(--shadow-sm);
}
.help-row {
display: grid;
grid-template-columns: minmax(160px, 0.28fr) minmax(0, 1fr);
gap: 14px;
align-items: start;
padding: 12px 14px;
border-width: 0 0 1px;
border-radius: 0;
box-shadow: none;
}
.help-row:last-child {
border-bottom: none;
}
/* ===== TEMPLATE HOME ===== */ /* ===== TEMPLATE HOME ===== */
.template-home { .template-home {
min-height: 100vh; min-height: 100vh;
@@ -1587,6 +2198,43 @@ body {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.navbar {
min-width: 0;
}
.navbar-brand {
min-width: 132px;
padding: 0 12px;
}
.navbar-actions {
overflow-x: auto;
}
.navbar-end {
padding: 0 10px;
gap: 6px;
}
.floating-panel {
width: min(360px, calc(100% - 16px)) !important;
height: min(520px, calc(100% - 16px)) !important;
}
.help-hero {
grid-template-columns: 1fr;
align-items: start;
}
.help-steps {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.help-grid.two,
.help-row {
grid-template-columns: 1fr;
}
.studio-panel, .studio-panel,
.studio-properties { .studio-properties {
--panel-width: 220px; --panel-width: 220px;
@@ -1608,6 +2256,23 @@ body {
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.floating-panel {
left: 8px !important;
top: 8px !important;
}
.help-main {
padding: 14px;
}
.help-hero {
padding: 18px;
}
.help-steps {
grid-template-columns: 1fr;
}
.template-home-main { .template-home-main {
padding: 14px; padding: 14px;
} }
@@ -1641,4 +2306,3 @@ body.resizing .side-panel,
body.resizing .studio-panel { body.resizing .studio-panel {
transition: none !important; transition: none !important;
} }
+24
View File
@@ -101,6 +101,30 @@ export interface CanvasElementBase {
height: number; height: number;
rotation: number; rotation: number;
opacity: number; opacity: number;
lineSpacingAnalysis?: LineSpacingAnalysisSummary;
}
export interface LineSpacingAnalysisSummary {
percentile: number;
spacingPx: number;
spacingMm: number;
minSpacingPx: number;
minSpacingMm: number;
sampleStep: number;
curveCount: number;
segmentCount: number;
nearestCount: number;
sourceWidth: number;
sourceHeight: number;
elementWidth: number;
elementHeight: number;
computedAt: string;
closestPoints?: {
ax: number;
ay: number;
bx: number;
by: number;
};
} }
export interface StickerCanvasElement extends CanvasElementBase { export interface StickerCanvasElement extends CanvasElementBase {
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />