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
55 changes: 54 additions & 1 deletion src/components/Viewer/NodeTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
* 選択変更時に自動スクロール + 祖先を自動展開。
*/

import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEditor } from '../../pen/state/EditorContext';
import type { PenNode } from '../../pen/types';
import { filterNodeTree } from '../../utils/filterNodeTree';

const EYE_ON = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
Expand Down Expand Up @@ -78,6 +79,7 @@ function NodeItem({
selectedId,
selectedIds,
expandedIds,
visibleIds,
onSelect,
onToggle,
onReorder,
Expand All @@ -92,12 +94,15 @@ function NodeItem({
selectedId: string | null;
selectedIds: Set<string>;
expandedIds: Set<string>;
/** null = フィルタなし(全表示)。Set のときは含まれる id のみ表示 */
visibleIds: Set<string> | null;
onSelect: (id: string) => void;
onToggle: (id: string) => void;
onReorder: (parentId: string | null, fromIdx: number, toIdx: number) => void;
onToggleVisibility: (id: string) => void;
onToggleLock: (id: string) => void;
}) {
if (visibleIds && !visibleIds.has(node.id)) return null;
const isSelected = selectedId === node.id;
const isMulti = selectedIds.has(node.id);
const children = hasChildren(node) ? node.children : [];
Expand Down Expand Up @@ -207,6 +212,7 @@ function NodeItem({
selectedId={selectedId}
selectedIds={selectedIds}
expandedIds={expandedIds}
visibleIds={visibleIds}
onSelect={onSelect}
onToggle={onToggle}
onReorder={onReorder}
Expand Down Expand Up @@ -273,6 +279,26 @@ export function NodeTree({ collapsed, onTogglePanel }: { collapsed?: boolean; on
});
}, []);

// -- 検索フィルタ
const [query, setQuery] = useState('');
const filter = useMemo(
() => filterNodeTree(state.doc.children, query),
[state.doc.children, query],
);
const filterActive = query.trim().length > 0;
const visibleIds = filterActive ? filter.visible : null;

// フィルタ中はヒットの祖先を自動展開
useEffect(() => {
if (!filterActive) return;
if (filter.autoExpand.size === 0) return;
setExpandedIds((prev) => {
const next = new Set(prev);
for (const id of filter.autoExpand) next.add(id);
return next;
});
}, [filterActive, filter.autoExpand]);

// 選択変更時: 祖先を自動展開 + スクロール
useEffect(() => {
if (!state.selectedNodeId) return;
Expand Down Expand Up @@ -317,6 +343,32 @@ export function NodeTree({ collapsed, onTogglePanel }: { collapsed?: boolean; on
</button>
)}
</div>
<div className="node-tree__search">
<input
type="text"
className="node-tree__search-input"
placeholder="Search layers..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{filterActive && (
<button
type="button"
className="node-tree__search-clear"
onClick={() => setQuery('')}
title="Clear search"
>
×
</button>
)}
</div>
{filterActive && (
<div className="node-tree__search-result">
{filter.matchCount === 0
? 'No matches'
: `${filter.matchCount} match${filter.matchCount === 1 ? '' : 'es'}`}
</div>
)}
<div className="node-tree__list" ref={listRef}>
{state.doc.children.map((node, i) => (
<NodeItem
Expand All @@ -328,6 +380,7 @@ export function NodeTree({ collapsed, onTogglePanel }: { collapsed?: boolean; on
selectedId={state.selectedNodeId}
selectedIds={state.selectedNodeIds}
expandedIds={expandedIds}
visibleIds={visibleIds}
onSelect={selectNode}
onToggle={onToggle}
onReorder={reorderChildren}
Expand Down
47 changes: 47 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2326,6 +2326,53 @@ html,
flex: 1;
}

.node-tree__search {
position: relative;
padding: 6px 10px 4px;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border);
}

.node-tree__search-input {
width: 100%;
box-sizing: border-box;
padding: 4px 22px 4px 8px;
font-size: 12px;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 4px;
outline: none;
}

.node-tree__search-input:focus {
border-color: var(--color-accent, #6366f1);
}

.node-tree__search-clear {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: 0;
color: var(--color-text-subtle);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
width: 16px;
height: 16px;
}

.node-tree__search-result {
padding: 4px 12px;
font-size: 10px;
color: var(--color-text-subtle);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}

.node-tree__item {
display: flex;
align-items: center;
Expand Down
61 changes: 61 additions & 0 deletions src/utils/filterNodeTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Layers パネル用のノードツリー検索。
* name / id / type / content(テキスト) に対して部分一致(大文字小文字無視)。
*
* 返り値:
* visible – 表示すべき node id(ヒット自身 + 全祖先)
* autoExpand – ヒットを内包するため自動展開すべき祖先 id
*/

import type { PenNode } from '../pen/types';

function hasChildren(node: PenNode): node is PenNode & { children: PenNode[] } {
return 'children' in node && Array.isArray((node as { children?: unknown }).children);
}

export interface FilterResult {
visible: Set<string>;
autoExpand: Set<string>;
matchCount: number;
}

export function filterNodeTree(nodes: PenNode[], query: string): FilterResult {
const visible = new Set<string>();
const autoExpand = new Set<string>();
const trimmed = query.trim();
if (!trimmed) return { visible, autoExpand, matchCount: 0 };
const q = trimmed.toLowerCase();
let matchCount = 0;

function walk(node: PenNode, ancestors: string[]): boolean {
const name = ((node as { name?: string }).name ?? node.id).toLowerCase();
const content = (node as { content?: string }).content;
const contentLow = typeof content === 'string' ? content.toLowerCase() : '';
const selfMatch =
name.includes(q) ||
node.id.toLowerCase().includes(q) ||
node.type.toLowerCase().includes(q) ||
contentLow.includes(q);

let childMatch = false;
if (hasChildren(node)) {
for (const c of node.children) {
if (walk(c, [...ancestors, node.id])) childMatch = true;
}
}

if (selfMatch || childMatch) {
if (selfMatch) matchCount++;
visible.add(node.id);
for (const a of ancestors) {
visible.add(a);
autoExpand.add(a);
}
return true;
}
return false;
}

for (const n of nodes) walk(n, []);
return { visible, autoExpand, matchCount };
}
103 changes: 103 additions & 0 deletions tests/filterNodeTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { filterNodeTree } from '../src/utils/filterNodeTree';
import type { PenNode } from '../src/pen/types';

const doc: PenNode[] = [
{
type: 'frame',
id: 'home',
name: 'Home Screen',
x: 0,
y: 0,
width: 400,
height: 400,
children: [
{ type: 'text', id: 'title', content: 'Welcome back', x: 0, y: 0 } as PenNode,
{ type: 'rectangle', id: 'btn-primary', name: 'Primary Button', x: 0, y: 0, width: 120, height: 40 } as PenNode,
{
type: 'frame',
id: 'sidebar',
name: 'Nav',
x: 0,
y: 0,
width: 200,
height: 400,
children: [
{ type: 'text', id: 'logo', content: 'Acme', x: 0, y: 0 } as PenNode,
],
},
],
},
{
type: 'frame',
id: 'login',
name: 'Login',
x: 0,
y: 0,
width: 400,
height: 400,
children: [
{ type: 'text', id: 'welcome', content: 'Welcome', x: 0, y: 0 } as PenNode,
],
},
];

describe('filterNodeTree', () => {
it('returns empty sets when query is empty or whitespace', () => {
expect(filterNodeTree(doc, '').visible.size).toBe(0);
expect(filterNodeTree(doc, ' ').visible.size).toBe(0);
expect(filterNodeTree(doc, '').matchCount).toBe(0);
});

it('matches by node id (case-insensitive)', () => {
const r = filterNodeTree(doc, 'BTN');
expect(r.visible.has('btn-primary')).toBe(true);
expect(r.visible.has('home')).toBe(true); // ancestor
expect(r.visible.has('login')).toBe(false);
});

it('matches by name', () => {
const r = filterNodeTree(doc, 'primary');
expect(r.visible.has('btn-primary')).toBe(true);
expect(r.visible.has('home')).toBe(true);
expect(r.matchCount).toBe(1);
});

it('matches by type', () => {
const r = filterNodeTree(doc, 'rectangle');
expect(r.visible.has('btn-primary')).toBe(true);
expect(r.matchCount).toBe(1);
});

it('matches text content', () => {
const r = filterNodeTree(doc, 'welcome');
expect(r.visible.has('title')).toBe(true);
expect(r.visible.has('welcome')).toBe(true);
expect(r.visible.has('home')).toBe(true);
expect(r.visible.has('login')).toBe(true);
expect(r.matchCount).toBe(2);
});

it('autoExpand includes all ancestors of matches, not the match itself', () => {
const r = filterNodeTree(doc, 'logo');
expect(r.autoExpand.has('home')).toBe(true);
expect(r.autoExpand.has('sidebar')).toBe(true);
expect(r.autoExpand.has('logo')).toBe(false);
});

it('returns zero match count with no match but empty visible', () => {
const r = filterNodeTree(doc, 'zzzzz');
expect(r.matchCount).toBe(0);
expect(r.visible.size).toBe(0);
});

it('a match hides siblings that do not match', () => {
const r = filterNodeTree(doc, 'primary');
// login branch not visible
expect(r.visible.has('login')).toBe(false);
expect(r.visible.has('welcome')).toBe(false);
// sibling at same level as match: title should NOT be visible
expect(r.visible.has('title')).toBe(false);
expect(r.visible.has('sidebar')).toBe(false);
});
});
Loading