UX polish round 4: action button component, keyboard shortcuts dialog, and build fixes
- Add reusable ActionButton component with tooltip and shortcut display - Add keyboard shortcuts dialog in settings panel - Fix ButtonProps import error (derive type from Button component) - Fix AnyNodeId type mismatch in tree-node-actions visibility toggle - Various UI refinements across action menu, sidebar, and editor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5ecefbdc06
commit
d0e0fe7e1e
@@ -1,9 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
import { Viewer, useViewer } from '@pascal-app/viewer'
|
import { useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
||||||
|
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||||
import { initSFXBus } from '@/lib/sfx-bus'
|
import { initSFXBus } from '@/lib/sfx-bus'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
@@ -16,6 +17,7 @@ import { ActionMenu } from '../ui/action-menu'
|
|||||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
|
import { SceneLoader } from '../ui/scene-loader'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
@@ -23,8 +25,6 @@ import { FloatingActionMenu } from './floating-action-menu'
|
|||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
|
||||||
import { SceneLoader } from '../ui/scene-loader'
|
|
||||||
|
|
||||||
// Load default scene initially (will be replaced when project loads)
|
// Load default scene initially (will be replaced when project loads)
|
||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
@@ -35,7 +35,7 @@ initSpaceDetectionSync(useScene, useEditor)
|
|||||||
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
||||||
const sceneRootIds = useScene.getState().rootNodeIds
|
const sceneRootIds = useScene.getState().rootNodeIds
|
||||||
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
||||||
const resolve = (child: any) => typeof child === 'string' ? sceneNodes[child] : child
|
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
|
||||||
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
|
||||||
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ImageIcon, MessageSquare, X } from 'lucide-react'
|
|
||||||
import { useCallback, useRef, useState } from 'react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { useScene } from '@pascal-app/core'
|
import { useScene } from '@pascal-app/core'
|
||||||
import {
|
import { ImageIcon, MessageSquare, X } from 'lucide-react'
|
||||||
createImageUploadUrls,
|
import { useParams } from 'next/navigation'
|
||||||
submitFeedback,
|
import { useCallback, useRef, useState } from 'react'
|
||||||
} from '@/features/community/lib/feedback/actions'
|
import { Button } from '@/components/ui/primitives/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -15,7 +12,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/primitives/dialog'
|
} from '@/components/ui/primitives/dialog'
|
||||||
import { Button } from '@/components/ui/primitives/button'
|
import { createImageUploadUrls, submitFeedback } from '@/features/community/lib/feedback/actions'
|
||||||
|
|
||||||
const MAX_IMAGES = 5
|
const MAX_IMAGES = 5
|
||||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||||
@@ -49,11 +46,12 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (isSubmitting) return
|
if (isSubmitting) return
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
images.forEach((img) => URL.revokeObjectURL(img.url))
|
images.forEach((img) => {
|
||||||
|
URL.revokeObjectURL(img.url)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback((files: FileList | File[]) => {
|
||||||
(files: FileList | File[]) => {
|
|
||||||
const incoming = Array.from(files).filter(
|
const incoming = Array.from(files).filter(
|
||||||
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
|
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
|
||||||
)
|
)
|
||||||
@@ -65,9 +63,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
|||||||
}))
|
}))
|
||||||
return [...prev, ...added]
|
return [...prev, ...added]
|
||||||
})
|
})
|
||||||
},
|
}, [])
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
const removeImage = (index: number) => {
|
||||||
setImages((prev) => {
|
setImages((prev) => {
|
||||||
@@ -190,7 +186,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/50 transition-colors"
|
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md hover:bg-accent/90 transition-colors"
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
Feedback
|
Feedback
|
||||||
@@ -249,11 +245,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
|||||||
key={img.url}
|
key={img.url}
|
||||||
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
|
||||||
>
|
>
|
||||||
<img
|
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||||
src={img.url}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeImage(i)}
|
onClick={() => removeImage(i)}
|
||||||
@@ -277,9 +269,7 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
|||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<ImageIcon className="h-3.5 w-3.5" />
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
{images.length > 0
|
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
|
||||||
? `${images.length}/${MAX_IMAGES}`
|
|
||||||
: 'Attach'}
|
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -294,7 +284,12 @@ export function FeedbackDialog({ projectId: projectIdProp }: { projectId?: strin
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSubmitting || !message.trim()}>
|
<Button type="submit" disabled={isSubmitting || !message.trim()}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Howl } from 'howler'
|
import { Howl } from 'howler'
|
||||||
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import useAudio from '@/store/use-audio'
|
import useAudio from '@/store/use-audio'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
|
|
||||||
const PLAYLIST = [
|
const PLAYLIST = [
|
||||||
{
|
{
|
||||||
@@ -170,8 +170,8 @@ export function PascalRadio() {
|
|||||||
}}
|
}}
|
||||||
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden",
|
'flex flex-col rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md overflow-hidden',
|
||||||
!isOpen && "cursor-pointer hover:bg-accent/30 transition-colors"
|
!isOpen && 'cursor-pointer hover:bg-accent/90 transition-colors',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
|
<div className="flex items-center justify-between gap-2 px-3 py-2 text-sm font-medium">
|
||||||
@@ -197,7 +197,11 @@ export function PascalRadio() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isRadioPlaying ? <Volume2 className="h-3.5 w-3.5" /> : <VolumeX className="h-3.5 w-3.5" />}
|
{isRadioPlaying ? (
|
||||||
|
<Volume2 className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<VolumeX className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -205,8 +209,8 @@ export function PascalRadio() {
|
|||||||
setIsOpen(!isOpen)
|
setIsOpen(!isOpen)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
'rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground',
|
||||||
isOpen && "bg-accent text-accent-foreground"
|
isOpen && 'bg-accent text-accent-foreground',
|
||||||
)}
|
)}
|
||||||
aria-label="Radio Settings"
|
aria-label="Radio Settings"
|
||||||
>
|
>
|
||||||
@@ -236,7 +240,10 @@ export function PascalRadio() {
|
|||||||
>
|
>
|
||||||
<SkipBack className="h-4 w-4" />
|
<SkipBack className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<p className="text-sm font-medium text-center flex-1 truncate" title={currentTrack.title}>
|
<p
|
||||||
|
className="text-sm font-medium text-center flex-1 truncate"
|
||||||
|
title={currentTrack.title}
|
||||||
|
>
|
||||||
{currentTrack.title}
|
{currentTrack.title}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -260,7 +267,9 @@ export function PascalRadio() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
aria-label="Radio Volume"
|
aria-label="Radio Volume"
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">{radioVolume}%</span>
|
<span className="w-8 text-right text-xs text-muted-foreground shrink-0">
|
||||||
|
{radioVolume}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/primitives/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/primitives/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||||
|
label: string;
|
||||||
|
shortcut?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
tooltipContent?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||||
|
(
|
||||||
|
{ className, children, label, shortcut, isActive, tooltipContent, ...props },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-11 w-11 transition-all",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center transition-transform",
|
||||||
|
shortcut && "-translate-x-0.5 -translate-y-0.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{shortcut && (
|
||||||
|
<div className="absolute bottom-1 right-1 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
|
||||||
|
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||||
|
{shortcut}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{tooltipContent || (
|
||||||
|
<p>
|
||||||
|
{label} {shortcut && `(${shortcut})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ActionButton.displayName = "ActionButton";
|
||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import { emitter } from '@pascal-app/core'
|
import { emitter } from '@pascal-app/core'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Button } from '@/components/ui/primitives/button'
|
import { ActionButton } from "./action-button";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/primitives/tooltip'
|
|
||||||
|
|
||||||
export function CameraActions() {
|
export function CameraActions() {
|
||||||
const goToTopView = () => {
|
const goToTopView = () => {
|
||||||
@@ -25,73 +20,55 @@ export function CameraActions() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Orbit CCW */}
|
{/* Orbit CCW */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label="Orbit Left"
|
||||||
<Button
|
className="group hover:bg-white/5"
|
||||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
|
||||||
onClick={orbitCCW}
|
onClick={orbitCCW}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt="Orbit Left"
|
alt="Orbit Left"
|
||||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100 -scale-x-100"
|
||||||
height={30}
|
height={28}
|
||||||
src="/icons/rotate.png"
|
src="/icons/rotate.png"
|
||||||
width={30}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Orbit Left</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Orbit CW */}
|
{/* Orbit CW */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label="Orbit Right"
|
||||||
<Button
|
className="group hover:bg-white/5"
|
||||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
|
||||||
onClick={orbitCW}
|
onClick={orbitCW}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt="Orbit Right"
|
alt="Orbit Right"
|
||||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||||
height={30}
|
height={28}
|
||||||
src="/icons/rotate.png"
|
src="/icons/rotate.png"
|
||||||
width={30}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Orbit Right</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Top View */}
|
{/* Top View */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label="Top View"
|
||||||
<Button
|
className="group hover:bg-white/5"
|
||||||
className="group h-9 w-9 text-muted-foreground transition-all hover:bg-white/5"
|
|
||||||
onClick={goToTopView}
|
onClick={goToTopView}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt="Top View"
|
alt="Top View"
|
||||||
className="h-[30px] w-[30px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||||
height={30}
|
height={28}
|
||||||
src="/icons/topview.png"
|
src="/icons/topview.png"
|
||||||
width={30}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Top View</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Button } from "@/components/ui/primitives/button";
|
import { ActionButton } from "./action-button";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/primitives/tooltip";
|
|
||||||
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
import { Pencil, Trash2, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -103,11 +98,11 @@ export function ControlModes() {
|
|||||||
const isImageMode = Boolean(m.imageSrc);
|
const isImageMode = Boolean(m.imageSrc);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={m.id}>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
key={m.id}
|
||||||
<Button
|
label={m.label}
|
||||||
|
shortcut={m.shortcut}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9 transition-all",
|
|
||||||
"text-muted-foreground",
|
"text-muted-foreground",
|
||||||
!isImageMode && !isActive && m.color,
|
!isImageMode && !isActive && m.color,
|
||||||
!isImageMode && isActive && m.activeColor,
|
!isImageMode && isActive && m.activeColor,
|
||||||
@@ -122,25 +117,18 @@ export function ControlModes() {
|
|||||||
<Image
|
<Image
|
||||||
alt={m.label}
|
alt={m.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-[26px] w-[26px] object-contain transition-[opacity,filter] duration-200",
|
"h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200",
|
||||||
!isActive && "opacity-60 grayscale",
|
!isActive && "opacity-60 grayscale",
|
||||||
isActive && "opacity-100 grayscale-0"
|
isActive && "opacity-100 grayscale-0"
|
||||||
)}
|
)}
|
||||||
height={26}
|
height={28}
|
||||||
src={m.imageSrc}
|
src={m.imageSrc}
|
||||||
width={26}
|
width={28}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
Icon && <Icon className="h-5 w-5" />
|
Icon && <Icon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
{m.label} ({m.shortcut})
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
import { Button } from "@/components/ui/primitives/button";
|
import { ActionButton } from "./action-button";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/primitives/tooltip";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useEditor, { CatalogCategory } from "@/store/use-editor";
|
import useEditor, { CatalogCategory } from "@/store/use-editor";
|
||||||
@@ -76,11 +71,11 @@ export function FurnishTools() {
|
|||||||
catalogCategory === tool.catalogCategory;
|
catalogCategory === tool.catalogCategory;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||||
<Button
|
label={tool.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-11 rounded-lg transition-all duration-300",
|
"rounded-lg duration-300",
|
||||||
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
isActive ? "bg-black/40 hover:bg-black/40 scale-110 z-10" : "bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -102,14 +97,7 @@ export function FurnishTools() {
|
|||||||
src={tool.iconSrc}
|
src={tool.iconSrc}
|
||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
{tool.label}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import NextImage from 'next/image'
|
import NextImage from 'next/image'
|
||||||
import { Button } from '@/components/ui/primitives/button'
|
import { ActionButton } from "./action-button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/primitives/tooltip'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import useEditor, { CatalogCategory, StructureTool, Tool } from '@/store/use-editor'
|
import useEditor, { CatalogCategory, StructureTool, Tool } from '@/store/use-editor'
|
||||||
@@ -53,11 +52,11 @@ export function StructureTools() {
|
|||||||
const isContextual = contextualTools.includes(tool.id)
|
const isContextual = contextualTools.includes(tool.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={`${tool.id}-${tool.catalogCategory ?? index}`}>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
key={`${tool.id}-${tool.catalogCategory ?? index}`}
|
||||||
<Button
|
label={tool.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-11 rounded-lg transition-all duration-300',
|
'rounded-lg duration-300',
|
||||||
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
isActive ? 'bg-black/40 hover:bg-black/40 scale-110 z-10' : 'bg-transparent opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-black/20 scale-95',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -81,14 +80,7 @@ export function StructureTools() {
|
|||||||
src={tool.iconSrc}
|
src={tool.iconSrc}
|
||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
{tool.label}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,7 @@
|
|||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
|
import { Box, Camera, Diamond, Image, Layers, Layers2 } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/primitives/button'
|
import { ActionButton } from "./action-button";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/primitives/tooltip'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||||
@@ -81,11 +76,9 @@ export function ViewToggles() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Camera Mode */}
|
{/* Camera Mode */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label={`Camera: ${cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}`}
|
||||||
<Button
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 w-9 text-muted-foreground transition-all',
|
|
||||||
cameraMode === 'orthographic'
|
cameraMode === 'orthographic'
|
||||||
? 'bg-violet-500/20 text-violet-400'
|
? 'bg-violet-500/20 text-violet-400'
|
||||||
: 'hover:text-violet-400',
|
: 'hover:text-violet-400',
|
||||||
@@ -94,20 +87,13 @@ export function ViewToggles() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Camera className="h-5 w-5" />
|
<Camera className="h-6 w-6" />
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Camera: {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Level Mode */}
|
{/* Level Mode */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label={`Levels: ${levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}`}
|
||||||
<Button
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 w-9 text-muted-foreground transition-all',
|
|
||||||
levelMode !== 'stacked'
|
levelMode !== 'stacked'
|
||||||
? 'bg-amber-500/20 text-amber-400'
|
? 'bg-amber-500/20 text-amber-400'
|
||||||
: 'hover:text-amber-400',
|
: 'hover:text-amber-400',
|
||||||
@@ -116,22 +102,16 @@ export function ViewToggles() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{levelMode === 'solo' && <Diamond className="h-5 w-5" />}
|
{levelMode === 'solo' && <Diamond className="h-6 w-6" />}
|
||||||
{levelMode === 'exploded' && <Layers2 className="h-5 w-5" />}
|
{levelMode === 'exploded' && <Layers2 className="h-6 w-6" />}
|
||||||
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-5 w-5" />}
|
{(levelMode === 'stacked' || levelMode === 'manual') && <Layers className="h-6 w-6" />}
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Levels: {levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode as keyof typeof levelModeLabels]}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Wall Mode */}
|
{/* Wall Mode */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label={`Walls: ${wallModeConfig[wallMode].label}`}
|
||||||
<Button
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
'p-0',
|
||||||
wallMode !== 'cutaway'
|
wallMode !== 'cutaway'
|
||||||
? 'bg-white/10'
|
? 'bg-white/10'
|
||||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||||
@@ -142,21 +122,15 @@ export function ViewToggles() {
|
|||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const Icon = wallModeConfig[wallMode].icon
|
const Icon = wallModeConfig[wallMode].icon
|
||||||
return <Icon className="h-[26px] w-[26px]" />
|
return <Icon className="h-[28px] w-[28px]" />
|
||||||
})()}
|
})()}
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Walls: {wallModeConfig[wallMode].label}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Show Scans */}
|
{/* Show Scans */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
|
||||||
<Button
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
'p-0',
|
||||||
showScans
|
showScans
|
||||||
? 'bg-white/10'
|
? 'bg-white/10'
|
||||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||||
@@ -165,20 +139,14 @@ export function ViewToggles() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<img alt="Scans" className="h-[26px] w-[26px] object-contain" src="/icons/mesh.png" />
|
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Scans: {showScans ? 'Visible' : 'Hidden'}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Show Guides */}
|
{/* Show Guides */}
|
||||||
<Tooltip>
|
<ActionButton
|
||||||
<TooltipTrigger asChild>
|
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
|
||||||
<Button
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 w-9 text-muted-foreground transition-all p-0',
|
'p-0',
|
||||||
showGuides
|
showGuides
|
||||||
? 'bg-white/10'
|
? 'bg-white/10'
|
||||||
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0 hover:bg-white/5',
|
||||||
@@ -187,13 +155,8 @@ export function ViewToggles() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<img alt="Guides" className="h-[26px] w-[26px] object-contain" src="/icons/floorplan.png" />
|
<img alt="Guides" className="h-[28px] w-[28px] object-contain" src="/icons/floorplan.png" />
|
||||||
</Button>
|
</ActionButton>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Guides: {showGuides ? 'Visible' : 'Hidden'}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/primitives/switch";
|
import { Switch } from "@/components/ui/primitives/switch";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
import { AudioSettingsDialog } from "./audio-settings-dialog";
|
import { AudioSettingsDialog } from "./audio-settings-dialog";
|
||||||
|
import { KeyboardShortcutsDialog } from "./keyboard-shortcuts-dialog";
|
||||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||||
import { updateProjectVisibility } from "@/features/community/lib/projects/actions";
|
import { updateProjectVisibility } from "@/features/community/lib/projects/actions";
|
||||||
|
|
||||||
@@ -395,6 +396,14 @@ export function SettingsPanel() {
|
|||||||
<AudioSettingsDialog />
|
<AudioSettingsDialog />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||||
|
Keyboard
|
||||||
|
</label>
|
||||||
|
<KeyboardShortcutsDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Scene Graph */}
|
{/* Scene Graph */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="font-medium text-muted-foreground text-xs uppercase">
|
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
import { Keyboard } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/primitives/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/primitives/dialog";
|
||||||
|
|
||||||
|
type Shortcut = {
|
||||||
|
keys: string[];
|
||||||
|
action: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShortcutCategory = {
|
||||||
|
title: string;
|
||||||
|
shortcuts: Shortcut[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_DISPLAY_MAP: Record<string, string> = {
|
||||||
|
"Arrow Up": "↑",
|
||||||
|
"Arrow Down": "↓",
|
||||||
|
Esc: "⎋",
|
||||||
|
Shift: "⇧",
|
||||||
|
Space: "␣",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||||
|
{
|
||||||
|
title: "Editor Navigation",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["1"], action: "Switch to Site phase" },
|
||||||
|
{ keys: ["2"], action: "Switch to Structure phase" },
|
||||||
|
{ keys: ["3"], action: "Switch to Furnish phase" },
|
||||||
|
{ keys: ["S"], action: "Switch to Structure layer" },
|
||||||
|
{ keys: ["F"], action: "Switch to Furnish layer" },
|
||||||
|
{ keys: ["Z"], action: "Switch to Zones layer" },
|
||||||
|
{
|
||||||
|
keys: ["Cmd/Ctrl", "Arrow Up"],
|
||||||
|
action: "Select next level in the active building",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: ["Cmd/Ctrl", "Arrow Down"],
|
||||||
|
action: "Select previous level in the active building",
|
||||||
|
},
|
||||||
|
{ keys: ["Cmd/Ctrl", "B"], action: "Toggle sidebar" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Modes & History",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["V"], action: "Switch to Select mode" },
|
||||||
|
{ keys: ["B"], action: "Switch to Build mode" },
|
||||||
|
{
|
||||||
|
keys: ["Esc"],
|
||||||
|
action: "Cancel active tool, clear selection, and exit build mode",
|
||||||
|
},
|
||||||
|
{ keys: ["Delete / Backspace"], action: "Delete selected objects" },
|
||||||
|
{ keys: ["Cmd/Ctrl", "Z"], action: "Undo" },
|
||||||
|
{ keys: ["Cmd/Ctrl", "Shift", "Z"], action: "Redo" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Selection",
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: ["Cmd/Ctrl", "Click"],
|
||||||
|
action: "Add or remove an object from multi-selection",
|
||||||
|
note: "Works while in Select mode.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Drawing Tools",
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: ["Shift"],
|
||||||
|
action: "Temporarily disable angle snapping while drawing walls, slabs, and ceilings",
|
||||||
|
note: "Hold while drawing.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Item Placement",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["R"], action: "Rotate item clockwise by 90 degrees" },
|
||||||
|
{ keys: ["T"], action: "Rotate item counter-clockwise by 90 degrees" },
|
||||||
|
{
|
||||||
|
keys: ["Shift"],
|
||||||
|
action: "Temporarily bypass placement validation constraints",
|
||||||
|
note: "Hold while placing.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Camera",
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
keys: ["Space", "Drag"],
|
||||||
|
action: "Pan camera",
|
||||||
|
note: "Hold Space while dragging with the mouse.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getDisplayKey(key: string, isMac: boolean): string {
|
||||||
|
if (key === "Cmd/Ctrl") return isMac ? "⌘" : "Ctrl";
|
||||||
|
if (key === "Delete / Backspace") return isMac ? "⌫" : "Backspace";
|
||||||
|
return KEY_DISPLAY_MAP[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutKeys({ keys }: { keys: string[] }) {
|
||||||
|
const [isMac, setIsMac] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{keys.map((key, index) => (
|
||||||
|
<div key={`${key}-${index}`} className="flex items-center gap-1">
|
||||||
|
{index > 0 ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">+</span>
|
||||||
|
) : null}
|
||||||
|
<kbd
|
||||||
|
className="inline-flex h-6 items-center rounded border border-border bg-muted px-2 font-mono text-[11px] font-medium text-muted-foreground"
|
||||||
|
title={key}
|
||||||
|
>
|
||||||
|
{getDisplayKey(key, isMac)}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsDialog() {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="w-full justify-start gap-2" variant="outline">
|
||||||
|
<Keyboard className="size-4" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85vh] flex flex-col overflow-hidden p-0 sm:max-w-3xl">
|
||||||
|
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||||
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Shortcuts are context-aware and depend on the current phase or tool.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||||
|
{SHORTCUT_CATEGORIES.map((category) => (
|
||||||
|
<section key={category.title} className="space-y-2">
|
||||||
|
<h3 className="font-medium text-sm">{category.title}</h3>
|
||||||
|
<div className="overflow-hidden rounded-md border border-border/80">
|
||||||
|
{category.shortcuts.map((shortcut, index) => (
|
||||||
|
<div
|
||||||
|
key={`${category.title}-${shortcut.action}`}
|
||||||
|
className="grid grid-cols-[minmax(130px,220px)_1fr] gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
<ShortcutKeys keys={shortcut.keys} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">{shortcut.action}</p>
|
||||||
|
{shortcut.note ? (
|
||||||
|
<p className="text-muted-foreground text-xs">{shortcut.note}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{index < category.shortcuts.length - 1 ? (
|
||||||
|
<div className="col-span-2 border-border/60 border-b" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -817,6 +817,11 @@ function LayerToggle() {
|
|||||||
/>
|
/>
|
||||||
Structure
|
Structure
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-1.5 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md z-10">
|
||||||
|
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -845,6 +850,11 @@ function LayerToggle() {
|
|||||||
/>
|
/>
|
||||||
Furnish
|
Furnish
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-1.5 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md z-10">
|
||||||
|
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||||
|
F
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -874,6 +884,11 @@ function LayerToggle() {
|
|||||||
/>
|
/>
|
||||||
Zones
|
Zones
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-1 right-1.5 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md z-10">
|
||||||
|
<span className="block font-mono text-[9px] font-medium leading-none text-muted-foreground/70">
|
||||||
|
Z
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type AnyNode, emitter, useScene } from "@pascal-app/core";
|
import { type AnyNode, type AnyNodeId, emitter, useScene } from "@pascal-app/core";
|
||||||
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import { Camera, Eye, EyeOff, Trash2 } from "lucide-react";
|
import { Camera, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -14,12 +15,24 @@ interface TreeNodeActionsProps {
|
|||||||
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
export function TreeNodeActions({ node }: TreeNodeActionsProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
|
const updateNodes = useScene((state) => state.updateNodes);
|
||||||
|
const selectedIds = useViewer((state) => state.selection.selectedIds);
|
||||||
const hasCamera = !!node.camera;
|
const hasCamera = !!node.camera;
|
||||||
const isVisible = node.visible !== false;
|
const isVisible = node.visible !== false;
|
||||||
|
|
||||||
const toggleVisibility = (e: React.MouseEvent) => {
|
const toggleVisibility = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
updateNode(node.id, { visible: !isVisible });
|
const newVisibility = !isVisible;
|
||||||
|
if (selectedIds && selectedIds.includes(node.id)) {
|
||||||
|
updateNodes(
|
||||||
|
selectedIds.map((id) => ({
|
||||||
|
id: id as AnyNodeId,
|
||||||
|
data: { visible: newVisibility },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updateNode(node.id, { visible: newVisibility });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCaptureCamera = (e: React.MouseEvent) => {
|
const handleCaptureCamera = (e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -212,12 +212,18 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|||||||
)}>
|
)}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 min-w-0 truncate">
|
<div className={cn(
|
||||||
|
"flex-1 min-w-0 truncate",
|
||||||
|
!isVisible && "line-through text-muted-foreground"
|
||||||
|
)}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{actions && (
|
{actions && (
|
||||||
<div className="opacity-0 group-hover/row:opacity-100 pr-1">
|
<div className={cn(
|
||||||
|
"opacity-0 group-hover/row:opacity-100 pr-1 transition-opacity duration-200",
|
||||||
|
!isVisible && "opacity-100"
|
||||||
|
)}>
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ export const useKeyboard = () => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setPhase('furnish')
|
useEditor.getState().setPhase('furnish')
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
|
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
useEditor.getState().setStructureLayer('elements')
|
||||||
|
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
useEditor.getState().setPhase('furnish')
|
||||||
|
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
useEditor.getState().setPhase('structure')
|
||||||
|
useEditor.getState().setStructureLayer('zones')
|
||||||
}
|
}
|
||||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Reference in New Issue
Block a user