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