Compare commits

..

2 Commits

Author SHA1 Message Date
clawdbot
213bfbd6d5 feat: improve goView media import and editor menu parity 2026-01-29 01:06:35 +08:00
clawdbot
c2d02ef817 feat: improve goView embed import + editor selection/menu types 2026-01-29 01:00:57 +08:00
9 changed files with 186 additions and 24 deletions

View File

@ -142,13 +142,14 @@ export function Canvas(props: CanvasProps) {
const additive = const additive =
(e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey || (e as React.MouseEvent).shiftKey; (e as React.MouseEvent).ctrlKey || (e as React.MouseEvent).metaKey || (e as React.MouseEvent).shiftKey;
const nextSelectionIds = targetId const nextSelectionIdsRaw = targetId
? props.selectionIds.includes(targetId) ? props.selectionIds.includes(targetId)
? props.selectionIds ? props.selectionIds
: additive : additive
? [...props.selectionIds, targetId] ? [...props.selectionIds, targetId]
: [targetId] : [targetId]
: props.selectionIds; : props.selectionIds;
const nextSelectionIds = Array.from(new Set(nextSelectionIdsRaw));
const selectionKey = selectionKeyOf(nextSelectionIds); const selectionKey = selectionKeyOf(nextSelectionIds);
if (targetId) { if (targetId) {

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) {
@ -585,11 +582,12 @@ export function EditorApp() {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const additive = e.ctrlKey || e.metaKey || e.shiftKey; const additive = e.ctrlKey || e.metaKey || e.shiftKey;
const nextSelectionIds = state.selection.ids.includes(node.id) const nextSelectionIdsRaw = state.selection.ids.includes(node.id)
? state.selection.ids ? state.selection.ids
: additive : additive
? [...state.selection.ids, node.id] ? [...state.selection.ids, node.id]
: [node.id]; : [node.id];
const nextSelectionIds = Array.from(new Set(nextSelectionIdsRaw));
const selectionKey = selectionKeyOf(nextSelectionIds); const selectionKey = selectionKeyOf(nextSelectionIds);
if (!state.selection.ids.includes(node.id)) { if (!state.selection.ids.includes(node.id)) {
if (additive) { if (additive) {
@ -637,7 +635,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

@ -100,4 +100,32 @@ export const goViewSamples: GoViewSample[] = [
], ],
}), }),
}, },
{
id: 'goview-video-type-hint',
title: 'Video (type hint)',
subtitle: 'Media / mediaType + playbackUrl',
description: 'Type hint fields + playbackUrl for video detection.',
focusId: 'video_2',
json: prettyJson({
editCanvasConfig: { projectName: 'Sample: video type hint', width: 1920, height: 1080, background: '#0b1220' },
componentList: [
{
id: 'video_2',
chartConfig: { key: 'EmbedCommon' },
attr: { x: 260, y: 140, w: 960, h: 540 },
option: {
mediaType: 'video',
playbackUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
controls: true,
},
},
{
id: 'text_2',
chartConfig: { key: 'TextCommon' },
attr: { x: 260, y: 60, w: 760, h: 60 },
option: { text: 'goView Sample: video via type hint + playbackUrl' },
},
],
}),
},
]; ];

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

@ -13,10 +13,20 @@ export interface GoViewIframeOption {
// common alternative shapes // common alternative shapes
iframeUrl?: unknown; iframeUrl?: unknown;
iframeSrc?: unknown; iframeSrc?: unknown;
iframe?: unknown;
embedUrl?: unknown; embedUrl?: unknown;
embedSrc?: unknown;
frameUrl?: unknown; frameUrl?: unknown;
frameSrc?: unknown; frameSrc?: unknown;
webUrl?: unknown; webUrl?: unknown;
websiteUrl?: unknown;
siteUrl?: unknown;
openUrl?: unknown;
openURL?: unknown;
linkUrl?: unknown;
linkURL?: unknown;
targetUrl?: unknown;
targetURL?: unknown;
webpageUrl?: unknown; webpageUrl?: unknown;
pageUrl?: unknown; pageUrl?: unknown;
h5Url?: unknown; h5Url?: unknown;
@ -27,7 +37,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 +198,11 @@ function pickHtmlString(option: GoViewIframeOption): string | undefined {
obj.html ?? obj.html ??
obj.htmlContent ?? obj.htmlContent ??
obj.htmlString ?? obj.htmlString ??
obj.iframe ??
obj.embed ??
obj.embedHtml ?? obj.embedHtml ??
obj.iframeHtml ?? obj.iframeHtml ??
obj.iframeEmbed ??
obj.embedCode ?? obj.embedCode ??
obj.iframeCode ?? obj.iframeCode ??
obj.code ?? obj.code ??
@ -202,10 +220,20 @@ function pickSrc(option: GoViewIframeOption): string {
pickUrlLike({ pickUrlLike({
iframeUrl: option.iframeUrl, iframeUrl: option.iframeUrl,
iframeSrc: option.iframeSrc, iframeSrc: option.iframeSrc,
iframe: option.iframe,
embedUrl: option.embedUrl, embedUrl: option.embedUrl,
embedSrc: option.embedSrc,
frameUrl: option.frameUrl, frameUrl: option.frameUrl,
frameSrc: option.frameSrc, frameSrc: option.frameSrc,
webUrl: option.webUrl, webUrl: option.webUrl,
websiteUrl: option.websiteUrl,
siteUrl: option.siteUrl,
openUrl: option.openUrl,
openURL: option.openURL,
linkUrl: option.linkUrl,
linkURL: option.linkURL,
targetUrl: option.targetUrl,
targetURL: option.targetURL,
webpageUrl: option.webpageUrl, webpageUrl: option.webpageUrl,
pageUrl: option.pageUrl, pageUrl: option.pageUrl,
h5Url: option.h5Url, h5Url: option.h5Url,

View File

@ -96,6 +96,18 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
'path', 'path',
'source', 'source',
'address', 'address',
'embedSrc',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'websiteUrl',
'siteUrl',
'openUrl',
'openURL',
'linkURL',
'targetUrl',
'targetURL',
// snake_case aliases seen in some exporters // snake_case aliases seen in some exporters
'src_url', 'src_url',
@ -110,6 +122,7 @@ function pickUrlLikeInner(input: unknown, depth: number): string {
'pageUrl', 'pageUrl',
'pageSrc', 'pageSrc',
'page', 'page',
'webpageUrl',
'documentUrl', 'documentUrl',
// iframe-ish HTML-in-URL fields (handled by iframe widget converter) // iframe-ish HTML-in-URL fields (handled by iframe widget converter)

View File

@ -13,6 +13,10 @@ export interface GoViewVideoOption {
// seen in some forks // seen in some forks
srcUrl?: unknown; srcUrl?: unknown;
sourceUrl?: unknown; sourceUrl?: unknown;
mediaUrl?: unknown;
mediaSrc?: unknown;
playbackUrl?: unknown;
playbackSrc?: unknown;
// streaming-ish aliases // streaming-ish aliases
webrtcUrl?: unknown; webrtcUrl?: unknown;
@ -29,6 +33,11 @@ export interface GoViewVideoOption {
playList?: unknown; playList?: unknown;
playlist?: unknown; playlist?: unknown;
mediaType?: unknown;
contentType?: unknown;
playerType?: unknown;
streamType?: unknown;
autoplay?: boolean; autoplay?: boolean;
autoPlay?: boolean; autoPlay?: boolean;
isAutoPlay?: boolean; isAutoPlay?: boolean;
@ -48,7 +57,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;
@ -219,6 +231,10 @@ function pickFirstUrlFromList(input: unknown): string {
'cameraUrl', 'cameraUrl',
'cctvUrl', 'cctvUrl',
'monitorUrl', 'monitorUrl',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'src', 'src',
'url', 'url',
'value', 'value',
@ -275,12 +291,17 @@ function pickSrc(option: GoViewVideoOption): string {
// generic fallbacks // generic fallbacks
'src', 'src',
'url', 'url',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'srcUrl', 'srcUrl',
'sourceUrl', 'sourceUrl',
'value', 'value',
// 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 +456,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 ??