From f8a907e7c53d23887af24e93101c8eed8f4f7e12 Mon Sep 17 00:00:00 2001 From: obroccolio Date: Mon, 13 Jul 2026 22:01:01 +0800 Subject: [PATCH] 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. --- backend/requirements.txt | 1 + backend/service/app.py | 43 + backend/service/line_spacing.py | 937 ++++++++++++++++++ backend/service/schemas.py | 32 + .../template.json | 90 ++ .../template.json | 122 +++ backend/start-dev.sh | 27 + frontend/src/App.tsx | 50 +- frontend/src/components/AdvancedPanel.tsx | 11 +- frontend/src/components/AppSettingsWindow.tsx | 112 +++ frontend/src/components/CanvasArea.tsx | 12 +- frontend/src/components/DockTabBar.tsx | 104 ++ frontend/src/components/EditPanel.tsx | 11 +- frontend/src/components/ExportPanel.tsx | 22 +- frontend/src/components/FindPanel.tsx | 18 +- frontend/src/components/FloatingPanel.tsx | 285 ++++++ frontend/src/components/Icons.tsx | 10 + frontend/src/components/ImportPanel.tsx | 10 +- frontend/src/components/ViewControls.tsx | 7 +- frontend/src/hooks/useFloatingPanels.ts | 224 +++++ frontend/src/hooks/usePanelDocking.ts | 109 ++ frontend/src/lib/api.ts | 33 + frontend/src/lib/canvasDocument.ts | 17 +- frontend/src/lib/stickerLibrary.ts | 15 +- frontend/src/lib/svgExport.ts | 182 +++- frontend/src/lib/templateLibrary.ts | 23 +- frontend/src/pages/CanvasStudio.tsx | 887 ++++++++++++++--- frontend/src/pages/HelpPage.tsx | 173 ++++ frontend/src/pages/TemplateHome.tsx | 23 +- frontend/src/pages/TestWorkbench.tsx | 496 ++++++--- frontend/src/styles.css | 778 +++++++++++++-- frontend/src/types.ts | 24 + frontend/src/vite-env.d.ts | 1 + 33 files changed, 4434 insertions(+), 455 deletions(-) create mode 100644 backend/service/line_spacing.py create mode 100644 backend/service_design_templates/tm/tmpl_38311b4587e04821966231f4eb4ce9d7/template.json create mode 100644 backend/service_design_templates/tm/tmpl_e935a04991b047979cf1e5f825e2e440/template.json create mode 100644 frontend/src/components/AppSettingsWindow.tsx create mode 100644 frontend/src/components/DockTabBar.tsx create mode 100644 frontend/src/components/FloatingPanel.tsx create mode 100644 frontend/src/hooks/useFloatingPanels.ts create mode 100644 frontend/src/hooks/usePanelDocking.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/pages/HelpPage.tsx create mode 100644 frontend/src/vite-env.d.ts diff --git a/backend/requirements.txt b/backend/requirements.txt index f0e7c1f..79ed405 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/service/app.py b/backend/service/app.py index 5196fb7..5481c9c 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -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) diff --git a/backend/service/line_spacing.py b/backend/service/line_spacing.py new file mode 100644 index 0000000..6ef1e76 --- /dev/null +++ b/backend/service/line_spacing.py @@ -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)) diff --git a/backend/service/schemas.py b/backend/service/schemas.py index 081afba..a6fdfd0 100644 --- a/backend/service/schemas.py +++ b/backend/service/schemas.py @@ -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 diff --git a/backend/service_design_templates/tm/tmpl_38311b4587e04821966231f4eb4ce9d7/template.json b/backend/service_design_templates/tm/tmpl_38311b4587e04821966231f4eb4ce9d7/template.json new file mode 100644 index 0000000..41eb81f --- /dev/null +++ b/backend/service_design_templates/tm/tmpl_38311b4587e04821966231f4eb4ce9d7/template.json @@ -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" +} \ No newline at end of file diff --git a/backend/service_design_templates/tm/tmpl_e935a04991b047979cf1e5f825e2e440/template.json b/backend/service_design_templates/tm/tmpl_e935a04991b047979cf1e5f825e2e440/template.json new file mode 100644 index 0000000..902af65 --- /dev/null +++ b/backend/service_design_templates/tm/tmpl_e935a04991b047979cf1e5f825e2e440/template.json @@ -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" +} \ No newline at end of file diff --git a/backend/start-dev.sh b/backend/start-dev.sh index 12b69cd..1c43a40 100755 --- a/backend/start-dev.sh +++ b/backend/start-dev.sh @@ -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" \ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 413429c..526e5b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('home'); - const [themeMode] = useState(getStoredTheme); - const [systemTheme] = useState<'light' | 'dark'>(getSystemTheme); + const [themeMode, setThemeMode] = useState(getStoredTheme); + const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme); const [initialDocument, setInitialDocument] = useState(null); const [pendingWordcloudSticker, setPendingWordcloudSticker] = useState(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 ( setPage('canvas')} + onOpenHelp={openHelp} onImportWordcloudSticker={(payload) => { setPendingWordcloudSticker(payload); setPage('canvas'); @@ -45,8 +64,12 @@ export default function App() { if (page === 'canvas') { return ( 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 ( + setPage('home')} + onOpenCanvas={() => setPage('canvas')} + onOpenWordcloud={() => setPage('wordcloud')} + /> + ); + } + return ( { setInitialDocument(createDefaultDocument()); setPage('canvas'); @@ -67,6 +106,7 @@ export default function App() { }} onOpenCanvas={() => setPage('canvas')} onOpenWordcloud={() => setPage('wordcloud')} + onOpenHelp={openHelp} /> ); } diff --git a/frontend/src/components/AdvancedPanel.tsx b/frontend/src/components/AdvancedPanel.tsx index fdcf0fa..f6dbca8 100644 --- a/frontend/src/components/AdvancedPanel.tsx +++ b/frontend/src/components/AdvancedPanel.tsx @@ -3,14 +3,17 @@ import { JobParams } from '../types'; interface AdvancedPanelProps { params: JobParams; onParamsChange: (partial: Partial) => void; + embedded?: boolean; } -export default function AdvancedPanel({ params, onParamsChange }: AdvancedPanelProps) { +export default function AdvancedPanel({ params, onParamsChange, embedded = false }: AdvancedPanelProps) { return ( <> -
-
高级参数
-
+ {!embedded && ( +
+
高级参数
+
+ )}
{/* SEED */} diff --git a/frontend/src/components/AppSettingsWindow.tsx b/frontend/src/components/AppSettingsWindow.tsx new file mode 100644 index 0000000..a4439cc --- /dev/null +++ b/frontend/src/components/AppSettingsWindow.tsx @@ -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(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 ( +
+ + {open && ( +
+
+
+
设置
+
外观与布局
+
+ +
+ +
+
外观
+
+ {THEME_OPTIONS.map(option => ( + + ))} +
+
+ +
+
布局
+ {onResetLayout ? ( + + ) : ( +
当前页面没有可重置的浮动面板布局
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 6526240..4e25af9 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -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(null); const wrapperRef = useRef(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(); diff --git a/frontend/src/components/DockTabBar.tsx b/frontend/src/components/DockTabBar.tsx new file mode 100644 index 0000000..7e60018 --- /dev/null +++ b/frontend/src/components/DockTabBar.tsx @@ -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(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 ( +
+ {panels.map(panel => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/EditPanel.tsx b/frontend/src/components/EditPanel.tsx index a338df9..9b6bc6f 100644 --- a/frontend/src/components/EditPanel.tsx +++ b/frontend/src/components/EditPanel.tsx @@ -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 && ( +
+
修改名单
+
+ )}
{/* Filter bar */}
diff --git a/frontend/src/components/ExportPanel.tsx b/frontend/src/components/ExportPanel.tsx index 0e6d41b..10edb44 100644 --- a/frontend/src/components/ExportPanel.tsx +++ b/frontend/src/components/ExportPanel.tsx @@ -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('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 && ( +
+
导出
+
+ )}
{/* ── SVG 导出 ── */} diff --git a/frontend/src/components/FindPanel.tsx b/frontend/src/components/FindPanel.tsx index 588b218..1eff49a 100644 --- a/frontend/src/components/FindPanel.tsx +++ b/frontend/src/components/FindPanel.tsx @@ -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([]); 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 && ( +
+
查找
+
+ )}
diff --git a/frontend/src/components/FloatingPanel.tsx b/frontend/src/components/FloatingPanel.tsx new file mode 100644 index 0000000..3164db9 --- /dev/null +++ b/frontend/src/components/FloatingPanel.tsx @@ -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) => 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; + /** Ref to the workspace node, used to compute cursor-relative localX for the highlight. */ + workspaceNodeRef?: MutableRefObject; +} + +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(null); + const [snapPreview, setSnapPreview] = useState(null); + const [edgeHighlight, setEdgeHighlight] = useState(null); + const frameRef = useRef(frame); + const workspaceRef = useRef(workspaceSize); + const framesRef = useRef | 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 ( + + ); +} diff --git a/frontend/src/components/Icons.tsx b/frontend/src/components/Icons.tsx index c824587..0d4c40c 100644 --- a/frontend/src/components/Icons.tsx +++ b/frontend/src/components/Icons.tsx @@ -258,3 +258,13 @@ export function IconDownload() { ); } + +export function IconHelp() { + return ( + + + + + + ); +} diff --git a/frontend/src/components/ImportPanel.tsx b/frontend/src/components/ImportPanel.tsx index 51d0e9b..47bded3 100644 --- a/frontend/src/components/ImportPanel.tsx +++ b/frontend/src/components/ImportPanel.tsx @@ -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(null); @@ -36,9 +38,11 @@ export default function ImportPanel({ return ( <> -
-
导入
-
+ {!embedded && ( +
+
导入
+
+ )}
{/* 底图导入 */} diff --git a/frontend/src/components/ViewControls.tsx b/frontend/src/components/ViewControls.tsx index ae3d54e..032b201 100644 --- a/frontend/src/components/ViewControls.tsx +++ b/frontend/src/components/ViewControls.tsx @@ -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 ( -
+
+ + +
+ ); + case 'export': + return ( + + ); + case 'properties': + return ( + + ); + default: + return null; + } + }; + + const renderPanel = (panel: CanvasPanelId) => { + const navItem = CANVAS_NAV_ITEMS.find(item => item.id === panel); + if (!navItem) return null; + const frame = floatingPanels.frames[panel]; + if (!frame) return null; + 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 accounting the + // DockTabBar itself uses (open + docked on the same side), so a closed-but- + // persisted leftover frame can't mistake itself for a tab sibling and blank + // out the header. + const tabSiblings = docked + ? (Object.keys(floatingPanels.frames) as CanvasPanelId[]) + .filter(id => id !== panel + && openPanels.includes(id) + && floatingPanels.frames[id]?.docked === docked) + .length > 0 + : false; + const handlePanelFrameChange = (partial: Partial) => { + // 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. + 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); + }; + return ( + handleDock(panel, side)} + onUndock={() => handleUndock(panel)} + onFrameChange={handlePanelFrameChange} + onFocus={() => { + floatingPanels.focusPanel(panel); + if (docked) floatingPanels.setActiveTab(docked, panel); + }} + onClose={() => closePanel(panel)} + hidden={hidden} + hasTabSiblings={tabSiblings} + > +
{renderPanelBody(panel)}
+
+ ); + }; + + const dockedPanelsBySide = (side: 'left' | 'right'): DockTabItem[] => + openDockedPanelsForSide(side) + .sort((a, b) => floatingPanels.frames[a].y - floatingPanels.frames[b].y) + .map(id => { + const navItem = CANVAS_NAV_ITEMS.find(item => item.id === id)!; + return { id, title: navItem.label, icon: navItem.icon }; + }); + + // Docked left/right panels occupy full-height columns of (user-resizable) + // width on the sides; the bottom size/zoom bar inlays between them instead + // of overlapping. Derive each side's column width so the bottom bar tracks + // width changes made via the e/w resize handles. + const leftDockFrames = (Object.keys(floatingPanels.frames) as CanvasPanelId[]) + .filter(id => openPanels.includes(id) && floatingPanels.frames[id]?.docked === 'left'); + const rightDockFrames = (Object.keys(floatingPanels.frames) as CanvasPanelId[]) + .filter(id => openPanels.includes(id) && floatingPanels.frames[id]?.docked === 'right'); + const leftDockWidth = leftDockFrames.length ? floatingPanels.frames[leftDockFrames[0]].width : DOCK_WIDTH; + const rightDockWidth = rightDockFrames.length ? floatingPanels.frames[rightDockFrames[0]].width : DOCK_WIDTH; + const hasLeftDock = leftDockFrames.length > 0; + const hasRightDock = rightDockFrames.length > 0; + + return (
- {(['layers', 'sticker', 'text', 'shape', 'export'] as ToolMode[]).map(item => ( + {CANVAS_NAV_ITEMS.map(item => ( ))}
- + {onOpenHelp && } +
-
- - +
setSelectedId(null)}> -
-
- {formatMm(normalizedDocument.width)} x {formatMm(normalizedDocument.height)} mm -
-
- - {Math.round(zoom * 100)}% - -
-
-
- - +
+ + {Math.round(zoom * 100)}% + +
+
+ {(['left', 'right'] as const).map(side => { + const panels = dockedPanelsBySide(side); + if (panels.length < 1) return null; + const columnWidth = (floatingPanels.frames[panels[0].id as CanvasPanelId]?.width) || DOCK_WIDTH; + return ( + handleSelectTab(side, id)} + onClose={id => closePanel(id as CanvasPanelId)} + onUndockTab={(id, point) => handleUndockTab(id as CanvasPanelId, point)} + /> + ); + })} + {openPanels.map(renderPanel)}
); @@ -793,6 +1095,15 @@ function StickerToolPanel({ ); } +function toColorInputValue(value: string, fallback = '#ffffff') { + const trimmed = value.trim(); + const shortHex = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(trimmed); + if (shortHex) { + return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`; + } + return /^#[0-9a-f]{6}$/i.test(trimmed) ? trimmed : fallback; +} + function CanvasExportPanel({ documentModel, stickerById, @@ -816,6 +1127,14 @@ function CanvasExportPanel({ const [referenceFiles, setReferenceFiles] = useState([]); const layers = documentModel.layers || []; const folders = documentModel.layerFolders || []; + const backgroundEnabled = hasCanvasBackground(documentModel.background); + const [backgroundColorDraft, setBackgroundColorDraft] = useState(() => toColorInputValue(documentModel.background)); + + useEffect(() => { + if (backgroundEnabled) { + setBackgroundColorDraft(toColorInputValue(documentModel.background, backgroundColorDraft)); + } + }, [backgroundEnabled, backgroundColorDraft, documentModel.background]); const saveTemplate = async () => { if (savingTemplate) return; @@ -846,42 +1165,60 @@ function CanvasExportPanel({ return ( <>
-
- - onUpdateDocument({ width: mmToPx(parseFloat(event.target.value) || 10) })} - /> - mm -
-
- - onUpdateDocument({ height: mmToPx(parseFloat(event.target.value) || 10) })} - /> - mm -
-
-
- - onUpdateDocument({ background: event.target.value })} + onUpdateDocument({ width: mmToPx(value) })} + /> + onUpdateDocument({ height: mmToPx(value) })} />
+
+ +
+ + +
+
+ {backgroundEnabled && ( +
+ + { + setBackgroundColorDraft(event.target.value); + onUpdateDocument({ background: event.target.value }); + }} + /> +
+ )}
@@ -947,25 +1284,137 @@ function CanvasExportPanel({ function PropertiesPanel({ element, + elements, + documentModel, layers, + stickerById, locked, onUpdate, + onUpdateElement, onDelete, onBringForward, onSendBackward, }: { element: CanvasElement | null; + elements: CanvasElement[]; + documentModel: CanvasDocument; layers: CanvasLayer[]; + stickerById: Map; locked: boolean; onUpdate: (partial: Partial) => void; + onUpdateElement: (elementId: string, partial: Partial) => void; onDelete: () => void; onBringForward: () => void; onSendBackward: () => void; }) { + const [spacingPercentile, setSpacingPercentile] = useState('3'); + const [spacingBusy, setSpacingBusy] = useState(false); + const [spacingMessage, setSpacingMessage] = useState(''); + const [spacingError, setSpacingError] = useState(''); + const [batchRows, setBatchRows] = useState>([]); + + const percentile = parseSpacingPercentile(spacingPercentile); + const selectedAsset = element?.type === 'sticker' ? stickerById.get(element.assetId) : undefined; + const selectedIsWordcloud = !!(element && isWordcloudStickerElement(element, selectedAsset)); + const wordcloudElements = elements.filter(item => ( + item.type === 'sticker' && isWordcloudStickerElement(item, stickerById.get(item.assetId)) + )); + + useEffect(() => { + setBatchRows([]); + setSpacingMessage(''); + setSpacingError(''); + }, [element?.id]); + + const updateSpacingPercentile = (value: number) => { + setSpacingPercentile(String(Math.max(0, Math.min(100, value)))); + setBatchRows([]); + setSpacingError(''); + }; + + const calculateOne = async (target: CanvasElement): Promise => { + if (target.type !== 'sticker') throw new Error('只能计算词云贴纸'); + const asset = stickerById.get(target.assetId); + if (!asset) throw new Error('词云资源不存在'); + const res = await ensureOk(await fetch(apiUrl(`/api/assets/${asset.id}/line-spacing`), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + percentile, + elementWidth: target.width, + elementHeight: target.height, + sampleStep: 2, + }), + }), '计算词云线距失败'); + const summary = await res.json() as LineSpacingAnalysisSummary; + onUpdateElement(target.id, { lineSpacingAnalysis: summary } as Partial); + return summary; + }; + + const calculateSelected = async () => { + if (!element || spacingBusy) return; + setSpacingBusy(true); + setSpacingError(''); + setBatchRows([]); + setSpacingMessage('正在计算当前词云线距'); + try { + const summary = await calculateOne(element); + setSpacingMessage(`已计算 ${summary.percentile}% 分位线距`); + } catch (error) { + setSpacingError(error instanceof Error ? error.message : '计算失败'); + setSpacingMessage(''); + } finally { + setSpacingBusy(false); + } + }; + + const calculateBatch = async () => { + if (spacingBusy || wordcloudElements.length === 0) return; + setSpacingBusy(true); + setSpacingError(''); + setBatchRows([]); + setSpacingMessage(`正在批量计算 ${wordcloudElements.length} 个词云`); + const rows: Array<{ id: string; name: string; spacingMm: number; percentile: number }> = []; + const errors: string[] = []; + try { + for (const target of wordcloudElements) { + try { + const summary = await calculateOne(target); + const asset = target.type === 'sticker' ? stickerById.get(target.assetId) : undefined; + rows.push({ + id: target.id, + name: asset?.name || target.id, + spacingMm: summary.spacingMm, + percentile: summary.percentile, + }); + } catch (error) { + const asset = target.type === 'sticker' ? stickerById.get(target.assetId) : undefined; + errors.push(`${asset?.name || target.id}: ${error instanceof Error ? error.message : '计算失败'}`); + } + } + setBatchRows(rows); + setSpacingMessage(`批量计算完成:${rows.length}/${wordcloudElements.length}`); + setSpacingError(errors.slice(0, 3).join(';')); + } finally { + setSpacingBusy(false); + } + }; + if (!element) { - return
选择画布元素后编辑属性
; + return ( +
+
选择画布元素后编辑属性
+
+ ); } + const spacingAnalysis = element.lineSpacingAnalysis; + const spacingStale = !!spacingAnalysis && ( + Math.abs(spacingAnalysis.elementWidth - element.width) > 0.1 || + Math.abs(spacingAnalysis.elementHeight - element.height) > 0.1 + ); + const spacingPercentileChanged = !!spacingAnalysis && spacingAnalysis.percentile !== percentile; + return (
{locked &&
当前图层已锁定
} @@ -1000,18 +1449,78 @@ function PropertiesPanel({
- onUpdate({ x: value })} /> - onUpdate({ y: value })} /> + onUpdate({ x: mmToPx(value) })} /> + onUpdate({ y: mmToPx(value) })} />
- onUpdate({ width: value })} /> - onUpdate({ height: value })} /> + onUpdate({ width: mmToPx(value) })} /> + onUpdate({ height: mmToPx(value) })} />
onUpdate({ rotation: value })} /> onUpdate({ opacity: value })} />
+ {selectedIsWordcloud && ( +
+
+
词云轮廓线距
+
+ +
+ +
{wordcloudElements.length}
+
+
+
+ + +
+ {spacingAnalysis && ( +
+
+ {spacingAnalysis.percentile}% 分位线距 + {spacingAnalysis.spacingMm.toFixed(3)} mm +
+
+ 绝对最小线距 + {spacingAnalysis.minSpacingMm.toFixed(3)} mm +
+
+ {spacingAnalysis.curveCount} 条曲线,{spacingAnalysis.segmentCount} 段采样,{spacingAnalysis.nearestCount} 个近邻样本 +
+ {spacingStale &&
词云尺寸已调整,需要重新计算。
} + {spacingPercentileChanged &&
当前输入分位与结果不一致,需要重新计算。
} +
+ )} + {!spacingAnalysis &&
尚未计算。填 0 表示使用绝对最小线距。
} + {spacingMessage &&
{spacingMessage}
} + {spacingError &&
{spacingError}
} + {batchRows.length > 0 && ( +
+ {batchRows.map(row => ( +
+ {row.name} + {row.spacingMm.toFixed(3)} mm +
+ ))} +
+ )} +
+ )} + {element.type === 'text' && ( <>
@@ -1070,6 +1579,8 @@ function NumberField({ min, max, step = 1, + suffix, + precision = 3, disabled = false, onChange, }: { @@ -1078,26 +1589,100 @@ function NumberField({ min?: number; max?: number; step?: number; + suffix?: string; + precision?: number; disabled?: boolean; onChange: (value: number) => void; }) { + const [draft, setDraft] = useState(() => formatNumberDraft(value, precision)); + const [editing, setEditing] = useState(false); + + useEffect(() => { + if (!editing) setDraft(formatNumberDraft(value, precision)); + }, [editing, precision, value]); + + const resetDraft = () => { + setDraft(formatNumberDraft(value, precision)); + }; + + const commitDraft = () => { + const normalized = draft.trim().replace(',', '.'); + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed)) { + resetDraft(); + return; + } + + const next = clampNumber(parsed, min, max); + setDraft(formatNumberDraft(next, precision)); + if (Math.abs(next - value) > 1e-9) onChange(next); + }; + + const input = ( + setEditing(true)} + onChange={event => setDraft(event.target.value)} + onBlur={() => { + commitDraft(); + setEditing(false); + }} + onKeyDown={event => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + if (event.key === 'Escape') { + resetDraft(); + event.currentTarget.blur(); + } + }} + /> + ); + return (
- onChange(parseFloat(event.target.value) || 0)} - /> + {suffix ? ( +
+ {input} + {suffix} +
+ ) : input}
); } +function clampNumber(value: number, min?: number, max?: number) { + let next = value; + if (min !== undefined) next = Math.max(min, next); + if (max !== undefined) next = Math.min(max, next); + return next; +} + +function formatNumberDraft(value: number, precision: number) { + if (!Number.isFinite(value)) return ''; + const factor = 10 ** precision; + const rounded = Math.round(value * factor) / factor; + return String(rounded).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1'); +} + +function parseSpacingPercentile(raw: string) { + const value = Number.parseFloat(raw); + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +function isWordcloudStickerElement(element: CanvasElement, asset?: StickerAsset) { + return element.type === 'sticker' + && asset?.type === 'svg' + && asset.tint !== 'gray' + && /^词云(?:\s|$)/.test(asset.name.trim()); +} + function CanvasElementView({ element, asset, @@ -1245,19 +1830,3 @@ function downloadBlob(blob: Blob, filename: string) { window.document.body.removeChild(a); URL.revokeObjectURL(url); } - -function toolIcon(tool: ToolMode) { - if (tool === 'layers') return ; - if (tool === 'sticker') return ; - if (tool === 'text') return ; - if (tool === 'shape') return ; - return ; -} - -function toolLabel(tool: ToolMode) { - if (tool === 'layers') return '图层'; - if (tool === 'sticker') return '贴纸'; - if (tool === 'text') return '文字'; - if (tool === 'shape') return '形状'; - return '画布'; -} diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx new file mode 100644 index 0000000..2fe6c44 --- /dev/null +++ b/frontend/src/pages/HelpPage.tsx @@ -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 ( +
+ + +
+
+
+

Wordcloud Studio

+

从名单到可编辑设计稿

+

+ 工作台采用浮动面板:每个面板都能拖动、缩放、关闭和重新打开。 + 生成词云后,可继续在画布里做图层、贴纸、文字、形状和模板复用。 +

+
+
+ + +
+
+ +
+

快速流程

+
+ {quickSteps.map(([num, title, body]) => ( +
+ {num} + {title} +

{body}

+
+ ))} +
+
+ +
+

浮动面板

+
+ + + + +
+
+ + + + + +
+

排错提示

+
+ + + + +
+
+
+
+ ); +} + +function HelpTable({ title, rows }: { title: string; rows: string[][] }) { + return ( +
+

{title}

+
+ {rows.map(([name, body]) => ( +
+ {name} +

{body}

+
+ ))} +
+
+ ); +} + +function HelpItem({ title, body }: { title: string; body: string }) { + return ( +
+ {title} +

{body}

+
+ ); +} diff --git a/frontend/src/pages/TemplateHome.tsx b/frontend/src/pages/TemplateHome.tsx index 606ae37..726408f 100644 --- a/frontend/src/pages/TemplateHome.tsx +++ b/frontend/src/pages/TemplateHome.tsx @@ -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([]); const [assets, setAssets] = useState([]); @@ -94,6 +104,8 @@ export default function TemplateHome({ 刷新 +
+
-
-
+ +
diff --git a/frontend/src/pages/TestWorkbench.tsx b/frontend/src/pages/TestWorkbench.tsx index 194501f..4eac599 100644 --- a/frontend/src/pages/TestWorkbench.tsx +++ b/frontend/src/pages/TestWorkbench.tsx @@ -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; + id: WorkbenchPanelId; label: string; icon: React.ReactNode; }; -type ThemeMode = 'light' | 'dark' | 'system'; +type WorkbenchPanelId = NonNullable; const NAV_ITEMS: NavItem[] = [ { id: 'import', label: '导入', icon: }, @@ -57,27 +61,31 @@ const NAV_ITEMS: NavItem[] = [ { id: 'advanced', label: '高级', icon: }, ]; -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 = { + 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(null); const [maskSubmitFile, setMaskSubmitFile] = useState(null); const [maskSource, setMaskSource] = useState(null); @@ -88,39 +96,73 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker } const [jobResult, setJobResult] = useState(null); const [progress, setProgress] = useState(null); const [isGenerating, setIsGenerating] = useState(false); - const [activePanel, setActivePanel] = useState('import'); + const [openPanels, setOpenPanels] = useState(['import', 'advanced']); const [viewMode, setViewMode] = useState<'2d' | '3d'>('2d'); const [zoom, setZoom] = useState(1); const [highlightLocation, setHighlightLocation] = useState(null); const [fonts, setFonts] = useState([]); const [selectedFontId, setSelectedFontId] = useState('__default__'); - const [themeMode, setThemeMode] = useState(getStoredTheme); - const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme); const sseRef = useRef(null); + const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT); + const workspaceRef = useRef(null); + const workspaceSizeRef = useRef({ width: 1200, height: 700 }); + const [workspaceSize, setWorkspaceSize] = useState({ 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 = { 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,7 +446,166 @@ 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) => { + 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 ( + + {panel === 'import' && ( + + )} + {panel === 'export' && ( + + )} + {panel === 'edit' && ( + + )} + {panel === 'find' && ( + + )} + {panel === 'advanced' && ( + + )} + + ); + }; return (
@@ -405,8 +619,8 @@ export default function TestWorkbench({ onOpenCanvas, onImportWordcloudSticker } {NAV_ITEMS.map(item => ( )} -
- {THEME_OPTIONS.map(option => ( - - ))} -
+ + {onOpenHelp && ( + + )} +
{/* ===== MAIN ===== */} -
- {/* ===== SIDE PANEL ===== */} - - - {/* ===== CANVAS ===== */} -
+
+
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')} />
+ {(['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 ( + { + 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)}
); @@ -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' }, + ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 28a8710..84e495c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } - diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ddeef4c..04d12d6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +///