Compare commits

..

2 Commits

Author SHA1 Message Date
clawdbot
ab84000919 fix-editor-sync-context-menu-selection-snapshot 2026-01-28 16:15:58 +08:00
clawdbot
9d95f34bd9 refactor: improve goView import and context menu selection parity 2026-01-28 14:15:27 +08:00
4 changed files with 110 additions and 26 deletions

View File

@ -141,6 +141,15 @@ export function Canvas(props: CanvasProps) {
const additive =
(e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey || (e as React.MouseEvent).shiftKey;
const nextSelectionIds = targetId
? props.selectionIds.includes(targetId)
? props.selectionIds
: additive
? [...props.selectionIds, targetId]
: [targetId]
: props.selectionIds;
const selectionKey = nextSelectionIds.join('|');
if (targetId) {
if (!props.selectionIds.includes(targetId)) {
// goView-ish: right click selects the item.
@ -158,8 +167,25 @@ export function Canvas(props: CanvasProps) {
props.onOpenContextMenu(
targetId
? { kind: 'node', clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y, targetId }
: { kind: 'canvas', clientX: e.clientX, clientY: e.clientY, worldX: p.x, worldY: p.y },
? {
kind: 'node',
clientX: e.clientX,
clientY: e.clientY,
worldX: p.x,
worldY: p.y,
targetId,
selectionKey,
selectionIds: nextSelectionIds,
}
: {
kind: 'canvas',
clientX: e.clientX,
clientY: e.clientY,
worldX: p.x,
worldY: p.y,
selectionKey,
selectionIds: nextSelectionIds,
},
);
};

View File

@ -8,6 +8,8 @@ export type ContextMenuState =
clientY: number;
worldX: number;
worldY: number;
selectionKey: string;
selectionIds: string[];
}
| {
kind: 'node';
@ -16,6 +18,8 @@ export type ContextMenuState =
worldX: number;
worldY: number;
targetId: string;
selectionKey: string;
selectionIds: string[];
};
export function ContextMenu(props: {

View File

@ -9,7 +9,7 @@ import {
Tabs,
Typography,
} from 'antd';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { bindEditorHotkeys } from './hotkeys';
import { Canvas } from './Canvas';
import { ContextMenu, type ContextMenuState } from './ContextMenu';
@ -48,17 +48,26 @@ export function EditorApp() {
const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
const hasSelection = state.selection.ids.length > 0;
const selection = useMemo(() => {
const ids = new Set(state.selection.ids);
// Note: selection lock/visibility flags for the context menu are computed from `selectionIdsForMenu` below.
// Context-menu parity: right-click selection updates happen via reducer dispatch,
// but the context menu opens immediately.
// Use the captured selectionIds from the context menu state to avoid a one-frame mismatch.
const selectionIdsForMenu = ctxMenu?.selectionIds ?? state.selection.ids;
const selectionForMenu = useMemo(() => {
const ids = new Set(selectionIdsForMenu);
return state.doc.screen.nodes.filter((n) => ids.has(n.id));
}, [state.doc.screen.nodes, state.selection.ids]);
}, [selectionIdsForMenu, state.doc.screen.nodes]);
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked);
const selectionSomeLocked = selection.length > 0 && selection.some((n) => n.locked) && !selectionAllLocked;
const selectionHasUnlocked = selection.length > 0 && selection.some((n) => !n.locked);
const menuSelectionAllLocked = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.locked);
const menuSelectionSomeLocked =
selectionForMenu.length > 0 && selectionForMenu.some((n) => n.locked) && !menuSelectionAllLocked;
const menuSelectionHasUnlocked = selectionForMenu.length > 0 && selectionForMenu.some((n) => !n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden);
const selectionSomeHidden = selection.length > 0 && selection.some((n) => n.hidden) && !selectionAllHidden;
const menuSelectionAllHidden = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.hidden);
const menuSelectionSomeHidden =
selectionForMenu.length > 0 && selectionForMenu.some((n) => n.hidden) && !menuSelectionAllHidden;
const bounds = useMemo(
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
@ -77,10 +86,32 @@ export function EditorApp() {
const closeContextMenu = useCallback(() => setCtxMenu(null), []);
// Selection parity: if selection changes via hotkeys/toolbar, close any open context menu.
const ctxMenuSyncedRef = useRef(false);
useEffect(() => {
// Reset the sync gate whenever a new context menu opens/closes.
ctxMenuSyncedRef.current = false;
}, [ctxMenu?.selectionKey]);
// Selection parity: if selection changes via hotkeys/toolbar *after* the context menu has opened,
// close it. But don't close immediately during the one-frame gap where right-click selection
// has not been reduced into state yet.
useEffect(() => {
if (!ctxMenu) return;
setCtxMenu(null);
const currentKey = state.selection.ids.join('|');
// Wait until reducer state has caught up with the context menu's intended selection.
if (!ctxMenuSyncedRef.current) {
if (currentKey === ctxMenu.selectionKey) {
ctxMenuSyncedRef.current = true;
}
return;
}
if (currentKey !== ctxMenu.selectionKey) {
setCtxMenu(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.selection.ids]);
@ -468,6 +499,12 @@ export function EditorApp() {
e.preventDefault();
e.stopPropagation();
const additive = e.ctrlKey || e.metaKey || e.shiftKey;
const nextSelectionIds = state.selection.ids.includes(node.id)
? state.selection.ids
: additive
? [...state.selection.ids, node.id]
: [node.id];
const selectionKey = nextSelectionIds.join('|');
if (!state.selection.ids.includes(node.id)) {
if (additive) {
dispatch({ type: 'toggleSelect', id: node.id });
@ -482,6 +519,8 @@ export function EditorApp() {
worldX: node.rect.x + node.rect.w / 2,
worldY: node.rect.y + node.rect.h / 2,
targetId: node.id,
selectionKey,
selectionIds: nextSelectionIds,
});
}}
style={{
@ -511,12 +550,12 @@ export function EditorApp() {
<ContextMenu
state={ctxMenu}
selectionIds={state.selection.ids}
selectionAllLocked={selectionAllLocked}
selectionSomeLocked={selectionSomeLocked}
selectionHasUnlocked={selectionHasUnlocked}
selectionAllHidden={selectionAllHidden}
selectionSomeHidden={selectionSomeHidden}
selectionIds={selectionIdsForMenu}
selectionAllLocked={menuSelectionAllLocked}
selectionSomeLocked={menuSelectionSomeLocked}
selectionHasUnlocked={menuSelectionHasUnlocked}
selectionAllHidden={menuSelectionAllHidden}
selectionSomeHidden={menuSelectionSomeHidden}
hasAnyNodes={state.doc.screen.nodes.length > 0}
onClose={closeContextMenu}
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}

View File

@ -175,11 +175,12 @@ function isVideo(c: GoViewComponentLike): boolean {
// Chinese low-code widget names.
if (
k.includes('视频') ||
k.includes('监控') ||
k.includes('直播') ||
k.includes('摄像') ||
k.includes('摄像头')
k.includes('\u89c6\u9891') ||
k.includes('\u76d1\u63a7') ||
k.includes('\u76f4\u64ad') ||
k.includes('\u6444\u50cf') ||
k.includes('\u6444\u50cf\u5934') ||
k.includes('\u56de\u653e')
) {
return true;
}
@ -206,7 +207,10 @@ function isVideo(c: GoViewComponentLike): boolean {
k.includes('live') ||
k.includes('camera') ||
k.includes('cctv') ||
k.includes('monitor')
k.includes('monitor') ||
k.includes('playback') ||
k.includes('streaming') ||
k.includes('videostream')
);
}
@ -276,7 +280,11 @@ function looksLikeIframeOption(option: unknown): boolean {
if (typeof option === 'string') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) return true;
if (trimmed.startsWith('<') && trimmed.includes('>')) {
// If the HTML is a video tag, let the video detector handle it.
if (/\bvideo\b/i.test(trimmed) || /\bsource\b/i.test(trimmed)) return false;
return true;
}
}
// Prefer explicit iframe-ish keys when option is an object (including nested shapes).
@ -331,6 +339,13 @@ function looksLikeVideoOption(option: unknown): boolean {
// Avoid false positives: some TextCommon widgets carry link-like fields.
if (looksLikeTextOption(option)) return false;
if (typeof option === 'string') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) {
return /\bvideo\b/i.test(trimmed) || /\bsource\b/i.test(trimmed);
}
}
// Prefer explicit video-ish keys when option is an object (including nested shapes).
if (typeof option === 'object') {
if (