git-subtree-dir: services/pascal-editor git-subtree-split: 312064e6e8122f958d2961e28ea85ad8b9db40c5
546 lines
18 KiB
TypeScript
546 lines
18 KiB
TypeScript
'use client'
|
|
|
|
import { Icon as IconifyIcon } from '@iconify/react'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
useEditor,
|
|
useSidebarStore,
|
|
type ViewMode,
|
|
} from '@pascal-app/editor'
|
|
import {
|
|
CLAY_PALETTE,
|
|
type EdgeMode,
|
|
getSceneTheme,
|
|
SCENE_THEMES,
|
|
useViewer,
|
|
} from '@pascal-app/viewer'
|
|
import {
|
|
Box,
|
|
Check,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
Columns2,
|
|
Contrast,
|
|
Eye,
|
|
EyeOff,
|
|
Footprints,
|
|
Grid2X2,
|
|
Magnet,
|
|
PenLine,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
SwatchBook,
|
|
} from 'lucide-react'
|
|
import Image from 'next/image'
|
|
import { type ReactNode, useCallback } from 'react'
|
|
import { flushSync } from 'react-dom'
|
|
import { withPascalBasePath } from '@/lib/pascal-base-path'
|
|
import { cn } from '@/lib/utils'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
|
|
|
|
const TOOLBAR_CONTAINER =
|
|
'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'
|
|
|
|
const TOOLBAR_BTN =
|
|
'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
|
|
|
|
function requestWalkthroughPointerLock() {
|
|
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
|
|
if (!canvas) return
|
|
|
|
if (!canvas.hasAttribute('tabindex')) {
|
|
canvas.tabIndex = -1
|
|
}
|
|
canvas.focus({ preventScroll: true })
|
|
|
|
if (document.pointerLockElement === canvas) return
|
|
|
|
try {
|
|
canvas.requestPointerLock?.()
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
|
|
{
|
|
id: '3d',
|
|
label: '3D',
|
|
icon: (
|
|
<Image
|
|
alt=""
|
|
className="h-3.5 w-3.5 object-contain"
|
|
height={14}
|
|
src={withPascalBasePath('/icons/building.webp')}
|
|
width={14}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: '2d',
|
|
label: '2D',
|
|
icon: (
|
|
<Image
|
|
alt=""
|
|
className="h-3.5 w-3.5 object-contain"
|
|
height={14}
|
|
src={withPascalBasePath('/icons/blueprint.webp')}
|
|
width={14}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: 'split',
|
|
label: '分屏',
|
|
icon: <Columns2 className="h-3 w-3" />,
|
|
},
|
|
]
|
|
|
|
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
|
|
const levelModeLabels: Record<string, string> = {
|
|
manual: '堆叠',
|
|
stacked: '堆叠',
|
|
exploded: '分层',
|
|
solo: '单层',
|
|
}
|
|
|
|
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
|
|
const wallModeConfig: Record<string, { icon: string; label: string }> = {
|
|
up: { icon: withPascalBasePath('/icons/room.webp'), label: '全高' },
|
|
cutaway: { icon: withPascalBasePath('/icons/wallcut.webp'), label: '剖切' },
|
|
down: { icon: withPascalBasePath('/icons/walllow.webp'), label: '低墙' },
|
|
translucent: { icon: withPascalBasePath('/icons/wall.webp'), label: '半透明' },
|
|
}
|
|
|
|
const SHADING_OPTIONS = [
|
|
{ id: 'solid', name: '实体', detail: '快速平面显示,不含环境光遮蔽', icon: Box },
|
|
{ id: 'rendered', name: '渲染', detail: '完整环境光遮蔽效果', icon: Sparkles },
|
|
] as const
|
|
|
|
function ViewModeControl() {
|
|
const viewMode = useEditor((state) => state.viewMode)
|
|
const setViewMode = useEditor((state) => state.setViewMode)
|
|
|
|
return (
|
|
<div className={TOOLBAR_CONTAINER}>
|
|
{VIEW_MODES.map((mode) => {
|
|
const isActive = viewMode === mode.id
|
|
return (
|
|
<ToolbarTooltip key={mode.id} label={mode.label}>
|
|
<button
|
|
aria-label={mode.label}
|
|
aria-pressed={isActive}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',
|
|
isActive
|
|
? 'bg-white/10 text-foreground'
|
|
: 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',
|
|
)}
|
|
onClick={() => setViewMode(mode.id)}
|
|
type="button"
|
|
>
|
|
{mode.icon}
|
|
<span>{mode.label}</span>
|
|
</button>
|
|
</ToolbarTooltip>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CollapseSidebarButton() {
|
|
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
|
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
|
|
|
const toggle = useCallback(() => {
|
|
setIsCollapsed(!isCollapsed)
|
|
}, [isCollapsed, setIsCollapsed])
|
|
|
|
return (
|
|
<div className={TOOLBAR_CONTAINER}>
|
|
<ToolbarTooltip label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}>
|
|
<button
|
|
aria-label={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
|
className={TOOLBAR_BTN}
|
|
onClick={toggle}
|
|
type="button"
|
|
>
|
|
{isCollapsed ? (
|
|
<ChevronsRight className="h-4 w-4" />
|
|
) : (
|
|
<ChevronsLeft className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</ToolbarTooltip>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LevelModeToggle() {
|
|
const levelMode = useViewer((state) => state.levelMode)
|
|
const setLevelMode = useViewer((state) => state.setLevelMode)
|
|
const isDefault = levelMode === 'stacked' || levelMode === 'manual'
|
|
|
|
const cycle = () => {
|
|
if (levelMode === 'manual') {
|
|
setLevelMode('stacked')
|
|
return
|
|
}
|
|
|
|
const index = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])
|
|
const next = levelModeOrder[(index + 1) % levelModeOrder.length]
|
|
if (next) setLevelMode(next)
|
|
}
|
|
|
|
const label = `楼层:${levelMode === 'manual' ? '手动' : (levelModeLabels[levelMode] ?? '堆叠')}`
|
|
|
|
return (
|
|
<ToolbarTooltip label={label}>
|
|
<button
|
|
className={cn(
|
|
TOOLBAR_BTN,
|
|
'w-auto gap-1.5 px-2.5',
|
|
!isDefault && 'bg-white/10 text-foreground/90',
|
|
)}
|
|
onClick={cycle}
|
|
type="button"
|
|
>
|
|
{levelMode === 'solo' ? (
|
|
<IconifyIcon height={14} icon="lucide:diamond" width={14} />
|
|
) : levelMode === 'exploded' ? (
|
|
<IconifyIcon height={14} icon="charm:stack-pop" width={14} />
|
|
) : (
|
|
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
|
|
)}
|
|
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? '堆叠'}</span>
|
|
</button>
|
|
</ToolbarTooltip>
|
|
)
|
|
}
|
|
|
|
function WallModeToggle() {
|
|
const wallMode = useViewer((state) => state.wallMode)
|
|
const setWallMode = useViewer((state) => state.setWallMode)
|
|
const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!
|
|
|
|
const cycle = () => {
|
|
const index = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])
|
|
const next = wallModeOrder[(index + 1) % wallModeOrder.length]
|
|
if (next) setWallMode(next)
|
|
}
|
|
|
|
return (
|
|
<ToolbarTooltip label={`墙体:${config.label}`}>
|
|
<button
|
|
className={cn(
|
|
TOOLBAR_BTN,
|
|
'w-auto gap-1.5 px-2.5',
|
|
wallMode !== 'cutaway'
|
|
? 'bg-white/10'
|
|
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
|
|
)}
|
|
onClick={cycle}
|
|
type="button"
|
|
>
|
|
<Image alt="" className="h-4 w-4 object-contain" height={16} src={config.icon} width={16} />
|
|
<span className="font-medium text-xs">{config.label}</span>
|
|
</button>
|
|
</ToolbarTooltip>
|
|
)
|
|
}
|
|
|
|
// One dropdown that gathers every "how the scene looks" control: grid, shadows,
|
|
// camera projection, units, render mode, edges and scene theme.
|
|
|
|
const EDGE_OPTIONS = [
|
|
{ id: 'off', name: '关闭', detail: '不显示边线' },
|
|
{ id: 'soft', name: '柔和', detail: '淡化显示主要折线' },
|
|
{ id: 'strong', name: '清晰', detail: '显示清晰不透明边线' },
|
|
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
|
|
|
const SCENE_THEME_LABELS: Record<string, string> = {
|
|
studio: '工作室',
|
|
paper: '纸张',
|
|
sunset: '日落',
|
|
overcast: '阴天',
|
|
blueprint: '蓝图',
|
|
mediterranean: '地中海',
|
|
twilight: '黄昏',
|
|
night: '夜景',
|
|
verdant: '绿意',
|
|
}
|
|
|
|
function getSceneThemeLabel(id: string, fallback: string) {
|
|
return SCENE_THEME_LABELS[id] ?? fallback
|
|
}
|
|
|
|
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
|
|
|
|
function DisplayMenu() {
|
|
const showGrid = useViewer((state) => state.showGrid)
|
|
const setShowGrid = useViewer((state) => state.setShowGrid)
|
|
const unit = useViewer((state) => state.unit)
|
|
const setUnit = useViewer((state) => state.setUnit)
|
|
const cameraMode = useViewer((state) => state.cameraMode)
|
|
const setCameraMode = useViewer((state) => state.setCameraMode)
|
|
const shading = useViewer((state) => state.shading)
|
|
const setShading = useViewer((state) => state.setShading)
|
|
const sceneTheme = useViewer((state) => state.sceneTheme)
|
|
const setSceneTheme = useViewer((state) => state.setSceneTheme)
|
|
const edges = useViewer((state) => state.edges)
|
|
const setEdges = useViewer((state) => state.setEdges)
|
|
const shadows = useViewer((state) => state.shadows)
|
|
const setShadows = useViewer((state) => state.setShadows)
|
|
const magneticSnap = useEditor((state) => state.magneticSnap)
|
|
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
|
|
|
|
const activeShading =
|
|
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
|
const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
|
|
const activeTheme = getSceneTheme(sceneTheme)
|
|
|
|
// Keep the menu open when flipping a toggle.
|
|
const keepOpen = (event: Event, fn: () => void) => {
|
|
event.preventDefault()
|
|
fn()
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<ToolbarTooltip label="显示设置">
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
aria-label="显示设置"
|
|
className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5 text-foreground/90')}
|
|
type="button"
|
|
>
|
|
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="font-medium text-xs">显示</span>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
</ToolbarTooltip>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
className="w-60 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
|
|
side="bottom"
|
|
sideOffset={8}
|
|
>
|
|
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShowGrid(!showGrid))}>
|
|
<Grid2X2 className="h-4 w-4" />
|
|
<span>网格</span>
|
|
{showGrid ? (
|
|
<Eye className="ml-auto h-4 w-4 text-foreground" />
|
|
) : (
|
|
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
|
|
<Magnet className="h-4 w-4" />
|
|
<span>磁吸捕捉</span>
|
|
<span className="ml-auto text-muted-foreground text-xs">
|
|
{magneticSnap ? '开' : '关'}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
|
|
<Contrast className="h-4 w-4" />
|
|
<span>阴影</span>
|
|
<span className="ml-auto text-muted-foreground text-xs">{shadows ? '开' : '关'}</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={(e) =>
|
|
keepOpen(e, () =>
|
|
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
|
|
)
|
|
}
|
|
>
|
|
<IconifyIcon
|
|
height={16}
|
|
icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'}
|
|
width={16}
|
|
/>
|
|
<span>相机</span>
|
|
<span className="ml-auto text-muted-foreground text-xs">
|
|
{cameraMode === 'perspective' ? '透视' : '正交'}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
|
|
>
|
|
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
|
|
{unit === 'metric' ? 'm' : 'ft'}
|
|
</span>
|
|
<span>单位</span>
|
|
<span className="ml-auto text-muted-foreground text-xs">
|
|
{unit === 'metric' ? '公制' : '英制'}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<activeShading.icon className="h-4 w-4" />
|
|
<span>渲染</span>
|
|
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
|
{SHADING_OPTIONS.map((option) => {
|
|
const OptionIcon = option.icon
|
|
return (
|
|
<DropdownMenuItem key={option.id} onSelect={() => setShading(option.id)}>
|
|
<OptionIcon className="h-4 w-4" />
|
|
<div className="flex flex-col">
|
|
<span className="text-foreground">{option.name}</span>
|
|
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
|
</div>
|
|
{shading === option.id ? (
|
|
<Check className="ml-auto h-4 w-4 text-foreground" />
|
|
) : null}
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<PenLine className="h-4 w-4" />
|
|
<span>边线</span>
|
|
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
|
{EDGE_OPTIONS.map((option) => (
|
|
<DropdownMenuItem key={option.id} onSelect={() => setEdges(option.id)}>
|
|
<div className="flex flex-col">
|
|
<span className="text-foreground">{option.name}</span>
|
|
<span className="text-muted-foreground text-xs">{option.detail}</span>
|
|
</div>
|
|
{edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<SwatchBook className="h-4 w-4" />
|
|
<span>主题</span>
|
|
<span className="ml-auto truncate text-muted-foreground text-xs">
|
|
{getSceneThemeLabel(activeTheme.id, activeTheme.name)}
|
|
</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="min-w-48 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl">
|
|
{SCENE_THEMES.map((theme) => {
|
|
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
|
|
(role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
|
|
)
|
|
return (
|
|
<DropdownMenuItem key={theme.id} onSelect={() => setSceneTheme(theme.id)}>
|
|
<span
|
|
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
|
|
style={{ backgroundColor: theme.background }}
|
|
>
|
|
{swatches.map((color, index) => (
|
|
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
|
|
))}
|
|
</span>
|
|
<span className="text-foreground">
|
|
{getSceneThemeLabel(theme.id, theme.name)}
|
|
</span>
|
|
{sceneTheme === theme.id ? (
|
|
<Check className="ml-auto h-4 w-4 text-foreground" />
|
|
) : null}
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
function WalkthroughButton() {
|
|
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
|
|
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
|
|
const handleClick = useCallback(() => {
|
|
if (isFirstPersonMode) {
|
|
setFirstPersonMode(false)
|
|
return
|
|
}
|
|
|
|
flushSync(() => setFirstPersonMode(true))
|
|
requestWalkthroughPointerLock()
|
|
}, [isFirstPersonMode, setFirstPersonMode])
|
|
|
|
return (
|
|
<ToolbarTooltip label="漫游模式">
|
|
<button
|
|
className={cn(
|
|
TOOLBAR_BTN,
|
|
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
|
|
)}
|
|
onClick={handleClick}
|
|
type="button"
|
|
>
|
|
<Footprints className="h-4 w-4" />
|
|
</button>
|
|
</ToolbarTooltip>
|
|
)
|
|
}
|
|
|
|
function PreviewButton() {
|
|
return (
|
|
<ToolbarTooltip label="预览模式">
|
|
<button
|
|
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
|
|
onClick={() => useEditor.getState().setPreviewMode(true)}
|
|
type="button"
|
|
>
|
|
<Eye className="h-3.5 w-3.5 shrink-0" />
|
|
<span>预览</span>
|
|
</button>
|
|
</ToolbarTooltip>
|
|
)
|
|
}
|
|
|
|
export function CommunityViewerToolbarLeft() {
|
|
return (
|
|
<>
|
|
<CollapseSidebarButton />
|
|
<ViewModeControl />
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function CommunityViewerToolbarRight() {
|
|
return (
|
|
<div className={TOOLBAR_CONTAINER}>
|
|
<LevelModeToggle />
|
|
<WallModeToggle />
|
|
<div className="my-1.5 w-px bg-border/50" />
|
|
<DisplayMenu />
|
|
<div className="my-1.5 w-px bg-border/50" />
|
|
<WalkthroughButton />
|
|
<PreviewButton />
|
|
</div>
|
|
)
|
|
}
|