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,