feat: improve goView embed import + editor selection/menu types

This commit is contained in:
clawdbot 2026-01-29 01:00:57 +08:00
parent 31ad748735
commit c2d02ef817
6 changed files with 95 additions and 14 deletions

View File

@ -236,7 +236,7 @@ export function ContextMenu(props: {
<MenuItem <MenuItem
label="Bring To Front" label="Bring To Front"
disabled={!hasSelection || !props.onBringToFrontSelected} disabled={!canModifySelection || !props.onBringToFrontSelected}
onClick={() => { onClick={() => {
props.onBringToFrontSelected?.(); props.onBringToFrontSelected?.();
onClose(); onClose();
@ -244,7 +244,7 @@ export function ContextMenu(props: {
/> />
<MenuItem <MenuItem
label="Send To Back" label="Send To Back"
disabled={!hasSelection || !props.onSendToBackSelected} disabled={!canModifySelection || !props.onSendToBackSelected}
onClick={() => { onClick={() => {
props.onSendToBackSelected?.(); props.onSendToBackSelected?.();
onClose(); onClose();

View File

@ -17,7 +17,7 @@ import { ContextMenu, type ContextMenuState } from './ContextMenu';
import { Inspector } from './Inspector'; import { Inspector } from './Inspector';
import { selectionKeyOf } from './selection'; import { selectionKeyOf } from './selection';
import { goViewSamples } from './samples/goviewSamples'; import { goViewSamples } from './samples/goviewSamples';
import { createInitialState, editorReducer, exportScreenJSON } from './store'; import { createInitialState, editorReducer, exportScreenJSON, type EditorAction } from './store';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@ -54,11 +54,8 @@ export function EditorApp() {
// Note: selection lock/visibility flags for the context menu are computed from `selectionIdsForMenu` below. // 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, // Context-menu parity: right-click selection updates happen via reducer dispatch,
// but the context menu opens immediately. // but the context menu opens immediately. <ContextMenu /> compares selection keys
// Use the captured selectionIds from the context menu state to avoid a one-frame mismatch. // and falls back to its captured snapshot while state catches up.
const selectionIdsForMenu = ctxMenu?.selectionIds ?? state.selection.ids;
// (Context menu selection flags are computed inside <ContextMenu /> using the captured selection.)
const bounds = useMemo( const bounds = useMemo(
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }), () => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
@ -82,7 +79,7 @@ export function EditorApp() {
const ctxMenuSyncedRef = useRef(false); const ctxMenuSyncedRef = useRef(false);
const dispatchWithMenuSelection = useCallback( const dispatchWithMenuSelection = useCallback(
(action: Parameters<typeof dispatch>[0]) => { (action: EditorAction) => {
if (ctxMenu) { if (ctxMenu) {
const currentKey = selectionKeyOf(state.selection.ids); const currentKey = selectionKeyOf(state.selection.ids);
if (currentKey !== ctxMenu.selectionKey) { if (currentKey !== ctxMenu.selectionKey) {
@ -637,7 +634,7 @@ export function EditorApp() {
<ContextMenu <ContextMenu
state={ctxMenu} state={ctxMenu}
nodes={state.doc.screen.nodes} nodes={state.doc.screen.nodes}
selectionIds={selectionIdsForMenu} selectionIds={state.selection.ids}
onClose={closeContextMenu} onClose={closeContextMenu}
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })} onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })} onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}

View File

@ -16,17 +16,16 @@ import { didRectsChange } from './history';
import { snapRect, snapRectResize } from './snap'; import { snapRect, snapRectResize } from './snap';
import type { EditorState, ResizeHandle } from './types'; import type { EditorState, ResizeHandle } from './types';
import { clampRectToBounds } from './types'; import { clampRectToBounds } from './types';
type EditorWidgetKind = 'text' | 'image' | 'iframe' | 'video';
type UpdateWidgetPropsAction = { type UpdateWidgetPropsAction = {
type: 'updateWidgetProps'; type: 'updateWidgetProps';
id: string; id: string;
} & { } & {
[K in EditorWidgetKind]: { [K in WidgetKind]: {
widgetType: K; widgetType: K;
props: Partial<WidgetPropsByType[K]>; props: Partial<WidgetPropsByType[K]>;
}; };
}[EditorWidgetKind]; }[WidgetKind];
export type EditorAction = export type EditorAction =
| { type: 'keyboard'; ctrl: boolean; space: boolean } | { type: 'keyboard'; ctrl: boolean; space: boolean }
@ -103,7 +102,7 @@ function isWidgetType<K extends WidgetKind>(
function updateWidgetProps(state: EditorRuntimeState, action: UpdateWidgetPropsAction): EditorRuntimeState { function updateWidgetProps(state: EditorRuntimeState, action: UpdateWidgetPropsAction): EditorRuntimeState {
// Helper to keep the per-widget prop merge strongly typed. // Helper to keep the per-widget prop merge strongly typed.
const update = <K extends EditorWidgetKind>(widgetType: K, props: Partial<WidgetPropsByType[K]>): EditorRuntimeState => { const update = <K extends WidgetKind>(widgetType: K, props: Partial<WidgetPropsByType[K]>): EditorRuntimeState => {
const target = state.doc.screen.nodes.find( const target = state.doc.screen.nodes.find(
(n): n is WidgetNodeByType[K] => n.id === action.id && n.type === widgetType, (n): n is WidgetNodeByType[K] => n.id === action.id && n.type === widgetType,
); );

View File

@ -19,10 +19,16 @@ export interface GoViewComponentLike {
// component identity // component identity
key?: string; // e.g. "TextCommon" (sometimes) key?: string; // e.g. "TextCommon" (sometimes)
componentKey?: string; componentKey?: string;
componentName?: string;
name?: string;
title?: string;
chartConfig?: { chartConfig?: {
key?: string; key?: string;
chartKey?: string; chartKey?: string;
type?: string; type?: string;
name?: string;
title?: string;
chartName?: string;
option?: unknown; option?: unknown;
data?: unknown; data?: unknown;
}; };
@ -102,8 +108,14 @@ function keyOf(cIn: GoViewComponentLike): string {
c.chartConfig?.key ?? c.chartConfig?.key ??
c.chartConfig?.chartKey ?? c.chartConfig?.chartKey ??
c.chartConfig?.type ?? c.chartConfig?.type ??
c.chartConfig?.name ??
c.chartConfig?.title ??
c.chartConfig?.chartName ??
c.componentKey ?? c.componentKey ??
c.key ?? c.key ??
c.componentName ??
c.name ??
c.title ??
'' ''
).toLowerCase(); ).toLowerCase();
} }
@ -337,6 +349,19 @@ function looksLikeIframeOption(option: unknown): boolean {
// but are still clearly text. // but are still clearly text.
if (looksLikeTextOption(option)) return false; if (looksLikeTextOption(option)) return false;
if (typeof option === 'object') {
const typeHint = pickStringLikeDeep(
option,
['type', 'widgetType', 'componentType', 'mediaType', 'contentType', 'embedType', 'frameType'],
2,
);
if (typeHint) {
const t = typeHint.toLowerCase();
if (/(video|mp4|m3u8|flv|hls|rtsp|rtmp|webrtc|stream|live)/i.test(t)) return false;
if (/(iframe|embed|webview|web|html|page|h5|browser)/i.test(t)) return true;
}
}
if (typeof option === 'string') { if (typeof option === 'string') {
const trimmed = option.trim(); const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) { if (trimmed.startsWith('<') && trimmed.includes('>')) {
@ -358,10 +383,20 @@ function looksLikeIframeOption(option: unknown): boolean {
[ [
'iframeUrl', 'iframeUrl',
'iframeSrc', 'iframeSrc',
'iframe',
'embedUrl', 'embedUrl',
'embedSrc',
'frameUrl', 'frameUrl',
'frameSrc', 'frameSrc',
'webUrl', 'webUrl',
'websiteUrl',
'siteUrl',
'openUrl',
'openURL',
'linkUrl',
'linkURL',
'targetUrl',
'targetURL',
'webpageUrl', 'webpageUrl',
'pageUrl', 'pageUrl',
'h5Url', 'h5Url',
@ -381,6 +416,7 @@ function looksLikeIframeOption(option: unknown): boolean {
'html', 'html',
'htmlContent', 'htmlContent',
'htmlString', 'htmlString',
'iframe',
'iframeHtml', 'iframeHtml',
'embedHtml', 'embedHtml',
'embedCode', 'embedCode',
@ -425,6 +461,19 @@ function looksLikeVideoOption(option: unknown): boolean {
// Avoid false positives: some TextCommon widgets carry link-like fields. // Avoid false positives: some TextCommon widgets carry link-like fields.
if (looksLikeTextOption(option)) return false; if (looksLikeTextOption(option)) return false;
if (typeof option === 'object') {
const typeHint = pickStringLikeDeep(
option,
['type', 'widgetType', 'componentType', 'mediaType', 'contentType', 'playerType', 'streamType'],
2,
);
if (typeHint) {
const t = typeHint.toLowerCase();
if (/(iframe|embed|webview|web|html|page|h5|browser)/i.test(t)) return false;
if (/(video|mp4|m3u8|flv|hls|rtsp|rtmp|webrtc|stream|live|camera|cctv)/i.test(t)) return true;
}
}
if (typeof option === 'string') { if (typeof option === 'string') {
const trimmed = option.trim(); const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) { if (trimmed.startsWith('<') && trimmed.includes('>')) {
@ -447,7 +496,12 @@ function looksLikeVideoOption(option: unknown): boolean {
'playUrl', 'playUrl',
'srcUrl', 'srcUrl',
'sourceUrl', 'sourceUrl',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'liveUrl', 'liveUrl',
'stream',
'streamUrl', 'streamUrl',
// very common forks / camera widgets // very common forks / camera widgets
'rtspUrl', 'rtspUrl',
@ -564,6 +618,25 @@ function toMaybeString(v: unknown): string | undefined {
return undefined; return undefined;
} }
function pickStringLikeDeep(input: unknown, keys: string[], depth = 2): string | undefined {
if (!input || typeof input !== 'object') return toMaybeString(input);
const obj = input as Record<string, unknown>;
for (const key of keys) {
const v = toMaybeString(obj[key]);
if (v) return v;
}
if (depth <= 0) return undefined;
for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
const nested = pickStringLikeDeep(obj[nestKey], keys, depth - 1);
if (nested) return nested;
}
return undefined;
}
function toNumber(v: unknown, fallback: number): number { function toNumber(v: unknown, fallback: number): number {
if (typeof v === 'number' && Number.isFinite(v)) return v; if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') { if (typeof v === 'string') {

View File

@ -27,7 +27,12 @@ export interface GoViewIframeOption {
// HTML/embed variants // HTML/embed variants
html?: unknown; html?: unknown;
htmlString?: unknown; htmlString?: unknown;
// some exports use a generic "embed" / "code" field
embed?: unknown;
embedCode?: unknown; embedCode?: unknown;
iframeEmbed?: unknown;
iframeCode?: unknown;
code?: unknown;
template?: unknown; template?: unknown;
content?: unknown; content?: unknown;
srcdoc?: unknown; srcdoc?: unknown;
@ -183,8 +188,10 @@ function pickHtmlString(option: GoViewIframeOption): string | undefined {
obj.html ?? obj.html ??
obj.htmlContent ?? obj.htmlContent ??
obj.htmlString ?? obj.htmlString ??
obj.embed ??
obj.embedHtml ?? obj.embedHtml ??
obj.iframeHtml ?? obj.iframeHtml ??
obj.iframeEmbed ??
obj.embedCode ?? obj.embedCode ??
obj.iframeCode ?? obj.iframeCode ??
obj.code ?? obj.code ??

View File

@ -48,7 +48,10 @@ export interface GoViewVideoOption {
// HTML/embed variants // HTML/embed variants
html?: unknown; html?: unknown;
htmlString?: unknown; htmlString?: unknown;
// some exports use a generic "embed" / "code" field
embed?: unknown;
embedCode?: unknown; embedCode?: unknown;
code?: unknown;
template?: unknown; template?: unknown;
content?: unknown; content?: unknown;
srcdoc?: unknown; srcdoc?: unknown;
@ -281,6 +284,7 @@ function pickSrc(option: GoViewVideoOption): string {
// sometimes low-code exports store <video> HTML under these fields // sometimes low-code exports store <video> HTML under these fields
'html', 'html',
'htmlString', 'htmlString',
'embed',
'code', 'code',
'embedCode', 'embedCode',
]) { ]) {
@ -435,6 +439,7 @@ export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption):
const v = const v =
obj.html ?? obj.html ??
obj.htmlString ?? obj.htmlString ??
obj.embed ??
obj.code ?? obj.code ??
obj.embedCode ?? obj.embedCode ??
obj.template ?? obj.template ??