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 =
(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
: additive
? [...props.selectionIds, targetId]
: [targetId]
: props.selectionIds;
const nextSelectionIds = Array.from(new Set(nextSelectionIdsRaw));
const selectionKey = selectionKeyOf(nextSelectionIds);
if (targetId) {

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

@ -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 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

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

View File

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

View File

@ -13,6 +13,10 @@ export interface GoViewVideoOption {
// seen in some forks
srcUrl?: unknown;
sourceUrl?: unknown;
mediaUrl?: unknown;
mediaSrc?: unknown;
playbackUrl?: unknown;
playbackSrc?: unknown;
// streaming-ish aliases
webrtcUrl?: unknown;
@ -29,6 +33,11 @@ export interface GoViewVideoOption {
playList?: unknown;
playlist?: unknown;
mediaType?: unknown;
contentType?: unknown;
playerType?: unknown;
streamType?: unknown;
autoplay?: boolean;
autoPlay?: boolean;
isAutoPlay?: boolean;
@ -48,7 +57,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;
@ -219,6 +231,10 @@ function pickFirstUrlFromList(input: unknown): string {
'cameraUrl',
'cctvUrl',
'monitorUrl',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'src',
'url',
'value',
@ -270,17 +286,22 @@ function pickSrc(option: GoViewVideoOption): string {
// streaming/camera keys
'rtsp',
'rtmp',
'stream',
'streamUrl',
// generic fallbacks
'src',
'url',
'srcUrl',
'sourceUrl',
'value',
'stream',
'streamUrl',
// generic fallbacks
'src',
'url',
'mediaUrl',
'mediaSrc',
'playbackUrl',
'playbackSrc',
'srcUrl',
'sourceUrl',
'value',
// sometimes low-code exports store <video> HTML under these fields
'html',
'htmlString',
'embed',
'code',
'embedCode',
]) {
@ -435,6 +456,7 @@ export function convertGoViewVideoOptionToNodeProps(option: GoViewVideoOption):
const v =
obj.html ??
obj.htmlString ??
obj.embed ??
obj.code ??
obj.embedCode ??
obj.template ??