@plannotator/ui 0.24.0 → 0.26.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.
- package/README.md +15 -0
- package/components/ResizeHandle.tsx +86 -47
- package/components/html-viewer/HtmlViewer.tsx +26 -52
- package/components/html-viewer/bridge-script.ts +26 -13
- package/components/html-viewer/srcdoc.ts +130 -0
- package/components/sidebar/FileBrowser.tsx +132 -6
- package/hooks/useResizablePanel.ts +36 -5
- package/package.json +1 -1
- package/styles.css +1 -1
- package/theme.css +16 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
|
+
import { Search, X } from "lucide-react";
|
|
9
10
|
import type { VaultNode } from "../../types";
|
|
10
11
|
import type { DirState } from "../../hooks/useFileBrowser";
|
|
11
12
|
import { CountBadge } from "./CountBadge";
|
|
@@ -42,6 +43,40 @@ interface AggregateWorkspaceChange {
|
|
|
42
43
|
files: number;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const FILE_EXTENSION_RE = /\.(mdx?|txt|html?)$/i;
|
|
47
|
+
|
|
48
|
+
function normalizeFilterText(value: string): string {
|
|
49
|
+
return value.replace(/\\/g, "/").toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFileTreeFilterTokens(query: string): string[] {
|
|
53
|
+
return normalizeFilterText(query)
|
|
54
|
+
.trim()
|
|
55
|
+
.split(/\s+/)
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function nodeMatchesFilter(node: VaultNode, tokens: string[]): boolean {
|
|
60
|
+
if (tokens.length === 0) return true;
|
|
61
|
+
const displayName = node.name.replace(FILE_EXTENSION_RE, "");
|
|
62
|
+
const haystack = normalizeFilterText(`${node.name} ${displayName} ${node.path}`);
|
|
63
|
+
return tokens.every((token) => haystack.includes(token));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function filterFileTree(nodes: VaultNode[], tokens: string[]): VaultNode[] {
|
|
67
|
+
if (tokens.length === 0) return nodes;
|
|
68
|
+
|
|
69
|
+
return nodes.flatMap((node) => {
|
|
70
|
+
if (node.type === "file") return nodeMatchesFilter(node, tokens) ? [node] : [];
|
|
71
|
+
|
|
72
|
+
if (nodeMatchesFilter(node, tokens)) return [node];
|
|
73
|
+
|
|
74
|
+
const children = filterFileTree(node.children ?? [], tokens);
|
|
75
|
+
if (children.length === 0) return [];
|
|
76
|
+
return [{ ...node, children }];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
45
80
|
export function normalizePathForLookup(path: string): string {
|
|
46
81
|
return normalizeBrowserPath(path);
|
|
47
82
|
}
|
|
@@ -207,21 +242,26 @@ const TreeNode: React.FC<{
|
|
|
207
242
|
highlightedFiles?: Set<string>;
|
|
208
243
|
editStatuses?: Map<string, FileEditStatus>;
|
|
209
244
|
workspaceStatus?: WorkspaceStatusPayload;
|
|
210
|
-
|
|
245
|
+
forceExpandFolders?: boolean;
|
|
246
|
+
}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false }) => {
|
|
211
247
|
const folderKey = `${dirPath}:${node.path}`;
|
|
212
248
|
const absolutePath = `${dirPath}/${node.path}`;
|
|
213
|
-
const isExpanded = expandedFolders.has(folderKey);
|
|
249
|
+
const isExpanded = forceExpandFolders || expandedFolders.has(folderKey);
|
|
214
250
|
const isActive = node.type === "file" && absolutePath === activeFile;
|
|
215
251
|
const paddingLeft = 8 + depth * 14;
|
|
216
252
|
|
|
217
253
|
if (node.type === "folder") {
|
|
218
254
|
const aggregateCount = annotationCounts ? getAggregateCount(node, dirPath, annotationCounts, workspaceStatus) : 0;
|
|
219
255
|
const aggregateChange = getAggregateWorkspaceChange(node, dirPath, workspaceStatus);
|
|
256
|
+
const folderButtonClassName = forceExpandFolders
|
|
257
|
+
? "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground transition-colors rounded-sm cursor-default disabled:opacity-100"
|
|
258
|
+
: "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm";
|
|
220
259
|
return (
|
|
221
260
|
<>
|
|
222
261
|
<button
|
|
262
|
+
disabled={forceExpandFolders}
|
|
223
263
|
onClick={() => onToggleFolder(folderKey)}
|
|
224
|
-
className=
|
|
264
|
+
className={folderButtonClassName}
|
|
225
265
|
style={{ paddingLeft }}
|
|
226
266
|
>
|
|
227
267
|
<svg
|
|
@@ -261,6 +301,7 @@ const TreeNode: React.FC<{
|
|
|
261
301
|
highlightedFiles={highlightedFiles}
|
|
262
302
|
editStatuses={editStatuses}
|
|
263
303
|
workspaceStatus={workspaceStatus}
|
|
304
|
+
forceExpandFolders={forceExpandFolders}
|
|
264
305
|
/>
|
|
265
306
|
))}
|
|
266
307
|
</>
|
|
@@ -348,7 +389,8 @@ const DirSection: React.FC<{
|
|
|
348
389
|
annotationCounts?: Map<string, number>;
|
|
349
390
|
highlightedFiles?: Set<string>;
|
|
350
391
|
editStatuses?: Map<string, FileEditStatus>;
|
|
351
|
-
|
|
392
|
+
forceExpandFolders?: boolean;
|
|
393
|
+
}> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false }) => {
|
|
352
394
|
const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]);
|
|
353
395
|
|
|
354
396
|
if (dir.isLoading) {
|
|
@@ -397,6 +439,7 @@ const DirSection: React.FC<{
|
|
|
397
439
|
highlightedFiles={highlightedFiles}
|
|
398
440
|
editStatuses={editStatuses}
|
|
399
441
|
workspaceStatus={workspaceStatus}
|
|
442
|
+
forceExpandFolders={forceExpandFolders}
|
|
400
443
|
/>
|
|
401
444
|
))}
|
|
402
445
|
</div>
|
|
@@ -417,6 +460,30 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
417
460
|
highlightedFiles,
|
|
418
461
|
editStatuses,
|
|
419
462
|
}) => {
|
|
463
|
+
const [isFilterOpen, setIsFilterOpen] = React.useState(false);
|
|
464
|
+
const [filterQuery, setFilterQuery] = React.useState("");
|
|
465
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
466
|
+
const deferredFilterQuery = React.useDeferredValue(filterQuery);
|
|
467
|
+
const filterTokens = React.useMemo(() => getFileTreeFilterTokens(deferredFilterQuery), [deferredFilterQuery]);
|
|
468
|
+
const isFiltering = filterTokens.length > 0;
|
|
469
|
+
const showFilterInput = isFilterOpen || filterQuery.trim().length > 0;
|
|
470
|
+
const visibleDirs = React.useMemo(() => {
|
|
471
|
+
if (!isFiltering) return dirs;
|
|
472
|
+
return dirs
|
|
473
|
+
.map((dir) => ({ ...dir, tree: filterFileTree(dir.tree, filterTokens) }))
|
|
474
|
+
.filter((dir) => dir.isLoading || dir.error || dir.tree.length > 0);
|
|
475
|
+
}, [dirs, filterTokens, isFiltering]);
|
|
476
|
+
const handleFilterBlur = React.useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
|
477
|
+
if (filterQuery.trim()) return;
|
|
478
|
+
if (event.currentTarget.contains(event.relatedTarget)) return;
|
|
479
|
+
setIsFilterOpen(false);
|
|
480
|
+
}, [filterQuery]);
|
|
481
|
+
|
|
482
|
+
React.useEffect(() => {
|
|
483
|
+
if (!showFilterInput) return;
|
|
484
|
+
inputRef.current?.focus();
|
|
485
|
+
}, [showFilterInput]);
|
|
486
|
+
|
|
420
487
|
if (dirs.length === 0) {
|
|
421
488
|
return (
|
|
422
489
|
<div className="p-3 text-[11px] text-muted-foreground">
|
|
@@ -454,8 +521,66 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
454
521
|
{workspaceTotals.deletions > 0 && <span className={`deletions ${workspaceTotals.additions > 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}</span>}
|
|
455
522
|
</div>
|
|
456
523
|
)}
|
|
457
|
-
|
|
458
|
-
|
|
524
|
+
<div className="border-b border-border/20 px-2 py-0.5">
|
|
525
|
+
{showFilterInput ? (
|
|
526
|
+
<div
|
|
527
|
+
onBlur={handleFilterBlur}
|
|
528
|
+
className="flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
|
|
529
|
+
>
|
|
530
|
+
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
|
|
531
|
+
<input
|
|
532
|
+
ref={inputRef}
|
|
533
|
+
type="search"
|
|
534
|
+
value={filterQuery}
|
|
535
|
+
onChange={(event) => setFilterQuery(event.target.value)}
|
|
536
|
+
onKeyDown={(event) => {
|
|
537
|
+
if (event.key !== "Escape") return;
|
|
538
|
+
if (filterQuery) setFilterQuery("");
|
|
539
|
+
else setIsFilterOpen(false);
|
|
540
|
+
}}
|
|
541
|
+
placeholder="Filter"
|
|
542
|
+
aria-label="Filter files"
|
|
543
|
+
autoComplete="off"
|
|
544
|
+
spellCheck={false}
|
|
545
|
+
data-lpignore="true"
|
|
546
|
+
data-1p-ignore
|
|
547
|
+
className="file-browser-filter-input h-full min-w-0 flex-1 bg-transparent p-0 text-[16px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/45 sm:text-[11px]"
|
|
548
|
+
/>
|
|
549
|
+
{filterQuery && (
|
|
550
|
+
<button
|
|
551
|
+
type="button"
|
|
552
|
+
onClick={() => {
|
|
553
|
+
setFilterQuery("");
|
|
554
|
+
inputRef.current?.focus();
|
|
555
|
+
}}
|
|
556
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
|
|
557
|
+
aria-label="Clear file filter"
|
|
558
|
+
title="Clear file filter"
|
|
559
|
+
>
|
|
560
|
+
<X size={12} aria-hidden="true" />
|
|
561
|
+
</button>
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
) : (
|
|
565
|
+
<button
|
|
566
|
+
type="button"
|
|
567
|
+
onClick={() => setIsFilterOpen(true)}
|
|
568
|
+
className="flex h-6 w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[10px] text-muted-foreground hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:bg-muted/50 focus-visible:text-foreground"
|
|
569
|
+
aria-label="Filter files"
|
|
570
|
+
title="Filter files"
|
|
571
|
+
>
|
|
572
|
+
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
|
|
573
|
+
<span className="truncate">Filter</span>
|
|
574
|
+
</button>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
{isFiltering && visibleDirs.length === 0 && (
|
|
578
|
+
<div className="px-3 py-8 text-center text-[11px] text-muted-foreground">
|
|
579
|
+
No files match "{deferredFilterQuery.trim()}"
|
|
580
|
+
</div>
|
|
581
|
+
)}
|
|
582
|
+
{visibleDirs.map((dir) => {
|
|
583
|
+
const isCollapsed = !isFiltering && collapsedDirs.has(dir.path);
|
|
459
584
|
return (
|
|
460
585
|
<div key={dir.path}>
|
|
461
586
|
<button
|
|
@@ -488,6 +613,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
|
488
613
|
annotationCounts={annotationCounts}
|
|
489
614
|
highlightedFiles={highlightedFiles}
|
|
490
615
|
editStatuses={editStatuses}
|
|
616
|
+
forceExpandFolders={isFiltering}
|
|
491
617
|
/>
|
|
492
618
|
)}
|
|
493
619
|
</div>
|
|
@@ -23,6 +23,16 @@ interface UseResizablePanelOptions {
|
|
|
23
23
|
* (still rAF-coalesced).
|
|
24
24
|
*/
|
|
25
25
|
apply?: (width: number) => void;
|
|
26
|
+
/**
|
|
27
|
+
* Fires on pointer-up when the pointer never moved beyond `clickThreshold` —
|
|
28
|
+
* i.e. a genuine click on the handle, not a drag. The hook owns the pointer
|
|
29
|
+
* state machine, so this is the only reliable way to tell a click from a
|
|
30
|
+
* drag-start. Lets a host make the whole handle a click target (e.g. click
|
|
31
|
+
* anywhere on the handle to collapse the panel). Not fired on a snap-close.
|
|
32
|
+
*/
|
|
33
|
+
onClick?: () => void;
|
|
34
|
+
/** Max pointer travel (px) still counted as a click for `onClick`. Default 4. */
|
|
35
|
+
clickThreshold?: number;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
export interface ResizeHandleProps {
|
|
@@ -43,6 +53,8 @@ export function useResizablePanel({
|
|
|
43
53
|
onSnapClose,
|
|
44
54
|
snapCloseRatio = 0.6,
|
|
45
55
|
apply,
|
|
56
|
+
onClick,
|
|
57
|
+
clickThreshold = 4,
|
|
46
58
|
}: UseResizablePanelOptions) {
|
|
47
59
|
const [width, setWidth] = useState(() => {
|
|
48
60
|
const saved = storage.getItem(storageKey);
|
|
@@ -61,6 +73,9 @@ export function useResizablePanel({
|
|
|
61
73
|
const startWidthRef = useRef(0);
|
|
62
74
|
const draggingRef = useRef(false);
|
|
63
75
|
const snappedRef = useRef(false);
|
|
76
|
+
// Latches true once the pointer travels past clickThreshold — distinguishes a
|
|
77
|
+
// click from a drag. Reset on each pointerdown.
|
|
78
|
+
const movedRef = useRef(false);
|
|
64
79
|
const latestXRef = useRef(0);
|
|
65
80
|
const rafRef = useRef<number | null>(null);
|
|
66
81
|
|
|
@@ -69,6 +84,8 @@ export function useResizablePanel({
|
|
|
69
84
|
applyRef.current = apply;
|
|
70
85
|
const onSnapCloseRef = useRef(onSnapClose);
|
|
71
86
|
onSnapCloseRef.current = onSnapClose;
|
|
87
|
+
const onClickRef = useRef(onClick);
|
|
88
|
+
onClickRef.current = onClick;
|
|
72
89
|
|
|
73
90
|
// rAF tick: compute the width from the most recent pointer position and apply
|
|
74
91
|
// it. At most one DOM/state write per frame regardless of pointer-event rate.
|
|
@@ -110,6 +127,7 @@ export function useResizablePanel({
|
|
|
110
127
|
startWidthRef.current = widthRef.current;
|
|
111
128
|
latestXRef.current = pos;
|
|
112
129
|
snappedRef.current = false;
|
|
130
|
+
movedRef.current = false;
|
|
113
131
|
draggingRef.current = true;
|
|
114
132
|
setIsDragging(true);
|
|
115
133
|
|
|
@@ -120,6 +138,8 @@ export function useResizablePanel({
|
|
|
120
138
|
const onMove = (ev: PointerEvent) => {
|
|
121
139
|
if (!draggingRef.current) return;
|
|
122
140
|
latestXRef.current = axis === 'y' ? ev.clientY : ev.clientX;
|
|
141
|
+
if (Math.abs(latestXRef.current - startXRef.current) > clickThreshold)
|
|
142
|
+
movedRef.current = true;
|
|
123
143
|
if (rafRef.current == null) rafRef.current = requestAnimationFrame(flush);
|
|
124
144
|
};
|
|
125
145
|
const cleanup = () => {
|
|
@@ -127,7 +147,11 @@ export function useResizablePanel({
|
|
|
127
147
|
window.removeEventListener('pointerup', onUp);
|
|
128
148
|
window.removeEventListener('pointercancel', onUp);
|
|
129
149
|
};
|
|
130
|
-
function onUp() {
|
|
150
|
+
function onUp(e: PointerEvent) {
|
|
151
|
+
// pointercancel = the browser aborted the gesture (palm rejection, a
|
|
152
|
+
// system gesture, focus loss). It is NOT a completed click — only clean
|
|
153
|
+
// up drag state, never treat it as a click-to-collapse.
|
|
154
|
+
const cancelled = e?.type === 'pointercancel';
|
|
131
155
|
const wasSnapped = snappedRef.current;
|
|
132
156
|
draggingRef.current = false;
|
|
133
157
|
snappedRef.current = false;
|
|
@@ -137,9 +161,16 @@ export function useResizablePanel({
|
|
|
137
161
|
}
|
|
138
162
|
setIsDragging(false);
|
|
139
163
|
if (!wasSnapped) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
if (onClickRef.current && !movedRef.current && !cancelled) {
|
|
165
|
+
// A pointerdown+up that never crossed the threshold is a click, not
|
|
166
|
+
// a resize — fire onClick and leave the width untouched. Only when a
|
|
167
|
+
// host opts in via onClick; otherwise commit exactly as before.
|
|
168
|
+
onClickRef.current();
|
|
169
|
+
} else {
|
|
170
|
+
// Commit the live width to React state + persist.
|
|
171
|
+
setWidth(widthRef.current);
|
|
172
|
+
storage.setItem(storageKey, String(widthRef.current));
|
|
173
|
+
}
|
|
143
174
|
}
|
|
144
175
|
cleanup();
|
|
145
176
|
}
|
|
@@ -148,7 +179,7 @@ export function useResizablePanel({
|
|
|
148
179
|
window.addEventListener('pointerup', onUp);
|
|
149
180
|
window.addEventListener('pointercancel', onUp);
|
|
150
181
|
},
|
|
151
|
-
[flush, storageKey, axis],
|
|
182
|
+
[flush, storageKey, axis, clickThreshold],
|
|
152
183
|
);
|
|
153
184
|
|
|
154
185
|
const resetWidth = useCallback(() => {
|