From ee3c4b316d3b09735b326d17c9047ede3897db10 Mon Sep 17 00:00:00 2001 From: Pregum Date: Thu, 23 Apr 2026 15:36:59 +0900 Subject: [PATCH] feat: Tidy Up + Select Same Fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tidy Up: 選択ノードを等間隔の row/column/grid に整列。 auto モードは bbox の縦横比から自動判定。 - Select Same Fill: 選択ノードと同じ fill を持つ全ノードを選択。 - utils/tidyUp: pure function (detectTidyMode, tidyUp) - tests: 10 tests for tidyUp, 4 tests for findNodesBySameFill Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/Viewer/AlignCommands.tsx | 23 +++++++ src/components/Viewer/ExtraCommands.tsx | 27 ++++++++ src/utils/tidyUp.ts | 85 +++++++++++++++++++++++ tests/extraCommands.test.ts | 31 +++++++++ tests/tidyUp.test.ts | 91 +++++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 src/utils/tidyUp.ts create mode 100644 tests/tidyUp.test.ts diff --git a/src/components/Viewer/AlignCommands.tsx b/src/components/Viewer/AlignCommands.tsx index 9e9d5ad..0a84226 100644 --- a/src/components/Viewer/AlignCommands.tsx +++ b/src/components/Viewer/AlignCommands.tsx @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import { useEditor } from '../../pen/state/EditorContext'; import type { PenNode } from '../../pen/types'; import type { Command } from './CommandPalette'; +import { tidyUp, type TidyItem, type TidyMode } from '../../utils/tidyUp'; function getNodeRect(node: PenNode): { id: string; x: number; y: number; w: number; h: number } | null { const x = node.x ?? 0; @@ -124,6 +125,23 @@ export function useAlignActions() { } }, [getSelectedRects, updateNodeSilent, pushUndoCheckpoint]); + const tidyUpSelection = useCallback((mode: TidyMode = 'auto') => { + const rects = getSelectedRects(); + if (rects.length < 2) return; + const items: TidyItem[] = rects.map((r) => ({ + id: r.id, + x: r.x, + y: r.y, + width: r.w, + height: r.h, + })); + const results = tidyUp(items, { mode }); + pushUndoCheckpoint(); + for (const r of results) { + updateNodeSilent(r.id, { x: Math.round(r.x), y: Math.round(r.y) } as Partial); + } + }, [getSelectedRects, updateNodeSilent, pushUndoCheckpoint]); + const sortByX = useCallback(() => { const rects = getSelectedRects(); if (rects.length < 2) return; @@ -147,6 +165,7 @@ export function useAlignActions() { distributeH, distributeV, sortByX, + tidyUpSelection, }; } @@ -163,5 +182,9 @@ export function useAlignCommands(): Command[] { { id: 'distribute-h', label: `Distribute Horizontal${suffix}`, action: a.distributeH }, { id: 'distribute-v', label: `Distribute Vertical${suffix}`, action: a.distributeV }, { id: 'sort-by-x', label: `Sort by X position${suffix}`, action: a.sortByX }, + { id: 'tidy-up', label: `Tidy Up${suffix}`, action: () => a.tidyUpSelection('auto') }, + { id: 'tidy-up-row', label: `Tidy Up — Row${suffix}`, action: () => a.tidyUpSelection('row') }, + { id: 'tidy-up-column', label: `Tidy Up — Column${suffix}`, action: () => a.tidyUpSelection('column') }, + { id: 'tidy-up-grid', label: `Tidy Up — Grid${suffix}`, action: () => a.tidyUpSelection('grid') }, ]; } diff --git a/src/components/Viewer/ExtraCommands.tsx b/src/components/Viewer/ExtraCommands.tsx index c072d45..e0c9f2b 100644 --- a/src/components/Viewer/ExtraCommands.tsx +++ b/src/components/Viewer/ExtraCommands.tsx @@ -60,6 +60,24 @@ export function findNodesByColor(nodes: PenNode[], color: string): string[] { .map((n) => n.id); } +/** Find all nodes whose fill shares any color with the source node's fill. */ +export function findNodesBySameFill(nodes: PenNode[], sourceId: string): string[] { + const all = flattenNodes(nodes); + const src = all.find((n) => n.id === sourceId); + if (!src) return []; + const srcFill = (src as { fill?: unknown }).fill; + if (!srcFill) return []; + const srcColors = new Set(extractColors(srcFill).map((c) => c.toLowerCase())); + if (srcColors.size === 0) return []; + return all + .filter((n) => { + const fill = (n as { fill?: unknown }).fill; + if (!fill) return false; + return extractColors(fill).some((c) => srcColors.has(c.toLowerCase())); + }) + .map((n) => n.id); +} + /** Collect all unique fill colors used across all nodes. */ export function collectColors(nodes: PenNode[]): string[] { const colors = new Set(); @@ -167,6 +185,14 @@ export function useExtraCommands(): Command[] { selectMultiple(ids); }, [state.doc.children, state.selectedNodeId, selectMultiple]); + const selectSameFill = useCallback(() => { + const srcId = state.selectedNodeId; + if (!srcId) return; + const ids = findNodesBySameFill(state.doc.children, srcId); + if (ids.length === 0) return; + selectMultiple(ids); + }, [state.doc.children, state.selectedNodeId, selectMultiple]); + // -- Edit -- const duplicateSelected = useCallback(() => { @@ -314,6 +340,7 @@ export function useExtraCommands(): Command[] { // Selection { id: 'select-all', label: 'Select All', shortcut: 'Ctrl+A', action: selectAll }, { id: 'select-same-type', label: 'Select Same Type', action: selectSameType }, + { id: 'select-same-fill', label: 'Select Same Fill', action: selectSameFill }, // Edit { id: 'duplicate-node', label: 'Duplicate Node', shortcut: 'Ctrl+D', action: duplicateSelected }, { id: 'group-selection', label: 'Group Selection', shortcut: 'Ctrl+G', action: groupSelection }, diff --git a/src/utils/tidyUp.ts b/src/utils/tidyUp.ts new file mode 100644 index 0000000..6dd5e87 --- /dev/null +++ b/src/utils/tidyUp.ts @@ -0,0 +1,85 @@ +/** + * Tidy Up: 選択ノードを等間隔の row / column / grid に整列する pure 関数。 + * + * Figma の Tidy Up に相当。現在の配置の広がり(bbox)から向きを auto 判定する。 + * UI や React 状態には依存しないのでテスト可能。 + */ + +export interface TidyItem { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export type TidyMode = 'auto' | 'row' | 'column' | 'grid'; + +export interface TidyOptions { + mode?: TidyMode; + gap?: number; +} + +export interface TidyResult { + id: string; + x: number; + y: number; +} + +export function detectTidyMode(items: TidyItem[]): Exclude { + if (items.length < 2) return 'row'; + const minX = Math.min(...items.map((i) => i.x)); + const maxX = Math.max(...items.map((i) => i.x + i.width)); + const minY = Math.min(...items.map((i) => i.y)); + const maxY = Math.max(...items.map((i) => i.y + i.height)); + const dx = maxX - minX; + const dy = maxY - minY; + if (dx > 2.5 * dy) return 'row'; + if (dy > 2.5 * dx) return 'column'; + return 'grid'; +} + +export function tidyUp(items: TidyItem[], opts: TidyOptions = {}): TidyResult[] { + if (items.length < 2) return items.map(({ id, x, y }) => ({ id, x, y })); + const gap = opts.gap ?? 16; + const mode = opts.mode && opts.mode !== 'auto' ? opts.mode : detectTidyMode(items); + + if (mode === 'row') { + const sorted = [...items].sort((a, b) => a.x - b.x); + const alignY = Math.min(...items.map((i) => i.y)); + let cursorX = sorted[0].x; + return sorted.map((it) => { + const pos = { id: it.id, x: cursorX, y: alignY }; + cursorX += it.width + gap; + return pos; + }); + } + + if (mode === 'column') { + const sorted = [...items].sort((a, b) => a.y - b.y); + const alignX = Math.min(...items.map((i) => i.x)); + let cursorY = sorted[0].y; + return sorted.map((it) => { + const pos = { id: it.id, x: alignX, y: cursorY }; + cursorY += it.height + gap; + return pos; + }); + } + + // grid + const cols = Math.max(1, Math.round(Math.sqrt(items.length))); + const sorted = [...items].sort((a, b) => a.y - b.y || a.x - b.x); + const cellW = Math.max(...items.map((i) => i.width)); + const cellH = Math.max(...items.map((i) => i.height)); + const originX = Math.min(...items.map((i) => i.x)); + const originY = Math.min(...items.map((i) => i.y)); + return sorted.map((it, i) => { + const r = Math.floor(i / cols); + const c = i % cols; + return { + id: it.id, + x: originX + c * (cellW + gap), + y: originY + r * (cellH + gap), + }; + }); +} diff --git a/tests/extraCommands.test.ts b/tests/extraCommands.test.ts index 192cc59..c4877dc 100644 --- a/tests/extraCommands.test.ts +++ b/tests/extraCommands.test.ts @@ -3,6 +3,7 @@ import type { PenNode } from '../src/pen/types'; import { findNodesByText, findNodesByColor, + findNodesBySameFill, collectColors, collectFonts, countComponentUsage, @@ -75,6 +76,36 @@ describe('findNodesByColor', () => { }); }); +describe('findNodesBySameFill', () => { + it('finds all nodes sharing a fill color with the source', () => { + const ids = findNodesBySameFill(sampleNodes, 't1'); // #FF0000 + expect(ids).toContain('t1'); + expect(ids).toContain('r1'); + expect(ids).toContain('r2'); // solid-fill object form + expect(ids).not.toContain('t2'); + expect(ids).not.toContain('e1'); + }); + + it('returns [] when source has no fill', () => { + const noFillDoc: PenNode[] = [{ type: 'rectangle', id: 'x', x: 0, y: 0, width: 10, height: 10 } as PenNode]; + expect(findNodesBySameFill(noFillDoc, 'x')).toEqual([]); + }); + + it('returns [] when source id does not exist', () => { + expect(findNodesBySameFill(sampleNodes, 'missing')).toEqual([]); + }); + + it('matches case-insensitively', () => { + const doc: PenNode[] = [ + { type: 'rectangle', id: 'a', x: 0, y: 0, width: 10, height: 10, fill: '#ff0000' } as PenNode, + { type: 'rectangle', id: 'b', x: 0, y: 0, width: 10, height: 10, fill: '#FF0000' } as PenNode, + ]; + const ids = findNodesBySameFill(doc, 'a'); + expect(ids).toContain('a'); + expect(ids).toContain('b'); + }); +}); + describe('collectColors', () => { it('collects all unique colors from the tree', () => { const colors = collectColors(sampleNodes); diff --git a/tests/tidyUp.test.ts b/tests/tidyUp.test.ts new file mode 100644 index 0000000..509def2 --- /dev/null +++ b/tests/tidyUp.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { detectTidyMode, tidyUp, type TidyItem } from '../src/utils/tidyUp'; + +const mk = (id: string, x: number, y: number, w = 100, h = 100): TidyItem => ({ + id, + x, + y, + width: w, + height: h, +}); + +describe('detectTidyMode', () => { + it('detects row when horizontal spread dominates', () => { + const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 400, 10)]; + expect(detectTidyMode(items)).toBe('row'); + }); + + it('detects column when vertical spread dominates', () => { + const items = [mk('a', 0, 0), mk('b', 10, 200), mk('c', 5, 400)]; + expect(detectTidyMode(items)).toBe('column'); + }); + + it('detects grid when spread is balanced', () => { + const items = [ + mk('a', 0, 0), + mk('b', 200, 0), + mk('c', 0, 200), + mk('d', 200, 200), + ]; + expect(detectTidyMode(items)).toBe('grid'); + }); +}); + +describe('tidyUp', () => { + it('no-ops when fewer than 2 items', () => { + expect(tidyUp([])).toEqual([]); + expect(tidyUp([mk('a', 5, 5)])).toEqual([{ id: 'a', x: 5, y: 5 }]); + }); + + it('lays out a row with uniform gap, sorted by x', () => { + const items = [mk('b', 300, 50), mk('a', 0, 40), mk('c', 150, 60)]; + const res = tidyUp(items, { mode: 'row', gap: 20 }); + expect(res.map((r) => r.id)).toEqual(['a', 'c', 'b']); + expect(res[0]).toEqual({ id: 'a', x: 0, y: 40 }); + expect(res[1]).toEqual({ id: 'c', x: 120, y: 40 }); + expect(res[2]).toEqual({ id: 'b', x: 240, y: 40 }); + }); + + it('lays out a column with uniform gap, sorted by y', () => { + const items = [mk('b', 50, 300), mk('a', 40, 0), mk('c', 60, 150)]; + const res = tidyUp(items, { mode: 'column', gap: 10 }); + expect(res.map((r) => r.id)).toEqual(['a', 'c', 'b']); + expect(res[0]).toEqual({ id: 'a', x: 40, y: 0 }); + expect(res[1]).toEqual({ id: 'c', x: 40, y: 110 }); + expect(res[2]).toEqual({ id: 'b', x: 40, y: 220 }); + }); + + it('grid mode snaps to square-ish rows using sqrt(n)', () => { + const items = [ + mk('a', 5, 5), + mk('b', 300, 5), + mk('c', 5, 300), + mk('d', 300, 300), + ]; + const res = tidyUp(items, { mode: 'grid', gap: 10 }); + expect(res).toHaveLength(4); + const ys = new Set(res.map((r) => r.y)); + const xs = new Set(res.map((r) => r.x)); + expect(ys.size).toBe(2); + expect(xs.size).toBe(2); + }); + + it('auto mode picks row for wide layouts', () => { + const items = [mk('a', 0, 0), mk('b', 200, 5), mk('c', 400, 0)]; + const res = tidyUp(items, { mode: 'auto', gap: 10 }); + const yVals = new Set(res.map((r) => r.y)); + expect(yVals.size).toBe(1); + }); + + it('preserves ids (no loss)', () => { + const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 400, 0)]; + const res = tidyUp(items, { mode: 'row' }); + expect(new Set(res.map((r) => r.id))).toEqual(new Set(['a', 'b', 'c'])); + }); + + it('uses default gap of 16', () => { + const items = [mk('a', 0, 0, 50, 50), mk('b', 200, 0, 50, 50)]; + const res = tidyUp(items, { mode: 'row' }); + expect(res[1].x).toBe(50 + 16); + }); +});