Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/components/Viewer/AlignCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PenNode>);
}
}, [getSelectedRects, updateNodeSilent, pushUndoCheckpoint]);

const sortByX = useCallback(() => {
const rects = getSelectedRects();
if (rects.length < 2) return;
Expand All @@ -147,6 +165,7 @@ export function useAlignActions() {
distributeH,
distributeV,
sortByX,
tidyUpSelection,
};
}

Expand All @@ -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') },
];
}
27 changes: 27 additions & 0 deletions src/components/Viewer/ExtraCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 },
Expand Down
85 changes: 85 additions & 0 deletions src/utils/tidyUp.ts
Original file line number Diff line number Diff line change
@@ -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<TidyMode, 'auto'> {
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),
};
});
}
31 changes: 31 additions & 0 deletions tests/extraCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PenNode } from '../src/pen/types';
import {
findNodesByText,
findNodesByColor,
findNodesBySameFill,
collectColors,
collectFonts,
countComponentUsage,
Expand Down Expand Up @@ -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);
Expand Down
91 changes: 91 additions & 0 deletions tests/tidyUp.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading