diff --git a/src/webview/components/ClusterCard.tsx b/src/webview/components/ClusterCard.tsx index 144ca79..5540881 100644 --- a/src/webview/components/ClusterCard.tsx +++ b/src/webview/components/ClusterCard.tsx @@ -8,12 +8,22 @@ interface ClusterCardProps { isNoise?: boolean; tags?: string[]; onRename?: (newName: string) => void; + onNoteDrop?: (noteIndex: number) => void; } -export const ClusterCard: React.FC = ({ title, noteIndices, notes, isNoise, tags, onRename }) => { +export const ClusterCard: React.FC = ({ + title, + noteIndices, + notes, + isNoise, + tags, + onRename, + onNoteDrop, +}) => { const [isExpanded, setIsExpanded] = React.useState(false); const [isEditing, setIsEditing] = React.useState(false); const [editValue, setEditValue] = React.useState(title); + const [isDragOver, setIsDragOver] = React.useState(false); React.useEffect(() => { setEditValue(title); @@ -58,7 +68,30 @@ export const ClusterCard: React.FC = ({ title, noteIndices, no const countLabel = count === 1 ? '1 note' : `${count} notes`; return ( -
+
{ + e.preventDefault(); + }} + onDragEnter={(e) => { + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => { + setIsDragOver(false); + }} + onDrop={(e) => { + e.preventDefault(); + setIsDragOver(false); + const noteIndexStr = e.dataTransfer.getData('text/plain'); + if (noteIndexStr) { + const noteIndex = parseInt(noteIndexStr, 10); + if (!isNaN(noteIndex) && onNoteDrop) { + onNoteDrop(noteIndex); + } + } + }} + >
{isEditing ? ( @@ -112,7 +145,15 @@ export const ClusterCard: React.FC = ({ title, noteIndices, no const note = notes[idx]; if (!note) return null; return ( -
handleNoteClick(note.noteId)}> +
handleNoteClick(note.noteId)} + draggable="true" + onDragStart={(e) => { + e.dataTransfer.setData('text/plain', idx.toString()); + }} + > void; setView: (view: ViewType) => void; updateClusterName: (clusterId: number, newName: string) => void; + moveNoteToCluster: (noteIndex: number, targetClusterId: number) => void; + addCluster: (name: string) => boolean; // settings states settings: { @@ -290,6 +292,64 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil }); }; + const moveNoteToCluster = (noteIndex: number, targetClusterId: number) => { + setStrategies((prev) => { + const next = [...prev]; + if (next[selectedStrategyIndex]) { + const strat = { ...next[selectedStrategyIndex] }; + const newAssignments = [...strat.assignments]; + + if (noteIndex < 0 || noteIndex >= newAssignments.length) return prev; + if (newAssignments[noteIndex] === targetClusterId) return prev; + + newAssignments[noteIndex] = targetClusterId; + strat.assignments = newAssignments; + next[selectedStrategyIndex] = strat; + } + return next; + }); + }; + + const addCluster = (name: string): boolean => { + const trimmedName = name.trim(); + if (!trimmedName) return false; + + const currentStrategy = strategies[selectedStrategyIndex]; + if (!currentStrategy) return false; + + const clusterNames = currentStrategy.clusterNames || {}; + const nameExists = Object.values(clusterNames).some((n) => n.toLowerCase() === trimmedName.toLowerCase()); + + if (nameExists) { + return false; + } + + setStrategies((prev) => { + const next = [...prev]; + const strat = next[selectedStrategyIndex]; + if (strat) { + const newStrat = { ...strat }; + const newClusterNames = { ...strat.clusterNames }; + const newTags = { ...strat.tags }; + + const clusterIds = Object.keys(newClusterNames).map(Number); + const newClusterId = clusterIds.length > 0 ? Math.max(...clusterIds) + 1 : 0; + + newClusterNames[newClusterId] = trimmedName; + newTags[newClusterId] = []; + + newStrat.clusterNames = newClusterNames; + newStrat.tags = newTags; + newStrat.clusterCount = (newStrat.clusterCount || 0) + 1; + + next[selectedStrategyIndex] = newStrat; + } + return next; + }); + + return true; + }; + const updateSetting = async (key: string, value: any) => { try { await webviewApi.postMessage({ @@ -391,6 +451,8 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil changeStrategy, setView, updateClusterName, + moveNoteToCluster, + addCluster, settings, updateSetting, fetchSettings, diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index 4ee76d0..2896156 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -13,6 +13,8 @@ export const DashboardPage: React.FC = () => { changeStrategy, notes, updateClusterName, + moveNoteToCluster, + addCluster, isApplying, applyProgress, applyError, @@ -24,10 +26,19 @@ export const DashboardPage: React.FC = () => { const selectedStrategy = strategies[selectedStrategyIndex]; + const [isAddingCluster, setIsAddingCluster] = React.useState(false); + const [newClusterName, setNewClusterName] = React.useState(''); + const [duplicateError, setDuplicateError] = React.useState(false); + const clusters: { [key: number]: number[] } = {}; const noise: number[] = []; if (selectedStrategy) { + const clusterNames = selectedStrategy.clusterNames || {}; + Object.keys(clusterNames).forEach((clusterId) => { + clusters[Number(clusterId)] = []; + }); + selectedStrategy.assignments.forEach((clusterId, noteIndex) => { if (clusterId === -1) { noise.push(noteIndex); @@ -44,6 +55,21 @@ export const DashboardPage: React.FC = () => { .map(Number) .sort((a, b) => clusters[b].length - clusters[a].length); + const handleAddClusterSubmit = (e?: React.FormEvent) => { + if (e) e.preventDefault(); + const name = newClusterName.trim(); + if (name) { + const success = addCluster(name); + if (success) { + setNewClusterName(''); + setIsAddingCluster(false); + setDuplicateError(false); + } else { + setDuplicateError(true); + } + } + }; + const handleApply = () => { applyChanges({ method: 'both', @@ -62,18 +88,75 @@ export const DashboardPage: React.FC = () => { />
- {sortedClusterIds.map((id) => ( + {selectedStrategy && + sortedClusterIds.map((id) => ( + updateClusterName(id, newName)} + onNoteDrop={(noteIndex) => moveNoteToCluster(noteIndex, id)} + /> + ))} + + {selectedStrategy && ( +
+ {isAddingCluster ? ( +
+ { + setNewClusterName(e.target.value); + setDuplicateError(false); + }} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Escape') { + setIsAddingCluster(false); + setNewClusterName(''); + setDuplicateError(false); + } + }} + /> + {duplicateError && Name already exists} +
+ + +
+
+ ) : ( + + )} +
+ )} + + {selectedStrategy && ( updateClusterName(id, newName)} + isNoise={true} + onNoteDrop={(noteIndex) => moveNoteToCluster(noteIndex, -1)} /> - ))} - {noise.length > 0 && ( - )}
diff --git a/src/webview/panel.css b/src/webview/panel.css index c4a217a..96b2500 100644 --- a/src/webview/panel.css +++ b/src/webview/panel.css @@ -796,3 +796,129 @@ body { outline: 2px solid color-mix(in srgb, var(--joplin-color) 40%, transparent); outline-offset: 2px; } + +/* DRAG AND DROP & CUSTOM CLUSTERS */ + +.note-item { + user-select: none; + -webkit-user-drag: element; +} + +.note-item[draggable="true"] { + cursor: grab; +} + +.note-item[draggable="true"]:active { + cursor: grabbing; +} + +.cluster-card { + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.cluster-card.drag-over { + background-color: var(--joplin-background-color-hover) !important; + border: 1px dashed var(--accent) !important; +} + +.add-cluster-card { + border: 1px dashed var(--joplin-divider-color); + background: transparent; + display: flex; + align-items: center; + justify-content: center; + padding: 12px 14px; + border-radius: 6px; + margin: 10px 14px; + transition: all 0.2s ease; +} + +.add-cluster-card:hover { + border-color: var(--accent); + background: var(--joplin-background-color-hover); +} + +.add-cluster-card.editing { + border-style: solid; + border-color: var(--joplin-divider-color); + display: block; + background: var(--joplin-background-color); +} + +.add-cluster-trigger-btn { + background: none; + border: none; + color: var(--joplin-color); + font-family: inherit; + font-size: 0.88em; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + justify-content: center; + opacity: 0.7; + transition: opacity 0.2s; +} + +.add-cluster-trigger-btn:hover { + opacity: 1; +} + +.add-cluster-icon { + font-size: 1.2em; + color: var(--accent); +} + +.add-cluster-form { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.add-cluster-error { + font-size: 0.75em; + color: #dc2626; + margin-top: -2px; + margin-left: 2px; +} + +.add-cluster-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.btn-add-confirm { + padding: 4px 10px; + background: var(--accent); + border: none; + color: #fff; + font-size: 0.8em; + font-weight: 600; + border-radius: 4px; + cursor: pointer; +} + +.btn-add-confirm:hover { + opacity: 0.9; +} + +.btn-add-cancel { + padding: 4px 10px; + background: transparent; + border: 1px solid var(--joplin-divider-color); + color: var(--joplin-color); + font-size: 0.8em; + border-radius: 4px; + cursor: pointer; + opacity: 0.8; +} + +.btn-add-cancel:hover { + opacity: 1; + background: var(--joplin-background-color-hover); +} +