feat: enhance Kanban board with grid layout and persistence improvements
- Introduced a new KanbanGridLayout component for improved task organization and layout management. - Updated KanbanBoard to utilize the new grid layout, enhancing the visual structure of tasks. - Added CSS styles for grid layout and resizing handles to improve user interaction. - Refactored KanbanColumn to support additional customization options for headers and body styles. - Enhanced persistence flows to rely on repository abstractions, promoting better separation of concerns in storage management. - Updated package dependencies to include react-grid-layout and react-resizable for enhanced layout capabilities.
This commit is contained in:
parent
6bcb81d337
commit
2317c948ff
14 changed files with 812 additions and 31 deletions
|
|
@ -187,3 +187,9 @@ Note: renderer utils/hooks/types do NOT have barrel exports — import directly
|
|||
1. External packages
|
||||
2. Path aliases (@main, @renderer, @shared)
|
||||
3. Relative imports
|
||||
|
||||
### Storage And Persistence
|
||||
- New persistence flows should depend on small repository/storage abstractions, not directly on `localStorage`, `IndexedDB`, Electron APIs, or JSON files from UI components/hooks.
|
||||
- Keep persistence concerns split by responsibility: schema/normalization, repository interface, concrete storage implementation, and UI adapter logic should live in separate modules.
|
||||
- Prefer designs where the high-level feature code can swap local browser/Electron storage for a server-backed implementation without rewriting the rendering layer.
|
||||
- Reuse generic persistence/layout infrastructure when adding new draggable/resizable surfaces instead of copying feature-specific storage code.
|
||||
|
|
|
|||
|
|
@ -133,7 +133,9 @@
|
|||
"node-pty": "^1.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
|
|
|||
|
|
@ -212,9 +212,15 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-grid-layout:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@18.3.27)(react@18.3.1)
|
||||
react-resizable:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
rehype-highlight:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
|
|
@ -3770,6 +3776,9 @@ packages:
|
|||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-equals@4.0.3:
|
||||
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
|
@ -5560,6 +5569,18 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-draggable@4.5.0:
|
||||
resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
react-dom: '>= 16.3.0'
|
||||
|
||||
react-grid-layout@2.2.2:
|
||||
resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
react-dom: '>= 16.3.0'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
|
|
@ -5593,6 +5614,12 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-resizable@3.1.3:
|
||||
resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3'
|
||||
react-dom: '>= 16.3'
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -5690,6 +5717,9 @@ packages:
|
|||
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
resize-observer-polyfill@1.5.1:
|
||||
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
||||
|
||||
resolve-alpn@1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
|
||||
|
|
@ -10785,6 +10815,8 @@ snapshots:
|
|||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-equals@4.0.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
|
|
@ -12938,6 +12970,24 @@ snapshots:
|
|||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-draggable@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-grid-layout@2.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
fast-equals: 4.0.3
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-draggable: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-resizable: 3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
resize-observer-polyfill: 1.5.1
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@18.3.27)(react@18.3.1):
|
||||
|
|
@ -12979,6 +13029,13 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
|
||||
react-resizable@3.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
react-draggable: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
|
|
@ -13125,6 +13182,8 @@ snapshots:
|
|||
dependencies:
|
||||
pe-library: 0.4.1
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
|
||||
resolve-alpn@1.2.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useResizableColumns } from '@renderer/hooks/useResizableColumns';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
import {
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
|
|
@ -23,6 +22,7 @@ import {
|
|||
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import { KanbanFilterPopover } from './KanbanFilterPopover';
|
||||
import { KanbanGridLayout } from './KanbanGridLayout';
|
||||
import { KanbanSortPopover } from './KanbanSortPopover';
|
||||
import { KanbanTaskCard } from './KanbanTaskCard';
|
||||
|
||||
|
|
@ -303,6 +303,8 @@ export const KanbanBoard = ({
|
|||
onOpenTrash,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
const enableTaskSorting =
|
||||
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
|
||||
|
||||
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
|
||||
const grouped = useMemo(() => {
|
||||
|
|
@ -390,7 +392,7 @@ export const KanbanBoard = ({
|
|||
)
|
||||
);
|
||||
}
|
||||
if (onColumnOrderChange && sort.field === 'manual') {
|
||||
if (enableTaskSorting) {
|
||||
const itemIds = columnTasks.map((t) => t.id);
|
||||
return (
|
||||
<>
|
||||
|
|
@ -541,33 +543,31 @@ export const KanbanBoard = ({
|
|||
</div>
|
||||
|
||||
{viewMode === 'grid' ? (
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
<KanbanGridLayout
|
||||
teamName={teamName}
|
||||
allColumnIds={COLUMNS.map((column) => column.id)}
|
||||
columns={visibleColumns.map((column) => {
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
title={column.title}
|
||||
count={columnTasks.length}
|
||||
icon={accent.icon}
|
||||
headerBg={accent.headerBg}
|
||||
bodyBg={accent.bodyBg}
|
||||
>
|
||||
{renderCards(column.id, columnTasks)}
|
||||
</KanbanColumn>
|
||||
);
|
||||
|
||||
return {
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
count: columnTasks.length,
|
||||
icon: accent.icon,
|
||||
headerBg: accent.headerBg,
|
||||
bodyBg: accent.bodyBg,
|
||||
content: renderCards(column.id, columnTasks),
|
||||
};
|
||||
})}
|
||||
</div>
|
||||
/>
|
||||
) : (
|
||||
<div className="flex overflow-x-auto pb-2">
|
||||
{visibleColumns.map((column, index) => {
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
const width = columnWidths.get(column.id) ?? 256;
|
||||
const handleProps = getHandleProps(column.id);
|
||||
return (
|
||||
<div key={column.id} className="flex shrink-0">
|
||||
<div style={{ width }}>
|
||||
|
|
@ -584,7 +584,9 @@ export const KanbanBoard = ({
|
|||
{index < visibleColumns.length - 1 ? (
|
||||
<div
|
||||
className="group relative mx-0.5 flex items-center"
|
||||
{...getHandleProps(column.id)}
|
||||
onPointerDown={handleProps.onPointerDown}
|
||||
style={handleProps.style}
|
||||
aria-label={handleProps['aria-label']}
|
||||
>
|
||||
<div className="h-full w-px bg-[var(--color-border)] transition-colors group-hover:bg-blue-500/50 group-active:bg-blue-500" />
|
||||
</div>
|
||||
|
|
@ -597,7 +599,7 @@ export const KanbanBoard = ({
|
|||
</>
|
||||
);
|
||||
|
||||
if (onColumnOrderChange && sort.field === 'manual') {
|
||||
if (enableTaskSorting) {
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
{boardContent}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ interface KanbanColumnProps {
|
|||
icon?: React.ReactNode;
|
||||
headerBg?: string;
|
||||
bodyBg?: string;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
headerAccessory?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -16,29 +20,42 @@ export const KanbanColumn = ({
|
|||
icon,
|
||||
headerBg,
|
||||
bodyBg,
|
||||
className,
|
||||
headerClassName,
|
||||
bodyClassName,
|
||||
headerAccessory,
|
||||
children,
|
||||
}: KanbanColumnProps): React.JSX.Element => {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-md border border-[var(--color-border)]',
|
||||
className,
|
||||
!bodyBg && 'bg-[var(--color-surface)]'
|
||||
)}
|
||||
style={bodyBg ? { backgroundColor: bodyBg } : undefined}
|
||||
>
|
||||
<header
|
||||
className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2"
|
||||
className={cn(
|
||||
'flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2',
|
||||
headerClassName
|
||||
)}
|
||||
style={headerBg ? { backgroundColor: headerBg } : undefined}
|
||||
>
|
||||
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
|
||||
{icon}
|
||||
{title}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||
{count}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{headerAccessory}
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex max-h-[480px] flex-col overflow-auto p-2">{children}</div>
|
||||
<div className={cn('flex max-h-[480px] flex-col overflow-auto p-2', bodyClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
185
src/renderer/components/team/kanban/KanbanGridLayout.tsx
Normal file
185
src/renderer/components/team/kanban/KanbanGridLayout.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
|
||||
|
||||
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
||||
import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
|
||||
import type { PersistedGridLayoutItem } from '@renderer/services/layout-system/gridLayoutTypes';
|
||||
import type { KanbanColumnId } from '@shared/types';
|
||||
import type { ReactElement, Ref } from 'react';
|
||||
import type { Layout, LayoutItem, ResizeHandleAxis } from 'react-grid-layout/legacy';
|
||||
|
||||
const GRID_COLS = 12;
|
||||
const GRID_ROW_HEIGHT = 18;
|
||||
const GRID_MARGIN: [number, number] = [12, 12];
|
||||
const DEFAULT_ITEM_WIDTH = 4;
|
||||
const DEFAULT_ITEM_HEIGHT_PX = 400;
|
||||
const DEFAULT_ITEM_HEIGHT = Math.max(
|
||||
1,
|
||||
Math.round((DEFAULT_ITEM_HEIGHT_PX + GRID_MARGIN[1]) / (GRID_ROW_HEIGHT + GRID_MARGIN[1]))
|
||||
);
|
||||
const DEFAULT_MIN_HEIGHT = 10;
|
||||
const DEFAULT_MIN_WIDTH = 3;
|
||||
const GRID_SCOPE_PREFIX = 'kanban-grid-layout:v2';
|
||||
const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'];
|
||||
const WidthAwareGridLayout = WidthProvider(ReactGridLayout);
|
||||
|
||||
export interface KanbanGridColumn {
|
||||
id: KanbanColumnId;
|
||||
title: string;
|
||||
count: number;
|
||||
icon?: React.ReactNode;
|
||||
headerBg?: string;
|
||||
bodyBg?: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
interface KanbanGridLayoutProps {
|
||||
teamName: string;
|
||||
columns: KanbanGridColumn[];
|
||||
allColumnIds: KanbanColumnId[];
|
||||
}
|
||||
|
||||
function buildDefaultItems(itemIds: string[]): PersistedGridLayoutItem[] {
|
||||
return itemIds.map((id, index) => ({
|
||||
id,
|
||||
x: (index % 3) * DEFAULT_ITEM_WIDTH,
|
||||
y: Math.floor(index / 3) * DEFAULT_ITEM_HEIGHT,
|
||||
w: DEFAULT_ITEM_WIDTH,
|
||||
h: DEFAULT_ITEM_HEIGHT,
|
||||
minW: DEFAULT_MIN_WIDTH,
|
||||
minH: DEFAULT_MIN_HEIGHT,
|
||||
}));
|
||||
}
|
||||
|
||||
function toReactGridLayoutItem(item: PersistedGridLayoutItem): LayoutItem {
|
||||
return {
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
minW: item.minW,
|
||||
minH: item.minH,
|
||||
maxW: item.maxW,
|
||||
maxH: item.maxH,
|
||||
};
|
||||
}
|
||||
|
||||
function fromReactGridLayout(layout: Layout): PersistedGridLayoutItem[] {
|
||||
return layout.map((item) => ({
|
||||
id: item.i,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
minW: item.minW,
|
||||
minH: item.minH,
|
||||
maxW: item.maxW,
|
||||
maxH: item.maxH,
|
||||
}));
|
||||
}
|
||||
|
||||
function renderResizeHandle(axis: ResizeHandleAxis, ref: Ref<HTMLElement>): ReactElement {
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={`kanban-grid-resize-handle kanban-grid-resize-handle-${axis}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const KanbanGridLayout = ({
|
||||
teamName,
|
||||
columns,
|
||||
allColumnIds,
|
||||
}: KanbanGridLayoutProps): React.JSX.Element => {
|
||||
const columnMap = useMemo(() => new Map(columns.map((column) => [column.id, column])), [columns]);
|
||||
const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||
const { visibleItems, applyVisibleItems } = usePersistedGridLayout({
|
||||
scopeKey: `${GRID_SCOPE_PREFIX}:${teamName}`,
|
||||
allItemIds: allColumnIds,
|
||||
visibleItemIds: visibleColumnIds,
|
||||
cols: GRID_COLS,
|
||||
repository: browserGridLayoutRepository,
|
||||
buildDefaultItems,
|
||||
});
|
||||
|
||||
const [renderLayout, setRenderLayout] = useState<Layout>(() =>
|
||||
visibleItems.map(toReactGridLayoutItem)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRenderLayout(visibleItems.map(toReactGridLayoutItem));
|
||||
}, [visibleItems]);
|
||||
|
||||
const applyReactGridLayout = useCallback(
|
||||
(layout: Layout, options?: { persist?: boolean }) => {
|
||||
setRenderLayout(layout);
|
||||
if (options?.persist) {
|
||||
applyVisibleItems(fromReactGridLayout(layout), options);
|
||||
}
|
||||
},
|
||||
[applyVisibleItems]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-1.5">
|
||||
<WidthAwareGridLayout
|
||||
className="kanban-grid-layout"
|
||||
layout={renderLayout}
|
||||
cols={GRID_COLS}
|
||||
rowHeight={GRID_ROW_HEIGHT}
|
||||
margin={GRID_MARGIN}
|
||||
containerPadding={[0, 0]}
|
||||
isDraggable
|
||||
isResizable
|
||||
draggableHandle=".kanban-grid-drag-handle"
|
||||
resizeHandles={RESIZE_HANDLES}
|
||||
resizeHandle={renderResizeHandle}
|
||||
onLayoutChange={(layout) => applyReactGridLayout(layout)}
|
||||
onDragStop={(layout) => applyReactGridLayout(layout, { persist: true })}
|
||||
onResizeStop={(layout) => applyReactGridLayout(layout, { persist: true })}
|
||||
>
|
||||
{visibleItems.map((layoutItem) => {
|
||||
const column = columnMap.get(layoutItem.id as KanbanColumnId);
|
||||
if (!column) {
|
||||
return <div key={layoutItem.id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={layoutItem.id} className="kanban-grid-item-wrapper min-h-0">
|
||||
<KanbanColumn
|
||||
title={column.title}
|
||||
count={column.count}
|
||||
icon={column.icon}
|
||||
headerBg={column.headerBg}
|
||||
bodyBg={column.bodyBg}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
headerClassName="shrink-0"
|
||||
bodyClassName="kanban-grid-no-drag min-h-0 max-h-none flex-1"
|
||||
headerAccessory={
|
||||
<button
|
||||
type="button"
|
||||
className="kanban-grid-drag-handle inline-flex cursor-grab items-center justify-center rounded-sm p-1 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)] active:cursor-grabbing"
|
||||
aria-label={`Drag ${column.title} column`}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{column.content}
|
||||
</KanbanColumn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</WidthAwareGridLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
|
||||
import {
|
||||
getSuggestionInsertionText,
|
||||
getSuggestionTriggerChar,
|
||||
} from '@renderer/utils/mentionSuggestions';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
||||
|
|
@ -217,7 +220,13 @@ export function useMentionDetection({
|
|||
|
||||
const before = value.slice(0, triggerIndexRef.current);
|
||||
const after = value.slice(triggerIndexRef.current + 1 + queryRef.current.length);
|
||||
const insertion = `${triggerChar}${getSuggestionInsertionText(s)} `;
|
||||
const suggestionText = getSuggestionInsertionText(s);
|
||||
const expectedTriggerChar = getSuggestionTriggerChar(s);
|
||||
const insertionBody =
|
||||
triggerChar === expectedTriggerChar && suggestionText.startsWith(triggerChar)
|
||||
? suggestionText
|
||||
: `${triggerChar}${suggestionText}`;
|
||||
const insertion = `${insertionBody} `;
|
||||
const newValue = before + insertion + after;
|
||||
const newCursorPos = before.length + insertion.length;
|
||||
|
||||
|
|
@ -226,8 +235,8 @@ export function useMentionDetection({
|
|||
|
||||
// Set cursor position after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
});
|
||||
},
|
||||
[value, onValueChange, textareaRef, dismiss]
|
||||
|
|
|
|||
107
src/renderer/hooks/usePersistedGridLayout.ts
Normal file
107
src/renderer/hooks/usePersistedGridLayout.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
createPersistedGridLayoutState,
|
||||
mergeGridLayoutItems,
|
||||
normalizePersistedGridLayoutState,
|
||||
projectVisibleGridLayoutItems,
|
||||
} from '@renderer/services/layout-system/gridLayoutSchema';
|
||||
|
||||
import type { GridLayoutRepository } from '@renderer/services/layout-system/GridLayoutRepository';
|
||||
import type {
|
||||
PersistedGridLayoutItem,
|
||||
PersistedGridLayoutState,
|
||||
} from '@renderer/services/layout-system/gridLayoutTypes';
|
||||
|
||||
interface UsePersistedGridLayoutOptions {
|
||||
scopeKey: string;
|
||||
allItemIds: string[];
|
||||
visibleItemIds: string[];
|
||||
cols: number;
|
||||
repository: GridLayoutRepository<PersistedGridLayoutState>;
|
||||
buildDefaultItems: (itemIds: string[]) => PersistedGridLayoutItem[];
|
||||
}
|
||||
|
||||
interface UsePersistedGridLayoutResult {
|
||||
allItems: PersistedGridLayoutItem[];
|
||||
visibleItems: PersistedGridLayoutItem[];
|
||||
isLoaded: boolean;
|
||||
applyVisibleItems: (items: PersistedGridLayoutItem[], options?: { persist?: boolean }) => void;
|
||||
}
|
||||
|
||||
export function usePersistedGridLayout({
|
||||
scopeKey,
|
||||
allItemIds,
|
||||
visibleItemIds,
|
||||
cols,
|
||||
repository,
|
||||
buildDefaultItems,
|
||||
}: UsePersistedGridLayoutOptions): UsePersistedGridLayoutResult {
|
||||
const defaultItems = useMemo(
|
||||
() => buildDefaultItems(allItemIds),
|
||||
[allItemIds, buildDefaultItems]
|
||||
);
|
||||
const [layoutState, setLayoutState] = useState<PersistedGridLayoutState>(() =>
|
||||
normalizePersistedGridLayoutState(null, defaultItems)
|
||||
);
|
||||
const [loadedScopeKey, setLoadedScopeKey] = useState<string | null>(null);
|
||||
const resolvedLayoutState = useMemo(
|
||||
() => normalizePersistedGridLayoutState(layoutState, defaultItems),
|
||||
[defaultItems, layoutState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void repository
|
||||
.load(scopeKey)
|
||||
.then((stored) => {
|
||||
if (cancelled) return;
|
||||
setLayoutState(normalizePersistedGridLayoutState(stored, defaultItems));
|
||||
setLoadedScopeKey(scopeKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setLayoutState(normalizePersistedGridLayoutState(null, defaultItems));
|
||||
setLoadedScopeKey(scopeKey);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [defaultItems, repository, scopeKey]);
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() => projectVisibleGridLayoutItems(resolvedLayoutState.items, visibleItemIds, cols),
|
||||
[cols, resolvedLayoutState.items, visibleItemIds]
|
||||
);
|
||||
|
||||
const applyVisibleItems = useCallback(
|
||||
(items: PersistedGridLayoutItem[], options?: { persist?: boolean }) => {
|
||||
setLayoutState((current) => {
|
||||
const mergedItems = mergeGridLayoutItems(
|
||||
normalizePersistedGridLayoutState(current, defaultItems).items,
|
||||
items
|
||||
);
|
||||
const nextState = normalizePersistedGridLayoutState(
|
||||
createPersistedGridLayoutState(mergedItems),
|
||||
defaultItems
|
||||
);
|
||||
|
||||
if (options?.persist) {
|
||||
void repository.save(scopeKey, nextState);
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
[defaultItems, repository, scopeKey]
|
||||
);
|
||||
|
||||
return {
|
||||
allItems: resolvedLayoutState.items,
|
||||
visibleItems,
|
||||
isLoaded: loadedScopeKey === scopeKey,
|
||||
applyVisibleItems,
|
||||
};
|
||||
}
|
||||
|
|
@ -260,6 +260,118 @@
|
|||
--compact-phase-text: #818cf8;
|
||||
}
|
||||
|
||||
.kanban-grid-layout {
|
||||
min-height: 640px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.kanban-grid-layout .react-grid-item {
|
||||
transition-property: transform, width, height;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.kanban-grid-item-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: rgba(129, 140, 248, 0.55);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.kanban-grid-layout .react-grid-item:hover .kanban-grid-resize-handle::after,
|
||||
.kanban-grid-layout
|
||||
.react-grid-item.react-grid-placeholder
|
||||
+ .react-grid-item
|
||||
.kanban-grid-resize-handle::after {
|
||||
animation: kanban-grid-handle-fade-in 120ms ease forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.kanban-grid-layout .react-grid-item.resizing .kanban-grid-resize-handle::after {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes kanban-grid-handle-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-n,
|
||||
.kanban-grid-resize-handle-s {
|
||||
left: 50%;
|
||||
width: 36px;
|
||||
height: 6px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-n {
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-s {
|
||||
bottom: -3px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-e,
|
||||
.kanban-grid-resize-handle-w {
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 36px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-e {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-w {
|
||||
left: -3px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-ne,
|
||||
.kanban-grid-resize-handle-nw,
|
||||
.kanban-grid-resize-handle-se,
|
||||
.kanban-grid-resize-handle-sw {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-ne {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-nw {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-se {
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
.kanban-grid-resize-handle-sw {
|
||||
left: -4px;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
|
||||
.file-icon-glow {
|
||||
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import './index.css';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { del, get, set } from 'idb-keyval';
|
||||
|
||||
import { sanitizePersistedGridLayoutState } from './gridLayoutSchema';
|
||||
|
||||
import type { GridLayoutRepository } from './GridLayoutRepository';
|
||||
import type { PersistedGridLayoutState } from './gridLayoutTypes';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'grid-layout:';
|
||||
|
||||
function storageKey(scopeKey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${scopeKey}`;
|
||||
}
|
||||
|
||||
function readLocalStorage(key: string): PersistedGridLayoutState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return sanitizePersistedGridLayoutState(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalStorage(key: string, state: PersistedGridLayoutState): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
} catch {
|
||||
// Ignore quota/storage errors and fall back to memory.
|
||||
}
|
||||
}
|
||||
|
||||
function removeLocalStorage(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserGridLayoutRepository implements GridLayoutRepository<PersistedGridLayoutState> {
|
||||
private idbUnavailable = false;
|
||||
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
|
||||
|
||||
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
|
||||
const key = storageKey(scopeKey);
|
||||
|
||||
if (!this.idbUnavailable) {
|
||||
try {
|
||||
const stored = await get<unknown>(key);
|
||||
const sanitized = sanitizePersistedGridLayoutState(stored);
|
||||
if (sanitized) {
|
||||
return sanitized;
|
||||
}
|
||||
} catch {
|
||||
this.idbUnavailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this.fallbackStore.get(key) ?? readLocalStorage(key);
|
||||
}
|
||||
|
||||
async save(scopeKey: string, state: PersistedGridLayoutState): Promise<void> {
|
||||
const key = storageKey(scopeKey);
|
||||
const sanitized = sanitizePersistedGridLayoutState(state);
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fallbackStore.set(key, sanitized);
|
||||
writeLocalStorage(key, sanitized);
|
||||
|
||||
if (!this.idbUnavailable) {
|
||||
try {
|
||||
await set(key, sanitized);
|
||||
} catch {
|
||||
this.idbUnavailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(scopeKey: string): Promise<void> {
|
||||
const key = storageKey(scopeKey);
|
||||
this.fallbackStore.delete(key);
|
||||
removeLocalStorage(key);
|
||||
|
||||
if (!this.idbUnavailable) {
|
||||
try {
|
||||
await del(key);
|
||||
} catch {
|
||||
this.idbUnavailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const browserGridLayoutRepository = new BrowserGridLayoutRepository();
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { PersistedGridLayoutState } from './gridLayoutTypes';
|
||||
|
||||
export interface GridLayoutRepository<TState = PersistedGridLayoutState> {
|
||||
load(scopeKey: string): Promise<TState | null>;
|
||||
save(scopeKey: string, state: TState): Promise<void>;
|
||||
clear(scopeKey: string): Promise<void>;
|
||||
}
|
||||
160
src/renderer/services/layout-system/gridLayoutSchema.ts
Normal file
160
src/renderer/services/layout-system/gridLayoutSchema.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import type { PersistedGridLayoutItem, PersistedGridLayoutState } from './gridLayoutTypes';
|
||||
|
||||
const GRID_LAYOUT_SCHEMA_VERSION = 1;
|
||||
|
||||
function toPositiveInt(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.max(1, Math.round(value))
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? Math.round(value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function sanitizeConstraint(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function sanitizeGridLayoutItem(
|
||||
raw: unknown,
|
||||
fallback?: PersistedGridLayoutItem
|
||||
): PersistedGridLayoutItem | null {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
const id = typeof candidate.id === 'string' ? candidate.id : fallback?.id;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
x: toNonNegativeInt(candidate.x, fallback?.x ?? 0),
|
||||
y: toNonNegativeInt(candidate.y, fallback?.y ?? 0),
|
||||
w: toPositiveInt(candidate.w, fallback?.w ?? 1),
|
||||
h: toPositiveInt(candidate.h, fallback?.h ?? 1),
|
||||
minW: sanitizeConstraint(candidate.minW ?? fallback?.minW),
|
||||
minH: sanitizeConstraint(candidate.minH ?? fallback?.minH),
|
||||
maxW: sanitizeConstraint(candidate.maxW ?? fallback?.maxW),
|
||||
maxH: sanitizeConstraint(candidate.maxH ?? fallback?.maxH),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPersistedGridLayoutState(
|
||||
items: PersistedGridLayoutItem[]
|
||||
): PersistedGridLayoutState {
|
||||
return {
|
||||
version: GRID_LAYOUT_SCHEMA_VERSION,
|
||||
updatedAt: Date.now(),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizePersistedGridLayoutState(raw: unknown): PersistedGridLayoutState | null {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
if (!Array.isArray(candidate.items)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = candidate.items
|
||||
.map((item) => sanitizeGridLayoutItem(item))
|
||||
.filter((item): item is PersistedGridLayoutItem => item !== null);
|
||||
|
||||
return {
|
||||
version: GRID_LAYOUT_SCHEMA_VERSION,
|
||||
updatedAt:
|
||||
typeof candidate.updatedAt === 'number' && Number.isFinite(candidate.updatedAt)
|
||||
? candidate.updatedAt
|
||||
: Date.now(),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePersistedGridLayoutState(
|
||||
rawState: unknown,
|
||||
defaultItems: PersistedGridLayoutItem[]
|
||||
): PersistedGridLayoutState {
|
||||
const sanitized = sanitizePersistedGridLayoutState(rawState);
|
||||
const persistedById = new Map(sanitized?.items.map((item) => [item.id, item]));
|
||||
|
||||
const items = defaultItems.map((defaultItem) => {
|
||||
const persisted = persistedById.get(defaultItem.id);
|
||||
return sanitizeGridLayoutItem(persisted ?? defaultItem, defaultItem) ?? defaultItem;
|
||||
});
|
||||
|
||||
return {
|
||||
version: GRID_LAYOUT_SCHEMA_VERSION,
|
||||
updatedAt: sanitized?.updatedAt ?? Date.now(),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeGridLayoutItems(
|
||||
currentItems: PersistedGridLayoutItem[],
|
||||
updatedItems: PersistedGridLayoutItem[]
|
||||
): PersistedGridLayoutItem[] {
|
||||
const updatedById = new Map(updatedItems.map((item) => [item.id, item]));
|
||||
|
||||
return currentItems.map((item) => {
|
||||
const updated = updatedById.get(item.id);
|
||||
return updated ? { ...item, ...updated } : item;
|
||||
});
|
||||
}
|
||||
|
||||
export function projectVisibleGridLayoutItems(
|
||||
allItems: PersistedGridLayoutItem[],
|
||||
visibleIds: string[],
|
||||
cols: number
|
||||
): PersistedGridLayoutItem[] {
|
||||
const visibleIdSet = new Set(visibleIds);
|
||||
const visibleItems = allItems
|
||||
.filter((item) => visibleIdSet.has(item.id))
|
||||
.sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y));
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fillWidth =
|
||||
visibleItems.length <= 3 ? Math.max(1, Math.floor(cols / visibleItems.length)) : 0;
|
||||
const projected: PersistedGridLayoutItem[] = [];
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let rowHeight = 0;
|
||||
|
||||
for (const item of visibleItems) {
|
||||
const nextWidth =
|
||||
visibleItems.length === 1 ? cols : Math.min(cols, Math.max(item.w, fillWidth || item.w));
|
||||
|
||||
if (currentX + nextWidth > cols) {
|
||||
currentX = 0;
|
||||
currentY += rowHeight;
|
||||
rowHeight = 0;
|
||||
}
|
||||
|
||||
projected.push({
|
||||
...item,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
w: nextWidth,
|
||||
});
|
||||
|
||||
currentX += nextWidth;
|
||||
rowHeight = Math.max(rowHeight, item.h);
|
||||
}
|
||||
|
||||
return projected;
|
||||
}
|
||||
17
src/renderer/services/layout-system/gridLayoutTypes.ts
Normal file
17
src/renderer/services/layout-system/gridLayoutTypes.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export interface PersistedGridLayoutItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
}
|
||||
|
||||
export interface PersistedGridLayoutState {
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
items: PersistedGridLayoutItem[];
|
||||
}
|
||||
Loading…
Reference in a new issue