Mayo commited on
Commit
04ac060
·
unverified ·
1 Parent(s): 4070117

feat: multi-selection on ui

Browse files
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, false)}
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
- onSelect(node.id)
 
 
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 { useCurrentPage, useSelectedTextNode, useTextNodes } from '@/hooks/useCurrentPage'
 
 
 
 
 
 
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 applyStyleToNode = async (nodeId: string, updates: Partial<TextStyle>) => {
184
- if (!page) return
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, existing.kind.text.fontPrediction),
192
  effect: updates.effect ?? current?.effect ?? null,
193
  stroke: updates.stroke ?? current?.stroke ?? null,
194
  textAlign: updates.textAlign ?? current?.textAlign ?? null,
195
  }
196
- await applyOp(
197
- ops.updateNode(page.id, nodeId, {
198
- data: { text: { style: nextStyle } } as never,
199
- }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  )
201
  }
202
 
203
  const applyStyleToSelected = (updates: Partial<TextStyle>): boolean => {
204
- if (!selectedNode) return false
205
- void applyStyleToNode(selectedNode.id, updates)
206
  return true
207
  }
208
 
209
  const applyStyleToAll = (updates: Partial<TextStyle>) => {
210
- if (!hasNodes || !page) return
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 = selectedNode
261
- ? t('render.fontScopeBlockIndex', {
262
- index: textNodes.findIndex((n) => n.id === selectedNode.id) + 1,
263
- })
264
- : t('render.fontScopeGlobal')
 
 
 
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 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'>
 
 
 
 
 
 
 
 
 
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
+ })