Files
Letters/gen-esp/js/main.js
T
biss 065c73f25e
Vercel Deploy / deploy (push) Successful in 1m3s
添加esp支持
2026-05-04 21:24:16 +08:00

2102 lines
74 KiB
JavaScript

let bleDevice, gattServer;
let epdService, epdCharacteristic;
let startTime, msgIndex, appVersion;
let canvas, ctx, textDecoder;
let paintManager, cropManager;
let activeCanvasPreset = null;
let draggedCountdownIndex = 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_COUNTDOWN: 0x40,
SET_TEMPLATE_META: 0x41,
WRITE_TEMPLATE_IMG: 0x42,
SET_CALENDAR_MARKS: 0x43,
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 countdownRegionsStorageKey = 'epdCountdownTemplateRegions';
const countdownImportVersion = 1;
const minimumCountdownWeight = 0;
const calendarMarksStorageKey = 'epdCalendarMarks';
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, weight: 2 },
{ name: '驾照考试', date: '2026-05-01', important: false, weight: 1 }
]
}
};
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;
let countdownManualRegions = null;
let countdownDateRegion = null;
let calendarMarks = [];
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 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;
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,
weight: sanitizeCountdownWeight(item.weight)
}));
}
}
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();
}
try {
const savedRegions = JSON.parse(localStorage.getItem(countdownRegionsStorageKey));
countdownManualRegions = normalizeCountdownRegions(savedRegions);
countdownDateRegion = normalizeCountdownDateRegion(savedRegions);
} catch (e) {
countdownManualRegions = null;
}
if (!countdownState.single.date) countdownState.single.date = getTodayISODate();
}
function saveCountdownState() {
localStorage.setItem(countdownStorageKey, JSON.stringify(countdownState));
if (countdownManualRegions && countdownManualRegions.length > 0) {
localStorage.setItem(countdownRegionsStorageKey, JSON.stringify(serializeCountdownRegions()));
}
}
function loadTodoState() {
try {
todoState = normalizeTodoState(JSON.parse(localStorage.getItem(todoStorageKey)));
} catch (e) {
todoState = cloneDefaultTodoState();
}
}
function saveTodoState() {
localStorage.setItem(todoStorageKey, JSON.stringify(todoState));
}
function normalizeCalendarMarks(value) {
if (!Array.isArray(value)) return [];
const seen = new Set();
return value
.map((item) => typeof item === 'string' ? item : item && item.date)
.filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date))
.filter((date) => {
if (seen.has(date)) return false;
seen.add(date);
return true;
})
.sort()
.slice(0, 24);
}
function loadCalendarMarks() {
try {
calendarMarks = normalizeCalendarMarks(JSON.parse(localStorage.getItem(calendarMarksStorageKey)));
} catch (e) {
calendarMarks = [];
}
}
function saveCalendarMarks() {
localStorage.setItem(calendarMarksStorageKey, JSON.stringify(calendarMarks));
}
function renderCalendarMarks() {
const container = document.getElementById('calendar-mark-items');
if (!container) return;
container.innerHTML = '';
calendarMarks.forEach((date, index) => {
const row = document.createElement('div');
row.className = 'template-item';
row.innerHTML = `
<strong>${date}</strong>
<button type="button" class="secondary calendar-mark-remove">删除</button>
`;
row.querySelector('.calendar-mark-remove').addEventListener('click', () => {
calendarMarks.splice(index, 1);
saveCalendarMarks();
renderCalendarMarks();
});
container.appendChild(row);
});
}
function addCalendarMark() {
const input = document.getElementById('calendar-mark-date');
if (!input || !input.value) return;
calendarMarks = normalizeCalendarMarks([...calendarMarks, input.value]);
input.value = '';
saveCalendarMarks();
renderCalendarMarks();
}
async function sendCalendarMarks() {
const marks = normalizeCalendarMarks(calendarMarks);
const payload = [marks.length];
marks.forEach((date) => {
const [year, month, day] = date.split('-').map((part) => parseInt(part, 10));
payload.push((year >> 8) & 0xFF, year & 0xFF, month & 0xFF, day & 0xFF);
});
return await write(EpdCmd.SET_CALENDAR_MARKS, new Uint8Array(payload));
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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 applyDeviceConfig();
}
async function applyDeviceConfig() {
if (!await write(EpdCmd.SET_PINS, document.getElementById("epdpins").value)) return false;
return await write(EpdCmd.INIT, document.getElementById("epddriver").value);
}
async function writeCurrentTime(mode) {
const timestamp = Math.floor(new Date().getTime() / 1000);
const data = new Uint8Array([
(timestamp >> 24) & 0xFF,
(timestamp >> 16) & 0xFF,
(timestamp >> 8) & 0xFF,
timestamp & 0xFF,
-(new Date().getTimezoneOffset() / 60),
mode
]);
return await write(EpdCmd.SET_TIME, data);
}
async function syncTime(mode) {
if (!await applyDeviceConfig()) return;
if (mode === 1 && !await sendCalendarMarks()) return;
if (await writeCurrentTime(mode)) {
addLog("时间已同步!");
addLog("屏幕刷新完成前请不要操作。");
}
}
function asciiBytes(value, fallback) {
const text = (value || fallback || '').replace(/[^\x20-\x7E]/g, '').trim() || fallback || '';
return Array.from(text.slice(0, 31)).map((char) => char.charCodeAt(0));
}
function autoTemplateRegions() {
if (countdownState.mode !== 'grid') {
return [{
x: Math.round(canvas.width * 0.12),
y: Math.round(canvas.height * 0.55),
w: Math.round(canvas.width * 0.64),
h: Math.round(canvas.height * 0.34)
}];
}
const scaleX = canvas.width / 400;
const scaleY = canvas.height / 300;
const scale = Math.min(scaleX, scaleY);
const items = countdownState.grid.items.slice(0, 9)
.map((item) => ({ ...item, weight: sanitizeCountdownWeight(item.weight) }))
.filter((item) => item.weight > 0);
const contentTop = 50 * scaleY;
const contentHeight = canvas.height - contentTop - 12 * scaleY;
const gap = 10 * scale;
return buildSequentialCountdownLayout(items, 12 * scaleX, contentTop, canvas.width - 24 * scaleX, contentHeight, gap)
.map(({ x, y, width, height }) => ({
x: Math.round(x + width * 0.08),
y: Math.round(y + height * 0.34),
w: Math.round(width * 0.84),
h: Math.round(height * 0.42)
}));
}
function autoTemplateDateRegion() {
const scaleX = canvas.width / 400;
const scaleY = canvas.height / 300;
if (countdownState.mode === 'grid') {
return {
x: Math.round(250 * scaleX),
y: Math.round(14 * scaleY),
w: Math.round(132 * scaleX),
h: Math.round(24 * scaleY)
};
}
return {
x: Math.round(canvas.width * 0.20),
y: Math.round(canvas.height * 0.07),
w: Math.round(canvas.width * 0.60),
h: Math.round(canvas.height * 0.11)
};
}
function currentCountdownRegions() {
return countdownManualRegions && countdownManualRegions.length > 0
? countdownManualRegions.map((region) => ({ ...region }))
: autoTemplateRegions();
}
function currentCountdownDateRegion() {
return countdownDateRegion ? { ...countdownDateRegion } : autoTemplateDateRegion();
}
function clampCountdownRegion(region) {
const w = Math.max(1, Math.min(Math.round(region.w), canvas.width));
const h = Math.max(1, Math.min(Math.round(region.h), canvas.height));
return {
x: Math.max(0, Math.min(Math.round(region.x), canvas.width - w)),
y: Math.max(0, Math.min(Math.round(region.y), canvas.height - h)),
w,
h
};
}
function normalizeCountdownRegions(data) {
const source = Array.isArray(data) ? { regions: data } : data;
if (!source || !Array.isArray(source.regions) || !canvas) return null;
const sourceCanvas = source.canvas || source.size || {};
const sourceWidth = Number(sourceCanvas.width || sourceCanvas.w || canvas.width);
const sourceHeight = Number(sourceCanvas.height || sourceCanvas.h || canvas.height);
const scaleX = sourceWidth > 0 ? canvas.width / sourceWidth : 1;
const scaleY = sourceHeight > 0 ? canvas.height / sourceHeight : 1;
const regions = source.regions
.map((region) => ({
x: Number(region.x) * scaleX,
y: Number(region.y) * scaleY,
w: Number(region.w) * scaleX,
h: Number(region.h) * scaleY
}))
.filter((region) => Number.isFinite(region.x) && Number.isFinite(region.y) && Number.isFinite(region.w) && Number.isFinite(region.h))
.map((region) => clampCountdownRegion(region));
return regions.length > 0 ? regions : null;
}
function normalizeCountdownDateRegion(data) {
const source = data && data.date ? data.date : data;
if (!source || !canvas) return null;
const sourceCanvas = data && (data.canvas || data.size) ? (data.canvas || data.size) : {};
const sourceWidth = Number(sourceCanvas.width || sourceCanvas.w || canvas.width);
const sourceHeight = Number(sourceCanvas.height || sourceCanvas.h || canvas.height);
const scaleX = sourceWidth > 0 ? canvas.width / sourceWidth : 1;
const scaleY = sourceHeight > 0 ? canvas.height / sourceHeight : 1;
const region = {
x: Number(source.x) * scaleX,
y: Number(source.y) * scaleY,
w: Number(source.w) * scaleX,
h: Number(source.h) * scaleY
};
return Number.isFinite(region.x) && Number.isFinite(region.y) && Number.isFinite(region.w) && Number.isFinite(region.h)
? clampCountdownRegion(region)
: null;
}
function serializeCountdownRegions() {
return {
canvas: {
width: canvas.width,
height: canvas.height
},
regions: currentCountdownRegions().map((region) => clampCountdownRegion(region)),
date: clampCountdownRegion(currentCountdownDateRegion())
};
}
function syncCountdownRegionInputs(index) {
const container = document.getElementById('countdown-region-items');
if (!container) return;
const region = index === 'date'
? countdownDateRegion
: countdownManualRegions && countdownManualRegions[index];
if (!region) return;
const row = container.querySelector(`[data-region-index="${index}"]`);
if (!row) return;
row.querySelector('.countdown-region-x').value = region.x;
row.querySelector('.countdown-region-y').value = region.y;
row.querySelector('.countdown-region-w').value = region.w;
row.querySelector('.countdown-region-h').value = region.h;
}
function countdownRegionOverlayEnabled() {
const panel = document.getElementById('countdown-template-panel');
return !!(panel && panel.classList.contains('active') && countdownManualRegions && countdownManualRegions.length > 0);
}
function placeCountdownRegionBox(box, region) {
const overlay = document.getElementById('countdown-region-overlay');
if (!overlay || !canvas) return;
const scaleX = overlay.clientWidth / canvas.width;
const scaleY = overlay.clientHeight / canvas.height;
box.style.left = `${region.x * scaleX}px`;
box.style.top = `${region.y * scaleY}px`;
box.style.width = `${region.w * scaleX}px`;
box.style.height = `${region.h * scaleY}px`;
}
function updateCountdownRegionBox(index) {
const overlay = document.getElementById('countdown-region-overlay');
const region = index === 'date'
? countdownDateRegion
: countdownManualRegions && countdownManualRegions[index];
if (!overlay || !region) return;
const box = overlay.querySelector(`[data-region-index="${index}"]`);
if (box) placeCountdownRegionBox(box, region);
}
function renderCountdownRegionOverlay() {
const overlay = document.getElementById('countdown-region-overlay');
if (!overlay || !canvas) return;
overlay.innerHTML = '';
overlay.classList.toggle('active', countdownRegionOverlayEnabled());
if (!countdownRegionOverlayEnabled()) return;
const overlayRegions = [
{ id: 'date', label: 'date', region: countdownDateRegion },
...countdownManualRegions.map((region, index) => ({ id: String(index), label: `#${index + 1}`, region }))
].filter((item) => item.region);
overlayRegions.forEach((item) => {
const box = document.createElement('div');
box.className = 'region-box';
box.dataset.regionIndex = item.id;
box.innerHTML = `<span class="region-label">${item.label}</span>`;
placeCountdownRegionBox(box, item.region);
box.addEventListener('pointerdown', (event) => {
event.preventDefault();
box.setPointerCapture(event.pointerId);
const boxRect = box.getBoundingClientRect();
const overlayRect = overlay.getBoundingClientRect();
const scaleX = canvas.width / overlayRect.width;
const scaleY = canvas.height / overlayRect.height;
const startX = event.clientX;
const startY = event.clientY;
const startRegion = { ...item.region };
const resize = boxRect.right - event.clientX <= 18 && boxRect.bottom - event.clientY <= 18;
const onMove = (moveEvent) => {
const dx = Math.round((moveEvent.clientX - startX) * scaleX);
const dy = Math.round((moveEvent.clientY - startY) * scaleY);
const nextRegion = resize
? clampCountdownRegion({ ...startRegion, w: startRegion.w + dx, h: startRegion.h + dy })
: clampCountdownRegion({ ...startRegion, x: startRegion.x + dx, y: startRegion.y + dy });
if (item.id === 'date') countdownDateRegion = nextRegion;
else countdownManualRegions[Number(item.id)] = nextRegion;
item.region = nextRegion;
syncCountdownRegionInputs(item.id === 'date' ? 'date' : Number(item.id));
updateCountdownRegionBox(item.id);
};
const onUp = () => {
box.releasePointerCapture(event.pointerId);
box.removeEventListener('pointermove', onMove);
box.removeEventListener('pointerup', onUp);
box.removeEventListener('pointercancel', onUp);
saveCountdownState();
};
box.addEventListener('pointermove', onMove);
box.addEventListener('pointerup', onUp);
box.addEventListener('pointercancel', onUp);
});
overlay.appendChild(box);
});
}
function renderCountdownRegions(regions = currentCountdownRegions()) {
countdownManualRegions = regions.map((region) => clampCountdownRegion(region));
countdownDateRegion = clampCountdownRegion(currentCountdownDateRegion());
const container = document.getElementById('countdown-region-items');
if (!container) {
renderCountdownRegionOverlay();
return;
}
container.innerHTML = '';
const rows = [
{ label: 'date', date: true, region: countdownDateRegion },
...countdownManualRegions.map((region, index) => ({ label: `#${index + 1}`, index, region }))
];
rows.forEach((item) => {
const row = document.createElement('div');
row.className = 'template-item';
if (item.date) row.dataset.regionIndex = 'date';
else row.dataset.regionIndex = String(item.index);
const region = item.region;
row.innerHTML = `
<strong>${item.label}</strong>
<label>x <input type="number" class="countdown-region-x" value="${region.x}"></label>
<label>y <input type="number" class="countdown-region-y" value="${region.y}"></label>
<label>w <input type="number" class="countdown-region-w" value="${region.w}"></label>
<label>h <input type="number" class="countdown-region-h" value="${region.h}"></label>
`;
row.addEventListener('input', () => {
const nextRegion = clampCountdownRegion({
x: parseInt(row.querySelector('.countdown-region-x').value, 10) || 0,
y: parseInt(row.querySelector('.countdown-region-y').value, 10) || 0,
w: parseInt(row.querySelector('.countdown-region-w').value, 10) || 1,
h: parseInt(row.querySelector('.countdown-region-h').value, 10) || 1
});
if (item.date) {
countdownDateRegion = nextRegion;
syncCountdownRegionInputs('date');
updateCountdownRegionBox('date');
} else {
countdownManualRegions[item.index] = nextRegion;
syncCountdownRegionInputs(item.index);
updateCountdownRegionBox(item.index);
}
});
row.addEventListener('change', saveCountdownState);
container.appendChild(row);
});
renderCountdownRegionOverlay();
}
function clearAutoTemplateRegions(regions) {
ctx.save();
ctx.fillStyle = '#FFFFFF';
regions.forEach((region) => {
ctx.fillRect(region.x, region.y, region.w, region.h);
});
ctx.restore();
}
async function uploadTemplateChannel(channel, data) {
const chunkSize = Math.max(16, document.getElementById('mtusize').value - 3);
for (let i = 0; i < data.length; i += chunkSize) {
const end = Math.min(i + chunkSize, data.length);
const payload = [
channel,
(i === 0 ? 0x00 : 0xF0) | (end >= data.length ? 0x01 : 0x00),
...data.slice(i, end)
];
if (!await write(EpdCmd.WRITE_TEMPLATE_IMG, payload, true)) return false;
}
return true;
}
async function uploadAutoTemplateBackground() {
const previousPreset = activeCanvasPreset;
applyCountdownTemplate();
const regions = currentCountdownRegions();
const dateRegion = clampCountdownRegion(currentCountdownDateRegion());
clearAutoTemplateRegions([...regions, dateRegion]);
const meta = [
(canvas.width >> 8) & 0xFF,
canvas.width & 0xFF,
(canvas.height >> 8) & 0xFF,
canvas.height & 0xFF,
regions.length
];
regions.forEach((region) => {
meta.push(
(region.x >> 8) & 0xFF, region.x & 0xFF,
(region.y >> 8) & 0xFF, region.y & 0xFF,
(region.w >> 8) & 0xFF, region.w & 0xFF,
(region.h >> 8) & 0xFF, region.h & 0xFF
);
});
meta.push(
(dateRegion.x >> 8) & 0xFF, dateRegion.x & 0xFF,
(dateRegion.y >> 8) & 0xFF, dateRegion.y & 0xFF,
(dateRegion.w >> 8) & 0xFF, dateRegion.w & 0xFF,
(dateRegion.h >> 8) & 0xFF, dateRegion.h & 0xFF
);
if (!await write(EpdCmd.SET_TEMPLATE_META, meta, true)) return false;
const epdDriverSelect = document.getElementById('epddriver');
const selectedOption = epdDriverSelect.options[epdDriverSelect.selectedIndex];
const colorMode = selectedOption && selectedOption.getAttribute('data-color') === 'threeColor' ? 'threeColor' : 'blackWhiteColor';
const processedData = processImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), colorMode);
let blackData = processedData;
let redData = new Uint8Array(processedData.length).fill(0xFF);
if (colorMode === 'threeColor') {
const halfLength = Math.floor(processedData.length / 2);
blackData = processedData.slice(0, halfLength);
redData = processedData.slice(halfLength);
}
const ok = await uploadTemplateChannel(0, blackData) && await uploadTemplateChannel(1, redData);
if (ok) addLog("自动模板背景已上传。");
activeCanvasPreset = previousPreset;
renderCanvasSource();
return ok;
}
async function activateCountdownMode() {
syncCountdownFormToState();
let data;
if (countdownState.mode === 'grid') {
const items = countdownState.grid.items
.filter((item) => sanitizeCountdownWeight(item.weight) > 0)
.slice(0, 9);
const title = [];
const payload = [2, title.length, ...title, items.length];
items.forEach((item) => {
const [year, month, day] = (item.date || getTodayISODate()).split('-').map((part) => parseInt(part, 10));
const name = [];
payload.push((year >> 8) & 0xFF, year & 0xFF, month & 0xFF, day & 0xFF, item.important ? 1 : 0, name.length, ...name);
});
data = new Uint8Array(payload);
} else {
const target = countdownState.single.date || getTodayISODate();
const [year, month, day] = target.split('-').map((part) => parseInt(part, 10));
const label = asciiBytes(countdownState.single.label, 'TARGET');
const motto = asciiBytes(countdownState.single.motto, 'FOCUS');
data = new Uint8Array([
(year >> 8) & 0xFF,
year & 0xFF,
month & 0xFF,
day & 0xFF,
label.length,
...label,
motto.length,
...motto
]);
}
if (await write(EpdCmd.SET_COUNTDOWN, data) && await uploadAutoTemplateBackground()) {
await syncTime(3);
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) {
alert('请选择有效的屏幕驱动。');
return;
}
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);
if (!await write(EpdCmd.SET_PINS, document.getElementById("epdpins").value)) {
updateButtonStatus();
return;
}
if (!await write(EpdCmd.INIT, epdDriverSelect.value)) {
updateButtonStatus();
return;
}
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 && epdCharacteristic != null;
const disabled = forceDisabled || !connected;
document.getElementById("reconnectbutton").disabled = (gattServer == null || gattServer.connected);
document.getElementById("sendcmdbutton").disabled = disabled;
document.getElementById("calendarmodebutton").disabled = disabled;
document.getElementById("clearscreenbutton").disabled = disabled;
document.getElementById("sendimgbutton").disabled = disabled;
document.getElementById("setDriverbutton").disabled = disabled;
const countdownModeButton = document.getElementById("countdownmodebutton");
if (countdownModeButton) countdownModeButton.disabled = disabled;
}
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);
const epddriver = document.getElementById("epddriver");
const driverValue = data.length > 7 ? bytes2hex(data.slice(7, 8)) : '';
const isConfig = data.length >= 8 && Array.from(epddriver.options).some((option) => option.value === driverValue);
if (isConfig) {
addLog(`收到配置:${bytes2hex(data)}`);
const epdpins = document.getElementById("epdpins");
epdpins.value = bytes2hex(data.slice(0, 7));
if (data.length > 10) epdpins.value += bytes2hex(data.slice(10, 11));
epddriver.value = driverValue;
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)}`);
document.getElementById('mtusize').value = 247;
document.getElementById('interleavedcount').value = 0;
} 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);
}
if (await writeCurrentTime(0xFF)) {
addLog("BLE 连接后已自动校时。");
}
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,
weight: sanitizeCountdownWeight(item.querySelector('.countdown-grid-weight').value)
}));
if (countdownState.grid.items.length === 0) {
countdownState.grid.items.push({ name: '未命名', date: getTodayISODate(), important: false, weight: 1 });
}
}
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);
});
renderCountdownRegionOverlay();
}
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.draggable = true;
row.dataset.countdownIndex = String(index);
row.innerHTML = `
<button type="button" class="countdown-grid-drag-handle" title="拖动排序" aria-label="拖动排序">☰</button>
<input type="text" class="countdown-grid-name" value="${escapeHtml(item.name)}">
<input type="date" class="countdown-grid-date" value="${escapeHtml(item.date)}">
<label>权重 <input type="number" class="countdown-grid-weight" min="${minimumCountdownWeight}" step="1" value="${sanitizeCountdownWeight(item.weight)}"></label>
<label><input type="checkbox" class="countdown-grid-important" ${item.important ? 'checked' : ''}> 重要</label>
<button type="button" class="secondary countdown-grid-remove">删除</button>
`;
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, weight: 1 });
}
renderCountdownGridItems();
saveCountdownState();
});
container.appendChild(row);
});
}
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';
document.getElementById('countdown-grid-panel').style.display = mode === 'grid' ? 'block' : 'none';
}
function refreshCountdownTemplateUI() {
syncCountdownStateToForm();
renderCountdownGridItems();
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() || '一次做好一件事';
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 = `
<input type="text" class="todo-item-text" value="${escapeHtml(item.text)}">
<label><input type="checkbox" class="todo-item-done" ${item.done ? 'checked' : ''}> 完成</label>
<label><input type="checkbox" class="todo-item-important" ${item.important ? 'checked' : ''}> 重要</label>
<button type="button" class="secondary todo-item-remove">删除</button>
`;
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,
countdownRegions: serializeCountdownRegions()
};
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);
const importedRegions = rawData && typeof rawData === 'object'
? (rawData.countdownRegions || rawData.regions || (importedState && importedState.regions))
: null;
countdownManualRegions = normalizeCountdownRegions(importedRegions);
countdownDateRegion = normalizeCountdownDateRegion(importedRegions);
if (!countdownState.single.date) countdownState.single.date = getTodayISODate();
saveCountdownState();
refreshCountdownTemplateUI();
renderCountdownRegions(countdownManualRegions || autoTemplateRegions());
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);
const importedRegions = rawData && typeof rawData === 'object'
? (rawData.countdownRegions || rawData.regions || (importedState && importedState.regions))
: null;
countdownManualRegions = normalizeCountdownRegions(importedRegions);
countdownDateRegion = normalizeCountdownDateRegion(importedRegions);
if (!countdownState.single.date) countdownState.single.date = getTodayISODate();
saveCountdownState();
refreshCountdownTemplateUI();
renderCountdownRegions(countdownManualRegions || autoTemplateRegions());
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)
.map((item) => ({
...item,
weight: sanitizeCountdownWeight(item.weight)
}))
.filter((item) => item.weight > 0);
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;
const cardLayouts = buildSequentialCountdownLayout(items, 12 * scaleX, contentTop, canvas.width - 24 * scaleX, contentHeight, 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, 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 + width - 8 * scaleX, y + 14 * scaleY);
}
const namePreferredSize = Math.min(height * 0.24, 24 * scale * cardAreaRatio * densityBoost);
const nameMinSize = Math.max(11, 12 * scale);
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 + width / 2, y + height * 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(height * 0.56, 56 * scale * cardAreaRatio * densityBoost);
const numberMinSize = Math.max(16, 18 * scale);
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 + height * 0.68;
if (unitText) {
const numberWidth = ctx.measureText(valueText).width;
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 + width / 2 - 8 * scaleX + numberWidth / 2 + 4 * scaleX, centerY + 6 * scaleY);
} else {
ctx.fillText(valueText, x + width / 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();
renderCountdownRegions(countdownManualRegions || autoTemplateRegions());
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, weight: 1 });
renderCountdownGridItems();
saveCountdownState();
});
document.getElementById('countdown-grid-items').addEventListener('input', () => {
syncCountdownFormToState();
saveCountdownState();
});
document.getElementById('apply-countdown-template').addEventListener('click', () => {
applyCountdownTemplate();
countdownDateRegion = null;
renderCountdownRegions(autoTemplateRegions());
saveCountdownState();
});
document.getElementById('countdown-regions-refresh').addEventListener('click', () => {
applyCountdownTemplate();
countdownDateRegion = null;
renderCountdownRegions(autoTemplateRegions());
saveCountdownState();
});
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();
renderCountdownRegionOverlay();
}
function updateDitcherOptions() {
const epdDriverSelect = document.getElementById('epddriver');
const selectedOption = epdDriverSelect.options[epdDriverSelect.selectedIndex];
if (!selectedOption) {
addLog(`无效驱动: ${epdDriverSelect.value || '(空)'}`);
return;
}
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() {
loadCalendarMarks();
renderCalendarMarks();
const addCalendarMarkButton = document.getElementById('calendar-mark-add');
if (addCalendarMarkButton) addCalendarMarkButton.addEventListener('click', addCalendarMark);
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();
window.addEventListener('resize', renderCountdownRegionOverlay);
updateButtonStatus();
checkDebugMode();
}