requestClose()}>
+
e.stopPropagation()}>
{/* Large preview with prev/next arrows */}
-
+

{slides.length > 1 && (
<>
@@ -270,42 +285,82 @@ function TemplateModal({
)}
-
-
{template.name}
- {template.description &&
{template.description}
}
-
- {formatMm(template.document.width)} x {formatMm(template.document.height)} mm
- {template.document.elements.length} 个元素
- {formatDate(templateUpdatedAt(template))}
-
-
- {/* Thumbnail strip */}
- {slides.length > 1 && (
-
- {slides.map((asset, i) => (
-
})
setIdx(i)}
- style={{ cursor: 'pointer', outline: i === safeIdx ? '2px solid var(--color-primary, #6c63ff)' : 'none', borderRadius: 4 }}
- />
- ))}
-
- )}
-
-
-
-
-
-
-
+
requestClose(onUse)}
+ onClose={() => requestClose()}
+ onDelete={onDelete}
+ referenceImages={slides.length > 1 ? slides.map((asset, i) => (
+
setIdx(i)}
+ />
+ )) : undefined}
+ />
);
}
+function TemplateDetailPanel({
+ template,
+ referenceImages,
+ onUse,
+ onClose,
+ onDelete,
+}: {
+ template: CanvasTemplate;
+ referenceImages?: ReactNode;
+ onUse: () => void;
+ onClose: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+ );
+}
+
function TemplateMasonryCard({
template,
cover,
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 83f5e93..c11e74d 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -36,6 +36,17 @@
--nav-height: 56px;
--panel-width: 240px;
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
+ --modal-backdrop-duration: 260ms;
+ --modal-backdrop-opacity: 0.05;
+ --modal-backdrop-blur: 8px;
+ --modal-open-duration: 320ms;
+ --modal-open-delay: 60ms;
+ --modal-open-easing: cubic-bezier(0.22, 1, 0.36, 1);
+ --modal-content-duration: 200ms;
+ --modal-content-delay: 120ms;
+ --modal-content-stagger: 55ms;
+ --modal-exit-duration: 160ms;
+ --modal-exit-easing: cubic-bezier(0.4, 0, 0.2, 1);
--font-main: 'DM Sans', sans-serif;
--font-mono: 'DM Mono', monospace;
/* 液态玻璃 */
@@ -1577,7 +1588,19 @@ body.resizing {
flex-shrink: 0;
box-shadow: 0 16px 50px rgba(0,0,0,0.18);
transform-origin: center center;
- overflow: hidden;
+ /* 画布背景仅限于本身,设计元素允许越出画布继续编辑。 */
+ overflow: visible;
+ isolation: isolate;
+ background: transparent;
+}
+
+.studio-stage::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ background: var(--canvas-background, #ffffff);
+ pointer-events: none;
}
.studio-bottombar {
@@ -2285,12 +2308,12 @@ body.resizing {
padding: 28px;
/* 只模糊、不压暗:去掉黑遮罩,保留毛玻璃磨砂。
模糊从 0 缓慢增强到目标值(700ms ease-out),进展不突兀 */
- background: rgba(255, 255, 255, 0.04);
- backdrop-filter: blur(10px);
- animation: modal-backdrop-in 700ms ease-out;
+ background: rgba(255, 255, 255, var(--modal-backdrop-opacity));
+ backdrop-filter: blur(var(--modal-backdrop-blur));
+ animation: modal-backdrop-in var(--modal-backdrop-duration) cubic-bezier(0.22, 1, 0.36, 1);
}
:root[data-theme="dark"] .template-modal-backdrop {
- background: rgba(10, 12, 18, 0.14);
+ background: rgba(10, 12, 18, calc(var(--modal-backdrop-opacity) * 2.4));
}
.template-modal {
width: min(960px, 100%);
@@ -2302,11 +2325,139 @@ body.resizing {
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
- animation: template-pop 160ms ease-out;
+ animation: template-pop var(--modal-open-duration) var(--modal-open-easing) var(--modal-open-delay) backwards;
}
-.modal-meta {
- margin-top: 10px;
+.template-modal-backdrop.template-modal-exiting {
+ animation: modal-backdrop-out var(--modal-exit-duration) var(--modal-exit-easing) forwards;
+}
+
+.template-modal.template-modal-exiting {
+ animation: template-exit var(--modal-exit-duration) var(--modal-exit-easing);
+}
+
+.modal-layer {
+ animation: modal-layer-in var(--modal-content-duration) var(--modal-open-easing)
+ calc(var(--modal-content-delay) + var(--modal-content-stagger) * var(--modal-layer, 0)) backwards;
+}
+
+.template-modal-exiting .modal-layer {
+ animation: modal-layer-out var(--modal-exit-duration) var(--modal-exit-easing);
+}
+
+.template-detail-panel {
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 22px;
+ overflow-y: auto;
+}
+
+.template-detail-summary {
+ display: grid;
+ gap: 5px;
+}
+
+.template-detail-eyebrow,
+.template-detail-section-label {
+ color: var(--lf-text-faint, var(--text-muted));
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+
+.template-detail-title {
+ margin: 0;
+ color: var(--lf-text, var(--text-primary));
+ font-size: 22px;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+}
+
+.template-detail-description {
+ display: grid;
+ gap: 8px;
+ padding: 13px 14px;
+ border: 1px solid var(--lf-glass-soft, var(--border));
+ border-radius: var(--radius-md);
+ background: var(--lf-input-bg, var(--bg-panel-alt));
+}
+
+.template-detail-description p {
+ display: -webkit-box;
+ margin: 0;
+ overflow: hidden;
+ color: var(--lf-text-dim, var(--text-secondary));
+ font-size: 13px;
+ line-height: 1.55;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+}
+
+.template-detail-specs {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin: 0;
+}
+
+.template-detail-spec {
+ min-width: 0;
+ padding: 11px 10px;
+ border: 1px solid var(--lf-glass-soft, var(--border));
+ border-radius: var(--radius-sm);
+ background: var(--lf-glass-bg, var(--bg-panel-alt));
+}
+
+.template-detail-spec dt {
+ margin-bottom: 6px;
+ color: var(--lf-text-faint, var(--text-muted));
+ font-size: 11px;
+}
+
+.template-detail-spec dd {
+ margin: 0;
+ overflow: hidden;
+ color: var(--lf-text, var(--text-primary));
+ font-family: var(--font-mono);
+ font-size: 12px;
+ line-height: 1.3;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.template-detail-spec:first-child {
+ grid-column: 1 / -1;
+}
+
+.template-detail-references {
+ display: grid;
+ gap: 8px;
+}
+
+.template-detail-references .reference-strip {
+ margin: 0;
+}
+
+.template-detail-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-top: auto;
+ padding-top: 16px;
+ border-top: 1px solid var(--lf-glass-soft, var(--border));
+}
+
+.template-detail-actions .btn {
+ min-width: 0;
+}
+
+.template-detail-delete {
+ grid-column: 1 / -1;
}
.reference-strip {
@@ -2324,11 +2475,12 @@ body.resizing {
border-radius: var(--radius-md);
background: var(--bg-panel-alt);
flex-shrink: 0;
- transition: outline 0.1s;
+ cursor: pointer;
+ transition: border-color 0.1s, outline 0.1s;
}
.reference-strip img.active {
- outline: 2px solid var(--color-primary, #6c63ff);
+ outline: 2px solid var(--accent);
outline-offset: 1px;
}
@@ -2367,11 +2519,57 @@ body.resizing {
@keyframes template-pop {
from {
opacity: 0;
- transform: scale(0.96);
+ transform: translateY(0) scale(0.98);
}
to {
opacity: 1;
- transform: scale(1);
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes modal-layer-in {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes template-exit {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(0) scale(0.98);
+ }
+}
+
+@keyframes modal-layer-out {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(5px);
+ }
+}
+
+@keyframes modal-backdrop-out {
+ from {
+ opacity: 1;
+ -webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
+ backdrop-filter: blur(var(--modal-backdrop-blur));
+ }
+ to {
+ opacity: 0;
+ -webkit-backdrop-filter: blur(0px);
+ backdrop-filter: blur(0px);
}
}
/* 遮罩只模糊不压暗 → 让模糊从 0 慢慢增强到目标值,而非瞬间全糊(进展缓一点) */
@@ -2383,8 +2581,8 @@ body.resizing {
}
to {
opacity: 1;
- -webkit-backdrop-filter: blur(10px);
- backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
+ backdrop-filter: blur(var(--modal-backdrop-blur));
}
}
@@ -2471,6 +2669,27 @@ body.resizing {
.template-masonry {
column-count: 1;
}
+
+ .template-modal-backdrop {
+ padding: 14px;
+ }
+
+ .template-detail-panel {
+ gap: 14px;
+ padding: 18px;
+ }
+
+ .template-detail-specs {
+ grid-template-columns: 1fr;
+ }
+
+ .template-detail-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .template-detail-delete {
+ grid-column: auto;
+ }
}
/* ===== RESIZABLE PANEL HANDLE ===== */
@@ -2574,6 +2793,24 @@ body.resizing .studio-panel {
box-shadow: inset 0 0 0 1px var(--lf-glass-soft), 0 12px 34px var(--lf-shadow-strong);
}
+/* 横向 SVG 使用满高白色画布承托,完整图形居中显示且不裁切。 */
+.template-home .template-modal .template-preview.large {
+ align-self: stretch;
+ aspect-ratio: 4 / 3;
+ min-height: 300px;
+ background: #fff;
+}
+
+.template-home .template-modal .template-preview.large img {
+ height: 100%;
+ max-height: none;
+}
+
+/* SVG 常带透明背景,缩略图统一以白底承托,保证预览可见。 */
+.template-home .template-detail-references .reference-strip img {
+ background: #fff;
+}
+
/* 空态与模态玻璃化 */
.template-home .template-empty {
background: var(--lf-glass-bg);
diff --git a/frontend/tests/canvas-layer-order.test.mjs b/frontend/tests/canvas-layer-order.test.mjs
new file mode 100644
index 0000000..e37ceef
--- /dev/null
+++ b/frontend/tests/canvas-layer-order.test.mjs
@@ -0,0 +1,60 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+import ts from 'typescript';
+
+async function loadCanvasDocumentModule() {
+ const source = await readFile(new URL('../src/lib/canvasDocument.ts', import.meta.url), 'utf8');
+ const compiled = ts.transpileModule(source, {
+ compilerOptions: {
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2020,
+ },
+ }).outputText;
+ const module = { exports: {} };
+ new Function('exports', 'module', compiled)(module.exports, module);
+ return module.exports;
+}
+
+const documentWithTwoLayers = {
+ width: 400,
+ height: 240,
+ background: '#ffffff',
+ layers: [
+ { id: 'layer-bottom', name: '底层', visible: true, locked: false },
+ { id: 'layer-top', name: '顶层', visible: true, locked: false },
+ ],
+ layerFolders: [],
+ elements: [
+ { id: 'top-first', type: 'rect', layerId: 'layer-top', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#f00', stroke: '#f00', strokeWidth: 0 },
+ { id: 'bottom-first', type: 'rect', layerId: 'layer-bottom', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#0f0', stroke: '#0f0', strokeWidth: 0 },
+ { id: 'top-second', type: 'rect', layerId: 'layer-top', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#00f', stroke: '#00f', strokeWidth: 0 },
+ ],
+};
+
+test('canvas paint order keeps every element above the background and honors layer order', async () => {
+ const { orderCanvasElementsByLayer } = await loadCanvasDocumentModule();
+
+ assert.deepEqual(
+ orderCanvasElementsByLayer(documentWithTwoLayers).map(element => element.id),
+ ['bottom-first', 'top-first', 'top-second'],
+ );
+});
+
+test('moving an element only changes its order inside its own canvas layer', async () => {
+ const { moveCanvasElementWithinLayer } = await loadCanvasDocumentModule();
+
+ const moved = moveCanvasElementWithinLayer(documentWithTwoLayers, 'top-first', 1);
+
+ assert.deepEqual(
+ moved.elements.map(element => element.id),
+ ['top-second', 'bottom-first', 'top-first'],
+ );
+});
+
+test('canvas elements remain visible when they extend beyond the document surface', async () => {
+ const styles = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8');
+ const stageRule = /\.studio-stage\s*\{([\s\S]*?)\n\}/.exec(styles)?.[1] ?? '';
+
+ assert.match(stageRule, /overflow:\s*visible/);
+});
diff --git a/frontend/tests/docking-preview.test.mjs b/frontend/tests/docking-preview.test.mjs
new file mode 100644
index 0000000..c5ab272
--- /dev/null
+++ b/frontend/tests/docking-preview.test.mjs
@@ -0,0 +1,10 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+
+test('dock preview is mounted in the workspace rather than inside the moving panel', async () => {
+ const source = await readFile(new URL('../src/components/FloatingPanel.tsx', import.meta.url), 'utf8');
+
+ assert.match(source, /createPortal\(/);
+ assert.match(source, /workspaceNodeRef\.current,\s*\n\s*\)/);
+});
diff --git a/frontend/tests/template-details-layout.test.mjs b/frontend/tests/template-details-layout.test.mjs
new file mode 100644
index 0000000..1ef0842
--- /dev/null
+++ b/frontend/tests/template-details-layout.test.mjs
@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+
+const sourcePath = new URL('../src/pages/TemplateHome.tsx', import.meta.url);
+const stylesPath = new URL('../src/styles.css', import.meta.url);
+
+test('template details are rendered through one reusable right-panel component', async () => {
+ const source = await readFile(sourcePath, 'utf8');
+
+ assert.match(source, /function TemplateDetailPanel\(/);
+ assert.match(source, /
{
+ const styles = await readFile(stylesPath, 'utf8');
+
+ assert.match(styles, /\.template-detail-summary/);
+ assert.match(styles, /\.template-detail-specs/);
+ assert.match(styles, /\.template-detail-actions/);
+});
+
+test('template detail specifications and actions stack on narrow screens', async () => {
+ const styles = await readFile(stylesPath, 'utf8');
+
+ assert.match(styles, /@media \(max-width: 560px\) \{[\s\S]*?\.template-detail-specs\s*\{\s*grid-template-columns: 1fr;/);
+ assert.match(styles, /@media \(max-width: 560px\) \{[\s\S]*?\.template-detail-actions\s*\{\s*grid-template-columns: 1fr;/);
+});
+
+test('narrow-screen modal keeps the action row reachable under the viewport cap', async () => {
+ const styles = await readFile(stylesPath, 'utf8');
+
+ // The modal caps height with overflow:hidden. On single-column mobile the
+ // detail panel must keep its own scroll so actions cannot be clipped away.
+ assert.match(styles, /\.template-modal\s*\{[^}]*max-height:\s*calc\(100vh\s*-\s*56px\);/);
+ assert.match(styles, /\.template-modal\s*\{[^}]*overflow:\s*hidden;/);
+ assert.match(styles, /\.template-detail-panel\s*\{[^}]*overflow-y:\s*auto;/);
+});
+
+test('modal previews fill their column with a white SVG backdrop and thumbnails stay opaque', async () => {
+ const styles = await readFile(stylesPath, 'utf8');
+
+ assert.match(styles, /\.template-home \.template-modal \.template-preview\.large\s*\{[^}]*align-self: stretch;[^}]*aspect-ratio: 4 \/ 3;[^}]*background: #fff;/);
+ assert.match(styles, /\.template-home \.template-modal \.template-preview\.large img\s*\{[^}]*height: 100%;[^}]*max-height: none;/);
+ assert.match(styles, /\.template-home \.template-detail-references \.reference-strip img\s*\{[^}]*background: #fff;/);
+});