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
pillow>=10.0.0
numpy>=2.0.0
scipy>=1.11.0
matplotlib>=3.10.0
pandas>=2.0.0
openpyxl>=3.1.0
+43
View File
@@ -8,6 +8,7 @@ import re
import shutil
import sqlite3
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -21,6 +22,7 @@ from PIL import Image, ImageDraw, ImageFont
from core import config as wc_config
from core.fonts import get_cached_font
from .job_manager import JobManager
from .line_spacing import analyze_svg_line_spacing_file
from .log_config import get_logger
from .runner import JobRunner
from .schemas import (
@@ -32,6 +34,8 @@ from .schemas import (
JobLocationSearchResult,
JobResult,
JobStatus,
LineSpacingAnalysisRequest,
LineSpacingAnalysisSummary,
Project,
ProjectSummary,
Template,
@@ -973,6 +977,45 @@ def download_asset(asset_id: str):
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)
def delete_asset(asset_id: str) -> None:
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
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):
template_id: 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
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 ────────────────────────────────────────
EXT=$($PYTHON -c 'import importlib.machinery; print(importlib.machinery.EXTENSION_SUFFIXES[0])')
EWC_SO="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/ewc_core${EXT}"
@@ -83,6 +109,7 @@ fi
# ── start ────────────────────────────────────────────────
echo "[INFO] 启动后端 http://0.0.0.0:${BACKEND_PORT}"
WORDCLOUD_SCIPY_SITE="$EXTRA_SCIPY_SITE" \
PYTHONPATH="$ROOT_DIR/EfficientWordCloud" \
"$PYTHON" -m uvicorn service.app:app \
--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 HelpPage from './pages/HelpPage';
import TemplateHome from './pages/TemplateHome';
import TestWorkbench from './pages/TestWorkbench';
import { CanvasDocument, WordcloudStickerPayload } from './types';
import { createDefaultDocument } from './lib/canvasDocument';
type AppPage = 'home' | 'canvas' | 'wordcloud';
type ThemeMode = 'light' | 'dark' | 'system';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'help';
const getStoredTheme = (): ThemeMode => {
const stored = window.localStorage.getItem('wordcloud-theme');
@@ -18,22 +19,40 @@ const getSystemTheme = () =>
export default function App() {
const [page, setPage] = useState<AppPage>('home');
const [themeMode] = useState<ThemeMode>(getStoredTheme);
const [systemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme);
const [initialDocument, setInitialDocument] = useState<CanvasDocument | null>(null);
const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState<WordcloudStickerPayload | null>(null);
const openHelp = () => setPage('help');
useLayoutEffect(() => {
const resolvedTheme = themeMode === 'system' ? systemTheme : themeMode;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themeMode = themeMode;
document.documentElement.style.colorScheme = resolvedTheme;
window.localStorage.setItem('wordcloud-theme', themeMode);
}, [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') {
return (
<TestWorkbench
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenCanvas={() => setPage('canvas')}
onOpenHelp={openHelp}
onImportWordcloudSticker={(payload) => {
setPendingWordcloudSticker(payload);
setPage('canvas');
@@ -45,8 +64,12 @@ export default function App() {
if (page === 'canvas') {
return (
<CanvasStudio
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenHelp={openHelp}
initialDocument={initialDocument}
onConsumeInitialDocument={() => setInitialDocument(null)}
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 (
<TemplateHome
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onCreateBlank={() => {
setInitialDocument(createDefaultDocument());
setPage('canvas');
@@ -67,6 +106,7 @@ export default function App() {
}}
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenHelp={openHelp}
/>
);
}
+4 -1
View File
@@ -3,14 +3,17 @@ import { JobParams } from '../types';
interface AdvancedPanelProps {
params: JobParams;
onParamsChange: (partial: Partial<JobParams>) => void;
embedded?: boolean;
}
export default function AdvancedPanel({ params, onParamsChange }: AdvancedPanelProps) {
export default function AdvancedPanel({ params, onParamsChange, embedded = false }: AdvancedPanelProps) {
return (
<>
{!embedded && (
<div className="panel-header">
<div className="panel-title"></div>
</div>
)}
<div className="panel-body">
{/* 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 { NameLocation, JobResult } from '../types';
import { apiUrl } from '../lib/api';
import { IconCloudy } from './Icons';
interface CanvasAreaProps {
maskFile: File | null;
jobResult: JobResult | null;
apiBase: string;
viewMode: '2d' | '3d';
zoom: number;
highlightLocation: NameLocation | null;
}
export default function CanvasArea({
maskFile, jobResult, apiBase, viewMode, zoom, highlightLocation
maskFile, jobResult, viewMode, zoom, highlightLocation
}: CanvasAreaProps) {
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
@@ -24,16 +24,12 @@ export default function CanvasArea({
return () => URL.revokeObjectURL(url);
}, [maskFile]);
// 图片 URL 处理:
// - 若已是完整 http URL 直接使用
// - 若是 /api/... 路径则拼接 apiBase
// - 否则回退到 /api/jobs/{id}/files/png
// 图片 URL 可能来自后端相对路径,也可能是本地预览地址。
const resolveImageUrl = (): string | null => {
if (!jobResult) return maskPreviewUrl;
const raw = jobResult.image_url;
if (!raw || !jobResult.job_id) return maskPreviewUrl;
if (raw.startsWith('http')) return raw;
return `${apiBase}${raw}`;
return apiUrl(raw);
};
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 {
entries: NameEntry[];
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 [filterVal, setFilterVal] = useState('');
@@ -40,9 +41,11 @@ export default function EditPanel({ entries, onEntriesChange }: EditPanelProps)
return (
<>
{!embedded && (
<div className="panel-header">
<div className="panel-title"></div>
</div>
)}
<div className="panel-body" style={{ padding: '10px 10px 0' }}>
{/* Filter bar */}
<div className="flex-row" style={{ gap: 4, marginBottom: 8 }}>
+9 -7
View File
@@ -1,14 +1,15 @@
import { useState } from 'react';
import { WordcloudMaskSource, WordcloudStickerPayload } from '../types';
import { apiUrl, ensureOk } from '../lib/api';
interface ExportPanelProps {
jobId: string | null;
apiBase: string;
svgUrl?: string;
imageUrl?: string;
onOpenCanvas?: () => void;
maskSource?: WordcloudMaskSource | null;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
embedded?: boolean;
}
type Format = 'jpg' | 'png';
@@ -16,12 +17,12 @@ type FillMode = 'fill' | 'dot' | 'line' | 'ring';
export default function ExportPanel({
jobId,
apiBase,
svgUrl,
imageUrl,
onOpenCanvas,
maskSource,
onImportWordcloudSticker,
embedded = false,
}: ExportPanelProps) {
const [bmpFormat, setBmpFormat] = useState<Format>('png');
const [exportW, setExportW] = useState('1920');
@@ -40,8 +41,8 @@ export default function ExportPanel({
const [ringSpacing, setRingSpacing] = useState(8);
const resolveUrl = (field: string | undefined, kind: string) => {
if (field) return field.startsWith('http') ? field : `${apiBase}${field}`;
if (jobId) return `${apiBase}/api/jobs/${jobId}/files/${kind}`;
if (field) return apiUrl(field);
if (jobId) return apiUrl(`/api/jobs/${jobId}/files/${kind}`);
return null;
};
@@ -64,7 +65,7 @@ export default function ExportPanel({
params.set('ring_width', String(ringWidth));
params.set('ring_spacing', String(ringSpacing));
}
return `${apiBase}/api/jobs/${jobId}/custom.svg?${params}`;
return apiUrl(`/api/jobs/${jobId}/custom.svg?${params}`);
};
const handleExportSvg = () => {
@@ -79,8 +80,7 @@ export default function ExportPanel({
setIsSavingSticker(true);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`读取 SVG 失败 (${res.status})`);
const res = await ensureOk(await fetch(url), '读取 SVG 失败');
const svg = await res.text();
if (onImportWordcloudSticker) {
onImportWordcloudSticker({ svg, mask: maskSource || undefined });
@@ -114,9 +114,11 @@ export default function ExportPanel({
return (
<>
{!embedded && (
<div className="panel-header">
<div className="panel-title"></div>
</div>
)}
<div className="panel-body">
{/* ── SVG 导出 ── */}
+7 -5
View File
@@ -1,13 +1,14 @@
import { useState } from 'react';
import { NameLocation } from '../types';
import { apiUrl, ensureOk } from '../lib/api';
interface FindPanelProps {
jobId: string | null;
apiBase: string;
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 [results, setResults] = useState<NameLocation[]>([]);
const [currentIdx, setCurrentIdx] = useState(-1);
@@ -19,9 +20,8 @@ export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps)
setLoading(true);
setSearched(false);
try {
const url = `${apiBase}/api/jobs/${jobId}/locations?name=${encodeURIComponent(query.trim())}`;
const res = await fetch(url);
if (!res.ok) throw new Error('请求失败');
const url = apiUrl(`/api/jobs/${jobId}/locations?name=${encodeURIComponent(query.trim())}`);
const res = await ensureOk(await fetch(url), '查找失败');
const data = await res.json();
const matches: NameLocation[] = (data.matches ?? []).map((m: any) => ({
...m,
@@ -54,9 +54,11 @@ export default function FindPanel({ jobId, apiBase, onLocate }: FindPanelProps)
return (
<>
{!embedded && (
<div className="panel-header">
<div className="panel-title"></div>
</div>
)}
<div className="panel-body">
<div className="form-group">
<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>
);
}
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;
onFontDelete: (fontId: string) => void;
onFontSelect: (fontId: string) => void;
embedded?: boolean;
}
const TEMPLATE_URL = '#';
@@ -23,6 +24,7 @@ export default function ImportPanel({
maskFile, namesFile, params, fonts, selectedFontId,
onMaskChange, onNamesChange, onParamsChange,
onFontUpload, onFontDelete, onFontSelect,
embedded = false,
}: ImportPanelProps) {
const fontInputRef = useRef<HTMLInputElement>(null);
@@ -36,9 +38,11 @@ export default function ImportPanel({
return (
<>
{!embedded && (
<div className="panel-header">
<div className="panel-title"></div>
</div>
)}
<div className="panel-body">
{/* 底图导入 */}
+5 -2
View File
@@ -1,6 +1,9 @@
import type { CSSProperties } from 'react';
interface ViewControlsProps {
zoom: number;
viewMode: '2d' | '3d';
style?: CSSProperties;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomReset: () => void;
@@ -8,10 +11,10 @@ interface ViewControlsProps {
}
export default function ViewControls({
zoom, viewMode, onZoomIn, onZoomOut, onZoomReset, onToggleView
zoom, viewMode, style, onZoomIn, onZoomOut, onZoomReset, onToggleView
}: ViewControlsProps) {
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={onZoomReset} style={{ fontSize: 10, width: 'auto', padding: '0 4px' }}>
{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 MM_PER_INCH = 25.4;
export const DEFAULT_LAYER_ID = 'layer-default';
export const TRANSPARENT_BACKGROUND = 'transparent';
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) {
@@ -21,6 +22,18 @@ export function formatMm(px: number) {
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) {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
@@ -95,7 +108,7 @@ export function normalizeDocument(input: CanvasDocument): CanvasDocument {
return {
width: Number.isFinite(input.width) ? input.width : 1600,
height: Number.isFinite(input.height) ? input.height : 1000,
background: input.background || '#ffffff',
background: normalizeCanvasBackground(input.background),
layers: nextLayers,
layerFolders,
elements,
+8 -7
View File
@@ -1,4 +1,5 @@
import { StickerAsset } from '../types';
import { apiUrl, ensureOk } from './api';
// ---------------------------------------------------------------------------
// Backend-based sticker library
@@ -76,8 +77,7 @@ interface BackendAsset {
}
async function apiListAssets(): Promise<BackendAsset[]> {
const res = await fetch('/api/assets?type=sticker');
if (!res.ok) throw new Error(`list assets failed: ${res.status}`);
const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败');
return res.json();
}
@@ -90,14 +90,15 @@ async function apiUploadAsset(
form.append('file', blob, filename);
form.append('name', name);
form.append('type', 'sticker');
const res = await fetch('/api/assets', { method: 'POST', body: form });
if (!res.ok) throw new Error(`upload asset failed: ${res.status}`);
const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: form }), '上传贴纸失败');
return res.json();
}
async function apiDeleteAsset(assetId: string): Promise<void> {
const res = await fetch(`/api/assets/${assetId}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`delete asset failed: ${res.status}`);
const res = await fetch(apiUrl(`/api/assets/${assetId}`), { method: 'DELETE' });
if (!res.ok && res.status !== 404) {
await ensureOk(res, '删除贴纸失败');
}
}
// ── Public API ─────────────────────────────────────────────────────────────
@@ -152,5 +153,5 @@ export function svgToDataUrl(svg: string) {
export function assetToDataUrl(asset: StickerAsset) {
// 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 { normalizeDocument, pxToMm } from './canvasDocument';
import { apiUrl } from './api';
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
import { createZip } from './zip';
export interface SerializeOptions {
@@ -8,7 +9,7 @@ export interface SerializeOptions {
}
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})`);
const blob = await res.blob();
return new Promise((resolve, reject) => {
@@ -19,16 +20,30 @@ async function fetchBlobAsDataUrl(url: string): Promise<string> {
});
}
async function resolveStickerHref(asset: StickerAsset): Promise<string> {
// Legacy inline content (still supported for imported files / tests)
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`;
async function fetchText(url: string): Promise<string> {
const res = await fetch(apiUrl(url));
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
return res.text();
}
if (asset.source.startsWith('data:')) {
return asset.source;
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;
}
// Backend URL: fetch and inline so the exported SVG is self-contained
return fetchBlobAsDataUrl(asset.source);
if (source.startsWith('data:')) {
return decodeDataUrl(source);
}
return fetchText(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(
@@ -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}">`,
];
if (options.includeBackground !== false) {
if (options.includeBackground !== false && hasCanvasBackground(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') {
const asset = stickerById.get(element.assetId);
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)"' : '';
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;
@@ -100,6 +122,13 @@ export async function createLayerExportZip(
const files: { name: string; content: string }[] = [];
const used = new Map<string, number>();
if (hasCanvasBackground(doc.background)) {
files.push({
name: uniqueSvgName('背景', used),
content: serializeBackgroundLayer(doc),
});
}
for (const layerId of selectedLayerIds) {
const layer = doc.layers?.find(item => item.id === layerId);
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);
}
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>) {
const base = sanitizeFileName(name || '未命名');
const count = used.get(base) || 0;
+8 -15
View File
@@ -1,8 +1,7 @@
import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types';
import { apiUrl, ensureOk } from './api';
import { cloneDocument, normalizeDocument } from './canvasDocument';
const API_BASE = '';
export function templateId(template: CanvasTemplate) {
return template.template_id || template.id || '';
}
@@ -24,8 +23,7 @@ export function templateCoverId(template: CanvasTemplate) {
}
export async function listDesignTemplates(): Promise<CanvasTemplate[]> {
const res = await fetch(`${API_BASE}/api/design-templates`);
if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl('/api/design-templates')), '读取模板库失败');
const items = (await res.json()) as CanvasTemplate[];
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('reference_asset_ids', JSON.stringify(input.referenceAssetIds || []));
fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || '');
const res = await fetch(`${API_BASE}/api/design-templates`, { method: 'POST', body: fd });
if (!res.ok) throw new Error(`保存模板失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl('/api/design-templates'), { method: 'POST', body: fd }), '保存模板失败');
const template = (await res.json()) as CanvasTemplate;
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.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds));
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 });
if (!res.ok) throw new Error(`更新模板失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'PATCH', body: fd }), '更新模板失败');
const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) };
}
export async function deleteCanvasTemplate(id: string) {
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`删除模板失败 (${res.status})`);
await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'DELETE' }), '删除模板失败');
}
export async function listAssets(type = ''): Promise<BackendAsset[]> {
const query = type ? `?type=${encodeURIComponent(type)}` : '';
const res = await fetch(`${API_BASE}/api/assets${query}`);
if (!res.ok) throw new Error(`读取素材失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl(`/api/assets${query}`)), '读取素材失败');
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('name', file.name.replace(/\.(svg|png|jpe?g)$/i, ''));
fd.append('type', type);
const res = await fetch(`${API_BASE}/api/assets`, { method: 'POST', body: fd });
if (!res.ok) throw new Error(`上传参考图失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: fd }), '上传参考图失败');
return (await res.json()) as BackendAsset;
}
@@ -97,7 +90,7 @@ export function assetUrl(assetOrPath?: BackendAsset | string) {
if (!assetOrPath) return '';
const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url;
if (!raw) return '';
return raw.startsWith('http') ? raw : `${API_BASE}${raw}`;
return apiUrl(raw);
}
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 AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { BackendAsset, CanvasTemplate } from '../types';
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
import {
@@ -18,20 +19,29 @@ import {
IconCloud,
IconRefresh,
IconCanvas,
IconHelp,
} from '../components/Icons';
interface TemplateHomeProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onCreateBlank: () => void;
onUseTemplate: (template: CanvasTemplate) => void;
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
onOpenHelp: () => void;
}
export default function TemplateHome({
themeMode,
systemTheme,
onThemeModeChange,
onCreateBlank,
onUseTemplate,
onOpenCanvas,
onOpenWordcloud,
onOpenHelp,
}: TemplateHomeProps) {
const [templates, setTemplates] = useState<CanvasTemplate[]>([]);
const [assets, setAssets] = useState<BackendAsset[]>([]);
@@ -94,6 +104,8 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconRefresh /></span>
<span className="nav-btn-label"></span>
</button>
</div>
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenCanvas}>
<span className="nav-btn-icon"><IconCanvas /></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-label"></span>
</button>
</div>
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenHelp}>
<span className="nav-btn-icon"><IconHelp /></span>
<span className="nav-btn-label"></span>
</button>
<button className="btn btn-primary btn-sm" onClick={onCreateBlank}></button>
<AppSettingsWindow
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={onThemeModeChange}
/>
</div>
</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 AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import {
NameEntry,
JobParams,
@@ -19,7 +20,11 @@ import AdvancedPanel from '../components/AdvancedPanel';
import ProgressPanel from '../components/ProgressPanel';
import CanvasArea from '../components/CanvasArea';
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 {
IconImport,
IconExport,
@@ -27,10 +32,9 @@ import {
IconFind,
IconSettings,
IconCloud,
IconHelp,
} from '../components/Icons';
const API_BASE = '';
const DEFAULT_PARAMS: JobParams = {
seed: 42,
dataColIndex: 1, // 0-based,默认第2列(B列)
@@ -42,12 +46,12 @@ const DEFAULT_PARAMS: JobParams = {
};
type NavItem = {
id: NonNullable<PanelType>;
id: WorkbenchPanelId;
label: string;
icon: React.ReactNode;
};
type ThemeMode = 'light' | 'dark' | 'system';
type WorkbenchPanelId = NonNullable<PanelType>;
const NAV_ITEMS: NavItem[] = [
{ id: 'import', label: '导入', icon: <IconImport /> },
@@ -57,27 +61,31 @@ const NAV_ITEMS: NavItem[] = [
{ id: 'advanced', label: '高级', icon: <IconSettings /> },
];
const THEME_OPTIONS: { id: ThemeMode; label: string }[] = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'system', label: '系统' },
];
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 WORKBENCH_PANEL_LAYOUT: FloatingPanelLayout<WorkbenchPanelId> = {
import: { x: 18, y: 18, width: 320, height: 580, zIndex: 4, docked: null },
export: { x: 710, y: 18, width: 360, height: 560, zIndex: 3, docked: null },
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 },
};
const getSystemTheme = () =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
interface TestWorkbenchProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenCanvas?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
onOpenHelp?: () => void;
}
export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }: TestWorkbenchProps) {
const { width: panelWidth, handleRef } = useResizablePanel('wb-panel-width', 240, 180, 400, 'left');
export default function TestWorkbench({
themeMode,
systemTheme,
onThemeModeChange,
onOpenCanvas,
onImportWordcloudSticker,
onOpenHelp,
}: TestWorkbenchProps) {
const [maskFile, setMaskFile] = useState<File | null>(null);
const [maskSubmitFile, setMaskSubmitFile] = useState<File | 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 [progress, setProgress] = useState<SSEProgress | null>(null);
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 [zoom, setZoom] = useState(1);
const [highlightLocation, setHighlightLocation] = useState<NameLocation | null>(null);
const [fonts, setFonts] = useState<Font[]>([]);
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 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 resolvedTheme = themeMode === 'system' ? systemTheme : themeMode;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themeMode = themeMode;
document.documentElement.style.colorScheme = resolvedTheme;
window.localStorage.setItem('wordcloud-theme', themeMode);
}, [themeMode, systemTheme]);
const measureWorkspace = useCallback(() => {
const node = workspaceRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const size = { width: rect.width, height: rect.height };
workspaceSizeRef.current = size;
setWorkspaceSize(size);
}, []);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setSystemTheme(event.matches ? 'dark' : 'light');
};
measureWorkspace();
if (typeof ResizeObserver === 'undefined') return;
const node = workspaceRef.current;
if (!node) return;
const observer = new ResizeObserver(() => measureWorkspace());
observer.observe(node);
return () => observer.disconnect();
}, [measureWorkspace]);
setSystemTheme(media.matches ? 'dark' : 'light');
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, []);
// Re-stack docked panels when the workspace size or dock membership changes
// (dock/undock/close can shift the tab-bar inset and stack offsets). Width is
// 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 () => {
try {
const res = await fetch(`${API_BASE}/api/fonts`);
const res = await fetch(apiUrl('/api/fonts'));
if (res.ok) {
const data: Font[] = await res.json();
setFonts(data);
@@ -135,7 +177,7 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
fd.append('file', file);
fd.append('name', file.name.replace(/\.(ttf|ttc|otf)$/i, ''));
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) {
const font: Font = await res.json();
setFonts(prev => [font, ...prev]);
@@ -146,7 +188,7 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
const handleFontDelete = useCallback(async (fontId: string) => {
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));
setSelectedFontId('__default__');
} catch (e) { console.error('font delete error:', e); }
@@ -240,8 +282,17 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
});
}, [namesFile, parseExcel]);
const handleNavClick = (panel: PanelType) => {
setActivePanel(prev => prev === panel ? null : panel);
const handleNavClick = (panel: WorkbenchPanelId) => {
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);
try {
const usingEditedEntries = nameEntries.length > 0;
const submitNamesFile = usingEditedEntries
? buildNamesWorkbookFile(nameEntries, namesFile)
: namesFile;
// ── 组装 params JSON(对应后端 config 别名键)──────────────────────
// 参见 README 4.8.3 / 4.8.10
const paramsObj: Record<string, unknown> = {
MODE: maskSubmitFile ? 'IMAGE' : 'TEXT',
DATA_COL_INDEX: params.dataColIndex,
DATA_COL_INDEX: usingEditedEntries ? 1 : params.dataColIndex,
};
if (params.seed !== null) {
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 权重列
}
if (params.fontColor) {
@@ -287,20 +345,16 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
// params : JSON 字符串
const formData = new FormData();
formData.append('name_list', namesFile);
formData.append('name_list', submitNamesFile);
if (maskSubmitFile) {
formData.append('mask_image', maskSubmitFile);
}
if (selectedFontId) {
if (selectedFontId && selectedFontId !== '__default__') {
formData.append('font_id', selectedFontId);
}
formData.append('params', JSON.stringify(paramsObj));
const res = await fetch(`${API_BASE}/api/jobs`, { method: 'POST', body: formData });
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new Error(`提交失败 (${res.status}): ${errText}`);
}
const res = await ensureOk(await fetch(apiUrl('/api/jobs'), { method: 'POST', body: formData }), '提交失败');
const data = await res.json();
// README 6.5 响应包含 job_id
@@ -311,7 +365,7 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
// ── SSE 监听进度 ───────────────────────────────────────────────────
// GET /api/jobs/{job_id}/events
sseRef.current?.close();
const sse = new EventSource(`${API_BASE}/api/jobs/${id}/events`);
const sse = apiEventSource(`/api/jobs/${id}/events`);
sseRef.current = sse;
sse.onmessage = (e) => {
@@ -346,18 +400,19 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
// GET /api/jobs/{job_id}/result
const fetchResult = async (id: string) => {
try {
const res = await fetch(`${API_BASE}/api/jobs/${id}/result`);
if (!res.ok) throw new Error(`获取结果失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl(`/api/jobs/${id}/result`)), '获取结果失败');
const data: JobResult = await res.json();
if (data.status === 'failed') {
// 尝试从 /detail 拿更详细的错误信息
let detail = '';
try {
const dr = await fetch(`${API_BASE}/api/jobs/${id}/detail`);
const dr = await fetch(apiUrl(`/api/jobs/${id}/detail`));
if (dr.ok) {
const dd = await dr.json();
detail = dd.error ?? dd.message ?? '';
} else {
detail = await readApiError(dr);
}
} catch { /* ignore */ }
setProgress({
@@ -391,61 +446,118 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
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 (
<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
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' && (
<FloatingPanel key={panel} {...commonProps}>
{panel === 'import' && (
<ImportPanel
embedded
maskFile={maskFile}
namesFile={namesFile}
params={params}
@@ -459,10 +571,10 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
onFontSelect={setSelectedFontId}
/>
)}
{activePanel === 'export' && (
{panel === 'export' && (
<ExportPanel
embedded
jobId={jobId}
apiBase={API_BASE}
svgUrl={jobResult?.svg_url}
imageUrl={jobResult?.image_url}
onOpenCanvas={onOpenCanvas}
@@ -470,49 +582,80 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
onImportWordcloudSticker={onImportWordcloudSticker}
/>
)}
{activePanel === 'edit' && (
{panel === 'edit' && (
<EditPanel
embedded
entries={nameEntries}
onEntriesChange={setNameEntries}
/>
)}
{activePanel === 'find' && (
{panel === 'find' && (
<FindPanel
embedded
jobId={jobId}
apiBase={API_BASE}
onLocate={handleLocate}
/>
)}
{activePanel === 'advanced' && (
{panel === 'advanced' && (
<AdvancedPanel
embedded
params={params}
onParamsChange={handleParamsChange}
/>
)}
</div>
</FloatingPanel>
);
};
{/* 生成按钮固定在面板底部 */}
<div className="panel-footer">
return (
<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
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}
disabled={isGenerating}
>
{isGenerating
? <><span className="spinner" /></>
: '生成'}
{isGenerating ? <><span className="spinner" /></> : '生成词云'}
</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 className="panel-resize-handle" ref={handleRef} />
</aside>
</nav>
{/* ===== CANVAS ===== */}
<main className="canvas-area">
{/* ===== MAIN ===== */}
<div className="main-content workspace-shell" ref={workspaceRef}>
<main className="canvas-area workspace-canvas">
<CanvasArea
maskFile={maskFile}
jobResult={jobResult}
apiBase={API_BASE}
viewMode={viewMode}
zoom={zoom}
highlightLocation={highlightLocation}
@@ -523,12 +666,40 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker }
<ViewControls
zoom={zoom}
viewMode={viewMode}
style={{ right: rightDockWidth ? rightDockWidth + 16 : 16 }}
onZoomIn={() => setZoom(z => Math.min(5, +(z + 0.1).toFixed(1)))}
onZoomOut={() => setZoom(z => Math.max(0.1, +(z - 0.1).toFixed(1)))}
onZoomReset={() => setZoom(1)}
onToggleView={() => setViewMode(v => v === '2d' ? '3d' : '2d')}
/>
</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>
);
@@ -584,3 +755,24 @@ function parseSvgNumber(svg: string, attr: 'width' | 'height') {
const match = svg.match(new RegExp(`${attr}=["']([0-9.]+)`));
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 {
color-scheme: light;
--bg: #f5f4f0;
--bg: #f4f6f8;
--workspace-bg: #eef1f5;
--bg-panel: #ffffff;
--bg-panel-alt: #fafaf8;
--border: #e2e0da;
--border-focus: #8b7355;
--text-primary: #1a1814;
--text-secondary: #6b6560;
--text-muted: #a09890;
--accent: #6b4f2e;
--accent-light: #f0ebe3;
--accent-hover: #5a4025;
--bg-panel-alt: #f7f9fb;
--border: #d8dde5;
--border-focus: #3b82f6;
--text-primary: #171a1f;
--text-secondary: #4c5563;
--text-muted: #7f8a99;
--accent: #2563eb;
--accent-light: #e8f1ff;
--accent-hover: #1d4ed8;
--on-accent: #ffffff;
--danger: #c0392b;
--danger-light: #fdf0ef;
--danger: #d83a3a;
--danger-light: #fff0f0;
--on-danger: #ffffff;
--success: #2d7a4f;
--success-light: #edf7f1;
--warn: #b8860b;
--success: #0f8f62;
--success-light: #e8f7f0;
--warn: #b7791f;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 16px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
--shadow-md: 0 10px 28px rgba(17,24,39,0.10);
--shadow-lg: 0 22px 60px rgba(17,24,39,0.18);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
@@ -41,23 +42,24 @@
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #000000;
--bg-panel: #211e19;
--bg-panel-alt: #2a251f;
--border: #3a332b;
--border-focus: #b99a70;
--text-primary: #f1ece4;
--text-secondary: #c9bfb2;
--text-muted: #8f8579;
--accent: #c3a174;
--accent-light: #3a3024;
--accent-hover: #d5b98f;
--on-accent: #1c1711;
--danger: #ef7569;
--danger-light: #3b2220;
--on-danger: #1c1711;
--success: #6ec794;
--success-light: #1f3328;
--bg: #101114;
--workspace-bg: #15181d;
--bg-panel: #1c1f24;
--bg-panel-alt: #23272e;
--border: #363c46;
--border-focus: #76a9ff;
--text-primary: #f2f5f8;
--text-secondary: #c4ccd6;
--text-muted: #8994a3;
--accent: #6ea8ff;
--accent-light: #24364f;
--accent-hover: #91bdff;
--on-accent: #09111f;
--danger: #ff7a7a;
--danger-light: #3b2428;
--on-danger: #1c1011;
--success: #58d69c;
--success-light: #1d3329;
--warn: #e4b95a;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.36);
--shadow-md: 0 8px 22px rgba(0,0,0,0.34);
@@ -126,7 +128,7 @@ body {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
letter-spacing: -0.02em;
letter-spacing: 0;
}
.navbar-actions {
@@ -148,7 +150,7 @@ body {
font-family: var(--font-main);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.01em;
letter-spacing: 0;
border-bottom: 2px solid transparent;
transition: all var(--transition);
position: relative;
@@ -182,6 +184,114 @@ body {
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 {
display: flex;
align-items: center;
@@ -225,6 +335,277 @@ body {
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 {
width: var(--panel-width);
@@ -258,7 +639,7 @@ body {
.panel-title {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
letter-spacing: 0;
text-transform: uppercase;
color: var(--text-muted);
}
@@ -423,7 +804,7 @@ body {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.01em;
letter-spacing: 0;
}
.form-input {
@@ -558,7 +939,7 @@ body {
font-family: var(--font-main);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
letter-spacing: 0;
transition: all var(--transition);
display: flex;
align-items: center;
@@ -666,7 +1047,7 @@ body {
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.04em;
letter-spacing: 0;
text-transform: uppercase;
margin-bottom: 4px;
margin-top: 2px;
@@ -725,7 +1106,7 @@ body {
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0;
}
.note-text {
@@ -757,7 +1138,7 @@ body {
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.04em;
letter-spacing: 0;
text-transform: uppercase;
border-right: 1px solid var(--border);
}
@@ -902,6 +1283,80 @@ body {
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 ===== */
.text-link {
color: var(--danger);
@@ -1023,19 +1478,6 @@ body {
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,
.zoom-controls {
display: flex;
@@ -1065,7 +1507,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
padding: 84px;
padding: 32px 16px 80px;
}
.studio-stage {
@@ -1076,6 +1518,23 @@ body {
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 {
position: absolute;
transform-origin: center center;
@@ -1405,6 +1864,158 @@ body {
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 {
min-height: 100vh;
@@ -1587,6 +2198,43 @@ body {
}
@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-properties {
--panel-width: 220px;
@@ -1608,6 +2256,23 @@ body {
}
@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 {
padding: 14px;
}
@@ -1641,4 +2306,3 @@ body.resizing .side-panel,
body.resizing .studio-panel {
transition: none !important;
}
+24
View File
@@ -101,6 +101,30 @@ export interface CanvasElementBase {
height: number;
rotation: 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 {
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />