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

Canvas Studio now uses dockable floating panels, app settings/help navigation, and improved SVG export; the backend adds an SVG line-spacing analysis API with SciPy acceleration and new design templates.
This commit is contained in:
2026-07-13 22:01:01 +08:00
parent d5d8caef2f
commit f8a907e7c5
33 changed files with 4434 additions and 455 deletions
+33
View File
@@ -0,0 +1,33 @@
const rawApiBase = (import.meta.env.VITE_API_BASE ?? '').trim();
export const API_BASE = rawApiBase.replace(/\/+$/, '');
export function apiUrl(path: string) {
if (!path) return API_BASE || '';
if (/^https?:\/\//i.test(path) || path.startsWith('data:') || path.startsWith('blob:')) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${API_BASE}${normalizedPath}`;
}
export function apiEventSource(path: string) {
return new EventSource(apiUrl(path));
}
export async function readApiError(res: Response) {
const text = await res.text().catch(() => '');
if (!text) return `${res.status} ${res.statusText}`.trim();
try {
const parsed = JSON.parse(text);
return parsed.detail || parsed.message || text;
} catch {
return text;
}
}
export async function ensureOk(res: Response, fallback: string) {
if (res.ok) return res;
const detail = await readApiError(res);
throw new Error(`${fallback} (${res.status})${detail ? `: ${detail}` : ''}`);
}
+15 -2
View File
@@ -8,9 +8,10 @@ import {
export const DPI = 96;
export const MM_PER_INCH = 25.4;
export const DEFAULT_LAYER_ID = 'layer-default';
export const TRANSPARENT_BACKGROUND = 'transparent';
export function mmToPx(mm: number) {
return Math.max(1, Math.round((mm / MM_PER_INCH) * DPI));
return Math.max(1, (mm / MM_PER_INCH) * DPI);
}
export function pxToMm(px: number) {
@@ -21,6 +22,18 @@ export function formatMm(px: number) {
return pxToMm(px).toFixed(1);
}
export function hasCanvasBackground(background?: string | null) {
const value = typeof background === 'string' ? background.trim().toLowerCase() : '';
return value !== '' && value !== TRANSPARENT_BACKGROUND && value !== 'none' && value !== 'rgba(0,0,0,0)' && value !== 'rgba(0, 0, 0, 0)';
}
export function normalizeCanvasBackground(background?: string | null) {
if (typeof background !== 'string') return '#ffffff';
const value = background.trim();
if (!value) return '#ffffff';
return hasCanvasBackground(value) ? value : TRANSPARENT_BACKGROUND;
}
export function makeId(prefix: string) {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
@@ -95,7 +108,7 @@ export function normalizeDocument(input: CanvasDocument): CanvasDocument {
return {
width: Number.isFinite(input.width) ? input.width : 1600,
height: Number.isFinite(input.height) ? input.height : 1000,
background: input.background || '#ffffff',
background: normalizeCanvasBackground(input.background),
layers: nextLayers,
layerFolders,
elements,
+8 -7
View File
@@ -1,4 +1,5 @@
import { StickerAsset } from '../types';
import { apiUrl, ensureOk } from './api';
// ---------------------------------------------------------------------------
// Backend-based sticker library
@@ -76,8 +77,7 @@ interface BackendAsset {
}
async function apiListAssets(): Promise<BackendAsset[]> {
const res = await fetch('/api/assets?type=sticker');
if (!res.ok) throw new Error(`list assets failed: ${res.status}`);
const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败');
return res.json();
}
@@ -90,14 +90,15 @@ async function apiUploadAsset(
form.append('file', blob, filename);
form.append('name', name);
form.append('type', 'sticker');
const res = await fetch('/api/assets', { method: 'POST', body: form });
if (!res.ok) throw new Error(`upload asset failed: ${res.status}`);
const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: form }), '上传贴纸失败');
return res.json();
}
async function apiDeleteAsset(assetId: string): Promise<void> {
const res = await fetch(`/api/assets/${assetId}`, { method: 'DELETE' });
if (!res.ok && res.status !== 404) throw new Error(`delete asset failed: ${res.status}`);
const res = await fetch(apiUrl(`/api/assets/${assetId}`), { method: 'DELETE' });
if (!res.ok && res.status !== 404) {
await ensureOk(res, '删除贴纸失败');
}
}
// ── Public API ─────────────────────────────────────────────────────────────
@@ -152,5 +153,5 @@ export function svgToDataUrl(svg: string) {
export function assetToDataUrl(asset: StickerAsset) {
// source is now a backend URL; return it directly
return asset.source;
return apiUrl(asset.source);
}
+165 -17
View File
@@ -1,5 +1,6 @@
import { CanvasDocument, StickerAsset } from '../types';
import { normalizeDocument, pxToMm } from './canvasDocument';
import { apiUrl } from './api';
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
import { createZip } from './zip';
export interface SerializeOptions {
@@ -8,7 +9,7 @@ export interface SerializeOptions {
}
async function fetchBlobAsDataUrl(url: string): Promise<string> {
const res = await fetch(url);
const res = await fetch(apiUrl(url));
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
const blob = await res.blob();
return new Promise((resolve, reject) => {
@@ -19,16 +20,30 @@ async function fetchBlobAsDataUrl(url: string): Promise<string> {
});
}
async function resolveStickerHref(asset: StickerAsset): Promise<string> {
// Legacy inline content (still supported for imported files / tests)
if (asset.type === 'svg' && asset.source.trim().startsWith('<svg')) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(asset.source)}`;
async function fetchText(url: string): Promise<string> {
const res = await fetch(apiUrl(url));
if (!res.ok) throw new Error(`fetch failed: ${url} (${res.status})`);
return res.text();
}
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:')) {
return asset.source;
if (source.startsWith('data:')) {
return decodeDataUrl(source);
}
// Backend URL: fetch and inline so the exported SVG is self-contained
return fetchBlobAsDataUrl(asset.source);
return fetchText(source);
}
async function resolveStickerImageHref(asset: StickerAsset): Promise<string | null> {
const source = asset.source.trim();
if (!source) return null;
if (source.startsWith('data:')) return source;
return fetchBlobAsDataUrl(source);
}
export async function serializeDocument(
@@ -45,7 +60,7 @@ export async function serializeDocument(
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
];
if (options.includeBackground !== false) {
if (options.includeBackground !== false && hasCanvasBackground(doc.background)) {
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
}
@@ -60,7 +75,14 @@ export async function serializeDocument(
if (element.type === 'sticker') {
const asset = stickerById.get(element.assetId);
if (!asset) continue;
const href = await resolveStickerHref(asset);
const svg = await resolveStickerSvg(asset);
if (svg) {
const inline = serializeInlineSvgSticker(svg, element.width, element.height, transform, opacity, asset.tint === 'gray');
if (inline) parts.push(inline);
continue;
}
const href = await resolveStickerImageHref(asset);
if (!href) continue;
const filter = asset.tint === 'gray' ? ' style="filter: grayscale(1)"' : '';
parts.push(`<image href="${escapeXml(href)}" x="0" y="0" width="${element.width}" height="${element.height}" preserveAspectRatio="xMidYMid meet" opacity="${opacity}" transform="${transform}"${filter}/>`);
continue;
@@ -100,6 +122,13 @@ export async function createLayerExportZip(
const files: { name: string; content: string }[] = [];
const used = new Map<string, number>();
if (hasCanvasBackground(doc.background)) {
files.push({
name: uniqueSvgName('背景', used),
content: serializeBackgroundLayer(doc),
});
}
for (const layerId of selectedLayerIds) {
const layer = doc.layers?.find(item => item.id === layerId);
if (!layer) continue;
@@ -118,14 +147,133 @@ export async function createLayerExportZip(
});
}
files.push({
name: uniqueSvgName('总效果', used),
content: await serializeDocument(doc, stickerById, { includeBackground: true }),
});
return createZip(files);
}
function serializeBackgroundLayer(documentModel: CanvasDocument) {
const doc = normalizeDocument(documentModel);
const widthMm = pxToMm(doc.width).toFixed(1);
const heightMm = pxToMm(doc.height).toFixed(1);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
'</svg>',
].join('\n');
}
function serializeInlineSvgSticker(
svgText: string,
targetWidth: number,
targetHeight: number,
transform: string,
opacity: number,
grayscale: boolean,
) {
const parsed = parseInlineSvg(svgText);
if (!parsed || !parsed.content.trim()) return null;
const scale = Math.min(targetWidth / parsed.width, targetHeight / parsed.height);
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const offsetX = (targetWidth - parsed.width * safeScale) / 2;
const offsetY = (targetHeight - parsed.height * safeScale) / 2;
const contentTransform = [
transform,
`translate(${formatSvgNumber(offsetX)} ${formatSvgNumber(offsetY)})`,
`scale(${formatSvgNumber(safeScale)})`,
`translate(${formatSvgNumber(-parsed.minX)} ${formatSvgNumber(-parsed.minY)})`,
].join(' ');
const filter = grayscale ? ' style="filter: grayscale(1)"' : '';
return `<g opacity="${formatSvgNumber(opacity)}" transform="${contentTransform}"${filter}>\n${parsed.content}\n</g>`;
}
function parseInlineSvg(svgText: string) {
const parser = new DOMParser();
const xml = parser.parseFromString(svgText, 'image/svg+xml');
if (xml.querySelector('parsererror')) return null;
const root = xml.documentElement;
if (!root || root.localName.toLowerCase() !== 'svg') return null;
removeEmptyReferences(root);
const viewBox = parseViewBox(root.getAttribute('viewBox') || root.getAttribute('viewbox'));
const sourceWidth = viewBox?.width || parseSvgLength(root.getAttribute('width'));
const sourceHeight = viewBox?.height || parseSvgLength(root.getAttribute('height'));
const width = sourceWidth && sourceWidth > 0 ? sourceWidth : viewBox?.width || 1;
const height = sourceHeight && sourceHeight > 0 ? sourceHeight : viewBox?.height || 1;
const minX = viewBox?.minX || 0;
const minY = viewBox?.minY || 0;
const serializer = new XMLSerializer();
const body = Array.from(root.childNodes)
.map(node => serializer.serializeToString(node))
.join('\n');
const rootAttributes = serializeRootPresentationAttributes(root);
const content = rootAttributes ? `<g ${rootAttributes}>\n${body}\n</g>` : body;
return { content, minX, minY, width, height };
}
function removeEmptyReferences(root: Element) {
Array.from(root.querySelectorAll('*')).forEach(element => {
const hrefAttributes = Array.from(element.attributes).filter(attr => attr.localName === 'href' || attr.name === 'href' || attr.name.endsWith(':href'));
hrefAttributes.forEach(attr => {
if (!attr.value.trim()) element.removeAttribute(attr.name);
});
if (element.localName.toLowerCase() === 'image') {
const href = element.getAttribute('href') || element.getAttribute('xlink:href') || element.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (!href || !href.trim()) element.remove();
}
});
}
function serializeRootPresentationAttributes(root: Element) {
const excluded = new Set([
'height',
'id',
'preserveAspectRatio',
'version',
'viewBox',
'viewbox',
'width',
'x',
'y',
]);
return Array.from(root.attributes)
.filter(attr => !excluded.has(attr.name) && !attr.name.startsWith('xmlns'))
.map(attr => `${attr.name}="${escapeXml(attr.value)}"`)
.join(' ');
}
function parseViewBox(value: string | null) {
if (!value) return null;
const parts = value.trim().split(/[\s,]+/).map(Number);
if (parts.length !== 4 || parts.some(part => !Number.isFinite(part))) return null;
const [minX, minY, width, height] = parts;
if (width <= 0 || height <= 0) return null;
return { minX, minY, width, height };
}
function parseSvgLength(value: string | null) {
if (!value) return null;
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const parsed = Number.parseFloat(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function decodeDataUrl(dataUrl: string) {
const commaIndex = dataUrl.indexOf(',');
if (commaIndex < 0) return null;
const header = dataUrl.slice(0, commaIndex);
const payload = dataUrl.slice(commaIndex + 1);
if (!/image\/svg\+xml/i.test(header)) return null;
try {
return /;base64/i.test(header) ? atob(payload) : decodeURIComponent(payload);
} catch {
return null;
}
}
function formatSvgNumber(value: number) {
return Number.isFinite(value) ? Number.parseFloat(value.toFixed(4)).toString() : '0';
}
function uniqueSvgName(name: string, used: Map<string, number>) {
const base = sanitizeFileName(name || '未命名');
const count = used.get(base) || 0;
+8 -15
View File
@@ -1,8 +1,7 @@
import { BackendAsset, CanvasDocument, CanvasTemplate } from '../types';
import { apiUrl, ensureOk } from './api';
import { cloneDocument, normalizeDocument } from './canvasDocument';
const API_BASE = '';
export function templateId(template: CanvasTemplate) {
return template.template_id || template.id || '';
}
@@ -24,8 +23,7 @@ export function templateCoverId(template: CanvasTemplate) {
}
export async function listDesignTemplates(): Promise<CanvasTemplate[]> {
const res = await fetch(`${API_BASE}/api/design-templates`);
if (!res.ok) throw new Error(`读取模板库失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl('/api/design-templates')), '读取模板库失败');
const items = (await res.json()) as CanvasTemplate[];
return items.map(item => ({ ...item, document: normalizeDocument(item.document) }));
}
@@ -43,8 +41,7 @@ export async function createCanvasTemplate(input: {
fd.append('document', JSON.stringify(normalizeDocument(input.document)));
fd.append('reference_asset_ids', JSON.stringify(input.referenceAssetIds || []));
fd.append('cover_asset_id', input.coverAssetId || input.referenceAssetIds?.[0] || '');
const res = await fetch(`${API_BASE}/api/design-templates`, { method: 'POST', body: fd });
if (!res.ok) throw new Error(`保存模板失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl('/api/design-templates'), { method: 'POST', body: fd }), '保存模板失败');
const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) };
}
@@ -65,21 +62,18 @@ export async function updateCanvasTemplate(
if (partial.document) fd.append('document', JSON.stringify(normalizeDocument(partial.document)));
if (partial.referenceAssetIds) fd.append('reference_asset_ids', JSON.stringify(partial.referenceAssetIds));
if (partial.coverAssetId !== undefined) fd.append('cover_asset_id', partial.coverAssetId);
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'PATCH', body: fd });
if (!res.ok) throw new Error(`更新模板失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'PATCH', body: fd }), '更新模板失败');
const template = (await res.json()) as CanvasTemplate;
return { ...template, document: normalizeDocument(template.document) };
}
export async function deleteCanvasTemplate(id: string) {
const res = await fetch(`${API_BASE}/api/design-templates/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`删除模板失败 (${res.status})`);
await ensureOk(await fetch(apiUrl(`/api/design-templates/${id}`), { method: 'DELETE' }), '删除模板失败');
}
export async function listAssets(type = ''): Promise<BackendAsset[]> {
const query = type ? `?type=${encodeURIComponent(type)}` : '';
const res = await fetch(`${API_BASE}/api/assets${query}`);
if (!res.ok) throw new Error(`读取素材失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl(`/api/assets${query}`)), '读取素材失败');
return (await res.json()) as BackendAsset[];
}
@@ -88,8 +82,7 @@ export async function uploadAsset(file: File, type = 'reference'): Promise<Backe
fd.append('file', file);
fd.append('name', file.name.replace(/\.(svg|png|jpe?g)$/i, ''));
fd.append('type', type);
const res = await fetch(`${API_BASE}/api/assets`, { method: 'POST', body: fd });
if (!res.ok) throw new Error(`上传参考图失败 (${res.status})`);
const res = await ensureOk(await fetch(apiUrl('/api/assets'), { method: 'POST', body: fd }), '上传参考图失败');
return (await res.json()) as BackendAsset;
}
@@ -97,7 +90,7 @@ export function assetUrl(assetOrPath?: BackendAsset | string) {
if (!assetOrPath) return '';
const raw = typeof assetOrPath === 'string' ? assetOrPath : assetOrPath.file_url;
if (!raw) return '';
return raw.startsWith('http') ? raw : `${API_BASE}${raw}`;
return apiUrl(raw);
}
export function duplicateDocument(document: CanvasDocument): CanvasDocument {