diff --git a/gen/css/main.css b/gen/css/main.css
index 18b19dd..b2b32ad 100644
--- a/gen/css/main.css
+++ b/gen/css/main.css
@@ -429,6 +429,30 @@ label {
background: rgba(248, 251, 255, 0.86);
}
+.template-item.dragging {
+ opacity: 0.55;
+}
+
+.template-item.drag-over {
+ border-color: rgba(15, 98, 254, 0.5);
+ box-shadow: inset 0 0 0 1px rgba(15, 98, 254, 0.18);
+}
+
+.countdown-grid-drag-handle {
+ width: 34px;
+ height: 34px;
+ border: 1px dashed rgba(121, 140, 176, 0.4);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.92);
+ color: var(--muted-text);
+ cursor: grab;
+ flex: 0 0 auto;
+}
+
+.countdown-grid-drag-handle:active {
+ cursor: grabbing;
+}
+
.template-item input[type="text"] {
min-width: 120px;
flex: 1 1 160px;
@@ -438,6 +462,10 @@ label {
min-width: 150px;
}
+.template-item input[type="number"] {
+ width: 72px;
+}
+
.template-item label {
display: inline-flex;
align-items: center;
diff --git a/gen/js/main.js b/gen/js/main.js
index 9d86c02..a52428f 100644
--- a/gen/js/main.js
+++ b/gen/js/main.js
@@ -4,6 +4,7 @@ let startTime, msgIndex, appVersion;
let canvas, ctx, textDecoder;
let paintManager, cropManager;
let activeCanvasPreset = null;
+let draggedCountdownIndex = null;
const EpdCmd = {
SET_PINS: 0x00,
@@ -55,6 +56,7 @@ const canvasSizes = [
const countdownStorageKey = 'epdCountdownTemplates';
const countdownImportVersion = 1;
+const minimumCountdownWeight = 0;
const todoStorageKey = 'epdTodoTemplates';
const todoImportVersion = 1;
const defaultCountdownState = {
@@ -67,8 +69,8 @@ const defaultCountdownState = {
grid: {
title: '倒计时看板',
items: [
- { name: '期末考试', date: '2026-06-20', important: true },
- { name: '驾照考试', date: '2026-05-01', important: false }
+ { name: '期末考试', date: '2026-06-20', important: true, weight: 2 },
+ { name: '驾照考试', date: '2026-05-01', important: false, weight: 1 }
]
}
};
@@ -96,6 +98,11 @@ function cloneDefaultTodoState() {
return JSON.parse(JSON.stringify(defaultTodoState));
}
+function sanitizeCountdownWeight(value) {
+ const parsed = Number.parseInt(value, 10);
+ return Number.isFinite(parsed) && parsed >= minimumCountdownWeight ? parsed : minimumCountdownWeight;
+}
+
function normalizeCountdownState(state) {
const normalized = cloneDefaultCountdownState();
if (!state || typeof state !== 'object') return normalized;
@@ -112,7 +119,8 @@ function normalizeCountdownState(state) {
normalized.grid.items = state.grid.items.map((item) => ({
name: item.name || '未命名',
date: item.date || getTodayISODate(),
- important: !!item.important
+ important: !!item.important,
+ weight: sanitizeCountdownWeight(item.weight)
}));
}
}
@@ -641,10 +649,11 @@ function syncCountdownFormToState() {
countdownState.grid.items = Array.from(document.querySelectorAll('#countdown-grid-items .template-item')).map((item) => ({
name: item.querySelector('.countdown-grid-name').value.trim() || '未命名',
date: item.querySelector('.countdown-grid-date').value || getTodayISODate(),
- important: item.querySelector('.countdown-grid-important').checked
+ important: item.querySelector('.countdown-grid-important').checked,
+ weight: sanitizeCountdownWeight(item.querySelector('.countdown-grid-weight').value)
}));
if (countdownState.grid.items.length === 0) {
- countdownState.grid.items.push({ name: '未命名', date: getTodayISODate(), important: false });
+ countdownState.grid.items.push({ name: '未命名', date: getTodayISODate(), important: false, weight: 1 });
}
}
@@ -672,16 +681,49 @@ function renderCountdownGridItems() {
countdownState.grid.items.forEach((item, index) => {
const row = document.createElement('div');
row.className = 'template-item';
+ row.draggable = true;
+ row.dataset.countdownIndex = String(index);
row.innerHTML = `
+
+
`;
+ row.addEventListener('dragstart', (event) => {
+ draggedCountdownIndex = index;
+ row.classList.add('dragging');
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', String(index));
+ }
+ });
+ row.addEventListener('dragend', () => {
+ draggedCountdownIndex = null;
+ row.classList.remove('dragging');
+ document.querySelectorAll('#countdown-grid-items .template-item').forEach((element) => {
+ element.classList.remove('drag-over');
+ });
+ });
+ row.addEventListener('dragover', (event) => {
+ event.preventDefault();
+ if (draggedCountdownIndex === null || draggedCountdownIndex === index) return;
+ row.classList.add('drag-over');
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
+ });
+ row.addEventListener('dragleave', () => {
+ row.classList.remove('drag-over');
+ });
+ row.addEventListener('drop', (event) => {
+ event.preventDefault();
+ row.classList.remove('drag-over');
+ moveCountdownGridItem(index);
+ });
row.querySelector('.countdown-grid-remove').addEventListener('click', () => {
countdownState.grid.items.splice(index, 1);
if (countdownState.grid.items.length === 0) {
- countdownState.grid.items.push({ name: '未命名', date: getTodayISODate(), important: false });
+ countdownState.grid.items.push({ name: '未命名', date: getTodayISODate(), important: false, weight: 1 });
}
renderCountdownGridItems();
saveCountdownState();
@@ -690,6 +732,17 @@ function renderCountdownGridItems() {
});
}
+function moveCountdownGridItem(targetIndex) {
+ if (draggedCountdownIndex === null || draggedCountdownIndex === targetIndex) return;
+
+ syncCountdownFormToState();
+ const [movedItem] = countdownState.grid.items.splice(draggedCountdownIndex, 1);
+ if (!movedItem) return;
+ countdownState.grid.items.splice(targetIndex, 0, movedItem);
+ renderCountdownGridItems();
+ saveCountdownState();
+}
+
function updateCountdownModeUI() {
const mode = document.getElementById('countdown-template-mode').value;
document.getElementById('countdown-single-panel').style.display = mode === 'single' ? 'block' : 'none';
@@ -702,6 +755,82 @@ function refreshCountdownTemplateUI() {
updateCountdownModeUI();
}
+function getCountdownGridRowCount(items) {
+ if (items.length <= 2) return 1;
+ if (items.length <= 6) return 2;
+ return 3;
+}
+
+function buildSequentialCountdownLayout(items, x, y, width, height, gap) {
+ if (items.length === 0 || width <= 0 || height <= 0) return [];
+ if (items.length === 1) {
+ return [{ item: items[0], x, y, width, height }];
+ }
+
+ const normalizedItems = items.map((item) => ({
+ ...item,
+ weight: Math.max(1, sanitizeCountdownWeight(item.weight))
+ }));
+ const rowCount = Math.min(getCountdownGridRowCount(normalizedItems), normalizedItems.length);
+ const totalWeight = normalizedItems.reduce((sum, item) => sum + item.weight, 0);
+ const rows = [];
+ let currentRow = [];
+ let currentRowWeight = 0;
+ let remainingWeight = totalWeight;
+
+ normalizedItems.forEach((item, index) => {
+ const rowsRemaining = rowCount - rows.length;
+ const itemsRemaining = normalizedItems.length - index;
+ const targetWeight = remainingWeight / Math.max(1, rowsRemaining);
+ const nextWeight = currentRowWeight + item.weight;
+ const canStartNextRow = rows.length < rowCount - 1 && currentRow.length > 0 && itemsRemaining >= rowsRemaining;
+
+ if (canStartNextRow && nextWeight > targetWeight) {
+ rows.push({ items: currentRow, weight: currentRowWeight });
+ remainingWeight -= currentRowWeight;
+ currentRow = [item];
+ currentRowWeight = item.weight;
+ return;
+ }
+
+ currentRow.push(item);
+ currentRowWeight = nextWeight;
+ });
+
+ if (currentRow.length > 0) {
+ rows.push({ items: currentRow, weight: currentRowWeight });
+ }
+
+ const usableHeight = height - gap * (rows.length - 1);
+ let yCursor = y;
+
+ return rows.flatMap((row, rowIndex) => {
+ const rowHeight = rowIndex === rows.length - 1
+ ? y + height - yCursor
+ : usableHeight * (row.weight / totalWeight);
+ const usableWidth = width - gap * (row.items.length - 1);
+ let xCursor = x;
+
+ const layouts = row.items.map((item, itemIndex) => {
+ const itemWidth = itemIndex === row.items.length - 1
+ ? x + width - xCursor
+ : usableWidth * (item.weight / row.weight);
+ const layout = {
+ item,
+ x: xCursor,
+ y: yCursor,
+ width: itemWidth,
+ height: rowHeight
+ };
+ xCursor += itemWidth + gap;
+ return layout;
+ });
+
+ yCursor += rowHeight + gap;
+ return layouts;
+ });
+}
+
function syncTodoFormToState() {
todoState.title = document.getElementById('todo-template-title').value.trim() || '今日重点';
todoState.note = document.getElementById('todo-template-note').value.trim() || '一次做好一件事';
@@ -954,7 +1083,12 @@ function drawGridCountdownTemplate() {
const scaleX = canvas.width / 400;
const scaleY = canvas.height / 300;
const scale = Math.min(scaleX, scaleY);
- const items = countdownState.grid.items.slice(0, 9);
+ const items = countdownState.grid.items.slice(0, 9)
+ .map((item) => ({
+ ...item,
+ weight: sanitizeCountdownWeight(item.weight)
+ }))
+ .filter((item) => item.weight > 0);
prepareTemplateCanvas();
@@ -981,38 +1115,39 @@ function drawGridCountdownTemplate() {
const contentTop = 50 * scaleY;
const contentHeight = canvas.height - contentTop - 12 * scaleY;
const gap = 10 * scale;
- let cols = 2;
- if (items.length <= 1) cols = 1;
- else if (items.length > 4) cols = 3;
- const rows = Math.max(1, Math.ceil(items.length / cols));
- const cardWidth = (canvas.width - 24 * scaleX - gap * (cols - 1)) / cols;
- const cardHeight = (contentHeight - gap * (rows - 1)) / rows;
- const cardAreaRatio = Math.sqrt((cardWidth * cardHeight) / ((canvas.width * canvas.height) / 6));
- const densityBoost = items.length <= 1 ? 1.65 : items.length === 2 ? 1.45 : items.length <= 4 ? 1.22 : 1;
+ const cardLayouts = buildSequentialCountdownLayout(items, 12 * scaleX, contentTop, canvas.width - 24 * scaleX, contentHeight, gap);
- items.forEach((item, index) => {
- const col = index % cols;
- const row = Math.floor(index / cols);
- const x = 12 * scaleX + col * (cardWidth + gap);
- const y = contentTop + row * (cardHeight + gap);
+ if (cardLayouts.length === 0) {
+ ctx.fillStyle = '#666666';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = `bold ${Math.max(14, 16 * scale)}px Microsoft YaHei`;
+ ctx.fillText('暂无显示项目', canvas.width / 2, contentTop + contentHeight / 2);
+ paintManager.saveToHistory();
+ return;
+ }
+
+ cardLayouts.forEach(({ item, x, y, width, height }) => {
const days = getCountdownDays(item.date);
+ const cardAreaRatio = Math.sqrt((width * height) / ((canvas.width * canvas.height) / Math.max(1, items.length)));
+ const densityBoost = items.length <= 1 ? 1.65 : items.length === 2 ? 1.45 : items.length <= 4 ? 1.22 : 1;
- drawRoundedRect(ctx, x, y, cardWidth, cardHeight, 6 * scale, item.important ? Math.max(2, 4 * scale) : Math.max(1, 2 * scale), item.important ? '#FF0000' : '#000000');
+ drawRoundedRect(ctx, x, y, width, height, 6 * scale, item.important ? Math.max(2, 4 * scale) : Math.max(1, 2 * scale), item.important ? '#FF0000' : '#000000');
if (item.important) {
ctx.fillStyle = '#FF0000';
ctx.font = `bold ${Math.max(14, 18 * scale)}px Arial`;
ctx.textAlign = 'right';
- ctx.fillText('*', x + cardWidth - 8 * scaleX, y + 14 * scaleY);
+ ctx.fillText('*', x + width - 8 * scaleX, y + 14 * scaleY);
}
- const namePreferredSize = Math.min(cardHeight * 0.24, 24 * scale * cardAreaRatio * densityBoost);
+ const namePreferredSize = Math.min(height * 0.24, 24 * scale * cardAreaRatio * densityBoost);
const nameMinSize = Math.max(11, 12 * scale);
- const nameSize = fitText(ctx, item.name, cardWidth - 18 * scaleX, namePreferredSize, nameMinSize, 'Microsoft YaHei', 'bold');
+ const nameSize = fitText(ctx, item.name, width - 18 * scaleX, namePreferredSize, nameMinSize, 'Microsoft YaHei', 'bold');
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.font = `bold ${nameSize}px Microsoft YaHei`;
- ctx.fillText(item.name, x + cardWidth / 2, y + cardHeight * 0.3);
+ ctx.fillText(item.name, x + width / 2, y + height * 0.3);
let valueText = '';
let unitText = '';
@@ -1027,22 +1162,22 @@ function drawGridCountdownTemplate() {
unitText = '天';
}
- const numberPreferredSize = Math.min(cardHeight * 0.56, 56 * scale * cardAreaRatio * densityBoost);
+ const numberPreferredSize = Math.min(height * 0.56, 56 * scale * cardAreaRatio * densityBoost);
const numberMinSize = Math.max(16, 18 * scale);
- const numberSize = fitText(ctx, valueText, cardWidth * (unitText ? 0.5 : 0.72), numberPreferredSize, numberMinSize, 'Arial Black', '900');
+ const numberSize = fitText(ctx, valueText, width * (unitText ? 0.5 : 0.72), numberPreferredSize, numberMinSize, 'Arial Black', '900');
ctx.fillStyle = valueColor;
ctx.font = `900 ${numberSize}px Arial Black`;
- const centerY = y + cardHeight * 0.68;
+ const centerY = y + height * 0.68;
if (unitText) {
const numberWidth = ctx.measureText(valueText).width;
- ctx.fillText(valueText, x + cardWidth / 2 - 8 * scaleX, centerY);
+ ctx.fillText(valueText, x + width / 2 - 8 * scaleX, centerY);
ctx.fillStyle = '#000000';
ctx.font = `bold ${Math.max(10, numberSize * 0.34)}px Microsoft YaHei`;
ctx.textAlign = 'left';
- ctx.fillText(unitText, x + cardWidth / 2 - 8 * scaleX + numberWidth / 2 + 4 * scaleX, centerY + 6 * scaleY);
+ ctx.fillText(unitText, x + width / 2 - 8 * scaleX + numberWidth / 2 + 4 * scaleX, centerY + 6 * scaleY);
} else {
- ctx.fillText(valueText, x + cardWidth / 2, centerY);
+ ctx.fillText(valueText, x + width / 2, centerY);
}
});
@@ -1208,7 +1343,7 @@ function initCountdownTemplate() {
});
document.getElementById('countdown-grid-add').addEventListener('click', () => {
syncCountdownFormToState();
- countdownState.grid.items.push({ name: `项目 ${countdownState.grid.items.length + 1}`, date: getTodayISODate(), important: false });
+ countdownState.grid.items.push({ name: `项目 ${countdownState.grid.items.length + 1}`, date: getTodayISODate(), important: false, weight: 1 });
renderCountdownGridItems();
saveCountdownState();
});