文件
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

558 行
21 KiB
TypeScript

import * as React from 'react';
import { DragEvent as ReactDragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Background,
Connection,
ConnectionMode,
Controls,
Edge,
MarkerType,
MiniMap,
Node,
NodeTypes,
OnConnect,
OnEdgesChange,
OnNodesChange,
OnReconnect,
ReactFlow,
ReactFlowProvider,
applyEdgeChanges,
applyNodeChanges,
reconnectEdge,
useReactFlow,
} from '@xyflow/react';
import generateId from '@joplin/lib/services/whiteboard/generateId';
import findEmptySpot from '@joplin/lib/services/whiteboard/findEmptySpot';
import { _, _n } from '@joplin/lib/locale';
import { Canvas, CanvasColor, CanvasEdge, CanvasNode } from '@joplin/lib/services/whiteboard/jsoncanvas';
import { presetColors, resolveCanvasColor } from '@joplin/lib/services/whiteboard/presetColors';
import { useWhiteboardContext } from './WhiteboardContext';
import shim from '@joplin/lib/shim';
import CommandService from '@joplin/lib/services/CommandService';
import { ModelType } from '@joplin/lib/BaseModel';
import { Mode } from '../../../../plugins/GotoAnything';
import { GotoAnythingOptions, UiType } from '../../../WindowCommandsAndDialogs/commands/gotoAnything';
import Logger from '@joplin/utils/Logger';
import { webUtils } from 'electron';
import { canvasNodeToFlowNode, canvasToFlow, flowToCanvas, WhiteboardFlowEdge, WhiteboardFlowNode } from './canvasFlow';
const logger = Logger.create('WhiteboardSurface');
import TextNode from './nodes/TextNode';
import FileNode from './nodes/FileNode';
import LinkNode from './nodes/LinkNode';
import GroupNode from './nodes/GroupNode';
import { ActionButton, ActionDivider, ActionInput, ActionPanel, ActionSwatch } from './ActionPanel';
const makeArrowMarker = () => ({ type: MarkerType.ArrowClosed, width: 27, height: 27, markerUnits: 'userSpaceOnUse' });
type ArrowMode = 'none' | 'forward' | 'backward' | 'both' | 'mixed';
const arrowModeFor = (e: WhiteboardFlowEdge): Exclude<ArrowMode, 'mixed'> => {
const start = !!e.markerStart;
const end = !!e.markerEnd;
if (start && end) return 'both';
if (end) return 'forward';
if (start) return 'backward';
return 'none';
};
interface Props {
canvas: Canvas;
onChange: (canvas: Canvas)=> void;
}
const nodeTypes: NodeTypes = {
wbText: TextNode as unknown as NodeTypes[string],
wbFile: FileNode as unknown as NodeTypes[string],
wbLink: LinkNode as unknown as NodeTypes[string],
wbGroup: GroupNode as unknown as NodeTypes[string],
};
const InnerSurface = ({ canvas, onChange }: Props) => {
const ctx = useWhiteboardContext();
const initial = useMemo(() => canvasToFlow(canvas), [canvas]);
const [flowNodes, setFlowNodes] = useState<WhiteboardFlowNode[]>(initial.nodes);
const [flowEdges, setFlowEdges] = useState<WhiteboardFlowEdge[]>(initial.edges);
const [labelFocusToken, setLabelFocusToken] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const rf = useReactFlow();
const lastEmittedRef = useRef<string>(JSON.stringify(flowToCanvas(initial.nodes, initial.edges)));
useEffect(() => {
const incoming = JSON.stringify(canvas);
if (incoming === lastEmittedRef.current) return;
const next = canvasToFlow(canvas);
setFlowNodes(next.nodes);
setFlowEdges(next.edges);
lastEmittedRef.current = JSON.stringify(flowToCanvas(next.nodes, next.edges));
}, [canvas]);
useEffect(() => {
const out = flowToCanvas(flowNodes, flowEdges);
const serialized = JSON.stringify(out);
if (serialized === lastEmittedRef.current) return;
lastEmittedRef.current = serialized;
onChange(out);
}, [flowNodes, flowEdges, onChange]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
setFlowNodes(prev => applyNodeChanges(changes, prev) as WhiteboardFlowNode[]);
}, []);
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
setFlowEdges(prev => applyEdgeChanges(changes, prev) as WhiteboardFlowEdge[]);
}, []);
// Drag-along for groups: when the user drags a group, all non-selected
// nodes whose centre lies inside the group's starting bounds move with it.
// The set is captured once at drag-start so adding/removing overlap
// mid-drag doesn't change membership. Other groups can be captives too;
// their own captives are NOT re-evaluated because we only translate them.
type Captive = { id: string; originX: number; originY: number };
const dragRef = useRef<{ groupId: string; originX: number; originY: number; captives: Captive[] } | null>(null);
const onNodeDragStart = useCallback((_e: React.MouseEvent, node: Node) => {
const dragged = flowNodes.find(n => n.id === node.id);
if (!dragged || dragged.data?.canvasNode?.type !== 'group') return;
const gx = dragged.position.x;
const gy = dragged.position.y;
const gw = (typeof dragged.width === 'number' ? dragged.width : (typeof dragged.style?.width === 'number' ? dragged.style.width : 0)) ?? 0;
const gh = (typeof dragged.height === 'number' ? dragged.height : (typeof dragged.style?.height === 'number' ? dragged.style.height : 0)) ?? 0;
// If the group itself is part of a multi-selection, React Flow drags
// every selected node together — in that case we must skip selected
// captives or they'd be double-moved by our delta. If the group isn't
// in the multi-selection, only the group moves, so all nodes inside
// (selected or not) need to come along.
const groupInMultiSelect = !!dragged.selected;
const captives: Captive[] = [];
for (const n of flowNodes) {
if (n.id === dragged.id) continue;
if (groupInMultiSelect && n.selected) continue;
const nw = (typeof n.width === 'number' ? n.width : (typeof n.style?.width === 'number' ? n.style.width : 0)) ?? 0;
const nh = (typeof n.height === 'number' ? n.height : (typeof n.style?.height === 'number' ? n.style.height : 0)) ?? 0;
const cx = n.position.x + nw / 2;
const cy = n.position.y + nh / 2;
if (cx >= gx && cx <= gx + gw && cy >= gy && cy <= gy + gh) {
captives.push({ id: n.id, originX: n.position.x, originY: n.position.y });
}
}
dragRef.current = { groupId: dragged.id, originX: gx, originY: gy, captives };
}, [flowNodes]);
const onNodeDrag = useCallback((_e: React.MouseEvent, node: Node) => {
const ctx = dragRef.current;
if (!ctx || node.id !== ctx.groupId || !ctx.captives.length) return;
const dx = node.position.x - ctx.originX;
const dy = node.position.y - ctx.originY;
const originById = new Map(ctx.captives.map(c => [c.id, c]));
setFlowNodes(prev => prev.map(n => {
const origin = originById.get(n.id);
if (!origin) return n;
return { ...n, position: { x: origin.originX + dx, y: origin.originY + dy } };
}));
}, []);
const onNodeDragStop = useCallback(() => {
dragRef.current = null;
}, []);
const onConnect: OnConnect = useCallback((connection: Connection) => {
const edge: WhiteboardFlowEdge = {
id: generateId(),
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
markerEnd: makeArrowMarker(),
data: {
canvasEdge: {
id: '',
fromNode: connection.source,
toNode: connection.target,
} as CanvasEdge,
},
};
setFlowEdges(prev => [...prev, edge]);
}, []);
const onReconnect: OnReconnect = useCallback((oldEdge, newConnection) => {
// `oldEdge` is the rendered edge, which carries the derived `style.stroke`
// (blue when selected) baked in by `renderedEdges`. reconnectEdge copies
// those fields onto the new edge, so it would persist that stroke into
// state and leave the edge blue-but-not-bold after deselection. Reconnect
// against the clean edge from state instead, keyed by id.
setFlowEdges(prev => {
const clean = prev.find(e => e.id === oldEdge.id);
return reconnectEdge((clean ?? oldEdge) as unknown as WhiteboardFlowEdge, newConnection, prev) as WhiteboardFlowEdge[];
});
}, []);
// Double-clicking an edge selects it exclusively and focuses the label
// input, so a user can start typing without moving the mouse to the panel.
const onEdgeDoubleClick = useCallback((_e: React.MouseEvent, edge: Edge) => {
setFlowEdges(prev => prev.map(x => ({ ...x, selected: x.id === edge.id })));
setFlowNodes(prev => prev.some(n => n.selected) ? prev.map(n => ({ ...n, selected: false })) : prev);
setLabelFocusToken(t => t + 1);
}, []);
const defaultEdgeOptions = useMemo(() => ({
markerEnd: makeArrowMarker(),
}), []);
const addCanvasNode = useCallback((n: CanvasNode) => {
const flow = canvasNodeToFlowNode(n);
setFlowNodes(prev => n.type === 'group' ? [flow, ...prev] : [...prev, flow]);
}, []);
const viewportCentre = useCallback((): { x: number; y: number } => {
const view = rf.getViewport();
const rect = containerRef.current?.getBoundingClientRect();
const cx = rect ? (rect.width / 2 - view.x) / view.zoom : 0;
const cy = rect ? (rect.height / 2 - view.y) / view.zoom : 0;
return { x: cx, y: cy };
}, [rf]);
// Viewport bounds in canvas coordinates.
const viewportBounds = useCallback((): { x: number; y: number; width: number; height: number } | null => {
const view = rf.getViewport();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return null;
return {
x: -view.x / view.zoom,
y: -view.y / view.zoom,
width: rect.width / view.zoom,
height: rect.height / view.zoom,
};
}, [rf]);
const existingRects = useCallback(() => {
return flowNodes.map(n => ({
x: n.position.x,
y: n.position.y,
width: (typeof n.width === 'number' ? n.width : (typeof n.style?.width === 'number' ? n.style.width : 0)) ?? 0,
height: (typeof n.height === 'number' ? n.height : (typeof n.style?.height === 'number' ? n.style.height : 0)) ?? 0,
}));
}, [flowNodes]);
const addFileCard = useCallback((itemId: string, at: { x: number; y: number }, index = 0) => {
const offset = index * 24;
addCanvasNode({
id: generateId(),
type: 'file',
x: at.x - 120 + offset,
y: at.y - 60 + offset,
width: 240,
height: 160,
file: `:/${itemId}`,
});
}, [addCanvasNode]);
const onPaneDoubleClick = useCallback(async (event: React.MouseEvent) => {
// The <Background> svg covers the pane, so it's the event target on an
// empty-space click.
const target = event.target as Element;
if (!target?.closest) return;
const onEmptySpace = target.classList.contains('react-flow__pane')
|| !!target.closest('.react-flow__background');
if (!onEmptySpace) return;
const at = rf.screenToFlowPosition({ x: event.clientX, y: event.clientY });
const options: GotoAnythingOptions = { mode: Mode.TitleOnly };
const result = await CommandService.instance().execute('gotoAnything', UiType.ControlledApi, options);
if (!result) return;
if (result.type !== ModelType.Note) {
logger.warn('Selected item is not a note:', result);
return;
}
addFileCard(result.item.id, at);
}, [rf, addFileCard]);
const onAddText = useCallback(() => {
const { x: cx, y: cy } = viewportCentre();
addCanvasNode({
id: generateId(),
type: 'text',
x: cx - 120,
y: cy - 70,
width: 240,
height: 140,
text: _('New text card'),
});
}, [viewportCentre, addCanvasNode]);
const onAddGroup = useCallback(() => {
const { x, y, width, height } = findEmptySpot({
existing: existingRects(),
viewport: viewportBounds(),
centre: viewportCentre(),
preferred: { width: 320, height: 240 },
min: { width: 100, height: 100 },
});
addCanvasNode({
id: generateId(),
type: 'group',
x,
y,
width,
height,
label: _('New group'),
});
}, [existingRects, viewportBounds, viewportCentre, addCanvasNode]);
const selectedEdges = useMemo(() => flowEdges.filter(e => e.selected), [flowEdges]);
const selectedNodes = useMemo(() => flowNodes.filter(n => n.selected), [flowNodes]);
const DEFAULT_SELECTION_COLOR = '#4a90e2';
const renderedEdges = useMemo<WhiteboardFlowEdge[]>(() => {
return flowEdges.map(e => {
// If the edge has a colour, use it for both the stroke and (when
// selected) the selection affordance — otherwise fall back to the
// default blue for selected edges, and to nothing (React Flow's
// theme default) for unselected ones.
const own = resolveCanvasColor(e.data?.color, ctx.themeAppearance, 'stroke');
const stroke = own ?? (e.selected ? DEFAULT_SELECTION_COLOR : undefined);
if (!stroke) return e;
const tint = (m: WhiteboardFlowEdge['markerEnd']) =>
(m && typeof m === 'object') ? { ...m, color: stroke } : m;
return {
...e,
style: { ...(e.style ?? {}), stroke },
markerEnd: tint(e.markerEnd),
markerStart: tint(e.markerStart),
};
});
}, [flowEdges, ctx.themeAppearance]);
const updateSelectedEdges = useCallback((patch: (e: WhiteboardFlowEdge)=> WhiteboardFlowEdge) => {
setFlowEdges(prev => prev.map(e => e.selected ? patch(e) : e));
}, []);
const currentArrowMode: ArrowMode = useMemo(() => {
if (!selectedEdges.length) return 'none';
const first = arrowModeFor(selectedEdges[0]);
for (const e of selectedEdges) if (arrowModeFor(e) !== first) return 'mixed';
return first;
}, [selectedEdges]);
const setArrowMode = useCallback((mode: Exclude<ArrowMode, 'mixed'>) => {
updateSelectedEdges(e => ({
...e,
markerStart: (mode === 'backward' || mode === 'both') ? makeArrowMarker() : undefined,
markerEnd: (mode === 'forward' || mode === 'both') ? makeArrowMarker() : undefined,
}));
}, [updateSelectedEdges]);
const flipDirection = useCallback(() => {
updateSelectedEdges(e => ({
...e,
source: e.target,
target: e.source,
sourceHandle: e.targetHandle,
targetHandle: e.sourceHandle,
markerStart: e.markerEnd,
markerEnd: e.markerStart,
}));
}, [updateSelectedEdges]);
const setEdgeLabel = useCallback((label: string) => {
updateSelectedEdges(e => ({ ...e, label }));
}, [updateSelectedEdges]);
const setSelectedColor = useCallback((color: CanvasColor | undefined) => {
setFlowEdges(prev => prev.map(e => e.selected
? { ...e, data: { ...(e.data as WhiteboardFlowEdge['data'] & object), color } }
: e));
setFlowNodes(prev => prev.map(n => {
if (!n.selected) return n;
const cn = n.data?.canvasNode;
if (!cn) return n;
const nextCanvasNode = { ...cn, ...(color ? { color } : {}) } as CanvasNode;
if (!color) delete (nextCanvasNode as { color?: CanvasColor }).color;
return { ...n, data: { ...n.data, canvasNode: nextCanvasNode } };
}));
}, []);
// A single preset "wins" as the current colour only when every selected
// item shares it. Mixed selections show no active swatch — clicking one
// still applies it to everything, which matches the arrow-mode behaviour.
const currentColor = useMemo<CanvasColor | undefined>(() => {
const colors: (CanvasColor | undefined)[] = [
...selectedEdges.map(e => e.data?.color),
...selectedNodes.map(n => n.data?.canvasNode?.color),
];
if (!colors.length) return undefined;
const first = colors[0];
return colors.every(c => c === first) ? first : undefined;
}, [selectedEdges, selectedNodes]);
const colorSwatches = (
<>
<ActionSwatch
color={null}
active={currentColor === undefined}
title={_('No colour')}
onClick={() => setSelectedColor(undefined)}
/>
{presetColors.map(p => (
<ActionSwatch
key={p.id}
color={resolveCanvasColor(p.id, ctx.themeAppearance, 'stroke') ?? '#888'}
active={currentColor === p.id}
title={p.label}
onClick={() => setSelectedColor(p.id)}
/>
))}
</>
);
const onDragOver = useCallback((e: ReactDragEvent<HTMLDivElement>) => {
const effectAllowed = e.dataTransfer.effectAllowed;
const canLink = effectAllowed.toLowerCase().includes('link') || ['all', 'uninitialized'].includes(effectAllowed);
const types = Array.from(e.dataTransfer.types);
if (types.includes('text/x-jop-note-ids') || types.includes('text/x-jop-resource-ids')) {
e.preventDefault();
// Workaround: On Linux, the 'link' drag effect prevents dropping items into the editor.
// See https://github.com/laurent22/joplin/issues/16457.
e.dataTransfer.dropEffect = canLink ? 'link' : 'copy';
} else if (types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}, []);
const onDrop = useCallback(async (e: ReactDragEvent<HTMLDivElement>) => {
const noteIdsRaw = e.dataTransfer.getData('text/x-jop-note-ids');
const resourceIdsRaw = e.dataTransfer.getData('text/x-jop-resource-ids');
// Electron's recommended way to get the on-disk path of a dropped File
// (the deprecated `File.path` was removed in recent Electron versions).
const droppedFiles = Array.from(e.dataTransfer.files);
const filePaths = droppedFiles.map(f => webUtils.getPathForFile(f)).filter((p): p is string => !!p);
if (!noteIdsRaw && !resourceIdsRaw && !filePaths.length) return;
e.preventDefault();
e.stopPropagation();
const drop = rf.screenToFlowPosition({ x: e.clientX, y: e.clientY });
const tryParse = (raw: string): string[] => {
if (!raw) return [];
try { const v = JSON.parse(raw); return Array.isArray(v) ? v : []; } catch { return []; }
};
const placeCardForResource = (resourceId: string, index: number) => {
addFileCard(resourceId, drop, index);
};
const internalIds = [
...tryParse(noteIdsRaw),
...tryParse(resourceIdsRaw),
];
for (let i = 0; i < internalIds.length; i++) placeCardForResource(internalIds[i], i);
if (filePaths.length) {
// Import each file into the resource store, then add a card. Done
// in parallel for throughput; the card index (and thus the offset)
// follows the original drop order so cards stagger predictably
// even if larger files finish importing later.
await Promise.all(filePaths.map(async (filePath, i) => {
try {
const resource = await shim.createResourceFromPath(filePath);
placeCardForResource(resource.id, internalIds.length + i);
} catch (error) {
logger.warn(`Could not import dropped file ${filePath}:`, error);
}
}));
}
}, [rf, addFileCard]);
return (
<div
ref={containerRef}
className="whiteboard-surface"
onDragOver={onDragOver}
onDrop={onDrop}
onDoubleClick={onPaneDoubleClick}
>
<ReactFlow
nodes={flowNodes as unknown as Node[]}
edges={renderedEdges as unknown as Edge[]}
nodeTypes={nodeTypes}
defaultEdgeOptions={defaultEdgeOptions}
connectionMode={ConnectionMode.Loose}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onEdgeDoubleClick={onEdgeDoubleClick}
edgesReconnectable
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
elevateEdgesOnSelect
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode={['Shift', 'Meta', 'Control']}
selectionKeyCode={['Shift']}
panOnScroll
panOnDrag
zoomOnPinch
zoomOnScroll={false}
zoomOnDoubleClick={false}
fitView={flowNodes.length > 0}
elevateNodesOnSelect={false}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} size={1} />
<Controls showInteractive={false} />
<MiniMap pannable zoomable />
<ActionPanel position="top-right">
<ActionButton onClick={onAddText} title={_('Add a text card')}>{_('+ Text')}</ActionButton>
<ActionButton onClick={onAddGroup} title={_('Add a group')}>{_('+ Group')}</ActionButton>
</ActionPanel>
{selectedEdges.length > 0 ? (
<ActionPanel
position="bottom-center"
caption={_n('%d connection', '%d connections', selectedEdges.length, selectedEdges.length)}
>
<ActionButton onClick={() => setArrowMode('none')} active={currentArrowMode === 'none'} title={_('No arrow')}>—</ActionButton>
<ActionButton onClick={() => setArrowMode('forward')} active={currentArrowMode === 'forward'} title={_('Arrow at target')}>→</ActionButton>
<ActionButton onClick={() => setArrowMode('backward')} active={currentArrowMode === 'backward'} title={_('Arrow at source')}>←</ActionButton>
<ActionButton onClick={() => setArrowMode('both')} active={currentArrowMode === 'both'} title={_('Bidirectional')}>↔</ActionButton>
<ActionDivider />
<ActionButton onClick={flipDirection} title={_('Swap source and target')}>{_('Flip')}</ActionButton>
<ActionDivider />
{colorSwatches}
{selectedEdges.length === 1 ? (
<>
<ActionDivider />
<ActionInput
value={typeof selectedEdges[0].label === 'string' ? selectedEdges[0].label : ''}
placeholder={_('Label')}
focusToken={labelFocusToken}
onChange={setEdgeLabel}
/>
</>
) : null}
</ActionPanel>
) : null}
{selectedNodes.length > 0 && selectedEdges.length === 0 ? (
<ActionPanel
position="bottom-center"
caption={_n('%d card', '%d cards', selectedNodes.length, selectedNodes.length)}
>
{colorSwatches}
</ActionPanel>
) : null}
</ReactFlow>
</div>
);
};
const WhiteboardSurface = (props: Props) => (
<ReactFlowProvider>
<InnerSurface {...props} />
</ReactFlowProvider>
);
export default WhiteboardSurface;