Ability to drag cards to a particular place.

This commit is contained in:
Chris McCormick 2025-12-30 20:32:16 +08:00
parent e3f16e914d
commit 22796b6c91
2 changed files with 234 additions and 60 deletions

View file

@ -2,11 +2,9 @@
## TODO
- [ ] When dragging between columns we should show the insertion point and insert the card at that point.
- [ ] Plus button in the columns to add a new item, with text input.
- [ ] Ability to create, move, remove columns.
- [ ] Ability to create a project on the projects select page, including if TODO.md is empty.
- [ ] Plus button in the columns to add a new item, with text input.
- [ ] Minimize the number of times the user is asked for access. If possible ask for all the access required at one time.
## In progress
@ -14,6 +12,7 @@
## Done
- [x] When dragging between columns we should show the insertion point and insert the card at that point.
- [x] Dropdown for setting the card as checked/unchecked.
- [x] Make it look more the makesprite/concrete.css theme.
- [x] When moving between columns don't insert an extra newline before, unless moving to an empty column.

View file

@ -145,6 +145,7 @@
flex-direction: column;
gap: 0.5em;
min-height: 100px;
position: relative;
}
.card {
@ -185,12 +186,27 @@
cursor: pointer;
background: transparent;
margin-left: 0.5em;
margin-bottom: 0.5em;
}
.card-checkbox.checked {
background: var(--fg);
}
.drop-indicator {
height: 3px;
background: var(--fg);
margin: -0.25em 0;
border-radius: 2px;
opacity: 0;
transition: opacity 0.1s;
pointer-events: none;
}
.drop-indicator.active {
opacity: 1;
}
/* Section Picker */
.section-list {
list-style: none;
@ -299,6 +315,11 @@
showCompleted: false
};
let dragState = {
source: null,
currentDropTarget: null
};
// --- IndexedDB Helper ---
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
@ -437,51 +458,83 @@
await writeFile(lines.join('\n'));
}
async function moveLineInFile(fromLine, toLine, targetColumnIsEmpty) {
console.log('moveLineInFile: from line', fromLine, 'to line', toLine, 'targetColumnIsEmpty:', targetColumnIsEmpty);
async function moveLineInFile(fromLine, toLine, targetColumnIndex, insertPosition) {
console.log('moveLineInFile: from line', fromLine, 'to line', toLine, 'targetColumnIndex:', targetColumnIndex, 'insertPosition:', insertPosition);
const file = await state.fileHandle.getFile();
const text = await file.text();
const lines = text.split('\n');
const section = state.sections[state.currentSectionIndex];
const targetColumn = section.columns[targetColumnIndex];
const targetColumnIsEmpty = targetColumn.cards.length === 0;
console.log('Before move, source line', fromLine, '=', lines[fromLine]);
console.log('Before move, target line', toLine, '=', lines[toLine]);
console.log('Target column is empty:', targetColumnIsEmpty);
const [movedLine] = lines.splice(fromLine, 1);
if (targetColumnIsEmpty) {
console.log('Moving to empty column - ensuring blank lines before and after');
let actualInsertLine;
if (toLine < lines.length && lines[toLine].trim() === '') {
lines.splice(toLine, 0, movedLine);
if (targetColumnIsEmpty) {
console.log('Moving to empty column - inserting after header');
actualInsertLine = targetColumn.headerLineNumber + 1;
if (actualInsertLine < lines.length && lines[actualInsertLine].trim() === '') {
lines.splice(actualInsertLine, 0, movedLine);
} else {
lines.splice(toLine, 0, '', movedLine);
lines.splice(actualInsertLine, 0, '', movedLine);
}
const afterLine = toLine + (lines[toLine].trim() === '' ? 2 : 1);
const afterLine = actualInsertLine + (lines[actualInsertLine].trim() === '' ? 2 : 1);
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
lines.splice(afterLine, 0, '');
}
} else {
console.log('Moving to non-empty column');
if (insertPosition === 0) {
console.log('Inserting at start of column');
actualInsertLine = targetColumn.headerLineNumber + 1;
if (toLine > 0 && lines[toLine - 1].trim() === '') {
console.log('Line before target is blank, replacing it');
lines[toLine - 1] = movedLine;
} else {
console.log('Inserting at target position');
lines.splice(toLine, 0, movedLine);
if (actualInsertLine < lines.length && lines[actualInsertLine].trim() === '') {
actualInsertLine++;
}
const movedLineIndex = toLine > 0 && lines[toLine - 1] === movedLine ? toLine - 1 : toLine;
lines.splice(actualInsertLine, 0, movedLine);
const afterLine = actualInsertLine + 1;
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
lines.splice(afterLine, 0, '');
}
} else {
console.log('Inserting at position', insertPosition);
const cardBefore = targetColumn.cards[insertPosition - 1];
actualInsertLine = cardBefore.lineNumber + 1;
if (cardBefore.nestedLines.length > 0) {
actualInsertLine += cardBefore.nestedLines.length;
}
if (fromLine < actualInsertLine) {
actualInsertLine--;
}
if (actualInsertLine > 0 && lines[actualInsertLine - 1].trim() === '') {
console.log('Line before target is blank, replacing it');
lines[actualInsertLine - 1] = movedLine;
} else {
console.log('Inserting at target position');
lines.splice(actualInsertLine, 0, movedLine);
}
const movedLineIndex = lines[actualInsertLine - 1] === movedLine ? actualInsertLine - 1 : actualInsertLine;
const afterLine = movedLineIndex + 1;
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
console.log('Ensuring blank line after moved item at line', afterLine);
lines.splice(afterLine, 0, '');
}
}
}
console.log('After move, lines around target:');
for (let i = Math.max(0, toLine - 2); i <= Math.min(lines.length - 1, toLine + 2); i++) {
for (let i = Math.max(0, actualInsertLine - 2); i <= Math.min(lines.length - 1, actualInsertLine + 2); i++) {
console.log(` line ${i}: "${lines[i]}"`);
}
@ -579,9 +632,6 @@
colDiv.className = 'column';
colDiv.dataset.colIndex = colIndex;
colDiv.ondragover = handleDragOver;
colDiv.ondrop = (e) => handleDrop(e, colIndex);
const header = document.createElement('div');
header.className = 'column-header';
header.textContent = column.name;
@ -589,6 +639,26 @@
const cardList = document.createElement('div');
cardList.className = 'card-list';
cardList.dataset.colIndex = colIndex;
cardList.ondragover = (e) => handleDragOver(e, colIndex, cardList);
cardList.ondrop = (e) => handleDrop(e, colIndex);
cardList.ondragleave = handleDragLeave;
const visibleCards = column.cards.filter((card, idx) => {
if (!state.showCompleted && card.completed) {
return false;
}
return true;
});
if (visibleCards.length > 0) {
const firstIndicator = document.createElement('div');
firstIndicator.className = 'drop-indicator';
firstIndicator.dataset.colIndex = colIndex;
firstIndicator.dataset.position = 0;
cardList.appendChild(firstIndicator);
}
column.cards.forEach((card, cardIndex) => {
if (!state.showCompleted && card.completed) {
@ -625,10 +695,25 @@
cardDiv.ondragstart = (e) => handleDragStart(e, cardDiv);
cardDiv.ondragend = handleDragEnd;
cardDiv.ondragenter = (e) => handleCardDragEnter(e, cardDiv, colIndex, cardIndex);
cardList.appendChild(cardDiv);
const indicator = document.createElement('div');
indicator.className = 'drop-indicator';
indicator.dataset.colIndex = colIndex;
indicator.dataset.position = cardIndex + 1;
cardList.appendChild(indicator);
});
if (visibleCards.length === 0) {
const indicator = document.createElement('div');
indicator.className = 'drop-indicator';
indicator.dataset.colIndex = colIndex;
indicator.dataset.position = 0;
cardList.appendChild(indicator);
}
colDiv.appendChild(cardList);
container.appendChild(colDiv);
});
@ -672,69 +757,159 @@
// --- Drag and Drop ---
let dragSource = null;
function handleDragStart(e, cardDiv) {
dragSource = {
dragState.source = {
lineNumber: parseInt(cardDiv.dataset.lineNumber),
colIndex: parseInt(cardDiv.dataset.colIndex),
cardIndex: parseInt(cardDiv.dataset.cardIndex)
};
e.target.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify(dragSource));
e.dataTransfer.setData('text/plain', JSON.stringify(dragState.source));
console.log('Drag started:', dragState.source);
}
function handleDragEnd(e) {
e.target.classList.remove('dragging');
dragSource = null;
clearDropIndicators();
dragState.source = null;
dragState.currentDropTarget = null;
}
function handleDragOver(e) {
function clearDropIndicators() {
document.querySelectorAll('.drop-indicator').forEach(el => {
el.classList.remove('active');
});
}
function handleCardDragEnter(e, cardDiv, colIndex, cardIndex) {
if (!dragState.source) return;
e.preventDefault();
e.stopPropagation();
const rect = cardDiv.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const mouseY = e.clientY;
let position;
if (mouseY < midpoint) {
position = cardIndex;
} else {
position = cardIndex + 1;
}
console.log('Card drag enter: col', colIndex, 'position', position);
dragState.currentDropTarget = { colIndex, position };
clearDropIndicators();
const indicator = document.querySelector(`.drop-indicator[data-col-index="${colIndex}"][data-position="${position}"]`);
if (indicator) {
indicator.classList.add('active');
}
}
function handleDragOver(e, colIndex, cardList) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
return false;
if (!dragState.source) return;
const cards = Array.from(cardList.querySelectorAll('.card'));
if (cards.length === 0) {
console.log('Drag over empty column', colIndex);
dragState.currentDropTarget = { colIndex, position: 0 };
clearDropIndicators();
const indicator = cardList.querySelector('.drop-indicator');
if (indicator) {
indicator.classList.add('active');
}
return;
}
let closestCard = null;
let closestDistance = Infinity;
let insertBefore = true;
cards.forEach(card => {
const rect = card.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const distance = Math.abs(e.clientY - midpoint);
if (distance < closestDistance) {
closestDistance = distance;
closestCard = card;
insertBefore = e.clientY < midpoint;
}
});
if (closestCard) {
const cardIndex = parseInt(closestCard.dataset.cardIndex);
const position = insertBefore ? cardIndex : cardIndex + 1;
if (!dragState.currentDropTarget ||
dragState.currentDropTarget.colIndex !== colIndex ||
dragState.currentDropTarget.position !== position) {
console.log('Drag over: col', colIndex, 'position', position);
dragState.currentDropTarget = { colIndex, position };
clearDropIndicators();
const indicator = document.querySelector(`.drop-indicator[data-col-index="${colIndex}"][data-position="${position}"]`);
if (indicator) {
indicator.classList.add('active');
}
}
}
}
function handleDragLeave(e) {
if (e.target.classList.contains('card-list')) {
const relatedTarget = e.relatedTarget;
if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) {
console.log('Drag leave card-list');
}
}
}
async function handleDrop(e, targetColIndex) {
e.stopPropagation();
e.preventDefault();
if (!dragSource) return;
clearDropIndicators();
const sourceColIndex = dragSource.colIndex;
const sourceLineNumber = dragSource.lineNumber;
if (!dragState.source || !dragState.currentDropTarget) {
console.log('Drop ignored: no source or target');
return;
}
console.log('handleDrop: sourceColIndex=', sourceColIndex, 'targetColIndex=', targetColIndex);
const sourceColIndex = dragState.source.colIndex;
const sourceLineNumber = dragState.source.lineNumber;
const targetPosition = dragState.currentDropTarget.position;
console.log('Drop: sourceCol', sourceColIndex, 'targetCol', targetColIndex, 'position', targetPosition);
if (sourceColIndex === targetColIndex) {
console.log('Same column, ignoring drop');
const sourceCardIndex = dragState.source.cardIndex;
if (targetPosition === sourceCardIndex || targetPosition === sourceCardIndex + 1) {
console.log('Same position, ignoring drop');
dragState.source = null;
dragState.currentDropTarget = null;
return;
}
}
const section = state.sections[state.currentSectionIndex];
const targetColumn = section.columns[targetColIndex];
console.log('Target column:', targetColumn.name, 'headerLineNumber=', targetColumn.headerLineNumber);
console.log('Target column:', targetColumn.name, 'position:', targetPosition);
const targetColumnIsEmpty = targetColumn.cards.length === 0;
let targetLineNumber;
await moveLineInFile(sourceLineNumber, 0, targetColIndex, targetPosition);
if (targetColumnIsEmpty) {
targetLineNumber = targetColumn.headerLineNumber + 1;
console.log('Empty column, inserting after header at line', targetLineNumber);
} else {
const lastCard = targetColumn.cards[targetColumn.cards.length - 1];
targetLineNumber = lastCard.lineNumber + 1;
if (lastCard.nestedLines.length > 0) {
targetLineNumber += lastCard.nestedLines.length;
}
console.log('Non-empty column, inserting after last card at line', targetLineNumber);
}
await moveLineInFile(sourceLineNumber, targetLineNumber, targetColumnIsEmpty);
dragSource = null;
dragState.source = null;
dragState.currentDropTarget = null;
return false;
}