diff --git a/packages/editor/src/editor/Canvas.tsx b/packages/editor/src/editor/Canvas.tsx
index 2cb55bc..baac35f 100644
--- a/packages/editor/src/editor/Canvas.tsx
+++ b/packages/editor/src/editor/Canvas.tsx
@@ -502,15 +502,18 @@ function NodeView(props: {
>
);
@@ -530,9 +533,11 @@ function NodeView(props: {
width={rect.w}
height={rect.h}
autoPlay={node.props.autoplay ?? false}
+ controls={node.props.controls ?? false}
playsInline
loop={node.props.loop ?? false}
muted={node.props.muted ?? false}
+ poster={node.props.poster}
style={{
display: 'block',
width: '100%',
diff --git a/packages/editor/src/editor/EditorApp.tsx b/packages/editor/src/editor/EditorApp.tsx
index 45ca9c9..d19fd37 100644
--- a/packages/editor/src/editor/EditorApp.tsx
+++ b/packages/editor/src/editor/EditorApp.tsx
@@ -93,6 +93,19 @@ export function EditorApp() {
const ctxMenuSyncedRef = useRef(false);
+ const dispatchWithMenuSelection = useCallback(
+ (action: Parameters[0]) => {
+ if (ctxMenu) {
+ const currentKey = selectionKeyOf(state.selection.ids);
+ if (currentKey !== ctxMenu.selectionKey) {
+ dispatch({ type: 'setSelection', ids: ctxMenu.selectionIds });
+ }
+ }
+ dispatch(action);
+ },
+ [ctxMenu, dispatch, selectionKeyOf, state.selection.ids],
+ );
+
useEffect(() => {
// Reset the sync gate whenever a new context menu opens/closes.
ctxMenuSyncedRef.current = false;
@@ -509,7 +522,7 @@ export function EditorApp() {
: additive
? [...state.selection.ids, node.id]
: [node.id];
- const selectionKey = nextSelectionIds.join('|');
+ const selectionKey = selectionKeyOf(nextSelectionIds);
if (!state.selection.ids.includes(node.id)) {
if (additive) {
dispatch({ type: 'toggleSelect', id: node.id });
@@ -566,12 +579,12 @@ export function EditorApp() {
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
onSelectAll={() => dispatch({ type: 'selectAll' })}
- onDuplicateSelected={() => dispatch({ type: 'duplicateSelected' })}
- onToggleLockSelected={() => dispatch({ type: 'toggleLockSelected' })}
- onToggleHideSelected={() => dispatch({ type: 'toggleHideSelected' })}
- onBringToFrontSelected={() => dispatch({ type: 'bringToFrontSelected' })}
- onSendToBackSelected={() => dispatch({ type: 'sendToBackSelected' })}
- onDeleteSelected={() => dispatch({ type: 'deleteSelected' })}
+ onDuplicateSelected={() => dispatchWithMenuSelection({ type: 'duplicateSelected' })}
+ onToggleLockSelected={() => dispatchWithMenuSelection({ type: 'toggleLockSelected' })}
+ onToggleHideSelected={() => dispatchWithMenuSelection({ type: 'toggleHideSelected' })}
+ onBringToFrontSelected={() => dispatchWithMenuSelection({ type: 'bringToFrontSelected' })}
+ onSendToBackSelected={() => dispatchWithMenuSelection({ type: 'sendToBackSelected' })}
+ onDeleteSelected={() => dispatchWithMenuSelection({ type: 'deleteSelected' })}
/>
);
diff --git a/packages/editor/src/editor/store.ts b/packages/editor/src/editor/store.ts
index 5e0fb23..dd28ef7 100644
--- a/packages/editor/src/editor/store.ts
+++ b/packages/editor/src/editor/store.ts
@@ -28,6 +28,7 @@ export type EditorAction =
| { type: 'updatePan'; current: { screenX: number; screenY: number } }
| { type: 'endPan' }
| { type: 'selectSingle'; id?: string }
+ | { type: 'setSelection'; ids: string[] }
| { type: 'selectAll' }
| { type: 'toggleSelect'; id: string }
| {
@@ -494,6 +495,11 @@ export function editorReducer(state: EditorRuntimeState, action: EditorAction):
case 'selectSingle':
return { ...state, selection: { ids: action.id ? [action.id] : [] } };
+ case 'setSelection': {
+ const ids = Array.from(new Set(action.ids));
+ return { ...state, selection: { ids } };
+ }
+
case 'selectAll':
return { ...state, selection: { ids: state.doc.screen.nodes.map((n) => n.id) } };
diff --git a/packages/sdk/src/core/goview/convert.ts b/packages/sdk/src/core/goview/convert.ts
index 809c17b..5eec2dc 100644
--- a/packages/sdk/src/core/goview/convert.ts
+++ b/packages/sdk/src/core/goview/convert.ts
@@ -284,6 +284,44 @@ function hasAnyKeyDeep(input: unknown, keys: string[], depth = 2): boolean {
return false;
}
+function containsVideoHtmlDeep(input: unknown, depth = 2): boolean {
+ if (!input || typeof input !== 'object') return false;
+ const obj = input as Record;
+
+ for (const key of [
+ 'srcdoc',
+ 'srcDoc',
+ 'html',
+ 'htmlContent',
+ 'htmlString',
+ 'iframeHtml',
+ 'embedHtml',
+ 'embedCode',
+ 'iframeCode',
+ 'iframeEmbed',
+ 'embed',
+ 'code',
+ 'content',
+ 'template',
+ ]) {
+ const v = obj[key];
+ if (typeof v === 'string') {
+ const s = v.trim();
+ if (s.startsWith('<') && s.includes('>') && (/\bvideo\b/i.test(s) || /\bsource\b/i.test(s))) {
+ return true;
+ }
+ }
+ }
+
+ if (depth <= 0) return false;
+
+ for (const nestKey of ['style', 'config', 'option', 'options', 'props', 'dataset', 'data']) {
+ if (containsVideoHtmlDeep(obj[nestKey], depth - 1)) return true;
+ }
+
+ return false;
+}
+
function looksLikeIframeOption(option: unknown): boolean {
if (!option) return false;
@@ -302,6 +340,10 @@ function looksLikeIframeOption(option: unknown): boolean {
// Prefer explicit iframe-ish keys when option is an object (including nested shapes).
if (typeof option === 'object') {
+ // If the embed/html content is clearly a