@aiquants/directory-tree 3.0.4 → 3.1.0

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.
@@ -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
+ )
@@ -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
+ }