Mayo commited on
feat: multi-selection on ui
Browse files- ui/components/MenuBar.tsx +11 -1
- ui/components/canvas/TextBlockLayer.tsx +13 -4
- ui/components/panels/RenderControlsPanel.tsx +43 -34
- ui/components/panels/TextBlocksPanel.tsx +13 -1
- ui/hooks/useCurrentPage.ts +20 -0
- ui/hooks/useKeyboardShortcuts.ts +9 -1
- ui/lib/io/scene.ts +16 -0
- ui/public/locales/en-US/translation.json +3 -1
- ui/public/locales/es-ES/translation.json +2 -0
- ui/public/locales/ja-JP/translation.json +2 -0
- ui/public/locales/ko-KR/translation.json +3 -1
- ui/public/locales/pt-BR/translation.json +3 -1
- ui/public/locales/ru-RU/translation.json +2 -0
- ui/public/locales/tr-TR/translation.json +3 -1
- ui/public/locales/zh-CN/translation.json +2 -0
- ui/public/locales/zh-TW/translation.json +2 -0
- ui/tests/hooks/useKeyboardShortcuts.test.tsx +65 -0
- ui/tests/lib/io/selectAll.test.ts +81 -0
ui/components/MenuBar.tsx
CHANGED
|
@@ -20,7 +20,7 @@ import { useScene } from '@/hooks/useScene'
|
|
| 20 |
import { getConfig, startPipeline } from '@/lib/api/default/default'
|
| 21 |
import { isTauri, openExternalUrl } from '@/lib/backend'
|
| 22 |
import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo'
|
| 23 |
-
import { closeProject, redoOp, undoOp } from '@/lib/io/scene'
|
| 24 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 25 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 26 |
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
|
@@ -275,6 +275,16 @@ export function MenuBar() {
|
|
| 275 |
{t('menu.redo')}
|
| 276 |
<MenubarShortcut>{isMacOS() ? '⇧⌘Z' : 'Ctrl+Shift+Z'}</MenubarShortcut>
|
| 277 |
</MenubarItem>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
</MenubarContent>
|
| 279 |
</MenubarMenu>
|
| 280 |
{menus.map(({ label, items, triggerTestId }) => (
|
|
|
|
| 20 |
import { getConfig, startPipeline } from '@/lib/api/default/default'
|
| 21 |
import { isTauri, openExternalUrl } from '@/lib/backend'
|
| 22 |
import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo'
|
| 23 |
+
import { closeProject, redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene'
|
| 24 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 25 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 26 |
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
|
|
|
| 275 |
{t('menu.redo')}
|
| 276 |
<MenubarShortcut>{isMacOS() ? '⇧⌘Z' : 'Ctrl+Shift+Z'}</MenubarShortcut>
|
| 277 |
</MenubarItem>
|
| 278 |
+
<MenubarSeparator />
|
| 279 |
+
<MenubarItem
|
| 280 |
+
data-testid='menu-edit-select-all'
|
| 281 |
+
className='text-[13px]'
|
| 282 |
+
disabled={!hasPage}
|
| 283 |
+
onSelect={() => selectAllTextNodesOnCurrentPage()}
|
| 284 |
+
>
|
| 285 |
+
{t('menu.selectAll')}
|
| 286 |
+
<MenubarShortcut>{isMacOS() ? '⌘A' : 'Ctrl+A'}</MenubarShortcut>
|
| 287 |
+
</MenubarItem>
|
| 288 |
</MenubarContent>
|
| 289 |
</MenubarMenu>
|
| 290 |
{menus.map(({ label, items, triggerTestId }) => (
|
ui/components/canvas/TextBlockLayer.tsx
CHANGED
|
@@ -80,7 +80,7 @@ export function TextBlockLayer({ showSprites, scale, style }: TextBlockLayerProp
|
|
| 80 |
scale={scale}
|
| 81 |
selected={selectedIds.has(n.id)}
|
| 82 |
interactive={interactive}
|
| 83 |
-
onSelect={(id) => select(id,
|
| 84 |
onCommit={(t) => void updateTransform(n.id, t)}
|
| 85 |
/>
|
| 86 |
))}
|
|
@@ -94,10 +94,16 @@ type TextBlockItemProps = {
|
|
| 94 |
scale: number
|
| 95 |
selected: boolean
|
| 96 |
interactive: boolean
|
| 97 |
-
onSelect: (id: string) => void
|
| 98 |
onCommit: (transform: Transform) => void
|
| 99 |
}
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
const RESIZE_HANDLE_SIZE = 8
|
| 102 |
|
| 103 |
type ResizeEdge = { top: boolean; bottom: boolean; left: boolean; right: boolean }
|
|
@@ -130,8 +136,9 @@ function TextBlockItem({
|
|
| 130 |
({ first, last, movement: [mx, my], event, tap }) => {
|
| 131 |
if (!interactive) return
|
| 132 |
event?.stopPropagation()
|
|
|
|
| 133 |
if (tap) {
|
| 134 |
-
onSelect(node.id)
|
| 135 |
return
|
| 136 |
}
|
| 137 |
if (first) {
|
|
@@ -141,7 +148,9 @@ function TextBlockItem({
|
|
| 141 |
w: t.width * scale,
|
| 142 |
h: t.height * scale,
|
| 143 |
}
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
const { x: sx, y: sy, w: sw, h: sh } = dragStart.current
|
| 147 |
const edge = edgeRef.current
|
|
|
|
| 80 |
scale={scale}
|
| 81 |
selected={selectedIds.has(n.id)}
|
| 82 |
interactive={interactive}
|
| 83 |
+
onSelect={(id, additive) => select(id, additive)}
|
| 84 |
onCommit={(t) => void updateTransform(n.id, t)}
|
| 85 |
/>
|
| 86 |
))}
|
|
|
|
| 94 |
scale: number
|
| 95 |
selected: boolean
|
| 96 |
interactive: boolean
|
| 97 |
+
onSelect: (id: string, additive: boolean) => void
|
| 98 |
onCommit: (transform: Transform) => void
|
| 99 |
}
|
| 100 |
|
| 101 |
+
const isAdditiveEvent = (event: unknown): boolean => {
|
| 102 |
+
if (!event || typeof event !== 'object') return false
|
| 103 |
+
const e = event as { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }
|
| 104 |
+
return !!(e.shiftKey || e.metaKey || e.ctrlKey)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
const RESIZE_HANDLE_SIZE = 8
|
| 108 |
|
| 109 |
type ResizeEdge = { top: boolean; bottom: boolean; left: boolean; right: boolean }
|
|
|
|
| 136 |
({ first, last, movement: [mx, my], event, tap }) => {
|
| 137 |
if (!interactive) return
|
| 138 |
event?.stopPropagation()
|
| 139 |
+
const additive = isAdditiveEvent(event)
|
| 140 |
if (tap) {
|
| 141 |
+
onSelect(node.id, additive)
|
| 142 |
return
|
| 143 |
}
|
| 144 |
if (first) {
|
|
|
|
| 148 |
w: t.width * scale,
|
| 149 |
h: t.height * scale,
|
| 150 |
}
|
| 151 |
+
// Keep multi-selection intact when dragging a node that's already selected;
|
| 152 |
+
// otherwise this click is a single-select (unless the modifier is held).
|
| 153 |
+
if (additive || !selected) onSelect(node.id, additive)
|
| 154 |
}
|
| 155 |
const { x: sx, y: sy, w: sw, h: sh } = dragStart.current
|
| 156 |
const edge = edgeRef.current
|
ui/components/panels/RenderControlsPanel.tsx
CHANGED
|
@@ -18,11 +18,18 @@ import { ColorPicker } from '@/components/ui/color-picker'
|
|
| 18 |
import { FontSelect } from '@/components/ui/font-select'
|
| 19 |
import { Input } from '@/components/ui/input'
|
| 20 |
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
| 21 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
import { useGetGoogleFontsCatalog, useListFonts } from '@/lib/api/default/default'
|
| 23 |
import type {
|
| 24 |
FontFaceInfo,
|
| 25 |
FontPrediction,
|
|
|
|
| 26 |
TextAlign,
|
| 27 |
TextShaderEffect,
|
| 28 |
TextStrokeStyle,
|
|
@@ -124,6 +131,7 @@ export function RenderControlsPanel() {
|
|
| 124 |
const page = useCurrentPage()
|
| 125 |
const textNodes = useTextNodes()
|
| 126 |
const selectedNode = useSelectedTextNode()
|
|
|
|
| 127 |
const { data: availableFonts = [] } = useListFonts()
|
| 128 |
useGetGoogleFontsCatalog() // prefetch catalog so picker can decorate Google entries
|
| 129 |
const appDefaultFont = usePreferencesStore((s) => s.defaultFont)
|
|
@@ -180,49 +188,47 @@ export function RenderControlsPanel() {
|
|
| 180 |
// Mutations
|
| 181 |
// ---------------------------------------------------------------------------
|
| 182 |
|
| 183 |
-
const
|
| 184 |
-
|
| 185 |
-
const existing = page.nodes[nodeId]
|
| 186 |
-
if (!existing || !('text' in existing.kind)) return
|
| 187 |
-
const current = existing.kind.text.style ?? undefined
|
| 188 |
const nextStyle: TextStyle = {
|
| 189 |
fontFamilies: updates.fontFamilies ?? current?.fontFamilies ?? [],
|
| 190 |
fontSize: updates.fontSize ?? current?.fontSize ?? null,
|
| 191 |
-
color: updates.color ?? effectiveColorOf(current,
|
| 192 |
effect: updates.effect ?? current?.effect ?? null,
|
| 193 |
stroke: updates.stroke ?? current?.stroke ?? null,
|
| 194 |
textAlign: updates.textAlign ?? current?.textAlign ?? null,
|
| 195 |
}
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
)
|
| 201 |
}
|
| 202 |
|
| 203 |
const applyStyleToSelected = (updates: Partial<TextStyle>): boolean => {
|
| 204 |
-
if (
|
| 205 |
-
|
| 206 |
return true
|
| 207 |
}
|
| 208 |
|
| 209 |
const applyStyleToAll = (updates: Partial<TextStyle>) => {
|
| 210 |
-
|
| 211 |
-
const batch = textNodes.map((n) => {
|
| 212 |
-
const current = n.data.style
|
| 213 |
-
const nextStyle: TextStyle = {
|
| 214 |
-
fontFamilies: updates.fontFamilies ?? current?.fontFamilies ?? [],
|
| 215 |
-
fontSize: updates.fontSize ?? current?.fontSize ?? null,
|
| 216 |
-
color: updates.color ?? effectiveColorOf(current, n.data.fontPrediction),
|
| 217 |
-
effect: updates.effect ?? current?.effect ?? null,
|
| 218 |
-
stroke: updates.stroke ?? current?.stroke ?? null,
|
| 219 |
-
textAlign: updates.textAlign ?? current?.textAlign ?? null,
|
| 220 |
-
}
|
| 221 |
-
return ops.updateNode(page.id, n.id, {
|
| 222 |
-
data: { text: { style: nextStyle } } as never,
|
| 223 |
-
})
|
| 224 |
-
})
|
| 225 |
-
void applyOp(ops.batch('Bulk style update', batch))
|
| 226 |
}
|
| 227 |
|
| 228 |
const applyStrokeSetting = (nextStroke: TextStrokeStyle) => {
|
|
@@ -257,11 +263,14 @@ export function RenderControlsPanel() {
|
|
| 257 |
{ value: 'right', label: t('render.alignRight'), Icon: AlignRightIcon },
|
| 258 |
]
|
| 259 |
|
| 260 |
-
const scopeLabel =
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
| 265 |
const scopeToneClass = selectedNode
|
| 266 |
? 'border-primary/20 bg-primary/10 text-primary'
|
| 267 |
: 'border-border/60 bg-muted text-muted-foreground'
|
|
|
|
| 18 |
import { FontSelect } from '@/components/ui/font-select'
|
| 19 |
import { Input } from '@/components/ui/input'
|
| 20 |
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
| 21 |
+
import {
|
| 22 |
+
useCurrentPage,
|
| 23 |
+
useSelectedTextNode,
|
| 24 |
+
useSelectedTextNodes,
|
| 25 |
+
useTextNodes,
|
| 26 |
+
type TextNodeEntry,
|
| 27 |
+
} from '@/hooks/useCurrentPage'
|
| 28 |
import { useGetGoogleFontsCatalog, useListFonts } from '@/lib/api/default/default'
|
| 29 |
import type {
|
| 30 |
FontFaceInfo,
|
| 31 |
FontPrediction,
|
| 32 |
+
Op,
|
| 33 |
TextAlign,
|
| 34 |
TextShaderEffect,
|
| 35 |
TextStrokeStyle,
|
|
|
|
| 131 |
const page = useCurrentPage()
|
| 132 |
const textNodes = useTextNodes()
|
| 133 |
const selectedNode = useSelectedTextNode()
|
| 134 |
+
const selectedNodes = useSelectedTextNodes()
|
| 135 |
const { data: availableFonts = [] } = useListFonts()
|
| 136 |
useGetGoogleFontsCatalog() // prefetch catalog so picker can decorate Google entries
|
| 137 |
const appDefaultFont = usePreferencesStore((s) => s.defaultFont)
|
|
|
|
| 188 |
// Mutations
|
| 189 |
// ---------------------------------------------------------------------------
|
| 190 |
|
| 191 |
+
const buildStyleOp = (n: TextNodeEntry, updates: Partial<TextStyle>): Op => {
|
| 192 |
+
const current = n.data.style
|
|
|
|
|
|
|
|
|
|
| 193 |
const nextStyle: TextStyle = {
|
| 194 |
fontFamilies: updates.fontFamilies ?? current?.fontFamilies ?? [],
|
| 195 |
fontSize: updates.fontSize ?? current?.fontSize ?? null,
|
| 196 |
+
color: updates.color ?? effectiveColorOf(current, n.data.fontPrediction),
|
| 197 |
effect: updates.effect ?? current?.effect ?? null,
|
| 198 |
stroke: updates.stroke ?? current?.stroke ?? null,
|
| 199 |
textAlign: updates.textAlign ?? current?.textAlign ?? null,
|
| 200 |
}
|
| 201 |
+
return ops.updateNode(page!.id, n.id, {
|
| 202 |
+
data: { text: { style: nextStyle } } as never,
|
| 203 |
+
})
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
const applyStyleToNodes = (
|
| 207 |
+
nodes: TextNodeEntry[],
|
| 208 |
+
updates: Partial<TextStyle>,
|
| 209 |
+
label: string,
|
| 210 |
+
) => {
|
| 211 |
+
if (!page || nodes.length === 0) return
|
| 212 |
+
if (nodes.length === 1) {
|
| 213 |
+
void applyOp(buildStyleOp(nodes[0], updates))
|
| 214 |
+
return
|
| 215 |
+
}
|
| 216 |
+
void applyOp(
|
| 217 |
+
ops.batch(
|
| 218 |
+
label,
|
| 219 |
+
nodes.map((n) => buildStyleOp(n, updates)),
|
| 220 |
+
),
|
| 221 |
)
|
| 222 |
}
|
| 223 |
|
| 224 |
const applyStyleToSelected = (updates: Partial<TextStyle>): boolean => {
|
| 225 |
+
if (selectedNodes.length === 0) return false
|
| 226 |
+
applyStyleToNodes(selectedNodes, updates, 'Multi-block style update')
|
| 227 |
return true
|
| 228 |
}
|
| 229 |
|
| 230 |
const applyStyleToAll = (updates: Partial<TextStyle>) => {
|
| 231 |
+
applyStyleToNodes(textNodes, updates, 'Bulk style update')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
const applyStrokeSetting = (nextStroke: TextStrokeStyle) => {
|
|
|
|
| 263 |
{ value: 'right', label: t('render.alignRight'), Icon: AlignRightIcon },
|
| 264 |
]
|
| 265 |
|
| 266 |
+
const scopeLabel =
|
| 267 |
+
selectedNodes.length > 1
|
| 268 |
+
? t('render.fontScopeBlocksCount', { count: selectedNodes.length })
|
| 269 |
+
: selectedNode
|
| 270 |
+
? t('render.fontScopeBlockIndex', {
|
| 271 |
+
index: textNodes.findIndex((n) => n.id === selectedNode.id) + 1,
|
| 272 |
+
})
|
| 273 |
+
: t('render.fontScopeGlobal')
|
| 274 |
const scopeToneClass = selectedNode
|
| 275 |
? 'border-primary/20 bg-primary/10 text-primary'
|
| 276 |
: 'border-border/60 bg-muted text-muted-foreground'
|
ui/components/panels/TextBlocksPanel.tsx
CHANGED
|
@@ -122,6 +122,7 @@ export function TextBlocksPanel() {
|
|
| 122 |
node={node}
|
| 123 |
index={index}
|
| 124 |
selected={selectedIds.has(node.id)}
|
|
|
|
| 125 |
onPatch={(patch) => void patchText(node.id, patch)}
|
| 126 |
onDelete={() => void removeNode(node.id)}
|
| 127 |
onGenerate={() => void generate()}
|
|
@@ -141,6 +142,7 @@ type BlockCardProps = {
|
|
| 141 |
node: TextNodeEntry
|
| 142 |
index: number
|
| 143 |
selected: boolean
|
|
|
|
| 144 |
onPatch: (patch: TextDataPatch) => void
|
| 145 |
onDelete: () => void
|
| 146 |
onGenerate: () => void
|
|
@@ -152,6 +154,7 @@ function BlockCard({
|
|
| 152 |
node,
|
| 153 |
index,
|
| 154 |
selected,
|
|
|
|
| 155 |
onPatch,
|
| 156 |
onDelete,
|
| 157 |
onGenerate,
|
|
@@ -176,7 +179,16 @@ function BlockCard({
|
|
| 176 |
data-selected={selected}
|
| 177 |
className='overflow-hidden rounded-md bg-card/90 text-xs ring-1 ring-border data-[selected=true]:ring-primary'
|
| 178 |
>
|
| 179 |
-
<AccordionTrigger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
<span
|
| 181 |
className={`shrink-0 rounded-md px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
|
| 182 |
selected ? 'bg-primary' : 'bg-muted-foreground/60'
|
|
|
|
| 122 |
node={node}
|
| 123 |
index={index}
|
| 124 |
selected={selectedIds.has(node.id)}
|
| 125 |
+
onToggleSelect={() => select(node.id, true)}
|
| 126 |
onPatch={(patch) => void patchText(node.id, patch)}
|
| 127 |
onDelete={() => void removeNode(node.id)}
|
| 128 |
onGenerate={() => void generate()}
|
|
|
|
| 142 |
node: TextNodeEntry
|
| 143 |
index: number
|
| 144 |
selected: boolean
|
| 145 |
+
onToggleSelect: () => void
|
| 146 |
onPatch: (patch: TextDataPatch) => void
|
| 147 |
onDelete: () => void
|
| 148 |
onGenerate: () => void
|
|
|
|
| 154 |
node,
|
| 155 |
index,
|
| 156 |
selected,
|
| 157 |
+
onToggleSelect,
|
| 158 |
onPatch,
|
| 159 |
onDelete,
|
| 160 |
onGenerate,
|
|
|
|
| 179 |
data-selected={selected}
|
| 180 |
className='overflow-hidden rounded-md bg-card/90 text-xs ring-1 ring-border data-[selected=true]:ring-primary'
|
| 181 |
>
|
| 182 |
+
<AccordionTrigger
|
| 183 |
+
onClick={(e) => {
|
| 184 |
+
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
| 185 |
+
e.preventDefault()
|
| 186 |
+
e.stopPropagation()
|
| 187 |
+
onToggleSelect()
|
| 188 |
+
}
|
| 189 |
+
}}
|
| 190 |
+
className='flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left transition outline-none hover:no-underline data-[state=open]:bg-accent [&>svg]:hidden'
|
| 191 |
+
>
|
| 192 |
<span
|
| 193 |
className={`shrink-0 rounded-md px-1.5 py-0.5 text-center text-[10px] font-medium text-white tabular-nums ${
|
| 194 |
selected ? 'bg-primary' : 'bg-muted-foreground/60'
|
ui/hooks/useCurrentPage.ts
CHANGED
|
@@ -125,3 +125,23 @@ export function useSelectedTextNode(): TextNodeEntry | null {
|
|
| 125 |
return null
|
| 126 |
}, [page, nodeIds, epoch])
|
| 127 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return null
|
| 126 |
}, [page, nodeIds, epoch])
|
| 127 |
}
|
| 128 |
+
|
| 129 |
+
/** All selected text nodes in stacking order (for batch edits). */
|
| 130 |
+
export function useSelectedTextNodes(): TextNodeEntry[] {
|
| 131 |
+
const page = useCurrentPage()
|
| 132 |
+
const nodeIds = useSelectionStore((s) => s.nodeIds)
|
| 133 |
+
const { epoch } = useScene()
|
| 134 |
+
return useMemo(() => {
|
| 135 |
+
if (!page) return []
|
| 136 |
+
const out: TextNodeEntry[] = []
|
| 137 |
+
for (const [id, node] of Object.entries(page.nodes)) {
|
| 138 |
+
if (!nodeIds.has(id) || !isTextNode(node)) continue
|
| 139 |
+
out.push({
|
| 140 |
+
id,
|
| 141 |
+
transform: node.transform ?? { x: 0, y: 0, width: 0, height: 0 },
|
| 142 |
+
data: node.kind.text,
|
| 143 |
+
})
|
| 144 |
+
}
|
| 145 |
+
return out
|
| 146 |
+
}, [page, nodeIds, epoch])
|
| 147 |
+
}
|
ui/hooks/useKeyboardShortcuts.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useMemo } from 'react'
|
| 4 |
|
| 5 |
-
import { redoOp, undoOp } from '@/lib/io/scene'
|
| 6 |
import { getPlatform, formatShortcut, isModifierKey } from '@/lib/shortcutUtils'
|
| 7 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 8 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
@@ -34,6 +34,14 @@ export function useKeyboardShortcuts() {
|
|
| 34 |
return
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
// Every other shortcut is tool-level and should not fire while typing.
|
| 38 |
if (inTextField) return
|
| 39 |
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useMemo } from 'react'
|
| 4 |
|
| 5 |
+
import { redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene'
|
| 6 |
import { getPlatform, formatShortcut, isModifierKey } from '@/lib/shortcutUtils'
|
| 7 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 8 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
|
|
| 34 |
return
|
| 35 |
}
|
| 36 |
|
| 37 |
+
// Select all text blocks on the current page. Runs outside text fields;
|
| 38 |
+
// inside a textarea/input the browser's native "select all text" wins.
|
| 39 |
+
if (mod && (event.key === 'a' || event.key === 'A') && !inTextField) {
|
| 40 |
+
event.preventDefault()
|
| 41 |
+
selectAllTextNodesOnCurrentPage()
|
| 42 |
+
return
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
// Every other shortcut is tool-level and should not fire while typing.
|
| 46 |
if (inTextField) return
|
| 47 |
|
ui/lib/io/scene.ts
CHANGED
|
@@ -23,8 +23,10 @@ import type {
|
|
| 23 |
Op,
|
| 24 |
OpenProjectRequest,
|
| 25 |
ProjectSummary,
|
|
|
|
| 26 |
} from '@/lib/api/schemas'
|
| 27 |
import { queryClient } from '@/lib/queryClient'
|
|
|
|
| 28 |
|
| 29 |
/**
|
| 30 |
* Imperative action helpers. Every mutation below is a thin wrapper that
|
|
@@ -59,6 +61,20 @@ export async function redoOp(): Promise<void> {
|
|
| 59 |
await invalidateScene()
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
// Project lifecycle ----------------------------------------------------------
|
| 63 |
|
| 64 |
export async function createAndOpenProject(req: CreateProjectRequest): Promise<ProjectSummary> {
|
|
|
|
| 23 |
Op,
|
| 24 |
OpenProjectRequest,
|
| 25 |
ProjectSummary,
|
| 26 |
+
SceneSnapshot,
|
| 27 |
} from '@/lib/api/schemas'
|
| 28 |
import { queryClient } from '@/lib/queryClient'
|
| 29 |
+
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
| 30 |
|
| 31 |
/**
|
| 32 |
* Imperative action helpers. Every mutation below is a thin wrapper that
|
|
|
|
| 61 |
await invalidateScene()
|
| 62 |
}
|
| 63 |
|
| 64 |
+
/** Select every text node on the active page. No-op if no project/page open. */
|
| 65 |
+
export function selectAllTextNodesOnCurrentPage(): void {
|
| 66 |
+
const pageId = useSelectionStore.getState().pageId
|
| 67 |
+
if (!pageId) return
|
| 68 |
+
const snap = queryClient.getQueryData<SceneSnapshot>(getGetSceneJsonQueryKey())
|
| 69 |
+
const page = snap?.scene?.pages?.[pageId]
|
| 70 |
+
if (!page) return
|
| 71 |
+
const ids: string[] = []
|
| 72 |
+
for (const [id, node] of Object.entries(page.nodes)) {
|
| 73 |
+
if (node && 'text' in node.kind) ids.push(id)
|
| 74 |
+
}
|
| 75 |
+
useSelectionStore.getState().selectMany(ids)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
// Project lifecycle ----------------------------------------------------------
|
| 79 |
|
| 80 |
export async function createAndOpenProject(req: CreateProjectRequest): Promise<ProjectSummary> {
|
ui/public/locales/en-US/translation.json
CHANGED
|
@@ -20,6 +20,7 @@
|
|
| 20 |
"edit": "Edit",
|
| 21 |
"undo": "Undo",
|
| 22 |
"redo": "Redo",
|
|
|
|
| 23 |
"exportGroup": "Export",
|
| 24 |
"export": "Export...",
|
| 25 |
"exportPsd": "Export PSD...",
|
|
@@ -205,7 +206,8 @@
|
|
| 205 |
"fontSizeLabel": "Size",
|
| 206 |
"fontScopeGlobal": "Global",
|
| 207 |
"fontScopeBlock": "Per block",
|
| 208 |
-
"fontScopeBlockIndex": "Block {{index}}"
|
|
|
|
| 209 |
},
|
| 210 |
"textBlocks": {
|
| 211 |
"emptyPrompt": "Open an image to see text blocks.",
|
|
|
|
| 20 |
"edit": "Edit",
|
| 21 |
"undo": "Undo",
|
| 22 |
"redo": "Redo",
|
| 23 |
+
"selectAll": "Select All",
|
| 24 |
"exportGroup": "Export",
|
| 25 |
"export": "Export...",
|
| 26 |
"exportPsd": "Export PSD...",
|
|
|
|
| 206 |
"fontSizeLabel": "Size",
|
| 207 |
"fontScopeGlobal": "Global",
|
| 208 |
"fontScopeBlock": "Per block",
|
| 209 |
+
"fontScopeBlockIndex": "Block {{index}}",
|
| 210 |
+
"fontScopeBlocksCount": "{{count}} blocks"
|
| 211 |
},
|
| 212 |
"textBlocks": {
|
| 213 |
"emptyPrompt": "Open an image to see text blocks.",
|
ui/public/locales/es-ES/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "Editar",
|
| 19 |
"undo": "Deshacer",
|
| 20 |
"redo": "Rehacer",
|
|
|
|
| 21 |
"export": "Exportar...",
|
| 22 |
"exportPsd": "Exportar PSD...",
|
| 23 |
"exportAllInpainted": "Exportar todo lo rellenado...",
|
|
@@ -198,6 +199,7 @@
|
|
| 198 |
"fontScopeGlobal": "Global",
|
| 199 |
"fontScopeBlock": "Por bloque",
|
| 200 |
"fontScopeBlockIndex": "Bloque {{index}}",
|
|
|
|
| 201 |
"effectItalic": "Cursiva",
|
| 202 |
"effectBold": "Negrita",
|
| 203 |
"effectBorder": "Borde",
|
|
|
|
| 18 |
"edit": "Editar",
|
| 19 |
"undo": "Deshacer",
|
| 20 |
"redo": "Rehacer",
|
| 21 |
+
"selectAll": "Seleccionar todo",
|
| 22 |
"export": "Exportar...",
|
| 23 |
"exportPsd": "Exportar PSD...",
|
| 24 |
"exportAllInpainted": "Exportar todo lo rellenado...",
|
|
|
|
| 199 |
"fontScopeGlobal": "Global",
|
| 200 |
"fontScopeBlock": "Por bloque",
|
| 201 |
"fontScopeBlockIndex": "Bloque {{index}}",
|
| 202 |
+
"fontScopeBlocksCount": "{{count}} bloques",
|
| 203 |
"effectItalic": "Cursiva",
|
| 204 |
"effectBold": "Negrita",
|
| 205 |
"effectBorder": "Borde",
|
ui/public/locales/ja-JP/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "編集",
|
| 19 |
"undo": "元に戻す",
|
| 20 |
"redo": "やり直し",
|
|
|
|
| 21 |
"export": "エクスポート...",
|
| 22 |
"exportPsd": "PSD を書き出し...",
|
| 23 |
"exportAllInpainted": "インペイント画像をすべてエクスポート...",
|
|
@@ -198,6 +199,7 @@
|
|
| 198 |
"fontScopeGlobal": "全体",
|
| 199 |
"fontScopeBlock": "ブロックごと",
|
| 200 |
"fontScopeBlockIndex": "ブロック {{index}}",
|
|
|
|
| 201 |
"effectItalic": "斜体",
|
| 202 |
"effectBold": "太字",
|
| 203 |
"effectBorder": "縁取り",
|
|
|
|
| 18 |
"edit": "編集",
|
| 19 |
"undo": "元に戻す",
|
| 20 |
"redo": "やり直し",
|
| 21 |
+
"selectAll": "すべて選択",
|
| 22 |
"export": "エクスポート...",
|
| 23 |
"exportPsd": "PSD を書き出し...",
|
| 24 |
"exportAllInpainted": "インペイント画像をすべてエクスポート...",
|
|
|
|
| 199 |
"fontScopeGlobal": "全体",
|
| 200 |
"fontScopeBlock": "ブロックごと",
|
| 201 |
"fontScopeBlockIndex": "ブロック {{index}}",
|
| 202 |
+
"fontScopeBlocksCount": "{{count}} ブロック",
|
| 203 |
"effectItalic": "斜体",
|
| 204 |
"effectBold": "太字",
|
| 205 |
"effectBorder": "縁取り",
|
ui/public/locales/ko-KR/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "편집",
|
| 19 |
"undo": "실행 취소",
|
| 20 |
"redo": "다시 실행",
|
|
|
|
| 21 |
"export": "내보내기...",
|
| 22 |
"exportPsd": "PSD 내보내기...",
|
| 23 |
"exportAllInpainted": "모든 인페인트 이미지 내보내기...",
|
|
@@ -202,7 +203,8 @@
|
|
| 202 |
"fontSizeLabel": "크기",
|
| 203 |
"fontScopeGlobal": "전역",
|
| 204 |
"fontScopeBlock": "블록별",
|
| 205 |
-
"fontScopeBlockIndex": "블록 {{index}}"
|
|
|
|
| 206 |
},
|
| 207 |
"textBlocks": {
|
| 208 |
"emptyPrompt": "텍스트 블록을 보려면 이미지를 열어주세요.",
|
|
|
|
| 18 |
"edit": "편집",
|
| 19 |
"undo": "실행 취소",
|
| 20 |
"redo": "다시 실행",
|
| 21 |
+
"selectAll": "모두 선택",
|
| 22 |
"export": "내보내기...",
|
| 23 |
"exportPsd": "PSD 내보내기...",
|
| 24 |
"exportAllInpainted": "모든 인페인트 이미지 내보내기...",
|
|
|
|
| 203 |
"fontSizeLabel": "크기",
|
| 204 |
"fontScopeGlobal": "전역",
|
| 205 |
"fontScopeBlock": "블록별",
|
| 206 |
+
"fontScopeBlockIndex": "블록 {{index}}",
|
| 207 |
+
"fontScopeBlocksCount": "블록 {{count}}개"
|
| 208 |
},
|
| 209 |
"textBlocks": {
|
| 210 |
"emptyPrompt": "텍스트 블록을 보려면 이미지를 열어주세요.",
|
ui/public/locales/pt-BR/translation.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
| 19 |
"edit": "Editar",
|
| 20 |
"undo": "Desfazer",
|
| 21 |
"redo": "Refazer",
|
|
|
|
| 22 |
"export": "Exportar...",
|
| 23 |
"exportPsd": "Exportar PSD...",
|
| 24 |
"exportAllInpainted": "Exportar todos os inpaintings...",
|
|
@@ -203,7 +204,8 @@
|
|
| 203 |
"fontSizeLabel": "Tamanho",
|
| 204 |
"fontScopeGlobal": "Global",
|
| 205 |
"fontScopeBlock": "Por bloco",
|
| 206 |
-
"fontScopeBlockIndex": "Bloco {{index}}"
|
|
|
|
| 207 |
},
|
| 208 |
"textBlocks": {
|
| 209 |
"emptyPrompt": "Abra uma imagem para ver os blocos de texto.",
|
|
|
|
| 19 |
"edit": "Editar",
|
| 20 |
"undo": "Desfazer",
|
| 21 |
"redo": "Refazer",
|
| 22 |
+
"selectAll": "Selecionar tudo",
|
| 23 |
"export": "Exportar...",
|
| 24 |
"exportPsd": "Exportar PSD...",
|
| 25 |
"exportAllInpainted": "Exportar todos os inpaintings...",
|
|
|
|
| 204 |
"fontSizeLabel": "Tamanho",
|
| 205 |
"fontScopeGlobal": "Global",
|
| 206 |
"fontScopeBlock": "Por bloco",
|
| 207 |
+
"fontScopeBlockIndex": "Bloco {{index}}",
|
| 208 |
+
"fontScopeBlocksCount": "{{count}} blocos"
|
| 209 |
},
|
| 210 |
"textBlocks": {
|
| 211 |
"emptyPrompt": "Abra uma imagem para ver os blocos de texto.",
|
ui/public/locales/ru-RU/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "Правка",
|
| 19 |
"undo": "Отменить",
|
| 20 |
"redo": "Повторить",
|
|
|
|
| 21 |
"export": "Экспортировать...",
|
| 22 |
"exportPsd": "Экспортировать PSD...",
|
| 23 |
"exportAllInpainted": "Экспортировать все изображения после инпейнтинга...",
|
|
@@ -198,6 +199,7 @@
|
|
| 198 |
"fontScopeGlobal": "Глобально",
|
| 199 |
"fontScopeBlock": "Для блока",
|
| 200 |
"fontScopeBlockIndex": "Блок {{index}}",
|
|
|
|
| 201 |
"effectItalic": "Курсив",
|
| 202 |
"effectBold": "Жирный",
|
| 203 |
"effectBorder": "Обводка",
|
|
|
|
| 18 |
"edit": "Правка",
|
| 19 |
"undo": "Отменить",
|
| 20 |
"redo": "Повторить",
|
| 21 |
+
"selectAll": "Выделить всё",
|
| 22 |
"export": "Экспортировать...",
|
| 23 |
"exportPsd": "Экспортировать PSD...",
|
| 24 |
"exportAllInpainted": "Экспортировать все изображения после инпейнтинга...",
|
|
|
|
| 199 |
"fontScopeGlobal": "Глобально",
|
| 200 |
"fontScopeBlock": "Для блока",
|
| 201 |
"fontScopeBlockIndex": "Блок {{index}}",
|
| 202 |
+
"fontScopeBlocksCount": "Блоков: {{count}}",
|
| 203 |
"effectItalic": "Курсив",
|
| 204 |
"effectBold": "Жирный",
|
| 205 |
"effectBorder": "Обводка",
|
ui/public/locales/tr-TR/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "Düzenle",
|
| 19 |
"undo": "Geri Al",
|
| 20 |
"redo": "Yinele",
|
|
|
|
| 21 |
"export": "Dışa Aktar...",
|
| 22 |
"exportPsd": "PSD Olarak Dışa Aktar...",
|
| 23 |
"exportAllInpainted": "Tüm İnpaint Görsellerini Dışa Aktar...",
|
|
@@ -202,7 +203,8 @@
|
|
| 202 |
"fontSizeLabel": "Boyut",
|
| 203 |
"fontScopeGlobal": "Genel",
|
| 204 |
"fontScopeBlock": "Blok bazında",
|
| 205 |
-
"fontScopeBlockIndex": "Blok {{index}}"
|
|
|
|
| 206 |
},
|
| 207 |
"textBlocks": {
|
| 208 |
"emptyPrompt": "Metin bloklarını görmek için bir görsel açın.",
|
|
|
|
| 18 |
"edit": "Düzenle",
|
| 19 |
"undo": "Geri Al",
|
| 20 |
"redo": "Yinele",
|
| 21 |
+
"selectAll": "Tümünü Seç",
|
| 22 |
"export": "Dışa Aktar...",
|
| 23 |
"exportPsd": "PSD Olarak Dışa Aktar...",
|
| 24 |
"exportAllInpainted": "Tüm İnpaint Görsellerini Dışa Aktar...",
|
|
|
|
| 203 |
"fontSizeLabel": "Boyut",
|
| 204 |
"fontScopeGlobal": "Genel",
|
| 205 |
"fontScopeBlock": "Blok bazında",
|
| 206 |
+
"fontScopeBlockIndex": "Blok {{index}}",
|
| 207 |
+
"fontScopeBlocksCount": "{{count}} blok"
|
| 208 |
},
|
| 209 |
"textBlocks": {
|
| 210 |
"emptyPrompt": "Metin bloklarını görmek için bir görsel açın.",
|
ui/public/locales/zh-CN/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "编辑",
|
| 19 |
"undo": "撤销",
|
| 20 |
"redo": "重做",
|
|
|
|
| 21 |
"export": "导出...",
|
| 22 |
"exportPsd": "导出 PSD...",
|
| 23 |
"exportAllInpainted": "导出所有修复后的图像...",
|
|
@@ -198,6 +199,7 @@
|
|
| 198 |
"fontScopeGlobal": "全局",
|
| 199 |
"fontScopeBlock": "按文本块",
|
| 200 |
"fontScopeBlockIndex": "文本块 {{index}}",
|
|
|
|
| 201 |
"effectItalic": "斜体",
|
| 202 |
"effectBold": "粗体",
|
| 203 |
"effectBorder": "描边",
|
|
|
|
| 18 |
"edit": "编辑",
|
| 19 |
"undo": "撤销",
|
| 20 |
"redo": "重做",
|
| 21 |
+
"selectAll": "全选",
|
| 22 |
"export": "导出...",
|
| 23 |
"exportPsd": "导出 PSD...",
|
| 24 |
"exportAllInpainted": "导出所有修复后的图像...",
|
|
|
|
| 199 |
"fontScopeGlobal": "全局",
|
| 200 |
"fontScopeBlock": "按文本块",
|
| 201 |
"fontScopeBlockIndex": "文本块 {{index}}",
|
| 202 |
+
"fontScopeBlocksCount": "{{count}} 个文本块",
|
| 203 |
"effectItalic": "斜体",
|
| 204 |
"effectBold": "粗体",
|
| 205 |
"effectBorder": "描边",
|
ui/public/locales/zh-TW/translation.json
CHANGED
|
@@ -18,6 +18,7 @@
|
|
| 18 |
"edit": "編輯",
|
| 19 |
"undo": "復原",
|
| 20 |
"redo": "重做",
|
|
|
|
| 21 |
"export": "匯出...",
|
| 22 |
"exportPsd": "匯出 PSD...",
|
| 23 |
"exportAllInpainted": "匯出所有修補後的影像...",
|
|
@@ -198,6 +199,7 @@
|
|
| 198 |
"fontScopeGlobal": "全域",
|
| 199 |
"fontScopeBlock": "依文字區塊",
|
| 200 |
"fontScopeBlockIndex": "文字區塊 {{index}}",
|
|
|
|
| 201 |
"effectItalic": "斜體",
|
| 202 |
"effectBold": "粗體",
|
| 203 |
"effectBorder": "描邊",
|
|
|
|
| 18 |
"edit": "編輯",
|
| 19 |
"undo": "復原",
|
| 20 |
"redo": "重做",
|
| 21 |
+
"selectAll": "全選",
|
| 22 |
"export": "匯出...",
|
| 23 |
"exportPsd": "匯出 PSD...",
|
| 24 |
"exportAllInpainted": "匯出所有修補後的影像...",
|
|
|
|
| 199 |
"fontScopeGlobal": "全域",
|
| 200 |
"fontScopeBlock": "依文字區塊",
|
| 201 |
"fontScopeBlockIndex": "文字區塊 {{index}}",
|
| 202 |
+
"fontScopeBlocksCount": "{{count}} 個文字區塊",
|
| 203 |
"effectItalic": "斜體",
|
| 204 |
"effectBold": "粗體",
|
| 205 |
"effectBorder": "描邊",
|
ui/tests/hooks/useKeyboardShortcuts.test.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { renderHook } from '@testing-library/react'
|
| 2 |
+
import { fireEvent } from '@testing-library/react'
|
| 3 |
+
import { beforeEach, describe, expect, it } from 'vitest'
|
| 4 |
+
|
| 5 |
+
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
| 6 |
+
import { getGetSceneJsonQueryKey } from '@/lib/api/default/default'
|
| 7 |
+
import type { Node, Page, SceneSnapshot } from '@/lib/api/schemas'
|
| 8 |
+
import { queryClient } from '@/lib/queryClient'
|
| 9 |
+
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
| 10 |
+
|
| 11 |
+
function textNode(id: string): Node {
|
| 12 |
+
return {
|
| 13 |
+
id,
|
| 14 |
+
transform: { x: 0, y: 0, width: 10, height: 10, rotationDeg: 0 },
|
| 15 |
+
visible: true,
|
| 16 |
+
kind: { text: { raw: `t-${id}` } },
|
| 17 |
+
} as unknown as Node
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function seedScene(): SceneSnapshot {
|
| 21 |
+
const page: Page = {
|
| 22 |
+
id: 'p-1',
|
| 23 |
+
name: 'P',
|
| 24 |
+
width: 10,
|
| 25 |
+
height: 10,
|
| 26 |
+
nodes: { t1: textNode('t1'), t2: textNode('t2') },
|
| 27 |
+
} as unknown as Page
|
| 28 |
+
return {
|
| 29 |
+
epoch: 1,
|
| 30 |
+
scene: { pages: { 'p-1': page }, project: { name: 'P' } as never } as never,
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
describe('useKeyboardShortcuts — Ctrl+A', () => {
|
| 35 |
+
beforeEach(() => {
|
| 36 |
+
useSelectionStore.getState().setPage(null)
|
| 37 |
+
queryClient.clear()
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
it('Ctrl+A selects every text node on the active page', () => {
|
| 41 |
+
queryClient.setQueryData(getGetSceneJsonQueryKey(), seedScene())
|
| 42 |
+
useSelectionStore.getState().setPage('p-1')
|
| 43 |
+
renderHook(() => useKeyboardShortcuts())
|
| 44 |
+
|
| 45 |
+
fireEvent.keyDown(window, { key: 'a', ctrlKey: true })
|
| 46 |
+
|
| 47 |
+
expect([...useSelectionStore.getState().nodeIds].sort()).toEqual(['t1', 't2'])
|
| 48 |
+
})
|
| 49 |
+
|
| 50 |
+
it('Ctrl+A is a no-op while typing inside a textarea', () => {
|
| 51 |
+
queryClient.setQueryData(getGetSceneJsonQueryKey(), seedScene())
|
| 52 |
+
useSelectionStore.getState().setPage('p-1')
|
| 53 |
+
renderHook(() => useKeyboardShortcuts())
|
| 54 |
+
|
| 55 |
+
const textarea = document.createElement('textarea')
|
| 56 |
+
document.body.appendChild(textarea)
|
| 57 |
+
textarea.focus()
|
| 58 |
+
|
| 59 |
+
fireEvent.keyDown(textarea, { key: 'a', ctrlKey: true })
|
| 60 |
+
|
| 61 |
+
expect(useSelectionStore.getState().nodeIds.size).toBe(0)
|
| 62 |
+
|
| 63 |
+
document.body.removeChild(textarea)
|
| 64 |
+
})
|
| 65 |
+
})
|
ui/tests/lib/io/selectAll.test.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it } from 'vitest'
|
| 2 |
+
|
| 3 |
+
import { getGetSceneJsonQueryKey } from '@/lib/api/default/default'
|
| 4 |
+
import type { Node, Page, SceneSnapshot } from '@/lib/api/schemas'
|
| 5 |
+
import { selectAllTextNodesOnCurrentPage } from '@/lib/io/scene'
|
| 6 |
+
import { queryClient } from '@/lib/queryClient'
|
| 7 |
+
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
| 8 |
+
|
| 9 |
+
function textNode(id: string): Node {
|
| 10 |
+
return {
|
| 11 |
+
id,
|
| 12 |
+
transform: { x: 0, y: 0, width: 10, height: 10, rotationDeg: 0 },
|
| 13 |
+
visible: true,
|
| 14 |
+
kind: { text: { raw: `t-${id}` } },
|
| 15 |
+
} as unknown as Node
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function imageNode(id: string): Node {
|
| 19 |
+
return {
|
| 20 |
+
id,
|
| 21 |
+
transform: { x: 0, y: 0, width: 10, height: 10, rotationDeg: 0 },
|
| 22 |
+
visible: true,
|
| 23 |
+
kind: {
|
| 24 |
+
image: { role: 'source', blob: `b-${id}`, opacity: 1, naturalWidth: 1, naturalHeight: 1 },
|
| 25 |
+
},
|
| 26 |
+
} as unknown as Node
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function seedScene(): SceneSnapshot {
|
| 30 |
+
const page: Page = {
|
| 31 |
+
id: 'p-1',
|
| 32 |
+
name: 'P',
|
| 33 |
+
width: 10,
|
| 34 |
+
height: 10,
|
| 35 |
+
nodes: {
|
| 36 |
+
src: imageNode('src'),
|
| 37 |
+
t1: textNode('t1'),
|
| 38 |
+
t2: textNode('t2'),
|
| 39 |
+
rend: imageNode('rend'),
|
| 40 |
+
},
|
| 41 |
+
} as unknown as Page
|
| 42 |
+
return {
|
| 43 |
+
epoch: 1,
|
| 44 |
+
scene: { pages: { 'p-1': page }, project: { name: 'P' } as never } as never,
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
describe('selectAllTextNodesOnCurrentPage', () => {
|
| 49 |
+
beforeEach(() => {
|
| 50 |
+
useSelectionStore.getState().setPage(null)
|
| 51 |
+
queryClient.clear()
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
it('is a no-op when no page is selected', () => {
|
| 55 |
+
queryClient.setQueryData(getGetSceneJsonQueryKey(), seedScene())
|
| 56 |
+
selectAllTextNodesOnCurrentPage()
|
| 57 |
+
expect(useSelectionStore.getState().nodeIds.size).toBe(0)
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
it('is a no-op when the scene snapshot is not cached', () => {
|
| 61 |
+
useSelectionStore.getState().setPage('p-1')
|
| 62 |
+
selectAllTextNodesOnCurrentPage()
|
| 63 |
+
expect(useSelectionStore.getState().nodeIds.size).toBe(0)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
it('selects only text nodes on the active page', () => {
|
| 67 |
+
queryClient.setQueryData(getGetSceneJsonQueryKey(), seedScene())
|
| 68 |
+
useSelectionStore.getState().setPage('p-1')
|
| 69 |
+
selectAllTextNodesOnCurrentPage()
|
| 70 |
+
expect([...useSelectionStore.getState().nodeIds].sort()).toEqual(['t1', 't2'])
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
it('replaces existing selection with the full text-node set', () => {
|
| 74 |
+
queryClient.setQueryData(getGetSceneJsonQueryKey(), seedScene())
|
| 75 |
+
useSelectionStore.getState().setPage('p-1')
|
| 76 |
+
useSelectionStore.getState().select('src', false)
|
| 77 |
+
expect(useSelectionStore.getState().nodeIds.has('src')).toBe(true)
|
| 78 |
+
selectAllTextNodesOnCurrentPage()
|
| 79 |
+
expect([...useSelectionStore.getState().nodeIds].sort()).toEqual(['t1', 't2'])
|
| 80 |
+
})
|
| 81 |
+
})
|