feat(canvas): 画布 Memphis 化并补全编辑功能

- 后端新增 POST /admin/api/my-images 上传接口:图片存入用户
  owner 目录并生成缩略图,复用 /images 服务与 my-images picker
- 本地图片三种入画布路径:上传按钮 / 拖入舞台(落点即拖放
  位置) / Ctrl+V 粘贴
- 节点选中后显示悬浮工具条(预览/复制/置顶/删除)与右下角
  缩放手柄;图片等比缩放,便签自由缩放;Ctrl+D 复制选中(含
  相互连线);双击空白建笔记;按 1 / ⤢ 按钮适应全部内容
- 整套画布 UI 换 Memphis:纸张底+墨点网格、黑边硬影节点、
  黄色贴纸便签、橙色连线/选中态、虚线框选、顶栏/生成面板/
  预览灯箱/图片选择器全部换墨边 chunky 样式,随站点明暗主题

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-08 17:45:41 +08:00
co-authored by Devin
parent d0506b0d2e
commit 27d58cfb5d
6 changed files with 625 additions and 186 deletions
@@ -101,6 +101,14 @@ defineExpose({ startEdit, commitNote })
>
<!-- 连线出口:拖到目标节点创建连线(舞台接管手势) -->
<span v-if="node.type !== 'gen'" class="cv-port" data-port="out" />
<!-- 右下角缩放手柄 + 悬浮工具条:仅选中时显示,舞台接管 resize 手势 -->
<span v-if="selected && node.type !== 'gen'" class="cv-size" data-resize />
<div v-if="selected && node.type !== 'gen'" class="cv-tools" @dblclick.stop @pointerdown.stop>
<button v-if="node.type === 'image'" title="预览" @click.stop="emit('node-action', { type: 'preview' })">⤢</button>
<button title="复制 (Ctrl+D)" @click.stop="emit('node-action', { type: 'duplicate' })">⧉</button>
<button title="置顶" @click.stop="emit('node-action', { type: 'front' })">⤒</button>
<button class="cv-tools__del" title="删除" @click.stop="emit('node-action', { type: 'delete' })">✕</button>
</div>
<!-- 图片节点 -->
<template v-if="node.type === 'image'">
<img
@@ -144,17 +152,19 @@ defineExpose({ startEdit, commitNote })
<style scoped>
.cv-node {
position: absolute;
border-radius: 10px;
border-radius: 14px;
border: 2.5px solid var(--ink);
overflow: hidden;
contain: layout style paint;
user-select: none;
touch-action: none;
cursor: grab;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.35);
background: var(--surface);
box-shadow: 5px 5px 0 var(--shadow-ink);
transition: box-shadow 0.15s ease;
}
.cv-node.is-selected {
outline: 2px solid #7c6cff;
outline-offset: 2px;
box-shadow: 7px 7px 0 var(--m-orange);
}
.cv-node:active {
cursor: grabbing;
@@ -165,7 +175,7 @@ defineExpose({ startEdit, commitNote })
height: 100%;
object-fit: cover;
display: block;
background: #14161d;
background: var(--surface-2);
}
.cv-node__cap {
@@ -173,20 +183,22 @@ defineExpose({ startEdit, commitNote })
left: 0;
right: 0;
bottom: 0;
padding: 14px 8px 5px;
padding: 5px 9px;
font-size: 11px;
font-weight: 600;
line-height: 1.35;
color: #d6d9e2;
background: linear-gradient(transparent, rgb(8 9 13 / 0.92));
color: var(--fg);
background: var(--surface);
border-top: 2.5px solid var(--ink);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
pointer-events: none;
}
/* 笔记 = 孟菲斯黄贴纸 */
.cv-node--note {
background: #23262f;
border: 1px solid #343947;
background: var(--m-yellow);
cursor: text;
}
.cv-node--note:not(.is-editing) {
@@ -197,8 +209,8 @@ defineExpose({ startEdit, commitNote })
height: 100%;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
color: #e8eaf0;
line-height: 1.55;
color: #1a1a1a;
outline: none;
white-space: pre-wrap;
word-break: break-word;
@@ -216,21 +228,23 @@ defineExpose({ startEdit, commitNote })
gap: 10px;
align-items: center;
justify-content: center;
background: #1a1d26;
border: 1.5px dashed #4a4f61;
color: #9aa1b2;
background: var(--surface);
border-style: dashed;
color: var(--fg-2);
font-size: 13px;
font-weight: 600;
}
.cv-node--gen.is-failed {
border-color: #6b3b44;
color: #e58c96;
border-color: #e5484d;
color: #e5484d;
background: #ffe4e6;
}
.cv-node__spin {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2.5px solid #3b4152;
border-top-color: #7c6cff;
border: 3px solid var(--ink-soft);
border-top-color: var(--m-orange);
animation: cv-spin 0.9s linear infinite;
}
@keyframes cv-spin {
@@ -246,21 +260,67 @@ defineExpose({ startEdit, commitNote })
.cv-port {
position: absolute;
right: -8px;
right: -5px;
top: 50%;
transform: translateY(-50%);
width: 13px;
height: 13px;
width: 15px;
height: 15px;
border-radius: 50%;
background: #7c6cff;
border: 2.5px solid #0e1015;
background: var(--m-orange);
border: 2.5px solid var(--ink);
opacity: 0;
transition: opacity 0.12s;
cursor: crosshair;
z-index: 2;
z-index: 3;
}
.cv-node:hover .cv-port,
.cv-node.is-selected .cv-port {
opacity: 1;
}
/* 右下角缩放方块(节点 contain:paint,只能放内侧) */
.cv-size {
position: absolute;
right: 4px;
bottom: 4px;
width: 15px;
height: 15px;
border-radius: 4px;
background: var(--m-blue);
border: 2.5px solid var(--ink);
cursor: nwse-resize;
z-index: 3;
}
/* 选中悬浮工具条:贴节点内上缘(节点 overflow:hidden,外侧会被裁) */
.cv-tools {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 4px;
z-index: 4;
}
.cv-tools button {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2.5px solid var(--ink);
background: var(--m-yellow);
color: #1a1a1a;
font-size: 12px;
line-height: 1;
cursor: pointer;
box-shadow: 2.5px 2.5px 0 var(--shadow-ink);
display: grid;
place-items: center;
padding: 0;
}
.cv-tools button:hover {
transform: translate(1px, 1px);
box-shadow: 1.5px 1.5px 0 var(--shadow-ink);
}
.cv-tools__del {
background: #ffb4ab !important;
}
</style>
+107 -18
View File
@@ -27,6 +27,7 @@ const emit = defineEmits([
'img-size', // 图片真实尺寸校正 -> { id, w, h }
'viewport-rest', // 视口静止 700ms -> viewport
'pan-zoom', // 视口变化(用于百分比显示,高频)
'blank-dblclick', // 双击空白 -> { x, y } 世界坐标(父级建笔记)
])
const stageEl = ref(null)
@@ -186,6 +187,25 @@ function onPointerDown(e) {
// 文本编辑中完全放行
if (e.target.closest?.('.cv-node--note.is-editing')) return
// 0) 缩放手柄:右下角墨边方块,拖动能改节点尺寸
const sizeGrip = e.target.closest?.('[data-resize]')
if (sizeGrip && e.button === 0) {
const node = hitNode(e)
if (node) {
clearEdgeSel()
setSelection(new Set([node.id]))
const start = screenToWorld(localXY(e).x, localXY(e).y, vp)
act = {
mode: 'resize', node,
startX: start.x, startY: start.y,
w0: node.w, h0: node.h,
aspect: node.h > 0 ? node.w / node.h : 1,
}
try { stageEl.value.setPointerCapture(e.pointerId) } catch { /* 合成/失效指针 */ }
return
}
}
// 1) 连线出口:拖到目标节点建边
const port = e.target.closest?.('[data-port]')
if (port && e.button === 0) {
@@ -292,6 +312,20 @@ function applyMove(e) {
}
return
}
if (act.mode === 'resize') {
const cur = screenToWorld(p.x, p.y, vp)
const n = act.node
if (n.type === 'note') {
n.w = Math.max(140, Math.round(act.w0 + cur.x - act.startX))
n.h = Math.max(90, Math.round(act.h0 + cur.y - act.startY))
} else {
// 图片锁长宽比:以宽度为准推导高度
const w = Math.max(96, act.w0 + cur.x - act.startX)
n.w = Math.round(w)
n.h = Math.round(w / act.aspect)
}
return
}
if (act.mode === 'link' && linkLine.value) {
const cur = screenToWorld(p.x, p.y, vp)
linkLine.value.x2 = cur.x
@@ -320,6 +354,7 @@ function onPointerUp(e) {
panning.value = false
try { stageEl.value.releasePointerCapture(e.pointerId) } catch { /* 已释放 */ }
if (a.mode === 'drag' && a.moved) emit('move-end')
if (a.mode === 'resize') emit('move-end') // 尺寸进了快照,可撤销
if (a.mode === 'link') {
const target = hitNode(e)
if (target && target.id !== a.fromId) emit('link-create', { from: a.fromId, to: target.id })
@@ -328,6 +363,7 @@ function onPointerUp(e) {
}
function onPointerCancel() {
if (act?.mode === 'drag' && act.moved) emit('move-end')
if (act?.mode === 'resize') emit('move-end')
act = null
marquee.value = null
linkLine.value = null
@@ -390,6 +426,14 @@ function setSelection(s) {
selection.value = s
emit('selection', [...s])
}
// 父级复制/成组操作后同步舞台内部选择集
function selectIds(ids) {
setSelection(new Set(ids))
}
function bumpNode(id) {
const n = nodeById.value.get(id)
if (n) bumpZ(n)
}
// ---- 对外 API ----
function setViewport(v) {
@@ -412,6 +456,37 @@ function centerOn(x, y) {
function clearSelection() {
if (selection.value.size) setSelection(new Set())
}
// 双击空白:交给父级在落点建笔记
function onDblClick(e) {
if (e.target.closest?.('[data-node-id]') || e.target.closest?.('.cv-edge-hit')) return
const p = localXY(e)
const w = screenToWorld(p.x, p.y, vp)
emit('blank-dblclick', { x: w.x, y: w.y })
}
// 适应内容:把所有节点缩放进视口(留 80px 边距,最大放到 1.2x)
function fitToContent() {
const r = stageEl.value?.getBoundingClientRect()
const sw = r ? r.width : 800
const sh = r ? r.height : 600
const nodes = props.nodes
if (!nodes.length) {
setViewport({ x: 0, y: 0, scale: 1 })
return
}
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity
for (const n of nodes) {
x1 = Math.min(x1, n.x); y1 = Math.min(y1, n.y)
x2 = Math.max(x2, n.x + n.w); y2 = Math.max(y2, n.y + n.h)
}
const pad = 80
const bw = Math.max(1, x2 - x1 + pad * 2)
const bh = Math.max(1, y2 - y1 + pad * 2)
const s = Math.min(sw / bw, sh / bh, 1.2)
vp.scale = s
vp.x = sw / 2 - (x1 - pad + bw / 2) * s
vp.y = sh / 2 - (y1 - pad + bh / 2) * s
notifyViewport()
}
function selectedIds() {
return [...selection.value]
}
@@ -430,7 +505,7 @@ function screenRectToWorld(r) {
}
defineExpose({
setViewport, zoomBy, centerOn, clearSelection, selectedIds, selectAll, syncZTop, vp,
cancelLink, consumeSelectedEdges, hasSelectedEdges,
cancelLink, consumeSelectedEdges, hasSelectedEdges, fitToContent, selectIds, bumpNode,
})
</script>
@@ -444,6 +519,7 @@ defineExpose({
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
@dblclick="onDblClick"
@contextmenu.prevent
>
<div class="cv-world" :style="worldStyle">
@@ -484,8 +560,12 @@ defineExpose({
inset: 0;
overflow: hidden;
touch-action: none;
background-color: #0e1015;
background-image: radial-gradient(circle, rgb(255 255 255 / 0.07) 1px, transparent 1px);
background-color: var(--app-bg);
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--ink) 18%, transparent) 1.3px,
transparent 1.3px
);
}
.cv-stage.is-panning {
cursor: grab;
@@ -509,17 +589,17 @@ defineExpose({
}
.cv-edge {
fill: none;
stroke: rgb(140 146 165 / 0.5);
stroke-width: 1.5;
}
.cv-edge--hot {
stroke: #7c6cff;
stroke: var(--ink-soft);
stroke-width: 2;
}
.cv-edge--sel {
stroke: #ff7b8f;
.cv-edge--hot {
stroke: var(--m-orange);
stroke-width: 2.5;
filter: drop-shadow(0 0 3px rgb(255 123 143 / 0.6));
}
.cv-edge--sel {
stroke: var(--m-orange);
stroke-width: 3.5;
stroke-dasharray: 10 6;
}
.cv-edge-hit {
fill: none;
@@ -530,17 +610,17 @@ defineExpose({
}
.cv-linkline {
fill: none;
stroke: #a99dff;
stroke-width: 2;
stroke-dasharray: 7 6;
stroke: var(--m-orange);
stroke-width: 2.5;
stroke-dasharray: 8 6;
pointer-events: none;
}
.cv-marquee {
position: absolute;
border: 1px solid #7c6cff;
background: rgb(124 108 255 / 0.12);
border: 2px dashed var(--ink);
background: color-mix(in srgb, var(--m-yellow) 35%, transparent);
pointer-events: none;
border-radius: 2px;
border-radius: 4px;
}
.cv-hint {
position: absolute;
@@ -548,8 +628,17 @@ defineExpose({
bottom: 10px;
transform: translateX(-50%);
font-size: 11px;
color: rgb(255 255 255 / 0.28);
font-weight: 600;
color: var(--fg-2);
background: var(--surface);
border: 2px solid var(--ink);
border-radius: 999px;
padding: 3px 14px;
box-shadow: 3px 3px 0 var(--shadow-ink);
pointer-events: none;
white-space: nowrap;
max-width: 92%;
overflow: hidden;
text-overflow: ellipsis;
}
</style>