From f8dc24ea4a122410a9a96d63b1938a4272426fd7 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 16 Sep 2026 20:24:35 -0700 Subject: [PATCH] feat(editor): Open in New Tab is a menu item, double-click frames the camera A double click in the Objects tree frames the camera on the row's object again, on every row. Nothing opens a tab by double click. Opening a document is now the explicit item "Open in New Tab", first on the right click menu, in the Objects tree and in the Files panel. The Files panel keeps its double click open, because a file list has no camera to frame. The prop that resolved a row to an asset answers a path or null now, and the tree builds the item from that. Its old rule rejected any object that carries _tpRootPath. Every object in an open document carries it, the scene's own children included, so that rule answered a path for no row and the double click open it guarded never ran. The dead newTab branch in the Files panel's openFile, a second browser window that was never built, goes with its parameter. --- .../src/components/BPHierarchyComponent.tsx | 22 ++++++--- packages/editor/src/components/FilesPanel.tsx | 45 ++++++++++--------- .../components/ObjectHierarchyComponent.tsx | 23 +++++----- 3 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/editor/src/components/BPHierarchyComponent.tsx b/packages/editor/src/components/BPHierarchyComponent.tsx index b3583ca4..0ac9de11 100644 --- a/packages/editor/src/components/BPHierarchyComponent.tsx +++ b/packages/editor/src/components/BPHierarchyComponent.tsx @@ -26,10 +26,10 @@ import {EditModePlugin} from "../utils/EditModePlugin.ts"; interface BPHierarchyComponentPropsExtras extends HandleContextMenuCallback{ /** - * A double click on an asset row opens that asset's own document. The wrapper resolves the path - * and answers true when it opened one, because then focusing the camera would be wasted work. + * The document path a row's object opens as, or null for a row with no file of its own. A row + * that answers a path gets "Open in New Tab" on its menu. */ - onOpenAsset?: (object: IObject3D) => boolean + documentPath?: (object: IObject3D) => string | null } const referenceKindIcons: Record, IconName> = { @@ -236,15 +236,12 @@ export class BPHierarchyComponent extends BPTre if(!node) return const obj = node.nodeData! const isScene = obj === this.context.viewer.scene as any - // An asset row opens its own document, the way the Inspector's Edit Asset button does. - // Focusing the camera on a row whose document replaces the viewport would be wasted. - const opened = !isScene && (this.props.onOpenAsset?.(obj) ?? false) obj.dispatchEvent({ type: 'select', value: obj, object: obj, ui: true, - focusCamera: !isScene && !opened, + focusCamera: !isScene, bubbleToParent: true, }) if(isScene){ @@ -265,6 +262,17 @@ export class BPHierarchyComponent extends BPTre if(!node) return const obj = node.nodeData! + // A row that came from a file opens that file as a tab, the way the Files panel does. + const path = this.props.documentPath?.(obj) + if (path) { + items.push({ + props: {text: 'Open in New Tab'}, + key: 'openInNewTab', + action: 'openDocument', + data: {path}, + }) + } + const editMode = this.context.viewer.getPlugin(EditModePlugin) const selected = this.context.viewer.getPlugin(PickingPlugin)?.getSelectedObjects() ?? [] // Isolating a node that is not in the selection isolates that node alone. diff --git a/packages/editor/src/components/FilesPanel.tsx b/packages/editor/src/components/FilesPanel.tsx index 4af2af24..1a40296c 100644 --- a/packages/editor/src/components/FilesPanel.tsx +++ b/packages/editor/src/components/FilesPanel.tsx @@ -132,6 +132,16 @@ const menuItemsFiles: MenuItem2[] = [{ props: {text: 'Reveal in System File Explorer', icon: 'folder-shared'}, }*/] +/** + * The item a file the viewport has a view for gets, above the rest. It stands outside the list + * above because no extension tag names its files: the document kinds are a list of their own. + */ +const menuItemOpenInNewTab: MenuItem2 = { + action: 'openInNewTab', + key: 'openInNewTab', + props: {text: 'Open in New Tab', icon: 'open-application'}, +} + export function FilesPanelGrid({}: { }){ const {selectedFiles, setSelectedFiles, currentPath, setCurrentPath, fileManifest, refreshManifest }= useAssets() @@ -149,6 +159,16 @@ export function FilesPanelGrid({}: { if (res && 'error' in res) showSuccessErrorToast('', 'Unable to open file', res as ErrorRes) } + /** What a double click and the Open in New Tab item both do: walk into a folder, or open a file. */ + const openFile = (f: FileManifestEntry) => { + if (f.type === 'directory') { + setCurrentPath(f.path) + setSelectedFiles([]) + } else if (documentKind(f.path)) { + updateLoading(f.path, loadFile(f.path)) + } + } + let items = fileManifest const path = currentPath.replace(/^\//, '') let dirHandle = !path ? project?.handle : undefined @@ -244,6 +264,7 @@ export function FilesPanelGrid({}: { }) } const actions: Record = { + openInNewTab: (data: { file: FileManifestEntry })=> openFile(data.file), createEmptyScene: async ()=>{ if(!dirHandle || !project?.handle) return await whileExistsPrompt({ @@ -487,21 +508,6 @@ export class MyComponent extends Object3DComponent { useEffect(() => { selectedFilesRef.current = selectedFiles }, [selectedFiles]) - const openFile = (f: FileManifestEntry, newTab: boolean) => { - if (f.type === 'directory') { - setCurrentPath(f.path) - setSelectedFiles([]) - } else { - if (documentKind(f.path)) { - if (newTab) { - // todo - // window.open(window.location.pathname + '?project=' + encodeURIComponent(f.path) + (project?.path ? '&base=' + encodeURIComponent(project.path) : ''), '_blank') - return - } - updateLoading(f.path, loadFile(f.path)) - } - } - } const selectFiles = async (files: FileManifestEntry[], e: React.MouseEvent|React.KeyboardEvent)=>{ selectedFilesRef.current = files setSelectedFiles(files) @@ -562,9 +568,7 @@ export class MyComponent extends Object3DComponent { if(e.key === 'Enter' && selectedFiles.length === 1) { e.preventDefault() e.stopPropagation() - const f = selectedFiles[0] - const newTab = e.metaKey || e.ctrlKey - openFile(f, newTab); + openFile(selectedFiles[0]); } if(e.key === 'ArrowRight' || e.key === 'ArrowLeft'){ e.preventDefault() @@ -610,13 +614,14 @@ export class MyComponent extends Object3DComponent { return false })) + if(f.type !== 'directory' && documentKind(f.path)) menuItemsFiles2 = [menuItemOpenInNewTab, ...menuItemsFiles2] + handleContextMenu(e, menuItemsFiles2.map(i=>({...i, data: {file: f}})), f) }} onDoubleClick={(e)=>{ e.preventDefault() e.stopPropagation() - const newTab = e.metaKey || e.ctrlKey - openFile(f, newTab); + openFile(f); }} onClick={(e)=>{ // if(f.type === 'directory') setCurrentPath(f.path) diff --git a/packages/editor/src/components/ObjectHierarchyComponent.tsx b/packages/editor/src/components/ObjectHierarchyComponent.tsx index 85adbeef..1b436cd8 100644 --- a/packages/editor/src/components/ObjectHierarchyComponent.tsx +++ b/packages/editor/src/components/ObjectHierarchyComponent.tsx @@ -34,9 +34,13 @@ export function ExtraMenuItems(props: { export function ObjectHierarchyComponent({className}: { className: string }) { const manager = useManager() const viewer = manager.get() + const {store} = useDocuments() const {makeAsset} = useMakeAsset() const actions = {makeAsset, + openDocument: (data: { path: string }) => { + store.open(data.path).catch(e => showErrorToast(`Unable to open ${data.path}`, e)) + }, moveInParent: (data: { obj: IObject3D, delta: number }) => { const parent = data.obj.parent if (!parent) return @@ -58,20 +62,17 @@ export function ObjectHierarchyComponent({className}: { className: string }) { // }) const contextMenu = useContextMenu() - const {store} = useDocuments() /** - * A placed asset names the file it came from in userData.rootPath. A double click on that row - * opens the file as a document, the same call the Inspector's Edit Asset button makes. A clone - * of an asset child carries _tpRootPath instead, and that one stays a plain row. + * A placed asset names the file it came from in userData.rootPath, and that file is what its row + * opens as a tab. An object inside an asset has no rootPath of its own, so it answers nothing. */ - const onOpenAsset = (obj: IObject3D) => { - const rootPath = (obj as {_tpRootPath?: string})._tpRootPath ? null : obj.userData?.rootPath - if (!rootPath || !rootPath.startsWith(assetUrlPrefix)) return false + const documentPath = (obj: IObject3D) => { + const rootPath = obj.userData?.rootPath + if (!rootPath || !rootPath.startsWith(assetUrlPrefix)) return null const path = manager.resolveAssetIdPath(rootPath) - if (!path || path.startsWith(assetUrlPrefix) || !documentKind(path)) return false - store.open(path).catch(e => showErrorToast(`Unable to open ${path}`, e)) - return true + if (!path || path.startsWith(assetUrlPrefix) || !documentKind(path)) return null + return path } const config: UiObjectConfig = useMemo(() => ({ @@ -90,7 +91,7 @@ export function ObjectHierarchyComponent({className}: { className: string }) { { contextMenu.handleContextMenu({ event: e,