Ability to drag cards to a particular place.
This commit is contained in:
parent
e3f16e914d
commit
22796b6c91
2 changed files with 234 additions and 60 deletions
5
TODO.md
5
TODO.md
|
|
@ -2,11 +2,9 @@
|
||||||
|
|
||||||
## TODO
|
## 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, move, remove columns.
|
||||||
- [ ] Ability to create a project on the projects select page, including if TODO.md is empty.
|
- [ ] 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.
|
- [ ] Minimize the number of times the user is asked for access. If possible ask for all the access required at one time.
|
||||||
|
|
||||||
## In progress
|
## In progress
|
||||||
|
|
@ -14,6 +12,7 @@
|
||||||
|
|
||||||
## Done
|
## 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] Dropdown for setting the card as checked/unchecked.
|
||||||
- [x] Make it look more the makesprite/concrete.css theme.
|
- [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.
|
- [x] When moving between columns don't insert an extra newline before, unless moving to an empty column.
|
||||||
|
|
|
||||||
277
index.html
277
index.html
|
|
@ -145,6 +145,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|
@ -185,12 +186,27 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-checkbox.checked {
|
.card-checkbox.checked {
|
||||||
background: var(--fg);
|
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 Picker */
|
||||||
.section-list {
|
.section-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -299,6 +315,11 @@
|
||||||
showCompleted: false
|
showCompleted: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let dragState = {
|
||||||
|
source: null,
|
||||||
|
currentDropTarget: null
|
||||||
|
};
|
||||||
|
|
||||||
// --- IndexedDB Helper ---
|
// --- IndexedDB Helper ---
|
||||||
const dbPromise = new Promise((resolve, reject) => {
|
const dbPromise = new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
@ -437,51 +458,83 @@
|
||||||
await writeFile(lines.join('\n'));
|
await writeFile(lines.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveLineInFile(fromLine, toLine, targetColumnIsEmpty) {
|
async function moveLineInFile(fromLine, toLine, targetColumnIndex, insertPosition) {
|
||||||
console.log('moveLineInFile: from line', fromLine, 'to line', toLine, 'targetColumnIsEmpty:', targetColumnIsEmpty);
|
console.log('moveLineInFile: from line', fromLine, 'to line', toLine, 'targetColumnIndex:', targetColumnIndex, 'insertPosition:', insertPosition);
|
||||||
const file = await state.fileHandle.getFile();
|
const file = await state.fileHandle.getFile();
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const lines = text.split('\n');
|
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, 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);
|
const [movedLine] = lines.splice(fromLine, 1);
|
||||||
|
|
||||||
if (targetColumnIsEmpty) {
|
let actualInsertLine;
|
||||||
console.log('Moving to empty column - ensuring blank lines before and after');
|
|
||||||
|
|
||||||
if (toLine < lines.length && lines[toLine].trim() === '') {
|
if (targetColumnIsEmpty) {
|
||||||
lines.splice(toLine, 0, movedLine);
|
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 {
|
} 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() !== '') {
|
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
|
||||||
lines.splice(afterLine, 0, '');
|
lines.splice(afterLine, 0, '');
|
||||||
}
|
}
|
||||||
} else {
|
} 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() === '') {
|
if (actualInsertLine < lines.length && lines[actualInsertLine].trim() === '') {
|
||||||
console.log('Line before target is blank, replacing it');
|
actualInsertLine++;
|
||||||
lines[toLine - 1] = movedLine;
|
|
||||||
} else {
|
|
||||||
console.log('Inserting at target position');
|
|
||||||
lines.splice(toLine, 0, movedLine);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
const afterLine = movedLineIndex + 1;
|
||||||
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
|
if (afterLine >= lines.length || lines[afterLine].trim() !== '') {
|
||||||
console.log('Ensuring blank line after moved item at line', afterLine);
|
console.log('Ensuring blank line after moved item at line', afterLine);
|
||||||
lines.splice(afterLine, 0, '');
|
lines.splice(afterLine, 0, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('After move, lines around target:');
|
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]}"`);
|
console.log(` line ${i}: "${lines[i]}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -579,9 +632,6 @@
|
||||||
colDiv.className = 'column';
|
colDiv.className = 'column';
|
||||||
colDiv.dataset.colIndex = colIndex;
|
colDiv.dataset.colIndex = colIndex;
|
||||||
|
|
||||||
colDiv.ondragover = handleDragOver;
|
|
||||||
colDiv.ondrop = (e) => handleDrop(e, colIndex);
|
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'column-header';
|
header.className = 'column-header';
|
||||||
header.textContent = column.name;
|
header.textContent = column.name;
|
||||||
|
|
@ -589,6 +639,26 @@
|
||||||
|
|
||||||
const cardList = document.createElement('div');
|
const cardList = document.createElement('div');
|
||||||
cardList.className = 'card-list';
|
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) => {
|
column.cards.forEach((card, cardIndex) => {
|
||||||
if (!state.showCompleted && card.completed) {
|
if (!state.showCompleted && card.completed) {
|
||||||
|
|
@ -625,10 +695,25 @@
|
||||||
|
|
||||||
cardDiv.ondragstart = (e) => handleDragStart(e, cardDiv);
|
cardDiv.ondragstart = (e) => handleDragStart(e, cardDiv);
|
||||||
cardDiv.ondragend = handleDragEnd;
|
cardDiv.ondragend = handleDragEnd;
|
||||||
|
cardDiv.ondragenter = (e) => handleCardDragEnter(e, cardDiv, colIndex, cardIndex);
|
||||||
|
|
||||||
cardList.appendChild(cardDiv);
|
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);
|
colDiv.appendChild(cardList);
|
||||||
container.appendChild(colDiv);
|
container.appendChild(colDiv);
|
||||||
});
|
});
|
||||||
|
|
@ -672,69 +757,159 @@
|
||||||
|
|
||||||
// --- Drag and Drop ---
|
// --- Drag and Drop ---
|
||||||
|
|
||||||
let dragSource = null;
|
|
||||||
|
|
||||||
function handleDragStart(e, cardDiv) {
|
function handleDragStart(e, cardDiv) {
|
||||||
dragSource = {
|
dragState.source = {
|
||||||
lineNumber: parseInt(cardDiv.dataset.lineNumber),
|
lineNumber: parseInt(cardDiv.dataset.lineNumber),
|
||||||
colIndex: parseInt(cardDiv.dataset.colIndex),
|
colIndex: parseInt(cardDiv.dataset.colIndex),
|
||||||
cardIndex: parseInt(cardDiv.dataset.cardIndex)
|
cardIndex: parseInt(cardDiv.dataset.cardIndex)
|
||||||
};
|
};
|
||||||
e.target.classList.add('dragging');
|
e.target.classList.add('dragging');
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
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) {
|
function handleDragEnd(e) {
|
||||||
e.target.classList.remove('dragging');
|
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.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
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) {
|
async function handleDrop(e, targetColIndex) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!dragSource) return;
|
clearDropIndicators();
|
||||||
|
|
||||||
const sourceColIndex = dragSource.colIndex;
|
if (!dragState.source || !dragState.currentDropTarget) {
|
||||||
const sourceLineNumber = dragSource.lineNumber;
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const section = state.sections[state.currentSectionIndex];
|
const section = state.sections[state.currentSectionIndex];
|
||||||
const targetColumn = section.columns[targetColIndex];
|
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;
|
await moveLineInFile(sourceLineNumber, 0, targetColIndex, targetPosition);
|
||||||
let targetLineNumber;
|
|
||||||
|
|
||||||
if (targetColumnIsEmpty) {
|
dragState.source = null;
|
||||||
targetLineNumber = targetColumn.headerLineNumber + 1;
|
dragState.currentDropTarget = null;
|
||||||
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;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue