@aiquants/directory-tree 3.0.5 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/styles/directory-tree.css +1 -1
- package/dist/styles/directory-tree.standalone.css +3 -0
- package/package.json +7 -5
- package/src/DirectoryTree.spec.md +473 -0
- package/src/DirectoryTree.tsx +978 -0
- package/src/DirectoryTreeGrid.tsx +261 -0
- package/src/TreeLine.tsx +300 -0
- package/src/cli.server.ts +38 -0
- package/src/entryRendering.tsx +131 -0
- package/src/gridUtils.ts +88 -0
- package/src/index.ts +31 -0
- package/src/logger.ts +162 -0
- package/src/styles/components.entry.css +10 -0
- package/src/styles/directory-tree.css +87 -0
- package/src/styles/standalone.entry.css +12 -0
- package/src/types.ts +619 -0
- package/src/useDirectoryTreeState.ts +133 -0
- package/src/useEntryInteraction.ts +74 -0
- package/dist/directory-tree.css +0 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module DirectoryTreeGrid
|
|
3
|
+
* @description Row, header and footer renderers for the TreeGrid (columns) mode. The name/tree
|
|
4
|
+
* column is frozen on the left; numeric columns render in a horizontally-scrollable
|
|
5
|
+
* track translated by the shared `--dt-hscroll` CSS variable (set on the container),
|
|
6
|
+
* so header / body rows / footer stay aligned without per-row re-render.
|
|
7
|
+
* TreeGrid(列)モードの行・ヘッダ・フッタ描画。名前列は凍結、数値列は共有変数で横移動。
|
|
8
|
+
*/
|
|
9
|
+
// biome-ignore-all lint/a11y/useSemanticElements: 仮想スクロールの treegrid は絶対配置行を使うため native table 要素にできない。ARIA ロールで意味を提供する。
|
|
10
|
+
// biome-ignore-all lint/a11y/useFocusableInteractive: treegrid の row/gridcell は widget ロールではなくフォーカス不要。フォーカスは行(tabIndex=0)が担う。
|
|
11
|
+
import type { CSSProperties, MouseEvent, ReactNode } from "react"
|
|
12
|
+
import { memo, useState } from "react"
|
|
13
|
+
import { twMerge } from "tailwind-merge"
|
|
14
|
+
import { directoryTreeClasses, getFileExtension, INDENT_SIZE, resolveEntryIcon, resolveExpandIcon } from "./entryRendering.tsx"
|
|
15
|
+
import type { DirectoryEntry, DirectoryTreeColumn, DirectoryTreeIconOverrides, HighlightStyles } from "./types.ts"
|
|
16
|
+
import { useEntryInteraction } from "./useEntryInteraction.ts"
|
|
17
|
+
|
|
18
|
+
/** Visual options shared by grid rows (mirrors the subset of DirectoryTree visual props needed here). */
|
|
19
|
+
export type GridRowVisual = {
|
|
20
|
+
iconOverrides?: DirectoryTreeIconOverrides
|
|
21
|
+
showExpandIcons?: boolean
|
|
22
|
+
showDirectoryIcons?: boolean
|
|
23
|
+
showFileIcons?: boolean
|
|
24
|
+
useCanvasExpandIcons?: boolean
|
|
25
|
+
expandIconSize?: number
|
|
26
|
+
highlightStyles?: HighlightStyles
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolves the per-column alignment class. / 列の寄せクラスを解決。 */
|
|
30
|
+
const alignClass = (align?: DirectoryTreeColumn["align"]): string => (align === "right" ? "dt-grid-cell--right" : align === "center" ? "dt-grid-cell--center" : "")
|
|
31
|
+
|
|
32
|
+
/** True when the entry is an expanded directory. Files short-circuit without querying isDirOpen. / 展開済みディレクトリなら true。 */
|
|
33
|
+
export const isEntryExpanded = (entry: DirectoryEntry, isDirOpen: (path: string) => boolean): boolean => entry.children !== undefined && isDirOpen(entry.absolutePath)
|
|
34
|
+
|
|
35
|
+
/** Shared track style for header / body / footer numeric regions (identical geometry ⇒ alignment). */
|
|
36
|
+
const numericTrackStyle = (numericTotal: number, numericTemplate: string): CSSProperties => ({
|
|
37
|
+
width: numericTotal,
|
|
38
|
+
gridTemplateColumns: numericTemplate,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export type DirectoryTreeGridRowProps = {
|
|
42
|
+
entry: DirectoryEntry
|
|
43
|
+
indentLevel: number
|
|
44
|
+
rowHeight: number
|
|
45
|
+
isDirOpen: (path: string) => boolean
|
|
46
|
+
columns: DirectoryTreeColumn[]
|
|
47
|
+
numericTemplate: string
|
|
48
|
+
numericTotal: number
|
|
49
|
+
nameWidth: number
|
|
50
|
+
rowClassName?: string | ((entry: DirectoryEntry) => string)
|
|
51
|
+
selection: {
|
|
52
|
+
selectedPath: string | null
|
|
53
|
+
mode?: "none" | "single" | "multiple"
|
|
54
|
+
selectedItems?: Set<string>
|
|
55
|
+
onEntryClick: (event: import("./types.ts").DirectoryTreeClickEvent) => void
|
|
56
|
+
onSelectionChange?: (entry: DirectoryEntry, isSelected: boolean) => void
|
|
57
|
+
}
|
|
58
|
+
expansion: {
|
|
59
|
+
toggleDirectory: (path: string) => void
|
|
60
|
+
onToggleDirectoryRecursive: (entry: DirectoryEntry) => void
|
|
61
|
+
}
|
|
62
|
+
visual: GridRowVisual
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @component DirectoryTreeGridRow
|
|
67
|
+
* @description A single TreeGrid row: frozen name cell (indent + glyph + icon + label) plus a
|
|
68
|
+
* horizontally-scrollable numeric track. Memoized; the `--dt-hscroll` variable is not
|
|
69
|
+
* a prop, so horizontal scrolling never re-renders rows.
|
|
70
|
+
*/
|
|
71
|
+
export const DirectoryTreeGridRow = memo<DirectoryTreeGridRowProps>(
|
|
72
|
+
({
|
|
73
|
+
entry,
|
|
74
|
+
indentLevel,
|
|
75
|
+
rowHeight,
|
|
76
|
+
isDirOpen,
|
|
77
|
+
columns,
|
|
78
|
+
numericTemplate,
|
|
79
|
+
numericTotal,
|
|
80
|
+
nameWidth,
|
|
81
|
+
rowClassName,
|
|
82
|
+
selection: { onEntryClick, selectedPath, mode: selectionMode = "none", selectedItems, onSelectionChange },
|
|
83
|
+
expansion: { toggleDirectory, onToggleDirectoryRecursive },
|
|
84
|
+
visual: { iconOverrides, showExpandIcons = true, showDirectoryIcons = true, showFileIcons = true, useCanvasExpandIcons = false, expandIconSize, highlightStyles },
|
|
85
|
+
}) => {
|
|
86
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
87
|
+
|
|
88
|
+
const isDirectory = entry.children !== undefined
|
|
89
|
+
const isExpanded = isDirectory && isDirOpen(entry.absolutePath)
|
|
90
|
+
const isSelected = entry.absolutePath === selectedPath
|
|
91
|
+
const isItemSelected = !isDirectory && Boolean(selectedItems?.has(entry.absolutePath))
|
|
92
|
+
const extension = isDirectory ? undefined : getFileExtension(entry.name)
|
|
93
|
+
|
|
94
|
+
const handleInteraction = useEntryInteraction({ entry, isDirectory, isExpanded, isItemSelected, selectionMode, onSelectionChange, onEntryClick, toggleDirectory, onToggleDirectoryRecursive })
|
|
95
|
+
|
|
96
|
+
const hoverClass = isHovered ? (highlightStyles?.hoverClassName !== undefined ? highlightStyles.hoverClassName : directoryTreeClasses.entryHover) : ""
|
|
97
|
+
const selectedClass = (() => {
|
|
98
|
+
if (isDirectory) {
|
|
99
|
+
if (isSelected) return highlightStyles?.directorySelectedClassName !== undefined ? highlightStyles.directorySelectedClassName : directoryTreeClasses.entrySelected
|
|
100
|
+
} else if (isSelected || isItemSelected) {
|
|
101
|
+
if (highlightStyles?.itemSelectedClassName !== undefined) return highlightStyles.itemSelectedClassName
|
|
102
|
+
return twMerge(isSelected && directoryTreeClasses.entrySelected, isItemSelected && directoryTreeClasses.entryItemSelected)
|
|
103
|
+
}
|
|
104
|
+
return ""
|
|
105
|
+
})()
|
|
106
|
+
|
|
107
|
+
const resolvedRowClass = typeof rowClassName === "function" ? rowClassName(entry) : rowClassName
|
|
108
|
+
const rowClasses = twMerge("dt-grid-row cursor-pointer select-none", hoverClass, selectedClass, resolvedRowClass, entry.className)
|
|
109
|
+
|
|
110
|
+
const expandIconClasses = twMerge(directoryTreeClasses.expandIcon, (isSelected || isItemSelected) && directoryTreeClasses.expandIconSelected)
|
|
111
|
+
const typeIconClasses = twMerge(directoryTreeClasses.typeIcon, (isSelected || isItemSelected) && directoryTreeClasses.typeIconSelected)
|
|
112
|
+
const nameLabelClasses = twMerge("dt-grid-name-label ml-1", "text-gray-700 dark:text-gray-200", isDirectory && directoryTreeClasses.nameDirectory, (isSelected || isItemSelected) && directoryTreeClasses.nameSelected)
|
|
113
|
+
|
|
114
|
+
const expandIconContent = resolveExpandIcon({ isDirectory, isExpanded, showExpandIcons, useCanvasExpandIcons, expandIconSize })
|
|
115
|
+
const resolvedIcon = (isDirectory ? showDirectoryIcons : showFileIcons) ? resolveEntryIcon({ entry, isDirectory, isExpanded, isSelected, isItemSelected, extension }, iconOverrides) : null
|
|
116
|
+
|
|
117
|
+
const ctx = { indentLevel, isDirectory, isExpanded }
|
|
118
|
+
const [nameColumn, ...numericColumns] = columns
|
|
119
|
+
const nameContent = nameColumn?.render ? nameColumn.render(entry, ctx) : (entry.label ?? entry.name)
|
|
120
|
+
|
|
121
|
+
const customHighlightStyle: CSSProperties = {
|
|
122
|
+
...(isHovered ? highlightStyles?.hoverStyle : {}),
|
|
123
|
+
...(isDirectory && isSelected ? highlightStyles?.directorySelectedStyle : {}),
|
|
124
|
+
...(!isDirectory && (isSelected || isItemSelected) ? highlightStyles?.itemSelectedStyle : {}),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className={rowClasses}
|
|
130
|
+
style={{ height: rowHeight, ...customHighlightStyle, ...entry.style }}
|
|
131
|
+
role="row"
|
|
132
|
+
aria-expanded={isDirectory ? isExpanded : undefined}
|
|
133
|
+
aria-level={indentLevel + 1}
|
|
134
|
+
data-entry-type={isDirectory ? "directory" : "file"}
|
|
135
|
+
data-entry-expanded={isDirectory ? String(isExpanded) : undefined}
|
|
136
|
+
data-entry-selected={isSelected ? "true" : undefined}
|
|
137
|
+
data-entry-item-selected={isItemSelected ? "true" : undefined}
|
|
138
|
+
onClick={handleInteraction}
|
|
139
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
140
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
141
|
+
onKeyDown={(e) => e.key === "Enter" && handleInteraction(e as unknown as MouseEvent<HTMLDivElement>)}
|
|
142
|
+
tabIndex={0}>
|
|
143
|
+
{/* Frozen name/tree cell — indent applied here only. / 凍結名前セル(インデントはここだけ)。 */}
|
|
144
|
+
<div className={twMerge("dt-grid-name-cell", nameColumn?.className)} role="gridcell" style={{ width: nameWidth, paddingLeft: Math.max(0, indentLevel * INDENT_SIZE) }}>
|
|
145
|
+
{(showExpandIcons || useCanvasExpandIcons) && <span className={expandIconClasses}>{expandIconContent}</span>}
|
|
146
|
+
{(isDirectory ? showDirectoryIcons : showFileIcons) && <span className={typeIconClasses}>{resolvedIcon}</span>}
|
|
147
|
+
<span className={nameLabelClasses}>{nameContent}</span>
|
|
148
|
+
</div>
|
|
149
|
+
{/* Scrollable numeric region. / 横スクロールする数値領域。 */}
|
|
150
|
+
{numericColumns.length > 0 && (
|
|
151
|
+
<div className="dt-grid-numeric-viewport">
|
|
152
|
+
<div className="dt-grid-numeric-track" style={numericTrackStyle(numericTotal, numericTemplate)}>
|
|
153
|
+
{numericColumns.map((column) => (
|
|
154
|
+
<div key={column.key} className={twMerge("dt-grid-cell", alignClass(column.align), column.className)} role="gridcell">
|
|
155
|
+
{column.render ? column.render(entry, ctx) : null}
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
},
|
|
164
|
+
(prev, next) => {
|
|
165
|
+
if (prev.entry !== next.entry) return false
|
|
166
|
+
const prevExpanded = isEntryExpanded(prev.entry, prev.isDirOpen)
|
|
167
|
+
const nextExpanded = isEntryExpanded(next.entry, next.isDirOpen)
|
|
168
|
+
return (
|
|
169
|
+
prev.indentLevel === next.indentLevel &&
|
|
170
|
+
prev.rowHeight === next.rowHeight &&
|
|
171
|
+
prevExpanded === nextExpanded &&
|
|
172
|
+
prev.columns === next.columns &&
|
|
173
|
+
prev.numericTemplate === next.numericTemplate &&
|
|
174
|
+
prev.numericTotal === next.numericTotal &&
|
|
175
|
+
prev.nameWidth === next.nameWidth &&
|
|
176
|
+
prev.rowClassName === next.rowClassName &&
|
|
177
|
+
prev.selection.selectedPath === next.selection.selectedPath &&
|
|
178
|
+
prev.selection.mode === next.selection.mode &&
|
|
179
|
+
prev.selection.selectedItems === next.selection.selectedItems &&
|
|
180
|
+
prev.visual === next.visual
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
DirectoryTreeGridRow.displayName = "DirectoryTreeGridRow"
|
|
185
|
+
|
|
186
|
+
/** Shared frozen-name + numeric-track layout used by header and footer. / ヘッダ/フッタ共通レイアウト。 */
|
|
187
|
+
const GridEdgeRow = ({
|
|
188
|
+
kind,
|
|
189
|
+
columns,
|
|
190
|
+
numericTemplate,
|
|
191
|
+
numericTotal,
|
|
192
|
+
nameWidth,
|
|
193
|
+
scrollBarWidth,
|
|
194
|
+
className,
|
|
195
|
+
nameContent,
|
|
196
|
+
renderCell,
|
|
197
|
+
}: {
|
|
198
|
+
kind: "header" | "footer"
|
|
199
|
+
columns: DirectoryTreeColumn[]
|
|
200
|
+
numericTemplate: string
|
|
201
|
+
numericTotal: number
|
|
202
|
+
nameWidth: number
|
|
203
|
+
scrollBarWidth: number
|
|
204
|
+
className?: string
|
|
205
|
+
nameContent: ReactNode
|
|
206
|
+
renderCell: (column: DirectoryTreeColumn) => ReactNode
|
|
207
|
+
}) => {
|
|
208
|
+
const [, ...numericColumns] = columns
|
|
209
|
+
const nameClass = kind === "header" ? "dt-grid-name-header" : "dt-grid-name-footer"
|
|
210
|
+
const rowClass = kind === "header" ? "dt-grid-header" : "dt-grid-footer"
|
|
211
|
+
const cellRole = kind === "header" ? "columnheader" : "gridcell"
|
|
212
|
+
return (
|
|
213
|
+
<div className={twMerge(rowClass, "text-gray-600 dark:text-gray-300", className)} role="row">
|
|
214
|
+
<div className={twMerge(nameClass, "px-1", columns[0]?.className)} role={cellRole} style={{ width: nameWidth }}>
|
|
215
|
+
{nameContent}
|
|
216
|
+
</div>
|
|
217
|
+
{numericColumns.length > 0 && (
|
|
218
|
+
<div className="dt-grid-numeric-viewport">
|
|
219
|
+
<div className="dt-grid-numeric-track" style={numericTrackStyle(numericTotal, numericTemplate)}>
|
|
220
|
+
{numericColumns.map((column) => (
|
|
221
|
+
<div key={column.key} className={twMerge("dt-grid-cell", alignClass(column.align), column.className)} role={cellRole}>
|
|
222
|
+
{renderCell(column)}
|
|
223
|
+
</div>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
{/* Right spacer aligns the numeric viewport with the body (reduced by the vertical scrollbar). */}
|
|
229
|
+
<div className="dt-grid-scrollbar-spacer" style={{ width: scrollBarWidth }} aria-hidden="true" />
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type DirectoryTreeGridHeaderRowProps = {
|
|
235
|
+
columns: DirectoryTreeColumn[]
|
|
236
|
+
numericTemplate: string
|
|
237
|
+
numericTotal: number
|
|
238
|
+
nameWidth: number
|
|
239
|
+
scrollBarWidth: number
|
|
240
|
+
className?: string
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const DirectoryTreeGridHeaderRow = ({ columns, className, ...rest }: DirectoryTreeGridHeaderRowProps) => <GridEdgeRow kind="header" columns={columns} className={className} nameContent={columns[0]?.header ?? null} renderCell={(column) => column.header ?? null} {...rest} />
|
|
244
|
+
|
|
245
|
+
export type DirectoryTreeGridFooterRowProps = {
|
|
246
|
+
columns: DirectoryTreeColumn[]
|
|
247
|
+
numericTemplate: string
|
|
248
|
+
numericTotal: number
|
|
249
|
+
nameWidth: number
|
|
250
|
+
scrollBarWidth: number
|
|
251
|
+
/** All nodes of the tree (collapsed included), for footer aggregation. / フッタ集計用の全ノード。 */
|
|
252
|
+
allEntries: DirectoryEntry[]
|
|
253
|
+
className?: string
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolves a column footer that may be a node or an aggregator over all entries. / フッタ解決。 */
|
|
257
|
+
const resolveFooter = (footer: DirectoryTreeColumn["footer"], allEntries: DirectoryEntry[]): ReactNode => (typeof footer === "function" ? footer(allEntries) : (footer ?? null))
|
|
258
|
+
|
|
259
|
+
export const DirectoryTreeGridFooterRow = ({ columns, allEntries, className, ...rest }: DirectoryTreeGridFooterRowProps) => (
|
|
260
|
+
<GridEdgeRow kind="footer" columns={columns} className={className} nameContent={resolveFooter(columns[0]?.footer, allEntries)} renderCell={(column) => resolveFooter(column.footer, allEntries)} {...rest} />
|
|
261
|
+
)
|
package/src/TreeLine.tsx
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useRef } from "react"
|
|
2
|
+
import type { TreeLineGlyph, TreeLineProps, TreeLineSegment } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collects the segments that fall inside the requested index window.
|
|
6
|
+
*
|
|
7
|
+
* 指定されたインデックス範囲に含まれるセグメントを収集する。
|
|
8
|
+
*
|
|
9
|
+
* @param segmentsByItem - アイテムごとのセグメント配列。
|
|
10
|
+
* @param startIndex - 開始インデックス。
|
|
11
|
+
* @param endExclusive - 終了インデックス (排他的)。
|
|
12
|
+
* @returns 表示対象のセグメント配列。
|
|
13
|
+
*/
|
|
14
|
+
const collectVisibleSegments = (segmentsByItem: TreeLineSegment[][], startIndex: number, endExclusive: number): TreeLineSegment[] => {
|
|
15
|
+
const collected: TreeLineSegment[] = []
|
|
16
|
+
for (let index = startIndex; index < endExclusive; index++) {
|
|
17
|
+
const group = segmentsByItem[index]
|
|
18
|
+
if (!group || group.length === 0) {
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
for (const segment of group) {
|
|
22
|
+
collected.push(segment)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return collected
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collects the glyphs that fall inside the requested index window.
|
|
30
|
+
*
|
|
31
|
+
* 指定されたインデックス範囲に含まれるグリフを収集する。
|
|
32
|
+
*
|
|
33
|
+
* @param glyphsByItem - アイテムごとのグリフ配列。
|
|
34
|
+
* @param startIndex - 開始インデックス。
|
|
35
|
+
* @param endExclusive - 終了インデックス (排他的)。
|
|
36
|
+
* @returns 表示対象のグリフ配列。
|
|
37
|
+
*/
|
|
38
|
+
const collectVisibleGlyphs = (glyphsByItem: TreeLineGlyph[][], startIndex: number, endExclusive: number): TreeLineGlyph[] => {
|
|
39
|
+
const collected: TreeLineGlyph[] = []
|
|
40
|
+
for (let index = startIndex; index < endExclusive; index++) {
|
|
41
|
+
const group = glyphsByItem[index]
|
|
42
|
+
if (!group || group.length === 0) {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
for (const glyph of group) {
|
|
46
|
+
collected.push(glyph)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return collected
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Adjusts the canvas backing store size to match CSS dimensions and device pixel ratio.
|
|
54
|
+
*
|
|
55
|
+
* Canvas の描画領域を CSS サイズとデバイスピクセル比に合わせて調整する。
|
|
56
|
+
*
|
|
57
|
+
* @param canvas - 対象のキャンバス要素。
|
|
58
|
+
* @param width - CSS 上の幅。
|
|
59
|
+
* @param height - CSS 上の高さ。
|
|
60
|
+
* @param devicePixelRatio - デバイスピクセル比。
|
|
61
|
+
*/
|
|
62
|
+
const resizeCanvas = (canvas: HTMLCanvasElement, width: number, height: number, devicePixelRatio: number): void => {
|
|
63
|
+
const pixelWidth = Math.max(1, Math.floor(width * devicePixelRatio))
|
|
64
|
+
const pixelHeight = Math.max(1, Math.floor(height * devicePixelRatio))
|
|
65
|
+
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
|
|
66
|
+
canvas.width = pixelWidth
|
|
67
|
+
canvas.height = pixelHeight
|
|
68
|
+
}
|
|
69
|
+
const style = canvas.style
|
|
70
|
+
if (style.width !== `${width}px`) {
|
|
71
|
+
style.width = `${width}px`
|
|
72
|
+
}
|
|
73
|
+
if (style.height !== `${height}px`) {
|
|
74
|
+
style.height = `${height}px`
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Draws the tree line segments on the provided canvas context.
|
|
80
|
+
*
|
|
81
|
+
* 指定のキャンバスコンテキストにツリーラインを描画する。
|
|
82
|
+
*
|
|
83
|
+
* @param context - 2D コンテキスト。
|
|
84
|
+
* @param segments - 描画対象のセグメント群。
|
|
85
|
+
* @param strokeWidth - 線の太さ。
|
|
86
|
+
*/
|
|
87
|
+
const drawSegments = (context: CanvasRenderingContext2D, segments: TreeLineSegment[], strokeWidth: number): void => {
|
|
88
|
+
context.lineWidth = strokeWidth
|
|
89
|
+
context.beginPath()
|
|
90
|
+
for (const segment of segments) {
|
|
91
|
+
context.moveTo(segment.x1, segment.y1)
|
|
92
|
+
context.lineTo(segment.x2, segment.y2)
|
|
93
|
+
}
|
|
94
|
+
context.stroke()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generates an rgba() string with alpha applied to a hex color.
|
|
99
|
+
*
|
|
100
|
+
* 16 進数カラーにアルファ値を適用した rgba() 文字列を生成する。
|
|
101
|
+
*
|
|
102
|
+
* @param color - 元のカラー文字列。
|
|
103
|
+
* @param alpha - 適用するアルファ値。
|
|
104
|
+
* @returns rgba() 形式のカラー文字列。解析できない場合は null。
|
|
105
|
+
*/
|
|
106
|
+
const deriveRgbaWithAlpha = (color: string, alpha: number): string | null => {
|
|
107
|
+
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())
|
|
108
|
+
if (!match) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
let hex = match[1]
|
|
112
|
+
if (hex.length === 3) {
|
|
113
|
+
hex = hex
|
|
114
|
+
.split("")
|
|
115
|
+
.map((digit) => digit + digit)
|
|
116
|
+
.join("")
|
|
117
|
+
}
|
|
118
|
+
const red = Number.parseInt(hex.slice(0, 2), 16)
|
|
119
|
+
const green = Number.parseInt(hex.slice(2, 4), 16)
|
|
120
|
+
const blue = Number.parseInt(hex.slice(4, 6), 16)
|
|
121
|
+
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Draws modern circular glyphs representing expand/collapse states.
|
|
126
|
+
*
|
|
127
|
+
* 展開/折りたたみ状態を表す円形グリフを描画する。
|
|
128
|
+
*
|
|
129
|
+
* @param context - 2D コンテキスト。
|
|
130
|
+
* @param glyphs - グリフ配列。
|
|
131
|
+
* @param color - ベースとなるカラー。
|
|
132
|
+
* @param overrideSize - サイズ上書き値。
|
|
133
|
+
*/
|
|
134
|
+
const drawGlyphs = (context: CanvasRenderingContext2D, glyphs: TreeLineGlyph[], color: string, overrideSize?: number): void => {
|
|
135
|
+
if (glyphs.length === 0) {
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const fillColor = deriveRgbaWithAlpha(color, 0.14)
|
|
139
|
+
const previousStrokeStyle = context.strokeStyle
|
|
140
|
+
const previousFillStyle = context.fillStyle
|
|
141
|
+
const previousLineWidth = context.lineWidth
|
|
142
|
+
const previousLineCap = context.lineCap
|
|
143
|
+
const previousLineJoin = context.lineJoin
|
|
144
|
+
context.strokeStyle = color
|
|
145
|
+
context.lineCap = "round"
|
|
146
|
+
context.lineJoin = "round"
|
|
147
|
+
for (const glyph of glyphs) {
|
|
148
|
+
const size = overrideSize ?? glyph.size
|
|
149
|
+
const radius = Math.max(0, size / 2)
|
|
150
|
+
const ringWidth = Math.max(1, Math.round(size * 0.08))
|
|
151
|
+
const symbolWidth = Math.max(1, Math.round(size * 0.14))
|
|
152
|
+
const innerRadius = Math.max(0, radius - ringWidth * 0.8)
|
|
153
|
+
const symbolLength = Math.max(0, innerRadius * 0.55)
|
|
154
|
+
if (fillColor) {
|
|
155
|
+
context.fillStyle = fillColor
|
|
156
|
+
context.beginPath()
|
|
157
|
+
context.arc(glyph.centerX, glyph.centerY, innerRadius, 0, Math.PI * 2)
|
|
158
|
+
context.fill()
|
|
159
|
+
}
|
|
160
|
+
context.lineWidth = ringWidth
|
|
161
|
+
context.beginPath()
|
|
162
|
+
context.arc(glyph.centerX, glyph.centerY, Math.max(0, radius - ringWidth * 0.3), 0, Math.PI * 2)
|
|
163
|
+
context.stroke()
|
|
164
|
+
context.lineWidth = symbolWidth
|
|
165
|
+
context.beginPath()
|
|
166
|
+
context.moveTo(glyph.centerX - symbolLength, glyph.centerY)
|
|
167
|
+
context.lineTo(glyph.centerX + symbolLength, glyph.centerY)
|
|
168
|
+
context.stroke()
|
|
169
|
+
if (!glyph.isExpanded) {
|
|
170
|
+
context.beginPath()
|
|
171
|
+
context.moveTo(glyph.centerX, glyph.centerY - symbolLength)
|
|
172
|
+
context.lineTo(glyph.centerX, glyph.centerY + symbolLength)
|
|
173
|
+
context.stroke()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
context.strokeStyle = previousStrokeStyle
|
|
177
|
+
context.fillStyle = previousFillStyle
|
|
178
|
+
context.lineWidth = previousLineWidth
|
|
179
|
+
context.lineCap = previousLineCap
|
|
180
|
+
context.lineJoin = previousLineJoin
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Canvas based tree line renderer that repaints on animation frames.
|
|
185
|
+
*
|
|
186
|
+
* アニメーションフレーム単位で再描画する Canvas ベースのツリーラインレンダラ。
|
|
187
|
+
*/
|
|
188
|
+
const TreeLine = memo<TreeLineProps>(({ segmentsByItem, glyphsByItem, itemHeight, width, viewportHeight, scrollPosition, color = "#a0aec0", strokeWidth = 1, renderStartIndex, renderEndIndex, lookaheadEndIndex, devicePixelRatio, expandGlyphColor, expandGlyphSize }) => {
|
|
189
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
190
|
+
const animationFrameRef = useRef<number | null>(null)
|
|
191
|
+
const latestStateRef = useRef({
|
|
192
|
+
segmentsByItem,
|
|
193
|
+
glyphsByItem,
|
|
194
|
+
itemHeight,
|
|
195
|
+
width,
|
|
196
|
+
viewportHeight,
|
|
197
|
+
scrollPosition,
|
|
198
|
+
color,
|
|
199
|
+
strokeWidth,
|
|
200
|
+
renderStartIndex,
|
|
201
|
+
renderEndIndex,
|
|
202
|
+
lookaheadEndIndex,
|
|
203
|
+
devicePixelRatio,
|
|
204
|
+
expandGlyphColor,
|
|
205
|
+
expandGlyphSize,
|
|
206
|
+
})
|
|
207
|
+
const renderFrame = useCallback(() => {
|
|
208
|
+
animationFrameRef.current = null
|
|
209
|
+
const canvas = canvasRef.current
|
|
210
|
+
if (!canvas) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const context = canvas.getContext("2d")
|
|
214
|
+
if (!context) {
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const state = latestStateRef.current
|
|
218
|
+
if (state.width <= 0 || state.viewportHeight <= 0) {
|
|
219
|
+
context.clearRect(0, 0, canvas.width, canvas.height)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const pixelRatio = Number.isFinite(state.devicePixelRatio ?? NaN) ? (state.devicePixelRatio as number) : typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
|
|
223
|
+
resizeCanvas(canvas, state.width, state.viewportHeight, pixelRatio)
|
|
224
|
+
const totalItems = state.segmentsByItem.length
|
|
225
|
+
const startIndex = (() => {
|
|
226
|
+
if (typeof state.renderStartIndex === "number" && Number.isFinite(state.renderStartIndex)) {
|
|
227
|
+
return Math.max(0, Math.floor(state.renderStartIndex))
|
|
228
|
+
}
|
|
229
|
+
return Math.max(0, Math.floor(state.scrollPosition / state.itemHeight))
|
|
230
|
+
})()
|
|
231
|
+
const defaultEndExclusive = Math.min(totalItems, Math.ceil((state.scrollPosition + state.viewportHeight) / state.itemHeight))
|
|
232
|
+
const endExclusive = (() => {
|
|
233
|
+
if (typeof state.renderEndIndex === "number" && Number.isFinite(state.renderEndIndex)) {
|
|
234
|
+
const inclusive = Math.floor(state.renderEndIndex)
|
|
235
|
+
return Math.max(Math.min(totalItems, inclusive + 1), startIndex)
|
|
236
|
+
}
|
|
237
|
+
return Math.max(defaultEndExclusive, startIndex)
|
|
238
|
+
})()
|
|
239
|
+
const lookaheadExclusive = (() => {
|
|
240
|
+
if (typeof state.lookaheadEndIndex === "number" && Number.isFinite(state.lookaheadEndIndex)) {
|
|
241
|
+
const inclusive = Math.floor(state.lookaheadEndIndex)
|
|
242
|
+
const candidate = Math.min(totalItems, inclusive + 1)
|
|
243
|
+
return Math.max(candidate, endExclusive)
|
|
244
|
+
}
|
|
245
|
+
return Math.max(defaultEndExclusive, endExclusive)
|
|
246
|
+
})()
|
|
247
|
+
const segments = collectVisibleSegments(state.segmentsByItem, startIndex, lookaheadExclusive)
|
|
248
|
+
const glyphs = collectVisibleGlyphs(state.glyphsByItem, startIndex, lookaheadExclusive)
|
|
249
|
+
context.save()
|
|
250
|
+
context.scale(pixelRatio, pixelRatio)
|
|
251
|
+
context.clearRect(0, 0, state.width, state.viewportHeight)
|
|
252
|
+
context.translate(0, -state.scrollPosition)
|
|
253
|
+
context.strokeStyle = state.color
|
|
254
|
+
drawSegments(context, segments, state.strokeWidth)
|
|
255
|
+
drawGlyphs(context, glyphs, state.expandGlyphColor ?? state.color, state.expandGlyphSize)
|
|
256
|
+
context.restore()
|
|
257
|
+
}, [])
|
|
258
|
+
|
|
259
|
+
const scheduleRender = useCallback(() => {
|
|
260
|
+
if (animationFrameRef.current !== null) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
animationFrameRef.current = window.requestAnimationFrame(renderFrame)
|
|
264
|
+
}, [renderFrame])
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
latestStateRef.current = {
|
|
268
|
+
segmentsByItem,
|
|
269
|
+
glyphsByItem,
|
|
270
|
+
itemHeight,
|
|
271
|
+
width,
|
|
272
|
+
viewportHeight,
|
|
273
|
+
scrollPosition,
|
|
274
|
+
color,
|
|
275
|
+
strokeWidth,
|
|
276
|
+
renderStartIndex,
|
|
277
|
+
renderEndIndex,
|
|
278
|
+
lookaheadEndIndex,
|
|
279
|
+
devicePixelRatio,
|
|
280
|
+
expandGlyphColor,
|
|
281
|
+
expandGlyphSize,
|
|
282
|
+
}
|
|
283
|
+
scheduleRender()
|
|
284
|
+
}, [segmentsByItem, glyphsByItem, itemHeight, width, viewportHeight, scrollPosition, color, strokeWidth, renderStartIndex, renderEndIndex, lookaheadEndIndex, devicePixelRatio, expandGlyphColor, expandGlyphSize, scheduleRender])
|
|
285
|
+
|
|
286
|
+
useEffect(() => {
|
|
287
|
+
return () => {
|
|
288
|
+
if (animationFrameRef.current !== null) {
|
|
289
|
+
window.cancelAnimationFrame(animationFrameRef.current)
|
|
290
|
+
animationFrameRef.current = null
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}, [])
|
|
294
|
+
|
|
295
|
+
return <canvas ref={canvasRef} className="pointer-events-none absolute top-0 left-0 z-0" />
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
TreeLine.displayName = "TreeLineCanvas"
|
|
299
|
+
|
|
300
|
+
export { TreeLine }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { Logger, LogLevel } from "./logger"
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
7
|
+
const __dirname = path.dirname(__filename)
|
|
8
|
+
|
|
9
|
+
const logger = new Logger(LogLevel.INFO, "[directory-tree]")
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2)
|
|
12
|
+
|
|
13
|
+
if (args[0] === "demo") {
|
|
14
|
+
logger.info("Starting demo server...")
|
|
15
|
+
|
|
16
|
+
// The demo directory is inside the package
|
|
17
|
+
const demoPath = path.join(__dirname, "..", "demo")
|
|
18
|
+
|
|
19
|
+
const child = spawn("pnpm", ["run", "dev"], {
|
|
20
|
+
cwd: demoPath,
|
|
21
|
+
stdio: "inherit",
|
|
22
|
+
shell: process.platform === "win32",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
child.on("close", (code) => {
|
|
26
|
+
if (code !== 0) {
|
|
27
|
+
logger.error(`Demo server process exited with code ${code}`)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
child.on("error", (err) => {
|
|
32
|
+
logger.error("Failed to start demo server:", err)
|
|
33
|
+
})
|
|
34
|
+
} else {
|
|
35
|
+
logger.info(`Unknown command: ${args[0]}`)
|
|
36
|
+
logger.info("Usage: npx @aiquants/directory-tree demo")
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|