All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
|
import * as d3 from 'd3'
|
|
import { BrainstormSession } from '@/types/brainstorm'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
|
|
interface WaveCanvasProps {
|
|
session: BrainstormSession
|
|
onNodeSelect: (id: string) => void
|
|
onPositionUpdate?: (id: string, pos: { x: number; y: number }) => void
|
|
selectedNodeId: string | null
|
|
onCreateIdea?: (data: { title: string; parentIdeaId?: string; x: number; y: number }) => void
|
|
remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null
|
|
manualEditTrigger?: number
|
|
playbackIdeas?: any[] | null
|
|
}
|
|
|
|
const WAVE_COLORS: Record<number, string> = {
|
|
1: '#fb923c',
|
|
2: '#60a5fa',
|
|
3: '#a78bfa',
|
|
}
|
|
|
|
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
|
session,
|
|
onNodeSelect,
|
|
onPositionUpdate,
|
|
selectedNodeId,
|
|
onCreateIdea,
|
|
remoteMove,
|
|
manualEditTrigger,
|
|
playbackIdeas,
|
|
}) => {
|
|
const { t, language } = useLanguage()
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const nodeRef = useRef<d3.Selection<SVGGElement, any, SVGGElement, unknown> | null>(null)
|
|
const linkRef = useRef<d3.Selection<SVGLineElement, any, SVGGElement, unknown> | null>(null)
|
|
const simulationRef = useRef<d3.Simulation<any, any> | null>(null)
|
|
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
|
|
|
|
const onNodeSelectRef = useRef(onNodeSelect)
|
|
onNodeSelectRef.current = onNodeSelect
|
|
const onPositionUpdateRef = useRef(onPositionUpdate)
|
|
onPositionUpdateRef.current = onPositionUpdate
|
|
const onCreateIdeaRef = useRef(onCreateIdea)
|
|
onCreateIdeaRef.current = onCreateIdea
|
|
|
|
const ideasKey = session?.ideas?.map(i => `${i.id}:${i.status}:${i.positionX}:${i.positionY}`).join('|') || ''
|
|
const sessionId = session?.id || ''
|
|
|
|
const [isDark, setIsDark] = useState(false)
|
|
useEffect(() => {
|
|
const el = document.documentElement
|
|
setIsDark(el.classList.contains('dark'))
|
|
const observer = new MutationObserver(() => {
|
|
setIsDark(el.classList.contains('dark'))
|
|
})
|
|
observer.observe(el, { attributes: true, attributeFilter: ['class'] })
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
const [editingNode, setEditingNode] = useState<{
|
|
x: number
|
|
y: number
|
|
parentId: string | null
|
|
} | null>(null)
|
|
const [editText, setEditText] = useState('')
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (editingNode && inputRef.current) {
|
|
inputRef.current.focus()
|
|
}
|
|
}, [editingNode])
|
|
|
|
useEffect(() => {
|
|
if (!manualEditTrigger || manualEditTrigger === 0) return
|
|
const container = containerRef.current
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
setEditingNode({
|
|
x: rect.width / 2,
|
|
y: rect.height / 2,
|
|
parentId: null,
|
|
})
|
|
setEditText('')
|
|
}, [manualEditTrigger])
|
|
|
|
const toSvgCoords = useCallback((clientX: number, clientY: number) => {
|
|
const svg = svgRef.current
|
|
if (!svg) return { x: 0, y: 0 }
|
|
const pt = svg.createSVGPoint()
|
|
pt.x = clientX
|
|
pt.y = clientY
|
|
const ctm = svg.getScreenCTM()
|
|
if (!ctm) return { x: 0, y: 0 }
|
|
const svgPt = pt.matrixTransform(ctm.inverse())
|
|
const t = transformRef.current
|
|
return {
|
|
x: (svgPt.x - t.x) / t.k,
|
|
y: (svgPt.y - t.y) / t.k,
|
|
}
|
|
}, [])
|
|
|
|
const toScreenCoords = useCallback((svgX: number, svgY: number) => {
|
|
const svg = svgRef.current
|
|
if (!svg) return { x: 0, y: 0 }
|
|
const t = transformRef.current
|
|
const screenX = svgX * t.k + t.x
|
|
const screenY = svgY * t.k + t.y
|
|
const ctm = svg.getScreenCTM()
|
|
if (!ctm) return { x: 0, y: 0 }
|
|
const pt = svg.createSVGPoint()
|
|
pt.x = screenX
|
|
pt.y = screenY
|
|
const screenPt = pt.matrixTransform(ctm)
|
|
return { x: screenPt.x, y: screenPt.y }
|
|
}, [])
|
|
|
|
const handleSubmitIdea = useCallback(() => {
|
|
if (!editText.trim() || !editingNode || !onCreateIdeaRef.current) return
|
|
onCreateIdeaRef.current({
|
|
title: editText.trim(),
|
|
parentIdeaId: editingNode.parentId || undefined,
|
|
x: editingNode.x,
|
|
y: editingNode.y,
|
|
})
|
|
setEditingNode(null)
|
|
setEditText('')
|
|
}, [editText, editingNode])
|
|
|
|
useEffect(() => {
|
|
if (!svgRef.current || !containerRef.current) return
|
|
if (!session?.id || !session?.ideas) return
|
|
|
|
const activeIdeas = playbackIdeas
|
|
? playbackIdeas.map((pi: any) => ({
|
|
...pi,
|
|
description: '',
|
|
connectionToSeed: null,
|
|
convertedToNoteId: null,
|
|
relatedNoteIds: null,
|
|
createdBy: null,
|
|
noteRefs: [],
|
|
creator: null,
|
|
createdAt: new Date(),
|
|
sessionId: session.id,
|
|
}))
|
|
: session.ideas
|
|
|
|
const width = containerRef.current.clientWidth
|
|
const height = containerRef.current.clientHeight
|
|
const centerX = width / 2
|
|
const centerY = height / 2
|
|
|
|
const svg = d3.select(svgRef.current)
|
|
svg.selectAll('*').remove()
|
|
|
|
const g = svg.append('g')
|
|
|
|
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 5])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform)
|
|
transformRef.current = event.transform
|
|
})
|
|
|
|
svg.call(zoom)
|
|
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8))
|
|
|
|
interface D3Node extends d3.SimulationNodeDatum {
|
|
id: string
|
|
type: 'root' | 'idea'
|
|
wave?: number
|
|
title: string
|
|
color: string
|
|
radius: number
|
|
status?: string
|
|
createdByType?: string | null
|
|
creatorInitial?: string
|
|
}
|
|
|
|
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
|
source: string | D3Node
|
|
target: string | D3Node
|
|
type: 'wave' | 'parent'
|
|
}
|
|
|
|
const nodes: D3Node[] = []
|
|
const links: D3Link[] = []
|
|
|
|
nodes.push({
|
|
id: 'root',
|
|
type: 'root',
|
|
title: session.seedIdea,
|
|
color: '#141414',
|
|
radius: 40,
|
|
fx: 0,
|
|
fy: 0,
|
|
})
|
|
|
|
activeIdeas.forEach((idea) => {
|
|
const creator = (idea as any).creator as { name: string | null } | undefined | null
|
|
nodes.push({
|
|
id: idea.id,
|
|
type: 'idea',
|
|
wave: idea.waveNumber,
|
|
title: idea.title,
|
|
color: WAVE_COLORS[idea.waveNumber as 1 | 2 | 3] || '#94a3b8',
|
|
radius: idea.status === 'dismissed' ? 18 : 28,
|
|
status: idea.status,
|
|
createdByType: idea.createdByType || 'ai',
|
|
creatorInitial: creator?.name ? creator.name.charAt(0).toUpperCase() : undefined,
|
|
x: idea.positionX ?? undefined,
|
|
y: idea.positionY ?? undefined,
|
|
})
|
|
|
|
if (idea.parentIdeaId) {
|
|
links.push({ source: idea.parentIdeaId, target: idea.id, type: 'parent' })
|
|
} else {
|
|
links.push({ source: 'root', target: idea.id, type: 'wave' })
|
|
}
|
|
})
|
|
|
|
const simulation = d3
|
|
.forceSimulation<D3Node>(nodes)
|
|
.force(
|
|
'link',
|
|
d3
|
|
.forceLink<D3Node, D3Link>(links)
|
|
.id((d) => d.id)
|
|
.distance((d) => {
|
|
if (d.type === 'wave') {
|
|
const targetNode = nodes.find(
|
|
(n) =>
|
|
n.id ===
|
|
(typeof d.target === 'string' ? d.target : (d.target as any).id)
|
|
)
|
|
return (targetNode?.wave || 1) * 200
|
|
}
|
|
if (d.type === 'parent') return 180
|
|
return 100
|
|
})
|
|
)
|
|
.force('charge', d3.forceManyBody().strength(-800))
|
|
.force(
|
|
'radial',
|
|
d3
|
|
.forceRadial<D3Node>(
|
|
(d) => {
|
|
if (d.type === 'root') return 0
|
|
return (d.wave || 1) * 200
|
|
},
|
|
0,
|
|
0
|
|
)
|
|
.strength(0.8)
|
|
)
|
|
.force('collision', d3.forceCollide<D3Node>().radius((d) => d.radius + 30))
|
|
|
|
simulationRef.current = simulation
|
|
|
|
const ringRadii = [200, 400, 600]
|
|
g.selectAll('.ring')
|
|
.data(ringRadii)
|
|
.enter()
|
|
.append('circle')
|
|
.attr('class', 'ring')
|
|
.attr('r', (d) => d)
|
|
.attr('fill', 'none')
|
|
.attr('stroke', isDark ? '#ffffff10' : '#e2e8f0')
|
|
.attr('stroke-width', 1)
|
|
.attr('stroke-dasharray', '4,4')
|
|
.style('opacity', 0.5)
|
|
|
|
const link = g
|
|
.append('g')
|
|
.selectAll('line')
|
|
.data(links)
|
|
.enter()
|
|
.append('line')
|
|
.attr('stroke', (d) =>
|
|
d.type === 'wave' ? (isDark ? '#334155' : '#cbd5e1') : (isDark ? '#854d0e' : '#fde047')
|
|
)
|
|
.attr('stroke-width', (d) => (d.type === 'wave' ? 1.5 : 2))
|
|
.attr('stroke-dasharray', (d) => (d.type === 'parent' ? 'none' : '4,4'))
|
|
|
|
linkRef.current = link
|
|
|
|
const node = g
|
|
.append('g')
|
|
.selectAll('.node')
|
|
.data(nodes)
|
|
.enter()
|
|
.append('g')
|
|
.attr('class', (d) => `node${d.status === 'dismissed' ? ' dismissed' : ''}`)
|
|
.style('cursor', 'pointer')
|
|
.style('opacity', (d) => d.status === 'dismissed' ? 0.3 : 1)
|
|
.attr('data-id', (d) => d.id)
|
|
.on('click', (_event, d) => {
|
|
if (d.type === 'idea') onNodeSelectRef.current?.(d.id)
|
|
})
|
|
.on('dblclick', (event, d) => {
|
|
if (!onCreateIdeaRef.current) return
|
|
event.stopPropagation()
|
|
const parentId = d.type === 'root' ? null : d.id
|
|
const angle = Math.random() * Math.PI * 2
|
|
const dist = 180
|
|
const nx = (d.x || 0) + Math.cos(angle) * dist
|
|
const ny = (d.y || 0) + Math.sin(angle) * dist
|
|
const screen = toScreenCoords(nx, ny)
|
|
const container = containerRef.current
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
setEditingNode({ x: screen.x - rect.left, y: screen.y - rect.top, parentId })
|
|
setEditText('')
|
|
})
|
|
.call(
|
|
d3
|
|
.drag<SVGGElement, D3Node>()
|
|
.on('start', (event, d) => {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart()
|
|
d.fx = d.x
|
|
d.fy = d.y
|
|
})
|
|
.on('drag', (event, d) => {
|
|
d.fx = event.x
|
|
d.fy = event.y
|
|
})
|
|
.on('end', (event, d) => {
|
|
if (!event.active) simulation.alphaTarget(0)
|
|
d.fx = null
|
|
d.fy = null
|
|
if (d.type === 'idea' && onPositionUpdateRef.current) {
|
|
onPositionUpdateRef.current(d.id, { x: event.x, y: event.y })
|
|
}
|
|
}) as any
|
|
)
|
|
|
|
nodeRef.current = node
|
|
|
|
node
|
|
.append('circle')
|
|
.attr('r', (d) => d.radius)
|
|
.attr('fill', (d) =>
|
|
d.status === 'converted'
|
|
? (isDark ? '#064e3b' : '#ecfdf5')
|
|
: d.type === 'root'
|
|
? (isDark ? '#f97316' : '#141414')
|
|
: (isDark ? '#1e1e1e' : '#fff')
|
|
)
|
|
.attr('stroke', (d) =>
|
|
d.status === 'converted' ? '#10b981' : d.color
|
|
)
|
|
.attr('stroke-width', 2)
|
|
|
|
node
|
|
.append('text')
|
|
.attr('dy', (d) =>
|
|
d.type === 'root' ? '.35em' : d.radius + 20
|
|
)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', (d) =>
|
|
d.type === 'root'
|
|
? '#fff'
|
|
: d.status === 'dismissed'
|
|
? (isDark ? '#475569' : '#94a3b8')
|
|
: (isDark ? '#e2e8f0' : '#141414')
|
|
)
|
|
.attr(
|
|
'class',
|
|
(d) =>
|
|
d.type === 'root'
|
|
? 'text-[10px] font-bold pointer-events-none tracking-widest'
|
|
: 'text-[11px] font-bold uppercase tracking-tight pointer-events-none'
|
|
)
|
|
.text((d) =>
|
|
d.type === 'root'
|
|
? t('brainstorm.seedNodeBadge')
|
|
: d.title.length > 18
|
|
? d.title.substring(0, 18) + '...'
|
|
: d.title
|
|
)
|
|
|
|
node
|
|
.filter((d) => d.status === 'converted')
|
|
.append('text')
|
|
.attr('x', (d) => d.radius * 0.55)
|
|
.attr('y', (d) => -d.radius * 0.55)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', isDark ? '#34d399' : '#10b981')
|
|
.attr('font-size', '14px')
|
|
.attr('class', 'pointer-events-none')
|
|
.text('✓')
|
|
|
|
node
|
|
.filter((d) => {
|
|
if (!d.wave || d.type === 'root') return false
|
|
try {
|
|
const ids: string[] = JSON.parse(
|
|
(session.ideas.find((i) => i.id === d.id)?.relatedNoteIds) || '[]'
|
|
)
|
|
return ids.length > 0
|
|
} catch { return false }
|
|
})
|
|
.append('text')
|
|
.attr('x', (d) => -d.radius * 0.55)
|
|
.attr('y', (d) => -d.radius * 0.55)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', isDark ? '#94a3b8' : '#64748b')
|
|
.attr('font-size', '12px')
|
|
.attr('class', 'pointer-events-none')
|
|
.text('📎')
|
|
|
|
node
|
|
.filter((d) => d.type === 'idea' && d.createdByType === 'human')
|
|
.append('circle')
|
|
.attr('cx', (d) => d.radius * 0.6)
|
|
.attr('cy', (d) => d.radius * 0.6)
|
|
.attr('r', 7)
|
|
.attr('fill', '#3b82f6')
|
|
.attr('stroke', isDark ? '#1e1e1e' : '#fff')
|
|
.attr('stroke-width', 1.5)
|
|
.attr('class', 'pointer-events-none')
|
|
|
|
node
|
|
.filter((d) => d.type === 'idea' && d.createdByType === 'human')
|
|
.append('text')
|
|
.attr('x', (d) => d.radius * 0.6)
|
|
.attr('y', (d) => d.radius * 0.6 + 3.5)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', '#fff')
|
|
.attr('font-size', '8px')
|
|
.attr('font-weight', 'bold')
|
|
.attr('class', 'pointer-events-none')
|
|
.text((d) => d.creatorInitial || 'U')
|
|
|
|
node
|
|
.filter((d) => d.type === 'idea' && d.createdByType === 'ai')
|
|
.append('text')
|
|
.attr('x', (d) => d.radius * 0.55)
|
|
.attr('y', (d) => -d.radius * 0.55 + 4)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('fill', '#a78bfa')
|
|
.attr('font-size', '10px')
|
|
.attr('class', 'pointer-events-none')
|
|
.text('✦')
|
|
|
|
g.append('text')
|
|
.attr('text-anchor', 'middle')
|
|
.attr('dy', 80)
|
|
.attr('class', 'text-lg font-serif italic pointer-events-none')
|
|
.attr('fill', isDark ? '#94a3b8' : '#333')
|
|
.text(session.seedIdea.length > 60 ? session.seedIdea.substring(0, 60) + '...' : session.seedIdea)
|
|
|
|
svg.on('dblclick', (event) => {
|
|
if (!onCreateIdeaRef.current) return
|
|
if ((event.target as Element).closest('.node')) return
|
|
const container = containerRef.current
|
|
if (!container) return
|
|
const rect = container.getBoundingClientRect()
|
|
setEditingNode({ x: event.clientX - rect.left, y: event.clientY - rect.top, parentId: null })
|
|
setEditText('')
|
|
})
|
|
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', (d) => (d.source as any).x)
|
|
.attr('y1', (d) => (d.source as any).y)
|
|
.attr('x2', (d) => (d.target as any).x)
|
|
.attr('y2', (d) => (d.target as any).y)
|
|
|
|
node.attr('transform', (d) => `translate(${d.x},${d.y})`)
|
|
})
|
|
|
|
return () => {
|
|
simulation.stop()
|
|
}
|
|
}, [sessionId, ideasKey, isDark, toSvgCoords, toScreenCoords, playbackIdeas, language, t])
|
|
|
|
useEffect(() => {
|
|
if (!nodeRef.current) return
|
|
nodeRef.current.selectAll('circle')
|
|
.attr('stroke-width', (d: any) => (d.id === selectedNodeId ? 4 : 2))
|
|
.style('filter', (d: any) =>
|
|
d.id === selectedNodeId
|
|
? `drop-shadow(0 0 12px ${d.color}cc)`
|
|
: 'none'
|
|
)
|
|
}, [selectedNodeId])
|
|
|
|
useEffect(() => {
|
|
if (!remoteMove || !nodeRef.current || !linkRef.current || !simulationRef.current) return
|
|
const sim = simulationRef.current
|
|
const node = nodeRef.current
|
|
const link = linkRef.current
|
|
|
|
node.each(function(d: any) {
|
|
if (d.id === remoteMove.ideaId) {
|
|
d.fx = remoteMove.x
|
|
d.fy = remoteMove.y
|
|
}
|
|
})
|
|
|
|
sim.alpha(0.3).restart()
|
|
|
|
for (let i = 0; i < 30; i++) sim.tick()
|
|
sim.stop()
|
|
|
|
link
|
|
.attr('x1', (d: any) => d.source.x)
|
|
.attr('y1', (d: any) => d.source.y)
|
|
.attr('x2', (d: any) => d.target.x)
|
|
.attr('y2', (d: any) => d.target.y)
|
|
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
|
}, [remoteMove])
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full h-full relative cursor-grab active:cursor-grabbing bg-[#F8F7F2] dark:bg-[#0A0A0A] bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]"
|
|
>
|
|
<svg ref={svgRef} className="w-full h-full" />
|
|
|
|
{editingNode && (
|
|
<div
|
|
className="absolute z-50 pointer-events-auto"
|
|
style={{
|
|
left: editingNode.x,
|
|
top: editingNode.y,
|
|
transform: 'translate(-50%, 12px)',
|
|
}}
|
|
>
|
|
<div className="w-[260px] bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl border border-black/10 dark:border-white/10 overflow-hidden">
|
|
<div className="px-3.5 pt-3 pb-1 flex items-center gap-2">
|
|
<div className="w-5 h-5 rounded-md bg-brand-accent/10 flex items-center justify-center">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2.5" strokeLinecap="round">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</div>
|
|
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-foreground/50">
|
|
{editingNode.parentId ? t('brainstorm.canvasEditTitleReply') : t('brainstorm.canvasEditTitleNewIdea')}
|
|
</span>
|
|
</div>
|
|
<div className="px-3.5 pb-3">
|
|
<input
|
|
ref={inputRef}
|
|
value={editText}
|
|
onChange={(e) => setEditText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && editText.trim()) handleSubmitIdea()
|
|
if (e.key === 'Escape') { setEditingNode(null); setEditText('') }
|
|
}}
|
|
placeholder={editingNode.parentId ? t('brainstorm.canvasPlaceholderReply') : t('brainstorm.canvasPlaceholderIdea')}
|
|
className="w-full px-3 py-2.5 text-sm font-serif bg-black/[0.03] dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.12] rounded-xl outline-none focus:border-brand-accent/50 focus:bg-white dark:focus:bg-white/[0.08] transition-all placeholder:text-foreground/25 text-foreground"
|
|
/>
|
|
<div className="flex items-center justify-between mt-1.5 px-0.5">
|
|
<div className="flex items-center gap-2.5">
|
|
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">↵</kbd>
|
|
<span className="text-[9px] text-foreground/25">{t('brainstorm.canvasShortcutSave')}</span>
|
|
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">esc</kbd>
|
|
<span className="text-[9px] text-foreground/25">{t('brainstorm.canvasShortcutCancel')}</span>
|
|
</div>
|
|
{editingNode.parentId && (
|
|
<span className="text-[9px] font-medium text-brand-accent/60 flex items-center gap-0.5">
|
|
→ {t('brainstorm.canvasChildBranch')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-center -mt-[1px]">
|
|
<div className="w-3 h-3 bg-white dark:bg-[#1A1A1A] border-r border-b border-black/10 dark:border-white/10 rotate-45 -translate-y-1.5" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="absolute bottom-6 left-6 pointer-events-none">
|
|
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-gray-400 dark:text-gray-600 opacity-60">
|
|
{t('brainstorm.canvasDoubleClickHint')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|