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

View File

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

View File

@ -16,17 +16,16 @@ import { didRectsChange } from './history';
import { snapRect, snapRectResize } from './snap';
import type { EditorState, ResizeHandle } from './types';
import { clampRectToBounds } from './types';
type EditorWidgetKind = 'text' | 'image' | 'iframe' | 'video';
type UpdateWidgetPropsAction = {
type: 'updateWidgetProps';
id: string;
} & {
[K in EditorWidgetKind]: {
[K in WidgetKind]: {
widgetType: K;
props: Partial<WidgetPropsByType[K]>;
};
}[EditorWidgetKind];
}[WidgetKind];
export type EditorAction =
| { type: 'keyboard'; ctrl: boolean; space: boolean }
@ -103,7 +102,7 @@ function isWidgetType<K extends WidgetKind>(
function updateWidgetProps(state: EditorRuntimeState, action: UpdateWidgetPropsAction): EditorRuntimeState {
// 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(
(n): n is WidgetNodeByType[K] => n.id === action.id && n.type === widgetType,
);

View File

@ -19,10 +19,16 @@ export interface GoViewComponentLike {
// component identity
key?: string; // e.g. "TextCommon" (sometimes)
componentKey?: string;
componentName?: string;
name?: string;
title?: string;
chartConfig?: {
key?: string;
chartKey?: string;
type?: string;
name?: string;
title?: string;
chartName?: string;
option?: unknown;
data?: unknown;
};
@ -102,8 +108,14 @@ function keyOf(cIn: GoViewComponentLike): string {
c.chartConfig?.key ??
c.chartConfig?.chartKey ??
c.chartConfig?.type ??
c.chartConfig?.name ??
c.chartConfig?.title ??
c.chartConfig?.chartName ??
c.componentKey ??
c.key ??
c.componentName ??
c.name ??
c.title ??
''
).toLowerCase();
}
@ -337,6 +349,19 @@ function looksLikeIframeOption(option: unknown): boolean {
// but are still clearly text.
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') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) {
@ -358,10 +383,20 @@ function looksLikeIframeOption(option: unknown): boolean {
[
'iframeUrl',
'iframeSrc',
'iframe',
'embedUrl',
'embedSrc',
'frameUrl',
'frameSrc',
'webUrl',
'websiteUrl',
'siteUrl',
'openUrl',
'openURL',
'linkUrl',
'linkURL',
'targetUrl',
'targetURL',
'webpageUrl',
'pageUrl',
'h5Url',
@ -381,6 +416,7 @@ function looksLikeIframeOption(option: unknown): boolean {
'html',
'htmlContent',
'htmlString',
'iframe',
'iframeHtml',
'embedHtml',
'embedCode',
@ -425,6 +461,19 @@ function looksLikeVideoOption(option: unknown): boolean {
// Avoid false positives: some TextCommon widgets carry link-like fields.
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') {
const trimmed = option.trim();
if (trimmed.startsWith('<') && trimmed.includes('>')) {
@ -447,7 +496,12 @@ function looksLikeVideoOption(option: unknown): boolean {
'playUrl',
'srcUrl',
'sourceUrl',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'liveUrl',
'stream',
'streamUrl',
// very common forks / camera widgets
'rtspUrl',
@ -564,6 +618,25 @@ function toMaybeString(v: unknown): string | 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 {
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string') {

View File

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

View File

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