kanban-lite 1.0.4
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/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react'
|
|
2
|
+
import { KanbanColumn } from './KanbanColumn'
|
|
3
|
+
import { useStore } from '../store'
|
|
4
|
+
import type { Feature, FeatureStatus } from '../../shared/types'
|
|
5
|
+
|
|
6
|
+
export interface DropTarget {
|
|
7
|
+
columnId: string
|
|
8
|
+
index: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface KanbanBoardProps {
|
|
12
|
+
onFeatureClick: (feature: Feature) => void
|
|
13
|
+
onAddFeature: (status: string) => void
|
|
14
|
+
onMoveFeature: (featureId: string, newStatus: string, newOrder: number) => void
|
|
15
|
+
onEditColumn: (columnId: string) => void
|
|
16
|
+
onRemoveColumn: (columnId: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEditColumn, onRemoveColumn }: KanbanBoardProps) {
|
|
20
|
+
const columns = useStore((s) => s.columns)
|
|
21
|
+
const getFilteredFeaturesByStatus = useStore((s) => s.getFilteredFeaturesByStatus)
|
|
22
|
+
const getFeaturesByStatus = useStore((s) => s.getFeaturesByStatus)
|
|
23
|
+
const layout = useStore((s) => s.layout)
|
|
24
|
+
const [draggedFeature, setDraggedFeature] = useState<Feature | null>(null)
|
|
25
|
+
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
|
|
26
|
+
|
|
27
|
+
const handleDragStart = useCallback((e: React.DragEvent, feature: Feature) => {
|
|
28
|
+
setDraggedFeature(feature)
|
|
29
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
30
|
+
e.dataTransfer.setData('text/plain', feature.id)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
e.dataTransfer.dropEffect = 'move'
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const handleDragOverCard = useCallback(
|
|
39
|
+
(e: React.DragEvent, columnId: string, cardIndex: number) => {
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
e.dataTransfer.dropEffect = 'move'
|
|
42
|
+
|
|
43
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
44
|
+
const midY = rect.top + rect.height / 2
|
|
45
|
+
const insertIndex = e.clientY < midY ? cardIndex : cardIndex + 1
|
|
46
|
+
|
|
47
|
+
setDropTarget((prev) => {
|
|
48
|
+
if (prev && prev.columnId === columnId && prev.index === insertIndex) return prev
|
|
49
|
+
return { columnId, index: insertIndex }
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
[]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const handleDrop = useCallback(
|
|
56
|
+
(e: React.DragEvent, columnId: string) => {
|
|
57
|
+
e.preventDefault()
|
|
58
|
+
if (!draggedFeature) return
|
|
59
|
+
|
|
60
|
+
const filteredFeatures = getFilteredFeaturesByStatus(columnId as FeatureStatus)
|
|
61
|
+
let filteredInsertIndex: number
|
|
62
|
+
|
|
63
|
+
if (dropTarget && dropTarget.columnId === columnId) {
|
|
64
|
+
filteredInsertIndex = dropTarget.index
|
|
65
|
+
} else {
|
|
66
|
+
// Dropped on empty area of the column — append to end
|
|
67
|
+
filteredInsertIndex = filteredFeatures.length
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Adjust index if dragging within the same column and moving downward
|
|
71
|
+
if (draggedFeature.status === columnId) {
|
|
72
|
+
const currentIndex = filteredFeatures.findIndex((f) => f.id === draggedFeature.id)
|
|
73
|
+
if (currentIndex !== -1 && filteredInsertIndex > currentIndex) {
|
|
74
|
+
filteredInsertIndex--
|
|
75
|
+
}
|
|
76
|
+
// No-op if dropping in the same position
|
|
77
|
+
if (currentIndex === filteredInsertIndex) {
|
|
78
|
+
setDraggedFeature(null)
|
|
79
|
+
setDropTarget(null)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Translate filtered index to unfiltered index
|
|
85
|
+
const allFeatures = getFeaturesByStatus(columnId as FeatureStatus)
|
|
86
|
+
.filter((f) => f.id !== draggedFeature.id)
|
|
87
|
+
const filteredWithoutDragged = filteredFeatures.filter((f) => f.id !== draggedFeature.id)
|
|
88
|
+
|
|
89
|
+
let unfilteredInsertIndex: number
|
|
90
|
+
|
|
91
|
+
if (filteredWithoutDragged.length === 0) {
|
|
92
|
+
// No visible features — append to end of unfiltered list
|
|
93
|
+
unfilteredInsertIndex = allFeatures.length
|
|
94
|
+
} else if (filteredInsertIndex >= filteredWithoutDragged.length) {
|
|
95
|
+
// Inserting past end of filtered list — place after last visible feature
|
|
96
|
+
const lastVisible = filteredWithoutDragged[filteredWithoutDragged.length - 1]
|
|
97
|
+
const lastVisibleUnfilteredIdx = allFeatures.findIndex((f) => f.id === lastVisible.id)
|
|
98
|
+
unfilteredInsertIndex = lastVisibleUnfilteredIdx + 1
|
|
99
|
+
} else {
|
|
100
|
+
// Find the anchor feature at the filtered insert position
|
|
101
|
+
const anchorFeature = filteredWithoutDragged[filteredInsertIndex]
|
|
102
|
+
unfilteredInsertIndex = allFeatures.findIndex((f) => f.id === anchorFeature.id)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onMoveFeature(draggedFeature.id, columnId, unfilteredInsertIndex)
|
|
106
|
+
setDraggedFeature(null)
|
|
107
|
+
setDropTarget(null)
|
|
108
|
+
},
|
|
109
|
+
[draggedFeature, dropTarget, getFilteredFeaturesByStatus, getFeaturesByStatus, onMoveFeature]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const handleDragEnd = useCallback(() => {
|
|
113
|
+
setDraggedFeature(null)
|
|
114
|
+
setDropTarget(null)
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
const isVertical = layout === 'vertical'
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className={isVertical ? "h-full overflow-y-auto p-4" : "h-full overflow-x-auto p-4"}>
|
|
121
|
+
<div className={isVertical ? "flex flex-col gap-4" : "flex gap-4 h-full min-w-max"}>
|
|
122
|
+
{columns.map((column) => (
|
|
123
|
+
<KanbanColumn
|
|
124
|
+
key={column.id}
|
|
125
|
+
column={column}
|
|
126
|
+
features={getFilteredFeaturesByStatus(column.id as FeatureStatus)}
|
|
127
|
+
onFeatureClick={onFeatureClick}
|
|
128
|
+
onAddFeature={onAddFeature}
|
|
129
|
+
onEditColumn={onEditColumn}
|
|
130
|
+
onRemoveColumn={onRemoveColumn}
|
|
131
|
+
onDragStart={handleDragStart}
|
|
132
|
+
onDragOver={handleDragOver}
|
|
133
|
+
onDragOverCard={handleDragOverCard}
|
|
134
|
+
onDrop={handleDrop}
|
|
135
|
+
onDragEnd={handleDragEnd}
|
|
136
|
+
draggedFeature={draggedFeature}
|
|
137
|
+
dropTarget={dropTarget}
|
|
138
|
+
layout={layout}
|
|
139
|
+
/>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { Plus, MoreVertical, Pencil, Trash2 } from 'lucide-react'
|
|
3
|
+
import { FeatureCard } from './FeatureCard'
|
|
4
|
+
import type { Feature, KanbanColumn as KanbanColumnType } from '../../shared/types'
|
|
5
|
+
import type { LayoutMode } from '../store'
|
|
6
|
+
import type { DropTarget } from './KanbanBoard'
|
|
7
|
+
|
|
8
|
+
interface KanbanColumnProps {
|
|
9
|
+
column: KanbanColumnType
|
|
10
|
+
features: Feature[]
|
|
11
|
+
onFeatureClick: (feature: Feature) => void
|
|
12
|
+
onAddFeature: (status: string) => void
|
|
13
|
+
onEditColumn: (columnId: string) => void
|
|
14
|
+
onRemoveColumn: (columnId: string) => void
|
|
15
|
+
onDragStart: (e: React.DragEvent, feature: Feature) => void
|
|
16
|
+
onDragOver: (e: React.DragEvent) => void
|
|
17
|
+
onDragOverCard: (e: React.DragEvent, columnId: string, cardIndex: number) => void
|
|
18
|
+
onDrop: (e: React.DragEvent, status: string) => void
|
|
19
|
+
onDragEnd: () => void
|
|
20
|
+
draggedFeature: Feature | null
|
|
21
|
+
dropTarget: DropTarget | null
|
|
22
|
+
layout: LayoutMode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function KanbanColumn({
|
|
26
|
+
column,
|
|
27
|
+
features,
|
|
28
|
+
onFeatureClick,
|
|
29
|
+
onAddFeature,
|
|
30
|
+
onEditColumn,
|
|
31
|
+
onRemoveColumn,
|
|
32
|
+
onDragStart,
|
|
33
|
+
onDragOver,
|
|
34
|
+
onDragOverCard,
|
|
35
|
+
onDrop,
|
|
36
|
+
onDragEnd,
|
|
37
|
+
draggedFeature,
|
|
38
|
+
dropTarget,
|
|
39
|
+
layout
|
|
40
|
+
}: KanbanColumnProps) {
|
|
41
|
+
const isVertical = layout === 'vertical'
|
|
42
|
+
const isDropTarget = dropTarget && dropTarget.columnId === column.id
|
|
43
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
44
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!menuOpen) return
|
|
48
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
49
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
50
|
+
setMenuOpen(false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
54
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
55
|
+
}, [menuOpen])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={
|
|
60
|
+
isVertical
|
|
61
|
+
? "flex flex-col bg-zinc-100 dark:bg-zinc-800/50 rounded-lg"
|
|
62
|
+
: "flex-shrink-0 w-72 h-full flex flex-col bg-zinc-100 dark:bg-zinc-800/50 rounded-lg"
|
|
63
|
+
}
|
|
64
|
+
onDragOver={onDragOver}
|
|
65
|
+
onDrop={(e) => onDrop(e, column.id)}
|
|
66
|
+
>
|
|
67
|
+
{/* Column Header */}
|
|
68
|
+
<div className="flex items-center justify-between w-full px-3 py-2 border-b border-zinc-200 dark:border-zinc-700">
|
|
69
|
+
<div className="flex items-center gap-2">
|
|
70
|
+
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: column.color }} />
|
|
71
|
+
<h3 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{column.name}</h3>
|
|
72
|
+
<span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded-full">
|
|
73
|
+
{features.length}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="flex items-center gap-0.5">
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => onAddFeature(column.id)}
|
|
80
|
+
className="p-1 rounded hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60 transition-colors"
|
|
81
|
+
title={`Add to ${column.name}`}
|
|
82
|
+
>
|
|
83
|
+
<Plus size={16} className="text-zinc-500" />
|
|
84
|
+
</button>
|
|
85
|
+
<div className="relative" ref={menuRef}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
89
|
+
className="p-1 rounded hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60 transition-colors"
|
|
90
|
+
title="Column options"
|
|
91
|
+
>
|
|
92
|
+
<MoreVertical size={16} className="text-zinc-500" />
|
|
93
|
+
</button>
|
|
94
|
+
{menuOpen && (
|
|
95
|
+
<div className="absolute right-0 top-full mt-1 z-50 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-md shadow-lg py-1 min-w-[140px]">
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => { setMenuOpen(false); onEditColumn(column.id) }}
|
|
99
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
|
100
|
+
>
|
|
101
|
+
<Pencil size={14} />
|
|
102
|
+
Edit List
|
|
103
|
+
</button>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => { setMenuOpen(false); onRemoveColumn(column.id) }}
|
|
107
|
+
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
|
108
|
+
>
|
|
109
|
+
<Trash2 size={14} />
|
|
110
|
+
Remove List
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Column Content */}
|
|
119
|
+
<div
|
|
120
|
+
className={
|
|
121
|
+
isVertical
|
|
122
|
+
? "flex-1 p-2 flex flex-wrap gap-2"
|
|
123
|
+
: "flex-1 overflow-y-auto p-2 space-y-2 min-h-[200px]"
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
{features.map((feature, index) => (
|
|
127
|
+
<div key={feature.id}>
|
|
128
|
+
{/* Drop indicator before this card */}
|
|
129
|
+
{isDropTarget && dropTarget.index === index && (
|
|
130
|
+
<div className="h-0.5 bg-blue-500 rounded-full mx-1 mb-1" />
|
|
131
|
+
)}
|
|
132
|
+
<div
|
|
133
|
+
draggable
|
|
134
|
+
onDragStart={(e) => onDragStart(e, feature)}
|
|
135
|
+
onDragOver={(e) => onDragOverCard(e, column.id, index)}
|
|
136
|
+
onDragEnd={onDragEnd}
|
|
137
|
+
className={`${isVertical ? "w-64" : ""} ${
|
|
138
|
+
draggedFeature?.id === feature.id ? "opacity-40" : ""
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
<FeatureCard feature={feature} onClick={() => onFeatureClick(feature)} />
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
))}
|
|
145
|
+
|
|
146
|
+
{/* Drop indicator at end of list */}
|
|
147
|
+
{isDropTarget && dropTarget.index === features.length && features.length > 0 && (
|
|
148
|
+
<div className="h-0.5 bg-blue-500 rounded-full mx-1" />
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{features.length === 0 && (
|
|
152
|
+
<div className={isVertical ? "text-sm text-zinc-400 dark:text-zinc-500 py-4" : "text-center py-8 text-sm text-zinc-400 dark:text-zinc-500"}>
|
|
153
|
+
No features
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
2
|
+
import { marked } from 'marked'
|
|
3
|
+
import { Heading, Bold, Italic, Quote, Code, Link, List, ListOrdered, ListChecks } from 'lucide-react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
|
|
6
|
+
interface MarkdownEditorProps {
|
|
7
|
+
value: string
|
|
8
|
+
onChange: (value: string) => void
|
|
9
|
+
placeholder?: string
|
|
10
|
+
className?: string
|
|
11
|
+
autoFocus?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type FormatAction = 'heading' | 'bold' | 'italic' | 'quote' | 'code' | 'link' | 'ul' | 'ol' | 'tasklist'
|
|
15
|
+
|
|
16
|
+
function wrapSelection(
|
|
17
|
+
textarea: HTMLTextAreaElement,
|
|
18
|
+
value: string,
|
|
19
|
+
onChange: (v: string) => void,
|
|
20
|
+
action: FormatAction
|
|
21
|
+
) {
|
|
22
|
+
const start = textarea.selectionStart
|
|
23
|
+
const end = textarea.selectionEnd
|
|
24
|
+
const selected = value.substring(start, end)
|
|
25
|
+
let before = value.substring(0, start)
|
|
26
|
+
const after = value.substring(end)
|
|
27
|
+
let replacement = selected
|
|
28
|
+
let cursorOffset = 0
|
|
29
|
+
|
|
30
|
+
switch (action) {
|
|
31
|
+
case 'heading': {
|
|
32
|
+
// Insert/cycle heading at line start
|
|
33
|
+
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
|
34
|
+
const linePrefix = value.substring(lineStart, start)
|
|
35
|
+
if (linePrefix.startsWith('### ')) {
|
|
36
|
+
// Remove heading
|
|
37
|
+
before = value.substring(0, lineStart) + linePrefix.slice(4)
|
|
38
|
+
replacement = selected
|
|
39
|
+
cursorOffset = -4
|
|
40
|
+
} else if (linePrefix.startsWith('## ')) {
|
|
41
|
+
before = value.substring(0, lineStart) + '### ' + linePrefix.slice(3)
|
|
42
|
+
replacement = selected
|
|
43
|
+
cursorOffset = 1
|
|
44
|
+
} else if (linePrefix.startsWith('# ')) {
|
|
45
|
+
before = value.substring(0, lineStart) + '## ' + linePrefix.slice(2)
|
|
46
|
+
replacement = selected
|
|
47
|
+
cursorOffset = 1
|
|
48
|
+
} else {
|
|
49
|
+
before = value.substring(0, lineStart) + '# ' + linePrefix
|
|
50
|
+
replacement = selected
|
|
51
|
+
cursorOffset = 2
|
|
52
|
+
}
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
case 'bold':
|
|
56
|
+
replacement = selected ? `**${selected}**` : '**bold**'
|
|
57
|
+
cursorOffset = selected ? 4 : 2
|
|
58
|
+
break
|
|
59
|
+
case 'italic':
|
|
60
|
+
replacement = selected ? `_${selected}_` : '_italic_'
|
|
61
|
+
cursorOffset = selected ? 2 : 1
|
|
62
|
+
break
|
|
63
|
+
case 'quote': {
|
|
64
|
+
const lines = selected ? selected.split('\n').map(l => `> ${l}`).join('\n') : '> '
|
|
65
|
+
replacement = lines
|
|
66
|
+
cursorOffset = selected ? replacement.length - selected.length : 2
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
case 'code':
|
|
70
|
+
if (selected.includes('\n')) {
|
|
71
|
+
replacement = `\`\`\`\n${selected}\n\`\`\``
|
|
72
|
+
cursorOffset = 4
|
|
73
|
+
} else {
|
|
74
|
+
replacement = selected ? `\`${selected}\`` : '`code`'
|
|
75
|
+
cursorOffset = selected ? 2 : 1
|
|
76
|
+
}
|
|
77
|
+
break
|
|
78
|
+
case 'link':
|
|
79
|
+
replacement = selected ? `[${selected}](url)` : '[text](url)'
|
|
80
|
+
cursorOffset = selected ? selected.length + 3 : 1
|
|
81
|
+
break
|
|
82
|
+
case 'ul': {
|
|
83
|
+
const ulLines = selected ? selected.split('\n').map(l => `- ${l}`).join('\n') : '- '
|
|
84
|
+
replacement = ulLines
|
|
85
|
+
cursorOffset = selected ? replacement.length - selected.length : 2
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
case 'ol': {
|
|
89
|
+
const olLines = selected ? selected.split('\n').map((l, i) => `${i + 1}. ${l}`).join('\n') : '1. '
|
|
90
|
+
replacement = olLines
|
|
91
|
+
cursorOffset = selected ? replacement.length - selected.length : 3
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
case 'tasklist': {
|
|
95
|
+
const tlLines = selected ? selected.split('\n').map(l => `- [ ] ${l}`).join('\n') : '- [ ] '
|
|
96
|
+
replacement = tlLines
|
|
97
|
+
cursorOffset = selected ? replacement.length - selected.length : 6
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newValue = before + replacement + after
|
|
103
|
+
onChange(newValue)
|
|
104
|
+
requestAnimationFrame(() => {
|
|
105
|
+
textarea.focus()
|
|
106
|
+
const newPos = start + (selected ? replacement.length : cursorOffset)
|
|
107
|
+
textarea.selectionStart = textarea.selectionEnd = newPos
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ToolbarButtonProps {
|
|
112
|
+
icon: React.ReactNode
|
|
113
|
+
title: string
|
|
114
|
+
onClick: () => void
|
|
115
|
+
separator?: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function ToolbarButton({ icon, title, onClick, separator }: ToolbarButtonProps) {
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
121
|
+
{separator && (
|
|
122
|
+
<div
|
|
123
|
+
className="w-px h-4 mx-1"
|
|
124
|
+
style={{ background: 'var(--vscode-panel-border)' }}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={onClick}
|
|
130
|
+
title={title}
|
|
131
|
+
className="p-1 rounded transition-colors"
|
|
132
|
+
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
133
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
134
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
135
|
+
>
|
|
136
|
+
{icon}
|
|
137
|
+
</button>
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function MarkdownEditor({ value, onChange, placeholder = 'Write markdown...', className, autoFocus }: MarkdownEditorProps) {
|
|
143
|
+
const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write')
|
|
144
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
145
|
+
|
|
146
|
+
const previewHtml = useMemo(() => {
|
|
147
|
+
if (!value.trim()) return ''
|
|
148
|
+
return marked.parse(value, { async: false, gfm: true, breaks: true }) as string
|
|
149
|
+
}, [value])
|
|
150
|
+
|
|
151
|
+
// Auto-focus textarea when switching to write tab
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (activeTab === 'write' && textareaRef.current) {
|
|
154
|
+
textareaRef.current.focus()
|
|
155
|
+
}
|
|
156
|
+
}, [activeTab])
|
|
157
|
+
|
|
158
|
+
// Initial auto-focus
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (autoFocus && textareaRef.current) {
|
|
161
|
+
textareaRef.current.focus()
|
|
162
|
+
}
|
|
163
|
+
}, [autoFocus])
|
|
164
|
+
|
|
165
|
+
const handleFormat = useCallback((action: FormatAction) => {
|
|
166
|
+
if (textareaRef.current) {
|
|
167
|
+
wrapSelection(textareaRef.current, value, onChange, action)
|
|
168
|
+
}
|
|
169
|
+
}, [value, onChange])
|
|
170
|
+
|
|
171
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
172
|
+
// Tab key inserts spaces instead of changing focus
|
|
173
|
+
if (e.key === 'Tab') {
|
|
174
|
+
e.preventDefault()
|
|
175
|
+
const textarea = e.currentTarget
|
|
176
|
+
const start = textarea.selectionStart
|
|
177
|
+
const end = textarea.selectionEnd
|
|
178
|
+
const newValue = value.substring(0, start) + ' ' + value.substring(end)
|
|
179
|
+
onChange(newValue)
|
|
180
|
+
requestAnimationFrame(() => {
|
|
181
|
+
textarea.selectionStart = textarea.selectionEnd = start + 2
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
// Ctrl/Cmd+B for bold
|
|
185
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
|
186
|
+
e.preventDefault()
|
|
187
|
+
e.stopPropagation()
|
|
188
|
+
handleFormat('bold')
|
|
189
|
+
}
|
|
190
|
+
// Ctrl/Cmd+I for italic
|
|
191
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
|
|
192
|
+
e.preventDefault()
|
|
193
|
+
handleFormat('italic')
|
|
194
|
+
}
|
|
195
|
+
// Ctrl/Cmd+K for link
|
|
196
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
197
|
+
e.preventDefault()
|
|
198
|
+
handleFormat('link')
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className={cn('flex flex-col h-full', className)}>
|
|
204
|
+
{/* Tab bar + toolbar */}
|
|
205
|
+
<div
|
|
206
|
+
className="flex items-center shrink-0"
|
|
207
|
+
style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
|
|
208
|
+
>
|
|
209
|
+
{/* Tabs */}
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => setActiveTab('write')}
|
|
213
|
+
className="px-3 py-2 text-xs font-medium transition-colors relative"
|
|
214
|
+
style={{
|
|
215
|
+
color: activeTab === 'write' ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)',
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
Write
|
|
219
|
+
{activeTab === 'write' && (
|
|
220
|
+
<span
|
|
221
|
+
className="absolute bottom-0 left-0 right-0 h-[2px] rounded-t"
|
|
222
|
+
style={{ background: 'var(--vscode-focusBorder)' }}
|
|
223
|
+
/>
|
|
224
|
+
)}
|
|
225
|
+
</button>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => setActiveTab('preview')}
|
|
229
|
+
className="px-3 py-2 text-xs font-medium transition-colors relative"
|
|
230
|
+
style={{
|
|
231
|
+
color: activeTab === 'preview' ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)',
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
Preview
|
|
235
|
+
{activeTab === 'preview' && (
|
|
236
|
+
<span
|
|
237
|
+
className="absolute bottom-0 left-0 right-0 h-[2px] rounded-t"
|
|
238
|
+
style={{ background: 'var(--vscode-focusBorder)' }}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
</button>
|
|
242
|
+
|
|
243
|
+
{/* Toolbar - only visible on Write tab */}
|
|
244
|
+
{activeTab === 'write' && (
|
|
245
|
+
<div className="flex items-center ml-auto pr-2 gap-0.5">
|
|
246
|
+
<ToolbarButton icon={<Heading size={14} />} title="Heading" onClick={() => handleFormat('heading')} />
|
|
247
|
+
<ToolbarButton icon={<Bold size={14} />} title="Bold (⌘B)" onClick={() => handleFormat('bold')} />
|
|
248
|
+
<ToolbarButton icon={<Italic size={14} />} title="Italic (⌘I)" onClick={() => handleFormat('italic')} />
|
|
249
|
+
<ToolbarButton icon={<Quote size={14} />} title="Quote" onClick={() => handleFormat('quote')} separator />
|
|
250
|
+
<ToolbarButton icon={<Code size={14} />} title="Code" onClick={() => handleFormat('code')} />
|
|
251
|
+
<ToolbarButton icon={<Link size={14} />} title="Link (⌘K)" onClick={() => handleFormat('link')} />
|
|
252
|
+
<ToolbarButton icon={<List size={14} />} title="Bulleted list" onClick={() => handleFormat('ul')} separator />
|
|
253
|
+
<ToolbarButton icon={<ListOrdered size={14} />} title="Numbered list" onClick={() => handleFormat('ol')} />
|
|
254
|
+
<ToolbarButton icon={<ListChecks size={14} />} title="Task list" onClick={() => handleFormat('tasklist')} />
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Content */}
|
|
260
|
+
<div className="flex-1 overflow-auto">
|
|
261
|
+
{activeTab === 'write' ? (
|
|
262
|
+
<textarea
|
|
263
|
+
ref={textareaRef}
|
|
264
|
+
value={value}
|
|
265
|
+
onChange={(e) => onChange(e.target.value)}
|
|
266
|
+
onKeyDown={handleKeyDown}
|
|
267
|
+
placeholder={placeholder}
|
|
268
|
+
className="markdown-editor-textarea"
|
|
269
|
+
spellCheck={false}
|
|
270
|
+
/>
|
|
271
|
+
) : (
|
|
272
|
+
<div className="min-h-[200px]">
|
|
273
|
+
{previewHtml ? (
|
|
274
|
+
<div
|
|
275
|
+
className="prose prose-sm dark:prose-invert max-w-none p-4"
|
|
276
|
+
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
|
277
|
+
/>
|
|
278
|
+
) : (
|
|
279
|
+
<p
|
|
280
|
+
className="p-4 text-sm italic"
|
|
281
|
+
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
282
|
+
>
|
|
283
|
+
Nothing to preview
|
|
284
|
+
</p>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ChevronDown } from 'lucide-react'
|
|
2
|
+
import type { Priority } from '../../shared/types'
|
|
3
|
+
|
|
4
|
+
interface PrioritySelectProps {
|
|
5
|
+
value: Priority
|
|
6
|
+
onChange: (priority: Priority) => void
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const priorities: { value: Priority; label: string; color: string }[] = [
|
|
11
|
+
{ value: 'critical', label: 'Critical', color: 'bg-red-500' },
|
|
12
|
+
{ value: 'high', label: 'High', color: 'bg-orange-500' },
|
|
13
|
+
{ value: 'medium', label: 'Medium', color: 'bg-yellow-500' },
|
|
14
|
+
{ value: 'low', label: 'Low', color: 'bg-green-500' }
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export function PrioritySelect({ value, onChange, className = '' }: PrioritySelectProps) {
|
|
18
|
+
const current = priorities.find((p) => p.value === value) || priorities[2]
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className={`relative ${className}`}>
|
|
22
|
+
<select
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(e) => onChange(e.target.value as Priority)}
|
|
25
|
+
className="appearance-none w-full bg-zinc-100 dark:bg-zinc-700 border border-zinc-200 dark:border-zinc-600 rounded-md px-3 py-2 pr-8 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
|
26
|
+
>
|
|
27
|
+
{priorities.map((p) => (
|
|
28
|
+
<option key={p.value} value={p.value}>
|
|
29
|
+
{p.label}
|
|
30
|
+
</option>
|
|
31
|
+
))}
|
|
32
|
+
</select>
|
|
33
|
+
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pointer-events-none">
|
|
34
|
+
<div className={`w-2 h-2 rounded-full ${current.color}`} />
|
|
35
|
+
<ChevronDown size={14} className="text-zinc-400" />
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|