let bleDevice, gattServer; let epdService, epdCharacteristic; let startTime, msgIndex, appVersion; let canvas, ctx, textDecoder; let paintManager, cropManager; let activeCanvasPreset = null; const EpdCmd = { SET_PINS: 0x00, INIT: 0x01, CLEAR: 0x02, SEND_CMD: 0x03, SEND_DATA: 0x04, REFRESH: 0x05, SLEEP: 0x06, SET_TIME: 0x20, WRITE_IMG: 0x30, // v1.6 SET_CONFIG: 0x90, SYS_RESET: 0x91, SYS_SLEEP: 0x92, CFG_ERASE: 0x99, }; const canvasSizes = [ { name: '1.54_152_152', width: 152, height: 152 }, { name: '1.54_200_200', width: 200, height: 200 }, { name: '2.13_104_212', width: 104, height: 212 }, { name: '2.13_122_250', width: 122, height: 250 }, { name: '2.66_152_296', width: 152, height: 296 }, { name: '2.66_184_360', width: 184, height: 360 }, { name: '2.9_128_296', width: 128, height: 296 }, { name: '2.9_168_384', width: 168, height: 384 }, { name: '3.5_184_384', width: 184, height: 384 }, { name: '3.5_360_600', width: 360, height: 600 }, { name: '3.7_240_416', width: 240, height: 416 }, { name: '3.7_280_480', width: 280, height: 480 }, { name: '3.97_800_480', width: 800, height: 480 }, { name: '3.98_768_552', width: 768, height: 552 }, { name: '4.2_400_300', width: 400, height: 300 }, { name: '5.79_792_272', width: 792, height: 272 }, { name: '5.83_600_448', width: 600, height: 448 }, { name: '5.83_648_480', width: 648, height: 480 }, { name: '7.5_640_384', width: 640, height: 384 }, { name: '7.5_800_480', width: 800, height: 480 }, { name: '7.5_880_528', width: 880, height: 528 }, { name: '10.2_960_640', width: 960, height: 640 }, { name: '10.85_1360_480', width: 1360, height: 480 }, { name: '11.6_960_640', width: 960, height: 640 }, { name: '4.0E6_600_400', width: 600, height: 400 }, { name: '7.3E6_800_480', width: 800, height: 480 }, ]; const countdownStorageKey = 'epdCountdownTemplates'; const countdownImportVersion = 1; const todoStorageKey = 'epdTodoTemplates'; const todoImportVersion = 1; const defaultCountdownState = { mode: 'single', single: { motto: '保持专注', label: '目标日', date: '2026-12-21' }, grid: { title: '倒计时看板', items: [ { name: '期末考试', date: '2026-06-20', important: true }, { name: '驾照考试', date: '2026-05-01', important: false } ] } }; let countdownState = null; const defaultTodoState = { title: '今日重点', note: '一次做好一件事', items: [ { text: '检查设备状态', done: false, important: true }, { text: '完成一个关键任务', done: false, important: false }, { text: '睡前复盘今日安排', done: true, important: false } ] }; let todoState = null; function getTodayISODate() { return new Date().toISOString().split('T')[0]; } function cloneDefaultCountdownState() { return JSON.parse(JSON.stringify(defaultCountdownState)); } function cloneDefaultTodoState() { return JSON.parse(JSON.stringify(defaultTodoState)); } function normalizeCountdownState(state) { const normalized = cloneDefaultCountdownState(); if (!state || typeof state !== 'object') return normalized; normalized.mode = state.mode === 'grid' ? 'grid' : 'single'; if (state.single && typeof state.single === 'object') { normalized.single.motto = state.single.motto || normalized.single.motto; normalized.single.label = state.single.label || normalized.single.label; normalized.single.date = state.single.date || normalized.single.date; } if (state.grid && typeof state.grid === 'object') { normalized.grid.title = state.grid.title || normalized.grid.title; if (Array.isArray(state.grid.items) && state.grid.items.length > 0) { normalized.grid.items = state.grid.items.map((item) => ({ name: item.name || '未命名', date: item.date || getTodayISODate(), important: !!item.important })); } } return normalized; } function normalizeTodoState(state) { const normalized = cloneDefaultTodoState(); if (!state || typeof state !== 'object') return normalized; normalized.title = state.title || normalized.title; normalized.note = state.note || normalized.note; if (Array.isArray(state.items) && state.items.length > 0) { normalized.items = state.items.map((item) => ({ text: item && item.text ? item.text : '未命名任务', done: !!(item && item.done), important: !!(item && item.important) })); } return normalized; } function loadCountdownState() { try { countdownState = normalizeCountdownState(JSON.parse(localStorage.getItem(countdownStorageKey))); } catch (e) { countdownState = cloneDefaultCountdownState(); } if (!countdownState.single.date) countdownState.single.date = getTodayISODate(); } function saveCountdownState() { localStorage.setItem(countdownStorageKey, JSON.stringify(countdownState)); } function loadTodoState() { try { todoState = normalizeTodoState(JSON.parse(localStorage.getItem(todoStorageKey))); } catch (e) { todoState = cloneDefaultTodoState(); } } function saveTodoState() { localStorage.setItem(todoStorageKey, JSON.stringify(todoState)); } function getCountdownDays(targetDate) { const today = new Date(); today.setHours(0, 0, 0, 0); const target = new Date(targetDate || getTodayISODate()); target.setHours(0, 0, 0, 0); return Math.ceil((target.getTime() - today.getTime()) / 86400000); } function formatCountdownDate() { const now = new Date(); const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; return `${now.getFullYear()} / ${String(now.getMonth() + 1).padStart(2, '0')} / ${String(now.getDate()).padStart(2, '0')} ${weekNames[now.getDay()]}`; } function fitText(ctxIn, text, maxWidth, preferredSize, minSize, fontFamily, fontWeight = 'normal') { let fontSize = preferredSize; while (fontSize > minSize) { ctxIn.font = `${fontWeight} ${fontSize}px ${fontFamily}`; if (ctxIn.measureText(text).width <= maxWidth) break; fontSize -= 1; } return fontSize; } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function hex2bytes(hex) { for (var bytes = [], c = 0; c < hex.length; c += 2) bytes.push(parseInt(hex.substr(c, 2), 16)); return new Uint8Array(bytes); } function bytes2hex(data) { return new Uint8Array(data).reduce( function (memo, i) { return memo + ("0" + i.toString(16)).slice(-2); }, ""); } function intToHex(intIn) { let stringOut = ("0000" + intIn.toString(16)).substr(-4) return stringOut.substring(2, 4) + stringOut.substring(0, 2); } function resetVariables() { gattServer = null; epdService = null; epdCharacteristic = null; msgIndex = 0; document.getElementById("log").value = ''; } async function write(cmd, data, withResponse = true) { if (!epdCharacteristic) { addLog("服务不可用,请检查蓝牙连接"); return false; } let payload = [cmd]; if (data) { if (typeof data == 'string') data = hex2bytes(data); if (data instanceof Uint8Array) data = Array.from(data); payload.push(...data) } addLog(bytes2hex(payload), '⇑'); try { if (withResponse) await epdCharacteristic.writeValueWithResponse(Uint8Array.from(payload)); else await epdCharacteristic.writeValueWithoutResponse(Uint8Array.from(payload)); } catch (e) { console.error(e); if (e.message) addLog("write: " + e.message); return false; } return true; } async function writeImage(data, step = 'bw') { const chunkSize = document.getElementById('mtusize').value - 2; const interleavedCount = document.getElementById('interleavedcount').value; const count = Math.round(data.length / chunkSize); let chunkIdx = 0; let noReplyCount = interleavedCount; for (let i = 0; i < data.length; i += chunkSize) { let currentTime = (new Date().getTime() - startTime) / 1000.0; setStatus(`${step == 'bw' ? '黑白' : '颜色'}块: ${chunkIdx + 1}/${count + 1}, 总用时: ${currentTime}s`); const payload = [ (step == 'bw' ? 0x0F : 0x00) | (i == 0 ? 0x00 : 0xF0), ...data.slice(i, i + chunkSize), ]; if (noReplyCount > 0) { await write(EpdCmd.WRITE_IMG, payload, false); noReplyCount--; } else { await write(EpdCmd.WRITE_IMG, payload, true); noReplyCount = interleavedCount; } chunkIdx++; } } async function setDriver() { await write(EpdCmd.SET_PINS, document.getElementById("epdpins").value); await write(EpdCmd.INIT, document.getElementById("epddriver").value); } async function syncTime(mode) { if (mode === 2) { if (!confirm('提醒:时钟模式目前使用全刷实现,此功能目前多用于修复老化屏残影问题,不建议长期开启,是否继续?')) return; } const timestamp = new Date().getTime() / 1000; const data = new Uint8Array([ (timestamp >> 24) & 0xFF, (timestamp >> 16) & 0xFF, (timestamp >> 8) & 0xFF, timestamp & 0xFF, -(new Date().getTimezoneOffset() / 60), mode ]); if (await write(EpdCmd.SET_TIME, data)) { addLog("时间已同步!"); addLog("屏幕刷新完成前请不要操作。"); } } async function clearScreen() { if (confirm('确认清除屏幕内容?')) { await write(EpdCmd.CLEAR); addLog("清屏指令已发送!"); addLog("屏幕刷新完成前请不要操作。"); } } async function sendcmd() { const cmdTXT = document.getElementById('cmdTXT').value; if (cmdTXT == '') return; const bytes = hex2bytes(cmdTXT); await write(bytes[0], bytes.length > 1 ? bytes.slice(1) : null); } function convertUC8159(blackWhiteData, redWhiteData) { const halfLength = blackWhiteData.length; let payloadData = new Uint8Array(halfLength * 4); let payloadIdx = 0; let black_data, color_data, data; for (let i = 0; i < halfLength; i++) { black_data = blackWhiteData[i]; color_data = redWhiteData[i]; for (let j = 0; j < 8; j++) { if ((color_data & 0x80) == 0x00) data = 0x04; // red else if ((black_data & 0x80) == 0x00) data = 0x00; // black else data = 0x03; // white data = (data << 4) & 0xFF; black_data = (black_data << 1) & 0xFF; color_data = (color_data << 1) & 0xFF; j++; if ((color_data & 0x80) == 0x00) data |= 0x04; // red else if ((black_data & 0x80) == 0x00) data |= 0x00; // black else data |= 0x03; // white black_data = (black_data << 1) & 0xFF; color_data = (color_data << 1) & 0xFF; payloadData[payloadIdx++] = data; } } return payloadData; } async function sendimg() { if (cropManager.isCropMode()) { alert("请先完成图片裁剪!发送已取消。"); return; } const canvasSize = document.getElementById('canvasSize').value; const ditherMode = document.getElementById('ditherMode').value; const epdDriverSelect = document.getElementById('epddriver'); const selectedOption = epdDriverSelect.options[epdDriverSelect.selectedIndex]; if (selectedOption.getAttribute('data-size') !== canvasSize) { if (!confirm("警告:画布尺寸和驱动不匹配,是否继续?")) return; } if (selectedOption.getAttribute('data-color') !== ditherMode) { if (!confirm("警告:颜色模式和驱动不匹配,是否继续?")) return; } startTime = new Date().getTime(); const status = document.getElementById("status"); status.parentElement.style.display = "block"; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const processedData = processImageData(imageData, ditherMode); updateButtonStatus(true); await write(EpdCmd.INIT); if (ditherMode === 'fourColor') { await writeImage(processedData, 'color'); } else if (ditherMode === 'threeColor') { const halfLength = Math.floor(processedData.length / 2); const blackWhiteData = processedData.slice(0, halfLength); const redWhiteData = processedData.slice(halfLength); if (epdDriverSelect.value === '08' || epdDriverSelect.value === '09') { await writeImage(convertUC8159(blackWhiteData, redWhiteData), 'bw'); } else { await writeImage(blackWhiteData, 'bw'); await writeImage(redWhiteData, 'red'); } } else if (ditherMode === 'blackWhiteColor') { if (epdDriverSelect.value === '08' || epdDriverSelect.value === '09') { const emptyData = new Uint8Array(processedData.length).fill(0xFF); await writeImage(convertUC8159(processedData, emptyData), 'bw'); } else { await writeImage(processedData, 'bw'); } } else { addLog("当前固件不支持此颜色模式。"); updateButtonStatus(); return; } await write(EpdCmd.REFRESH); updateButtonStatus(); const sendTime = (new Date().getTime() - startTime) / 1000.0; addLog(`发送完成!耗时: ${sendTime}s`); setStatus(`发送完成!耗时: ${sendTime}s`); addLog("屏幕刷新完成前请不要操作。"); setTimeout(() => { status.parentElement.style.display = "none"; }, 5000); } function downloadDataArray() { if (cropManager.isCropMode()) { alert("请先完成图片裁剪!下载已取消。"); return; } const mode = document.getElementById('ditherMode').value; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const processedData = processImageData(imageData, mode); if (mode === 'sixColor' && processedData.length !== canvas.width * canvas.height) { console.log(`错误:预期${canvas.width * canvas.height}字节,但得到${processedData.length}字节`); addLog('数组大小不匹配。请检查图像尺寸和模式。'); return; } const dataLines = []; for (let i = 0; i < processedData.length; i++) { const hexValue = (processedData[i] & 0xff).toString(16).padStart(2, '0'); dataLines.push(`0x${hexValue}`); } const formattedData = []; for (let i = 0; i < dataLines.length; i += 16) { formattedData.push(dataLines.slice(i, i + 16).join(', ')); } const colorModeValue = mode === 'sixColor' ? 0 : mode === 'fourColor' ? 1 : mode === 'blackWhiteColor' ? 2 : 3; const arrayContent = [ 'const uint8_t imageData[] PROGMEM = {', formattedData.join(',\n'), '};', `const uint16_t imageWidth = ${canvas.width};`, `const uint16_t imageHeight = ${canvas.height};`, `const uint8_t colorMode = ${colorModeValue};` ].join('\n'); const blob = new Blob([arrayContent], { type: 'text/plain' }); const link = document.createElement('a'); link.download = 'imagedata.h'; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); } function updateButtonStatus(forceDisabled = false) { const connected = gattServer != null && gattServer.connected; const status = forceDisabled ? 'disabled' : (connected ? null : 'disabled'); document.getElementById("reconnectbutton").disabled = (gattServer == null || gattServer.connected) ? 'disabled' : null; document.getElementById("sendcmdbutton").disabled = status; document.getElementById("calendarmodebutton").disabled = status; document.getElementById("clockmodebutton").disabled = status; document.getElementById("clearscreenbutton").disabled = status; document.getElementById("sendimgbutton").disabled = status; document.getElementById("setDriverbutton").disabled = status; } function disconnect() { updateButtonStatus(); resetVariables(); addLog('已断开连接.'); document.getElementById("connectbutton").innerHTML = '连接'; } async function preConnect() { if (gattServer != null && gattServer.connected) { if (bleDevice != null && bleDevice.gatt.connected) { bleDevice.gatt.disconnect(); } } else { resetVariables(); try { bleDevice = await navigator.bluetooth.requestDevice({ optionalServices: ['62750001-d828-918d-fb46-b6c11c675aec'], acceptAllDevices: true }); } catch (e) { console.error(e); if (e.message) addLog("requestDevice: " + e.message); addLog("请检查蓝牙是否已开启,且使用的浏览器支持蓝牙!建议使用以下浏览器:"); addLog("• 电脑: Chrome/Edge"); addLog("• Android: Chrome/Edge"); addLog("• iOS: Bluefy 浏览器"); return; } await bleDevice.addEventListener('gattserverdisconnected', disconnect); setTimeout(async function () { await connect(); }, 300); } } async function reConnect() { if (bleDevice != null && bleDevice.gatt.connected) bleDevice.gatt.disconnect(); resetVariables(); addLog("正在重连"); setTimeout(async function () { await connect(); }, 300); } function handleNotify(value, idx) { const data = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); if (idx == 0) { addLog(`收到配置:${bytes2hex(data)}`); const epdpins = document.getElementById("epdpins"); const epddriver = document.getElementById("epddriver"); epdpins.value = bytes2hex(data.slice(0, 7)); if (data.length > 10) epdpins.value += bytes2hex(data.slice(10, 11)); epddriver.value = bytes2hex(data.slice(7, 8)); updateDitcherOptions(); } else { if (textDecoder == null) textDecoder = new TextDecoder(); const msg = textDecoder.decode(data); addLog(msg, '⇓'); if (msg.startsWith('mtu=') && msg.length > 4) { const mtuSize = parseInt(msg.substring(4)); document.getElementById('mtusize').value = mtuSize; addLog(`MTU 已更新为: ${mtuSize}`); } else if (msg.startsWith('t=') && msg.length > 2) { const t = parseInt(msg.substring(2)) + new Date().getTimezoneOffset() * 60; addLog(`远端时间: ${new Date(t * 1000).toLocaleString()}`); addLog(`本地时间: ${new Date().toLocaleString()}`); } } } async function connect() { if (bleDevice == null || epdCharacteristic != null) return; try { addLog("正在连接: " + bleDevice.name); gattServer = await bleDevice.gatt.connect(); addLog(' 找到 GATT Server'); epdService = await gattServer.getPrimaryService('62750001-d828-918d-fb46-b6c11c675aec'); addLog(' 找到 EPD Service'); epdCharacteristic = await epdService.getCharacteristic('62750002-d828-918d-fb46-b6c11c675aec'); addLog(' 找到 Characteristic'); } catch (e) { console.error(e); if (e.message) addLog("connect: " + e.message); disconnect(); return; } try { const versionCharacteristic = await epdService.getCharacteristic('62750003-d828-918d-fb46-b6c11c675aec'); const versionData = await versionCharacteristic.readValue(); appVersion = versionData.getUint8(0); addLog(`固件版本: 0x${appVersion.toString(16)}`); } catch (e) { console.error(e); appVersion = 0x15; } if (appVersion < 0x16) { const oldURL = "https://tsl0922.github.io/EPD-nRF5/v1.5"; alert("!!!注意!!!\n当前固件版本过低,可能无法正常使用部分功能,建议升级到最新版本。"); if (confirm('是否访问旧版本上位机?')) location.href = oldURL; setTimeout(() => { addLog(`如遇到问题,可访问旧版本上位机: ${oldURL}`); }, 500); } try { await epdCharacteristic.startNotifications(); epdCharacteristic.addEventListener('characteristicvaluechanged', (event) => { handleNotify(event.target.value, msgIndex++); }); } catch (e) { console.error(e); if (e.message) addLog("startNotifications: " + e.message); } await write(EpdCmd.INIT); document.getElementById("connectbutton").innerHTML = '断开'; updateButtonStatus(); } function setStatus(statusText) { document.getElementById("status").innerHTML = statusText; } function addLog(logTXT, action = '') { const log = document.getElementById("log"); const now = new Date(); const time = String(now.getHours()).padStart(2, '0') + ":" + String(now.getMinutes()).padStart(2, '0') + ":" + String(now.getSeconds()).padStart(2, '0') + " "; const logEntry = document.createElement('div'); const timeSpan = document.createElement('span'); logEntry.className = 'log-line'; timeSpan.className = 'time'; timeSpan.textContent = time; logEntry.appendChild(timeSpan); if (action !== '') { const actionSpan = document.createElement('span'); actionSpan.className = 'action'; actionSpan.innerHTML = action; logEntry.appendChild(actionSpan); } logEntry.appendChild(document.createTextNode(logTXT)); log.appendChild(logEntry); log.scrollTop = log.scrollHeight; while (log.childNodes.length > 20) { log.removeChild(log.firstChild); } } function clearLog() { document.getElementById("log").innerHTML = ''; } function fillCanvas(style) { ctx.fillStyle = style; ctx.fillRect(0, 0, canvas.width, canvas.height); } function setCanvasTitle(title) { const canvasTitle = document.querySelector('.canvas-title'); if (canvasTitle) { canvasTitle.innerText = title; canvasTitle.style.display = title && title !== '' ? 'block' : 'none'; } } function syncCountdownFormToState() { countdownState.mode = document.getElementById('countdown-template-mode').value; countdownState.single.motto = document.getElementById('countdown-single-motto').value.trim() || '保持专注'; countdownState.single.label = document.getElementById('countdown-single-label').value.trim() || '目标日'; countdownState.single.date = document.getElementById('countdown-single-date').value || getTodayISODate(); countdownState.grid.title = document.getElementById('countdown-grid-title').value.trim() || '倒计时看板'; 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 })); if (countdownState.grid.items.length === 0) { countdownState.grid.items.push({ name: '未命名', date: getTodayISODate(), important: false }); } } function syncCountdownStateToForm() { document.getElementById('countdown-template-mode').value = countdownState.mode; document.getElementById('countdown-single-motto').value = countdownState.single.motto; document.getElementById('countdown-single-label').value = countdownState.single.label; document.getElementById('countdown-single-date').value = countdownState.single.date; document.getElementById('countdown-grid-title').value = countdownState.grid.title; } function setActivePanelTab(targetId) { document.querySelectorAll('.panel-tab').forEach((button) => { button.classList.toggle('active', button.dataset.tabTarget === targetId); }); document.querySelectorAll('.panel-tab-content').forEach((panel) => { panel.classList.toggle('active', panel.id === targetId); }); } function renderCountdownGridItems() { const container = document.getElementById('countdown-grid-items'); container.innerHTML = ''; countdownState.grid.items.forEach((item, index) => { const row = document.createElement('div'); row.className = 'template-item'; row.innerHTML = ` `; 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 }); } renderCountdownGridItems(); saveCountdownState(); }); container.appendChild(row); }); } function updateCountdownModeUI() { const mode = document.getElementById('countdown-template-mode').value; document.getElementById('countdown-single-panel').style.display = mode === 'single' ? 'block' : 'none'; document.getElementById('countdown-grid-panel').style.display = mode === 'grid' ? 'block' : 'none'; } function refreshCountdownTemplateUI() { syncCountdownStateToForm(); renderCountdownGridItems(); updateCountdownModeUI(); } function syncTodoFormToState() { todoState.title = document.getElementById('todo-template-title').value.trim() || '今日重点'; todoState.note = document.getElementById('todo-template-note').value.trim() || '一次做好一件事'; todoState.items = Array.from(document.querySelectorAll('#todo-list-items .template-item')).map((item) => ({ text: item.querySelector('.todo-item-text').value.trim() || '未命名任务', done: item.querySelector('.todo-item-done').checked, important: item.querySelector('.todo-item-important').checked })); if (todoState.items.length === 0) { todoState.items.push({ text: '未命名任务', done: false, important: false }); } } function syncTodoStateToForm() { document.getElementById('todo-template-title').value = todoState.title; document.getElementById('todo-template-note').value = todoState.note; } function renderTodoItems() { const container = document.getElementById('todo-list-items'); container.innerHTML = ''; todoState.items.forEach((item, index) => { const row = document.createElement('div'); row.className = 'template-item'; row.innerHTML = ` `; row.querySelector('.todo-item-remove').addEventListener('click', () => { todoState.items.splice(index, 1); if (todoState.items.length === 0) { todoState.items.push({ text: '未命名任务', done: false, important: false }); } renderTodoItems(); saveTodoState(); }); container.appendChild(row); }); } function refreshTodoTemplateUI() { syncTodoStateToForm(); renderTodoItems(); } function exportCountdownTemplate() { syncCountdownFormToState(); saveCountdownState(); const payload = { version: countdownImportVersion, exportedAt: new Date().toISOString(), countdown: countdownState }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); const stamp = new Date().toISOString().slice(0, 10); link.download = `倒计时模板-${stamp}.json`; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); } function exportTodoTemplate() { syncTodoFormToState(); saveTodoState(); const payload = { version: todoImportVersion, exportedAt: new Date().toISOString(), todo: todoState }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const link = document.createElement('a'); const stamp = new Date().toISOString().slice(0, 10); link.download = `待办模板-${stamp}.json`; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); } function importCountdownTemplate(file) { if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const rawData = JSON.parse(reader.result); const importedState = rawData && typeof rawData === 'object' && rawData.countdown ? rawData.countdown : rawData; countdownState = normalizeCountdownState(importedState); if (!countdownState.single.date) countdownState.single.date = getTodayISODate(); saveCountdownState(); refreshCountdownTemplateUI(); setActivePanelTab('countdown-template-panel'); addLog('Countdown 模板已导入。'); } catch (error) { console.error(error); alert('导入失败,文件不是有效的倒计时模板 JSON。'); } }; reader.readAsText(file, 'utf-8'); } function importTodoTemplate(file) { if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const rawData = JSON.parse(reader.result); const importedState = rawData && typeof rawData === 'object' && rawData.todo ? rawData.todo : rawData; todoState = normalizeTodoState(importedState); saveTodoState(); refreshTodoTemplateUI(); setActivePanelTab('todo-template-panel'); addLog('Todo 模板已导入。'); } catch (error) { console.error(error); alert('导入失败,文件不是有效的待办模板 JSON。'); } }; reader.readAsText(file, 'utf-8'); } // Override localized import handlers to avoid mixed-language UI. function importCountdownTemplate(file) { if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const rawData = JSON.parse(reader.result); const importedState = rawData && typeof rawData === 'object' && rawData.countdown ? rawData.countdown : rawData; countdownState = normalizeCountdownState(importedState); if (!countdownState.single.date) countdownState.single.date = getTodayISODate(); saveCountdownState(); refreshCountdownTemplateUI(); setActivePanelTab('countdown-template-panel'); addLog('倒计时模板已导入。'); } catch (error) { console.error(error); alert('导入失败,文件不是有效的倒计时模板 JSON 文件。'); } }; reader.readAsText(file, 'utf-8'); } function importTodoTemplate(file) { if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const rawData = JSON.parse(reader.result); const importedState = rawData && typeof rawData === 'object' && rawData.todo ? rawData.todo : rawData; todoState = normalizeTodoState(importedState); saveTodoState(); refreshTodoTemplateUI(); setActivePanelTab('todo-template-panel'); addLog('待办模板已导入。'); } catch (error) { console.error(error); alert('导入失败,文件不是有效的待办模板 JSON 文件。'); } }; reader.readAsText(file, 'utf-8'); } function prepareTemplateCanvas() { if (cropManager.isCropMode()) cropManager.exitCropMode(); fillCanvas('white'); paintManager.clearElements(); paintManager.clearHistory(); paintManager.setActiveTool(null, ''); } function drawRoundedRect(ctxIn, x, y, width, height, radius, lineWidth, strokeStyle) { const r = Math.min(radius, width / 2, height / 2); ctxIn.beginPath(); ctxIn.moveTo(x + r, y); ctxIn.arcTo(x + width, y, x + width, y + height, r); ctxIn.arcTo(x + width, y + height, x, y + height, r); ctxIn.arcTo(x, y + height, x, y, r); ctxIn.arcTo(x, y, x + width, y, r); ctxIn.closePath(); ctxIn.lineWidth = lineWidth; ctxIn.strokeStyle = strokeStyle; ctxIn.stroke(); } function drawSingleCountdownTemplate() { const scaleX = canvas.width / 400; const scaleY = canvas.height / 300; const scale = Math.min(scaleX, scaleY); const motto = countdownState.single.motto || '保持专注'; const label = countdownState.single.label || '目标日'; const days = getCountdownDays(countdownState.single.date); prepareTemplateCanvas(); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#000000'; ctx.lineWidth = Math.max(1, 2 * scale); ctx.strokeRect(0, 0, canvas.width, canvas.height); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `bold ${Math.max(11, 11 * scale)}px Arial`; ctx.fillStyle = '#000000'; ctx.fillText(formatCountdownDate(), canvas.width / 2, 22 * scaleY); ctx.beginPath(); ctx.moveTo(canvas.width * 0.2, 32 * scaleY); ctx.lineTo(canvas.width * 0.8, 32 * scaleY); ctx.stroke(); const mottoSize = fitText(ctx, motto, canvas.width * 0.84, 42 * scale, 18 * scale, 'Microsoft YaHei', '900'); ctx.font = `900 ${mottoSize}px Microsoft YaHei`; ctx.fillText(motto, canvas.width / 2, 95 * scaleY); ctx.fillStyle = '#FF0000'; ctx.font = `bold ${Math.max(16, 20 * scale)}px Arial`; ctx.fillText('* * *', canvas.width / 2, 142 * scaleY); ctx.fillStyle = '#444444'; ctx.font = `bold ${Math.max(14, 15 * scale)}px Microsoft YaHei`; ctx.fillText(label, canvas.width / 2, 182 * scaleY); ctx.fillStyle = '#FF0000'; const numberText = days < 0 ? '!' : String(days); const numberSize = fitText(ctx, numberText, canvas.width * 0.48, 86 * scale, 30 * scale, 'Arial Black', '900'); ctx.font = `900 ${numberSize}px Arial Black`; ctx.fillText(numberText, canvas.width / 2 - 16 * scaleX, 240 * scaleY); ctx.fillStyle = '#000000'; ctx.font = `bold ${Math.max(20, 22 * scale)}px Arial`; ctx.fillText(days < 0 ? '结束' : '天', canvas.width / 2 + 72 * scaleX, 245 * scaleY); paintManager.saveToHistory(); } 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); prepareTemplateCanvas(); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#000000'; ctx.lineWidth = Math.max(1, scale); ctx.strokeRect(0, 0, canvas.width, canvas.height); ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#000000'; ctx.font = `900 ${Math.max(18, 18 * scale)}px Microsoft YaHei`; ctx.fillText(countdownState.grid.title || '倒计时看板', 24 * scaleX, 28 * scaleY); ctx.fillStyle = '#FF0000'; ctx.fillRect(10 * scaleX, 13 * scaleY, 6 * scaleX, 28 * scaleY); ctx.textAlign = 'right'; ctx.fillStyle = '#000000'; ctx.font = `bold ${Math.max(10, 10 * scale)}px Arial`; ctx.fillText(formatCountdownDate(), canvas.width - 12 * scaleX, 18 * scaleY); 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; 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); const days = getCountdownDays(item.date); drawRoundedRect(ctx, x, y, cardWidth, cardHeight, 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); } const namePreferredSize = Math.min(cardHeight * 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'); ctx.fillStyle = '#000000'; ctx.textAlign = 'center'; ctx.font = `bold ${nameSize}px Microsoft YaHei`; ctx.fillText(item.name, x + cardWidth / 2, y + cardHeight * 0.3); let valueText = ''; let unitText = ''; let valueColor = '#FF0000'; if (days === 0) { valueText = '今天'; } else if (days < 0) { valueText = '已过'; valueColor = '#666666'; } else { valueText = String(days); unitText = '天'; } const numberPreferredSize = Math.min(cardHeight * 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'); ctx.fillStyle = valueColor; ctx.font = `900 ${numberSize}px Arial Black`; const centerY = y + cardHeight * 0.68; if (unitText) { const numberWidth = ctx.measureText(valueText).width; ctx.fillText(valueText, x + cardWidth / 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); } else { ctx.fillText(valueText, x + cardWidth / 2, centerY); } }); paintManager.saveToHistory(); } function drawTodoTemplate() { const scaleX = canvas.width / 400; const scaleY = canvas.height / 300; const scale = Math.min(scaleX, scaleY); const items = todoState.items.slice(0, 12); prepareTemplateCanvas(); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#000000'; ctx.lineWidth = Math.max(1, 2 * scale); ctx.strokeRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000000'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.font = `900 ${Math.max(20, 22 * scale)}px Microsoft YaHei`; ctx.fillText(todoState.title || '今日重点', 20 * scaleX, 24 * scaleY); ctx.textAlign = 'right'; ctx.font = `bold ${Math.max(10, 10 * scale)}px Arial`; ctx.fillText(formatCountdownDate(), canvas.width - 12 * scaleX, 20 * scaleY); ctx.fillStyle = '#FF0000'; ctx.fillRect(12 * scaleX, 36 * scaleY, canvas.width - 24 * scaleX, Math.max(2, 3 * scale)); const note = todoState.note || '一次做好一件事'; ctx.fillStyle = '#555555'; ctx.textAlign = 'left'; ctx.font = `bold ${Math.max(11, 12 * scale)}px Microsoft YaHei`; ctx.fillText(note, 20 * scaleX, 52 * scaleY); const startY = 78 * scaleY; const rowGap = Math.max(18 * scaleY, 24 * scale); const boxSize = Math.max(12, 16 * scale); items.forEach((item, index) => { const y = startY + index * rowGap; if (y > canvas.height - 18 * scaleY) return; ctx.lineWidth = Math.max(1, 2 * scale); ctx.strokeStyle = item.important ? '#FF0000' : '#000000'; ctx.strokeRect(20 * scaleX, y - boxSize / 2, boxSize, boxSize); if (item.done) { ctx.strokeStyle = '#000000'; ctx.beginPath(); ctx.moveTo(24 * scaleX, y); ctx.lineTo(28 * scaleX, y + 5 * scaleY); ctx.lineTo(36 * scaleX, y - 6 * scaleY); ctx.stroke(); } if (item.important) { ctx.fillStyle = '#FF0000'; ctx.beginPath(); ctx.arc(50 * scaleX, y, Math.max(2, 2.5 * scale), 0, Math.PI * 2); ctx.fill(); } const textX = item.important ? 60 * scaleX : 52 * scaleX; const maxWidth = canvas.width - textX - 18 * scaleX; const fontSize = fitText(ctx, item.text, maxWidth, 16 * scale, 10 * scale, 'Microsoft YaHei', item.done ? 'normal' : 'bold'); ctx.font = `${item.done ? 'normal' : 'bold'} ${fontSize}px Microsoft YaHei`; ctx.fillStyle = item.done ? '#777777' : '#000000'; ctx.textAlign = 'left'; ctx.fillText(item.text, textX, y); if (item.done) { const textWidth = Math.min(ctx.measureText(item.text).width, maxWidth); ctx.strokeStyle = '#777777'; ctx.lineWidth = Math.max(1, 1.5 * scale); ctx.beginPath(); ctx.moveTo(textX, y); ctx.lineTo(textX + textWidth, y); ctx.stroke(); } }); paintManager.saveToHistory(); } function applyCountdownTemplate() { syncCountdownFormToState(); saveCountdownState(); document.getElementById('imageFile').value = ''; setActivePanelTab('countdown-template-panel'); activeCanvasPreset = 'countdown'; if (countdownState.mode === 'grid') drawGridCountdownTemplate(); else drawSingleCountdownTemplate(); } function applyTodoTemplate() { syncTodoFormToState(); saveTodoState(); document.getElementById('imageFile').value = ''; setActivePanelTab('todo-template-panel'); activeCanvasPreset = 'todo'; drawTodoTemplate(); } function renderCanvasSource() { const imageFile = document.getElementById('imageFile'); if (imageFile.files.length > 0) { activeCanvasPreset = null; updateImage(); return; } if (activeCanvasPreset === 'countdown') { applyCountdownTemplate(); return; } if (activeCanvasPreset === 'todo') { applyTodoTemplate(); return; } fillCanvas('white'); paintManager.clearElements(); paintManager.clearHistory(); paintManager.saveToHistory(); } function initCountdownTemplate() { loadCountdownState(); refreshCountdownTemplateUI(); document.querySelectorAll('.panel-tab').forEach((button) => { button.addEventListener('click', () => { setActivePanelTab(button.dataset.tabTarget); }); }); document.getElementById('countdown-template-mode').addEventListener('change', () => { syncCountdownFormToState(); saveCountdownState(); updateCountdownModeUI(); }); document.getElementById('countdown-single-motto').addEventListener('input', () => { syncCountdownFormToState(); saveCountdownState(); }); document.getElementById('countdown-single-label').addEventListener('input', () => { syncCountdownFormToState(); saveCountdownState(); }); document.getElementById('countdown-single-date').addEventListener('input', () => { syncCountdownFormToState(); saveCountdownState(); }); document.getElementById('countdown-grid-title').addEventListener('input', () => { syncCountdownFormToState(); saveCountdownState(); }); document.getElementById('countdown-grid-add').addEventListener('click', () => { syncCountdownFormToState(); countdownState.grid.items.push({ name: `项目 ${countdownState.grid.items.length + 1}`, date: getTodayISODate(), important: false }); renderCountdownGridItems(); saveCountdownState(); }); document.getElementById('countdown-grid-items').addEventListener('input', () => { syncCountdownFormToState(); saveCountdownState(); }); document.getElementById('apply-countdown-template').addEventListener('click', () => { applyCountdownTemplate(); }); document.getElementById('import-countdown-template').addEventListener('click', () => { document.getElementById('countdown-import-file').click(); }); document.getElementById('export-countdown-template').addEventListener('click', () => { exportCountdownTemplate(); }); document.getElementById('countdown-import-file').addEventListener('change', (event) => { importCountdownTemplate(event.target.files[0]); event.target.value = ''; }); } function initTodoTemplate() { loadTodoState(); refreshTodoTemplateUI(); document.getElementById('todo-template-title').addEventListener('input', () => { syncTodoFormToState(); saveTodoState(); }); document.getElementById('todo-template-note').addEventListener('input', () => { syncTodoFormToState(); saveTodoState(); }); document.getElementById('todo-list-add').addEventListener('click', () => { syncTodoFormToState(); todoState.items.push({ text: `任务 ${todoState.items.length + 1}`, done: false, important: false }); renderTodoItems(); saveTodoState(); }); document.getElementById('todo-list-items').addEventListener('input', () => { syncTodoFormToState(); saveTodoState(); }); document.getElementById('apply-todo-template').addEventListener('click', () => { applyTodoTemplate(); }); document.getElementById('import-todo-template').addEventListener('click', () => { document.getElementById('todo-import-file').click(); }); document.getElementById('export-todo-template').addEventListener('click', () => { exportTodoTemplate(); }); document.getElementById('todo-import-file').addEventListener('change', (event) => { importTodoTemplate(event.target.files[0]); event.target.value = ''; }); } function updateImage() { const imageFile = document.getElementById('imageFile'); if (imageFile.files.length == 0) { renderCanvasSource(); return; } activeCanvasPreset = null; setActivePanelTab('image-upload-panel'); const image = new Image(); image.onload = function () { URL.revokeObjectURL(this.src); if (image.width / image.height == canvas.width / canvas.height) { if (cropManager.isCropMode()) cropManager.exitCropMode(); ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height); convertDithering(); } else { alert(`图片宽高比例与画布不匹配,将进入裁剪模式。\n请放大图片后移动图片使其充满画布, 再点击"完成"按钮。`); paintManager.setActiveTool(null, ''); cropManager.initializeCrop(); } }; image.src = URL.createObjectURL(imageFile.files[0]); } function updateCanvasSize() { const selectedSizeName = document.getElementById('canvasSize').value; const selectedSize = canvasSizes.find(size => size.name === selectedSizeName); canvas.width = selectedSize.width; canvas.height = selectedSize.height; renderCanvasSource(); } function updateDitcherOptions() { const epdDriverSelect = document.getElementById('epddriver'); const selectedOption = epdDriverSelect.options[epdDriverSelect.selectedIndex]; const colorMode = selectedOption.getAttribute('data-color'); const canvasSize = selectedOption.getAttribute('data-size'); if (colorMode) document.getElementById('ditherMode').value = colorMode; if (canvasSize) document.getElementById('canvasSize').value = canvasSize; updateCanvasSize(); // always update image } function rotateCanvas() { const currentWidth = canvas.width; const currentHeight = canvas.height; // Capture current canvas content const imageData = ctx.getImageData(0, 0, currentWidth, currentHeight); // Swap canvas dimensions canvas.width = currentHeight; canvas.height = currentWidth; // Create temporary canvas for rotation const tempCanvas = document.createElement('canvas'); tempCanvas.width = currentWidth; tempCanvas.height = currentHeight; const tempCtx = tempCanvas.getContext('2d'); tempCtx.putImageData(imageData, 0, 0); // Draw rotated image on the resized canvas ctx.translate(canvas.width / 2, canvas.height / 2); ctx.rotate(90 * Math.PI / 180); ctx.drawImage(tempCanvas, -currentWidth / 2, -currentHeight / 2); ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform paintManager.clearHistory(); // Clear history as canvas size changed paintManager.clearElements(); // Clear stored text positions and line segments paintManager.saveToHistory(); // Save rotated canvas to history } function clearCanvas() { if (confirm('清除画布内容?')) { activeCanvasPreset = null; document.getElementById('imageFile').value = ''; fillCanvas('white'); paintManager.clearElements(); // Clear stored text positions and line segments if (cropManager.isCropMode()) cropManager.exitCropMode(); paintManager.saveToHistory(); // Save cleared canvas to history return true; } return false; } function convertDithering() { paintManager.redrawTextElements(); paintManager.redrawLineSegments(); const contrast = parseFloat(document.getElementById('ditherContrast').value); const currentImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const imageData = new ImageData( new Uint8ClampedArray(currentImageData.data), currentImageData.width, currentImageData.height ); adjustContrast(imageData, contrast); const alg = document.getElementById('ditherAlg').value; const strength = parseFloat(document.getElementById('ditherStrength').value); const mode = document.getElementById('ditherMode').value; const processedData = processImageData(ditherImage(imageData, alg, strength, mode), mode); const finalImageData = decodeProcessedData(processedData, canvas.width, canvas.height, mode); ctx.putImageData(finalImageData, 0, 0); paintManager.saveToHistory(); // Save dithered image to history } function applyDither() { cropManager.finishCrop(() => convertDithering()); } function initEventHandlers() { document.getElementById("ditherStrength").addEventListener("input", (e) => { document.getElementById("ditherStrengthValue").innerText = parseFloat(e.target.value).toFixed(1); applyDither(); }); document.getElementById("ditherContrast").addEventListener("input", (e) => { document.getElementById("ditherContrastValue").innerText = parseFloat(e.target.value).toFixed(1); applyDither(); }); } function checkDebugMode() { const link = document.getElementById('debug-toggle'); const urlParams = new URLSearchParams(window.location.search); const debugMode = urlParams.get('debug'); if (debugMode === 'true') { document.body.classList.add('dark-mode'); link.innerHTML = '正常模式'; link.setAttribute('href', window.location.pathname); addLog("注意:开发模式功能已开启!不懂请不要随意修改,否则后果自负!"); } else { document.body.classList.remove('dark-mode'); link.innerHTML = '开发模式'; link.setAttribute('href', window.location.pathname + '?debug=true'); } } document.body.onload = () => { textDecoder = null; canvas = document.getElementById('canvas'); ctx = canvas.getContext("2d"); ctx.fillStyle = 'white'; ctx.fillRect(0, 0, canvas.width, canvas.height); paintManager = new PaintManager(canvas, ctx); cropManager = new CropManager(canvas, ctx, paintManager); paintManager.initPaintTools(); cropManager.initCropTools(); initCountdownTemplate(); initTodoTemplate(); initEventHandlers(); updateButtonStatus(); checkDebugMode(); }