mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 05:53:19 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
411
apps/web/src/node-props/NodePropEditor.tsx
Normal file
411
apps/web/src/node-props/NodePropEditor.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import type { MindNode, Op, StylePreset } from "@mind-map/shared";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
commitField,
|
||||
fieldEditorSeed,
|
||||
hiddenAddableFields,
|
||||
visiblePropRows,
|
||||
} from "./registry";
|
||||
import type { PropFieldDef, PropFieldId } from "./types";
|
||||
import { uploadImageFile } from "./upload-image";
|
||||
import "./node-shell.css";
|
||||
|
||||
type ApplyOps = (ops: Op[], summary?: string) => void | Promise<void>;
|
||||
|
||||
export function NodePropEditor({
|
||||
node,
|
||||
applyOps,
|
||||
accent,
|
||||
variant = "overlay",
|
||||
onDragHandlePointerDown,
|
||||
showStylePreset,
|
||||
}: {
|
||||
node: MindNode;
|
||||
applyOps: ApplyOps;
|
||||
accent?: string;
|
||||
variant?: "overlay" | "panel";
|
||||
onDragHandlePointerDown?: (e: React.PointerEvent) => void;
|
||||
showStylePreset?: boolean;
|
||||
}) {
|
||||
const [forced, setForced] = useState<Set<string>>(() => new Set());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForced(new Set());
|
||||
setMenuOpen(false);
|
||||
}, [node.id]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => visiblePropRows(node, forced),
|
||||
[node, forced],
|
||||
);
|
||||
const addable = useMemo(
|
||||
() => hiddenAddableFields(node, forced),
|
||||
[node, forced],
|
||||
);
|
||||
|
||||
const commit = (id: PropFieldId, raw: string) => {
|
||||
const ops = commitField(node, id, raw);
|
||||
if (!ops.length) return;
|
||||
void applyOps(ops, `prop ${id}`);
|
||||
if (!raw.trim() && id !== "text") {
|
||||
setForced((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ns-editor${variant === "panel" ? " ns-editor--panel" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="ns-editor__header"
|
||||
data-ns-drag-handle
|
||||
onPointerDown={onDragHandlePointerDown}
|
||||
>
|
||||
<span
|
||||
className="ns-editor__dot"
|
||||
style={accent ? { background: accent } : undefined}
|
||||
/>
|
||||
<input
|
||||
className="ns-editor__title"
|
||||
key={`title-${node.id}-${node.version}`}
|
||||
defaultValue={node.text}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== node.text) commit("text", e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ns-editor__body">
|
||||
{showStylePreset ? (
|
||||
<div className="ns-row">
|
||||
<span className="ns-row__label">类型</span>
|
||||
<div className="ns-row__ctrl">
|
||||
<select
|
||||
value={node.stylePreset}
|
||||
onChange={(e) => {
|
||||
void applyOps(
|
||||
[
|
||||
{
|
||||
type: "set_style_preset",
|
||||
nodeId: node.id,
|
||||
stylePreset: e.target.value as StylePreset,
|
||||
},
|
||||
],
|
||||
"style",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="default">默认外形</option>
|
||||
<option value="title">title</option>
|
||||
<option value="decision">决策</option>
|
||||
<option value="risk">风险</option>
|
||||
<option value="note">便签</option>
|
||||
<option value="muted">弱化</option>
|
||||
</select>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="ns-row">
|
||||
<span className="ns-row__label">强调色</span>
|
||||
<div className="ns-row__ctrl">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="#e74c3c"
|
||||
defaultValue={node.accentColor ?? ""}
|
||||
key={`ac-${node.id}-${node.version}`}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
const next = v || null;
|
||||
if ((node.accentColor ?? null) === next) return;
|
||||
void applyOps([
|
||||
{
|
||||
type: "update_node_meta",
|
||||
nodeId: node.id,
|
||||
patch: { accentColor: next as never },
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
<div className="ns-row">
|
||||
<span className="ns-row__label">图标</span>
|
||||
<div className="ns-row__ctrl">
|
||||
<select
|
||||
value={node.icon ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
void applyOps([
|
||||
{
|
||||
type: "update_node_meta",
|
||||
nodeId: node.id,
|
||||
patch: {
|
||||
icon: (v || null) as never,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<option value="">无</option>
|
||||
<option value="server">🖥 服务器</option>
|
||||
<option value="database">🗄 数据库</option>
|
||||
<option value="cloud">☁ 云</option>
|
||||
<option value="person">👤 人物</option>
|
||||
<option value="folder">📁 文件夹</option>
|
||||
<option value="doc">📄 文档</option>
|
||||
<option value="link">🔗 链接</option>
|
||||
<option value="warning">⚠ 警告</option>
|
||||
<option value="check">✓ 完成</option>
|
||||
<option value="star">★ 星标</option>
|
||||
<option value="gear">⚙ 设置</option>
|
||||
<option value="globe">🌐 全球</option>
|
||||
</select>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{rows
|
||||
.filter((r) => r.def.id !== "text")
|
||||
.map(({ def }) => (
|
||||
<FieldRow
|
||||
key={def.id}
|
||||
def={def}
|
||||
node={node}
|
||||
onCommit={(raw) => commit(def.id, raw)}
|
||||
onClear={() => {
|
||||
commit(def.id, "");
|
||||
setForced((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(def.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{addable.length > 0 ? (
|
||||
<div
|
||||
className="ns-add"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menuOpen ? (
|
||||
<div className="ns-add__menu">
|
||||
{addable.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className="ns-add__item"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setForced((prev) => new Set(prev).add(f.id));
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ns-add__btn"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
+ 添加字段
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
def,
|
||||
node,
|
||||
onCommit,
|
||||
onClear,
|
||||
}: {
|
||||
def: PropFieldDef;
|
||||
node: MindNode;
|
||||
onCommit: (raw: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const seed = fieldEditorSeed(node, def.id);
|
||||
const canClear = !def.always;
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadErr, setUploadErr] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (def.kind === "image") {
|
||||
return (
|
||||
<div className="ns-row">
|
||||
<span className="ns-row__label">{def.label}</span>
|
||||
<div className="ns-row__ctrl ns-row__ctrl--image" onPointerDown={(e) => e.stopPropagation()}>
|
||||
{seed ? <img className="ns-thumb" src={seed} alt="" /> : null}
|
||||
<div className="ns-image-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ns-image-pick"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "上传中…" : "选择文件"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setUploadErr(null);
|
||||
setUploading(true);
|
||||
void uploadImageFile(file)
|
||||
.then((url) => onCommit(url))
|
||||
.catch((err) =>
|
||||
setUploadErr(String(err?.message || err || "上传失败")),
|
||||
)
|
||||
.finally(() => setUploading(false));
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
key={`${def.id}-${node.id}-${node.version}`}
|
||||
className="ns-image-url"
|
||||
defaultValue={seed}
|
||||
placeholder="或粘贴图片 URL"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v !== (seed || "")) onCommit(v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
/>
|
||||
{canClear ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ns-row__clear"
|
||||
title="清除"
|
||||
onClick={onClear}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{uploadErr ? <div className="ns-image-err">{uploadErr}</div> : null}
|
||||
<div className="ns-image-hint">支持本地选图或粘贴 https://… 链接</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ns-row">
|
||||
<span className="ns-row__label">{def.label}</span>
|
||||
<div className="ns-row__ctrl" onPointerDown={(e) => e.stopPropagation()}>
|
||||
{def.kind === "textarea" || def.kind === "links" ? (
|
||||
<textarea
|
||||
key={`${def.id}-${node.id}-${node.version}`}
|
||||
defaultValue={seed}
|
||||
rows={def.kind === "links" ? 3 : 2}
|
||||
placeholder={def.kind === "links" ? "标题|url 每行一条" : ""}
|
||||
onBlur={(e) => onCommit(e.target.value)}
|
||||
/>
|
||||
) : def.kind === "datetime" ? (
|
||||
<input
|
||||
key={`${def.id}-${node.id}-${node.version}`}
|
||||
type="datetime-local"
|
||||
defaultValue={seed}
|
||||
onBlur={(e) => onCommit(e.target.value)}
|
||||
/>
|
||||
) : def.kind === "flag" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCommit(node.pinned ? "0" : "1")}
|
||||
>
|
||||
{node.pinned ? "解钉" : "钉住"}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
key={`${def.id}-${node.id}-${node.version}`}
|
||||
defaultValue={seed}
|
||||
placeholder={def.kind === "tags" ? "逗号分隔" : ""}
|
||||
onBlur={(e) => onCommit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canClear ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ns-row__clear"
|
||||
title="清除"
|
||||
onClick={onClear}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodePropOverlay({
|
||||
node,
|
||||
applyOps,
|
||||
screenX,
|
||||
screenY,
|
||||
zoom,
|
||||
accent,
|
||||
width,
|
||||
showStylePreset,
|
||||
}: {
|
||||
node: MindNode;
|
||||
applyOps: ApplyOps;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
zoom: number;
|
||||
accent?: string;
|
||||
width: number;
|
||||
showStylePreset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="ns-overlay"
|
||||
style={{
|
||||
left: screenX,
|
||||
top: screenY,
|
||||
width,
|
||||
transform: `translate(-50%, -50%) scale(${zoom})`,
|
||||
}}
|
||||
>
|
||||
<NodePropEditor
|
||||
node={node}
|
||||
applyOps={applyOps}
|
||||
accent={accent}
|
||||
showStylePreset={showStylePreset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue