2102 lines
74 KiB
JavaScript
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('&', '&')
|
|
.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 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();
|
|
}
|