diff --git a/gen-esp/css/main.css b/gen-esp/css/main.css new file mode 100644 index 0000000..7ac291a --- /dev/null +++ b/gen-esp/css/main.css @@ -0,0 +1,900 @@ +:root { + --primary-color: #0f62fe; + --primary-hover: #3b82ff; + --secondary-color: #3b485f; + --secondary-hover: #52627d; + --accent-color: #22d3ee; + --accent-soft: rgba(34, 211, 238, 0.18); + --surface-color: rgba(255, 255, 255, 0.8); + --surface-strong: rgba(255, 255, 255, 0.92); + --border-color: rgba(109, 128, 162, 0.22); + --text-color: #10203a; + --muted-text: #5e6c84; + --shadow-soft: 0 20px 45px rgba(15, 35, 95, 0.12); + --shadow-strong: 0 18px 50px rgba(15, 98, 254, 0.18); + --page-glow-1: rgba(15, 98, 254, 0.2); + --page-glow-2: rgba(34, 211, 238, 0.18); + + --dark-bg: #121212; + --dark-text: #e0e0e0; + --dark-fieldset-bg: #1e1e1e; + --dark-border: #333; + --dark-code-bg: #2d2d2d; + --dark-log-bg: #2a2a2a; + --dark-input-bg: #2d2d2d; + --dark-input-text: #e0e0e0; +} + +body { + margin: 0; + padding: 0; + font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: var(--text-color); + background: + radial-gradient(circle at top left, var(--page-glow-1), transparent 32%), + radial-gradient(circle at top right, var(--page-glow-2), transparent 28%), + linear-gradient(135deg, #eef5ff 0%, #f6fbff 45%, #f8f9fd 100%); + min-height: 100vh; + position: relative; +} + +body::before, +body::after { + content: ""; + position: fixed; + inset: auto; + width: 42vw; + height: 42vw; + border-radius: 50%; + pointer-events: none; + filter: blur(60px); + opacity: 0.45; + z-index: 0; +} + +body::before { + top: -12vw; + right: -10vw; + background: rgba(15, 98, 254, 0.12); +} + +body::after { + bottom: -14vw; + left: -8vw; + background: rgba(34, 211, 238, 0.1); +} + +button { + padding: 0.5rem 0.95rem; + border: 1px solid transparent; + border-radius: 8px; + margin-bottom: 5px; + white-space: nowrap; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.01em; + transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + box-shadow: 0 10px 20px rgba(16, 32, 58, 0.08); + backdrop-filter: blur(8px); +} + +button:hover { + transform: translateY(-1px); +} + +button:disabled { + opacity: 0.65; + cursor: not-allowed; + box-shadow: none; +} + +button.primary { + color: #fff; + background: linear-gradient(135deg, var(--primary-color), #2388ff); + box-shadow: 0 12px 24px rgba(15, 98, 254, 0.28); +} + +button.primary:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.3); + background: linear-gradient(135deg, var(--primary-hover), #45c3ff); + box-shadow: 0 16px 30px rgba(15, 98, 254, 0.34); +} + +button.secondary { + color: var(--text-color); + background: rgba(255, 255, 255, 0.72); + border-color: rgba(109, 128, 162, 0.22); +} + +button.secondary:hover { + color: var(--text-color); + border-color: rgba(59, 72, 95, 0.24); + background: rgba(255, 255, 255, 0.95); +} + +h3 { + padding: 1.6rem 0 1rem; + margin: 0 0 1.25rem; + border-bottom: 1px solid rgba(131, 149, 186, 0.26); + text-align: center; + font-size: clamp(1.5rem, 2vw, 2rem); + font-weight: 800; + letter-spacing: 0.04em; + color: #0f1f44; + text-shadow: 0 10px 25px rgba(15, 98, 254, 0.12); +} + +fieldset { + border: none; + box-shadow: var(--shadow-soft); + background: linear-gradient(180deg, var(--surface-strong), var(--surface-color)); + padding: 16px 16px 12px; + margin-bottom: 18px; + border-radius: 10px; + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; +} + +fieldset::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(34, 211, 238, 0.65), transparent); + opacity: 0.85; +} + +fieldset legend { + font-weight: bold; + color: #1862d9; + padding: 0 10px; + font-size: 0.95rem; + letter-spacing: 0.08em; +} + +code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background: #ccc; + border-radius: 3px; +} + +input[type=text], +textarea, +input[type=date], +input[type=number], +select { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--text-color); + border: 1px solid rgba(121, 140, 176, 0.26); + border-radius: 6px; + padding: .45rem .85rem; + max-width: 100%; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.86); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +input[type=file] { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--text-color); + max-width: 100%; +} + +input::file-selector-button { + font-size: 0.9rem; + font-weight: 600; + line-height: 1.5; + border: 1px solid rgba(121, 140, 176, 0.26); + border-radius: 8px; + cursor: pointer; + padding: 0.45rem 0.95rem; + margin-right: 10px; + color: var(--text-color); + background: rgba(255, 255, 255, 0.9); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +input::file-selector-button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(16, 32, 58, 0.08); +} + +select { + padding: .45rem 2.25rem .45rem .85rem; +} + +input:focus, +textarea:focus, +select:focus { + border: 1px solid rgba(15, 98, 254, 0.55); + box-shadow: 0 0 0 4px rgba(15, 98, 254, 0.14); + outline: 0; + background: #fff; +} + +input[type=text]:disabled, +textarea:disabled, +input[type=date]:disabled, +input[type=number]:disabled, +select:disabled { + opacity: 0.65; + cursor: not-allowed; + background-color: #e9ecef; + color: #6c757d; +} + +label { + margin-right: 4px; + white-space: nowrap; + color: var(--muted-text); + font-weight: 600; +} + +.main { + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0 1rem 1.5rem; + background: transparent; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + box-sizing: border-box; + position: relative; + z-index: 1; +} + +.footer { + display: flex; + gap: 10px; + font-size: 0.8rem; + color: var(--muted-text); + flex-wrap: wrap; + margin: 1rem 0 2rem; + justify-content: center; +} + +.footer .links { + display: flex; + align-items: center; +} + +.footer .links a { + color: var(--muted-text); + text-decoration: none; + position: relative; + padding: 0 8px; +} + +.footer .links a:first-child { + padding-left: 0; +} + +.footer .links a:not(:last-child)::after { + content: "•"; + position: absolute; + right: -4px; + color: #999; +} + +.footer a:hover { + color: var(--primary-color); + text-decoration: underline; +} + +.flex-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.flex-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.flex-group.right { + margin-left: auto; +} + +.debug { + display: none !important; +} + +.log-container { + width: 100%; + min-height: 100px; + max-height: 300px; + margin: 0; + padding: 10px 12px; + background: linear-gradient(180deg, rgba(14, 26, 48, 0.92), rgba(20, 36, 63, 0.96)); + color: #d9ecff; + overflow-y: auto; + overflow-x: hidden; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + box-sizing: border-box; + word-break: break-word; + border-radius: 16px; + border: 1px solid rgba(77, 102, 150, 0.4); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.log-container .log-line { + padding: 2px 0; +} + +.log-container .time, +.log-container .action { + display: inline-block; + white-space: nowrap; +} + +.log-container .time { + color: #7bdcff; + margin-right: 0.5em; +} + +.log-container .action { + color: #95aecd; + margin-right: 0.5em; +} + +.template-panel { + border: 1px solid rgba(121, 140, 176, 0.22); + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + padding: 12px; + margin-bottom: 10px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55); +} + +.panel-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.panel-tab { + color: var(--muted-text); + background: rgba(255, 255, 255, 0.7); + border-color: rgba(121, 140, 176, 0.18); +} + +.panel-tab:hover { + background: rgba(255, 255, 255, 0.96); + border-color: rgba(121, 140, 176, 0.26); +} + +.panel-tab.active { + color: #fff; + background: linear-gradient(135deg, var(--primary-color), #1ec8ff); + border-color: transparent; + box-shadow: 0 12px 24px rgba(15, 98, 254, 0.2); +} + +.panel-tab-content { + display: none; +} + +.panel-tab-content.active { + display: block; +} + +.template-header { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; + margin-bottom: 10px; +} + +.template-header span { + color: var(--muted-text); + font-size: 0.9rem; +} + +.template-mode-panel { + margin-top: 10px; +} + +.template-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.template-item { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 8px; + border: 1px solid rgba(121, 140, 176, 0.18); + border-radius: 6px; + background: rgba(248, 251, 255, 0.86); +} + +.template-item.dragging { + opacity: 0.55; +} + +.template-item.drag-over { + border-color: rgba(15, 98, 254, 0.5); + box-shadow: inset 0 0 0 1px rgba(15, 98, 254, 0.18); +} + +.countdown-grid-drag-handle { + width: 34px; + height: 34px; + border: 1px dashed rgba(121, 140, 176, 0.4); + border-radius: 6px; + background: rgba(255, 255, 255, 0.92); + color: var(--muted-text); + cursor: grab; + flex: 0 0 auto; +} + +.countdown-grid-drag-handle:active { + cursor: grabbing; +} + +.template-item input[type="text"] { + min-width: 120px; + flex: 1 1 160px; +} + +.template-item input[type="date"] { + min-width: 150px; +} + +.template-item input[type="number"] { + width: 72px; +} + +.template-item label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.canvas-container canvas { + border: 1px solid rgba(37, 61, 104, 0.24); + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(245, 249, 255, 0.98)); + border-radius: 0; + box-shadow: + 0 25px 60px rgba(15, 35, 95, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.canvas-frame { + position: relative; + width: fit-content; + max-width: 100%; + margin: 0 auto; +} + +.canvas-frame canvas { + margin: 0; +} + +.region-overlay { + position: absolute; + inset: 0; + display: none; + pointer-events: none; +} + +.region-overlay.active { + display: block; +} + +.region-box { + position: absolute; + box-sizing: border-box; + border: 2px solid #e53935; + background: rgba(229, 57, 53, 0.12); + cursor: move; + pointer-events: auto; + touch-action: none; +} + +.region-box::after { + content: ""; + position: absolute; + right: -6px; + bottom: -6px; + width: 12px; + height: 12px; + border: 2px solid #fff; + background: #e53935; + box-shadow: 0 2px 8px rgba(30, 41, 59, 0.28); + cursor: nwse-resize; +} + +.region-label { + position: absolute; + left: -2px; + top: -22px; + min-width: 22px; + padding: 2px 6px; + border-radius: 4px 4px 0 0; + background: #e53935; + color: #fff; + font-size: 12px; + line-height: 16px; + text-align: center; + pointer-events: none; +} + +.canvas-container.crop-mode canvas { + border: 2px dashed var(--primary-color); + cursor: grab; +} + +.status-bar { + display: none; + font-size: 85%; + color: var(--muted-text); + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dotted rgba(121, 140, 176, 0.45); +} + +canvas.text-placement-mode { + border: 2px dashed var(--primary-color) !important; + cursor: text !important; +} + +.canvas-title { + display: none; + text-align: center; + margin-bottom: 5px; + color: var(--primary-color); +} + +.canvas-container { + padding: 18px; + border-radius: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(242, 248, 255, 0.84)); + border: 1px solid rgba(121, 140, 176, 0.18); + box-shadow: var(--shadow-soft); +} + +.canvas-tools { + margin-top: 10px; + justify-content: center; +} + +.brush-tools, +.text-tools, +.crop-tools { + display: none; +} + +.markdown-text-input-group { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 240px; + flex: 1 1 280px; +} + +#text-input { + width: 100%; + min-height: 110px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; +} + +.markdown-text-hint { + color: var(--muted-text); + font-size: 0.82rem; + line-height: 1.4; +} + +.canvas-container.brush-mode .brush-tools, +.canvas-container.text-mode .brush-tools, +.canvas-container.eraser-mode .brush-tools, +.canvas-container.text-mode .text-tools, +.canvas-container.crop-mode .crop-tools { + display: flex; +} + +.canvas-container.crop-mode .tool-buttons, +.canvas-container.crop-mode .brush-tools, +.canvas-container.crop-mode .text-tools { + display: none; +} + +.tool-button { + width: 36px; + height: 36px; + font-size: 1.2rem; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 5px; + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(121, 140, 176, 0.22); + border-radius: 4px; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 8px 18px rgba(16, 32, 58, 0.08); +} + +.tool-button:hover { + background: #fff; + border-color: rgba(121, 140, 176, 0.3); +} + +.tool-button.active { + background: linear-gradient(135deg, var(--primary-color), #1ec8ff); + color: white; + border-color: transparent; + box-shadow: 0 12px 24px rgba(15, 98, 254, 0.24); +} + +.tool-button.hide { + display: none; +} + +@media (max-width: 768px) { + .flex-container { + flex-direction: column; + } + + .panel-tabs { + flex-direction: row; + } + + .panel-tab { + flex: 1 1 0; + text-align: center; + } + + .flex-container.options .flex-group label { + min-width: 80px; + } + + .flex-group.right { + margin-left: 0; + } + + .canvas-tools.flex-container { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + } + + .canvas-tools .flex-group { + justify-content: center; + width: 100%; + } + + .log-container { + height: 150px; + margin-top: 10px; + } + + fieldset { + padding: 8px; + } + + button { + width: auto; + } + + input[type=text], + textarea, + input[type=number], + select { + max-width: 100%; + margin-bottom: 5px; + } +} + +body.dark-mode .debug { + display: flex !important; +} + +body.dark-mode, +body.dark-mode .main { + background-color: var(--dark-bg); + color: var(--dark-text); +} + +body.dark-mode { + background: + radial-gradient(circle at top left, rgba(15, 98, 254, 0.16), transparent 32%), + radial-gradient(circle at bottom right, rgba(34, 211, 238, 0.14), transparent 30%), + linear-gradient(135deg, #0b1220 0%, #10192d 50%, #0c1220 100%); +} + +body.dark-mode fieldset { + background-color: var(--dark-fieldset-bg); + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.5); + border: 1px solid rgba(77, 102, 150, 0.24); +} + +body.dark-mode h3 { + border-bottom: 1px solid var(--dark-border); + color: var(--dark-text); + text-shadow: 0 12px 30px rgba(34, 211, 238, 0.12); +} + +body.dark-mode code { + background: var(--dark-code-bg); + color: #ff9800; +} + +body.dark-mode input[type=text], +body.dark-mode textarea, +body.dark-mode input[type=date], +body.dark-mode input[type=number], +body.dark-mode select { + background-color: var(--dark-input-bg); + color: var(--dark-input-text); + border-color: var(--dark-border); +} + +body.dark-mode input[type=text]:disabled, +body.dark-mode textarea:disabled, +body.dark-mode input[type=date]:disabled, +body.dark-mode input[type=number]:disabled, +body.dark-mode select:disabled { + background-color: #1a1a1a; + color: #666; + border-color: #2a2a2a; +} + +body.dark-mode input[type=file] { + color: var(--dark-input-text); + background-color: transparent; + border-color: var(--dark-border); +} + +body.dark-mode input[type=file]::file-selector-button { + background-color: var(--dark-fieldset-bg); + color: var(--dark-input-text); + border-color: var(--dark-border); +} + +body.dark-mode input[type=file]::file-selector-button:hover { + background-color: #333; + border-color: #444; +} + +body.dark-mode .log-container { + background: var(--dark-log-bg); + border: 1px solid var(--dark-border); +} + +body.dark-mode .log-container .time { + color: #8bc34a; +} + +body.dark-mode .log-container .action { + color: #03a9f4; +} + +body.dark-mode fieldset legend { + color: #64b5f6; +} + +body.dark-mode .footer .links a:not(:last-child)::after { + color: #666; +} + +body.dark-mode .footer { + color: #999; +} + +body.dark-mode .footer a { + color: #999; +} + +body.dark-mode .footer a:hover { + color: #64b5f6; +} + +body.dark-mode .tool-button { + background-color: var(--dark-input-bg); + border-color: var(--dark-border); + color: var(--dark-text); +} + +body.dark-mode .tool-button:hover { + background-color: #3a3a3a; + border-color: #444; +} + +body.dark-mode .tool-button.active { + background-color: var(--primary-color); + color: white; + border-color: var(--primary-hover); +} + +body.dark-mode .template-panel, +body.dark-mode .template-item { + background-color: var(--dark-fieldset-bg); + border-color: var(--dark-border); +} + +body.dark-mode .canvas-container { + background: linear-gradient(180deg, rgba(23, 31, 49, 0.92), rgba(17, 24, 39, 0.95)); + border-color: rgba(77, 102, 150, 0.24); +} + +body.dark-mode .canvas-container canvas { + background: #f8fbff; +} + +body.dark-mode .panel-tab { + color: var(--dark-text); + background-color: #2a2a2a; + border-color: var(--dark-border); +} + +body.dark-mode .panel-tab:hover { + background-color: #333; + border-color: #444; +} + +body.dark-mode .panel-tab.active { + background-color: var(--primary-color); + border-color: var(--primary-hover); + color: #fff; +} + +body.dark-mode .template-header span { + color: #aaa; +} + +@media (prefers-reduced-motion: no-preference) { + fieldset, + .canvas-container { + animation: floatIn 0.6s ease both; + } + + fieldset:nth-of-type(2) { + animation-delay: 0.06s; + } + + fieldset:nth-of-type(3) { + animation-delay: 0.12s; + } +} + +@keyframes floatIn { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/gen-esp/favicon.png b/gen-esp/favicon.png new file mode 100644 index 0000000..57da876 Binary files /dev/null and b/gen-esp/favicon.png differ diff --git a/gen-esp/index.html b/gen-esp/index.html new file mode 100644 index 0000000..13d85cb --- /dev/null +++ b/gen-esp/index.html @@ -0,0 +1,358 @@ + + + + + + + 墨水屏日历 + + + + + +
+

墨水屏日历

+
+ 蓝牙连接 +
+
+ + + +
+
+ + +
+
+ + + +
+
+
+
+
+ 设备控制 +
+
+ + +
+
+ + +
+
+
+
+ 重要日期 + 在月历上标注自定义日期 +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+ 倒计时模板 + 将 `countdown.html` 和 `countdown_s.html` 的核心样式渲染到当前画布 +
+
+
+ + + + +
+
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ 数字覆盖区域 + 上传自动模板前可手动微调 x/y/w/h +
+
+
+ +
+
+
+
+
+
+
+
+
+ 待办任务 + 创建简洁的待办清单并渲染到当前画布 +
+
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+
+ + + + +
+
+
状态:
+
+
+ + + + +
+
+
+
+
+ + +
+
+
+ + + + + +
+
+
+
+ + + + +
+
+
+
+ + + + +
+
+
+ +
支持标题、列表、引用、粗体、斜体、删除线和行内代码
+
+ + + +
+
+ + + + + + + +
+
+
+
+ +
+ + + + + + + + + diff --git a/gen-esp/js/crop.js b/gen-esp/js/crop.js new file mode 100644 index 0000000..dd9a4af --- /dev/null +++ b/gen-esp/js/crop.js @@ -0,0 +1,219 @@ +class CropManager { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.backgroundZoom = 1; + this.backgroundPanX = 0; + this.backgroundPanY = 0; + this.isPanning = false; + this.lastPanX = 0; + this.lastPanY = 0; + this.lastTouchDistance = 0; + + // Bind event handlers + this.handleBackgroundZoom = this.handleBackgroundZoom.bind(this); + this.handleBackgroundPanStart = this.handleBackgroundPanStart.bind(this); + this.handleBackgroundPan = this.handleBackgroundPan.bind(this); + this.handleBackgroundPanEnd = this.handleBackgroundPanEnd.bind(this); + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + } + + resetStates() { + this.backgroundZoom = 1; + this.backgroundPanX = 0; + this.backgroundPanY = 0; + this.isPanning = false; + this.lastPanX = 0; + this.lastPanY = 0; + this.lastTouchDistance = 0; + } + + isCropMode() { + return this.canvas.parentNode.classList.contains('crop-mode'); + } + + exitCropMode() { + this.canvas.parentNode.classList.remove('crop-mode'); + setCanvasTitle(""); + + this.canvas.removeEventListener('wheel', this.handleBackgroundZoom); + this.canvas.removeEventListener('mousedown', this.handleBackgroundPanStart); + this.canvas.removeEventListener('mousemove', this.handleBackgroundPan); + this.canvas.removeEventListener('mouseup', this.handleBackgroundPanEnd); + this.canvas.removeEventListener('mouseleave', this.handleBackgroundPanEnd); + this.canvas.removeEventListener('touchstart', this.handleTouchStart); + this.canvas.removeEventListener('touchmove', this.handleTouchMove); + this.canvas.removeEventListener('touchend', this.handleBackgroundPanEnd); + this.canvas.removeEventListener('touchcancel', this.handleBackgroundPanEnd); + } + + initializeCrop() { + const imageFile = document.getElementById('imageFile'); + if (imageFile.files.length == 0) { + fillCanvas('white'); + return; + } + + this.exitCropMode(); + this.resetStates(); + + this.canvas.style.backgroundImage = `url(${URL.createObjectURL(imageFile.files[0])})`; + this.canvas.style.backgroundSize = '100%'; + this.canvas.style.backgroundPosition = ''; + this.canvas.style.backgroundRepeat = 'no-repeat'; + + // add event listeners for zoom and pan + this.canvas.addEventListener('wheel', this.handleBackgroundZoom); + this.canvas.addEventListener('mousedown', this.handleBackgroundPanStart); + this.canvas.addEventListener('mousemove', this.handleBackgroundPan); + this.canvas.addEventListener('mouseup', this.handleBackgroundPanEnd); + this.canvas.addEventListener('mouseleave', this.handleBackgroundPanEnd); + + // Touch events for mobile devices + this.canvas.addEventListener('touchstart', this.handleTouchStart); + this.canvas.addEventListener('touchmove', this.handleTouchMove); + this.canvas.addEventListener('touchend', this.handleBackgroundPanEnd); + this.canvas.addEventListener('touchcancel', this.handleBackgroundPanEnd); + + // Make the canvas transparent + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + setCanvasTitle("裁剪模式: 可用鼠标滚轮或双指触摸缩放图片"); + this.canvas.parentNode.classList.add('crop-mode'); + } + + finishCrop(callback) { + const imageFile = document.getElementById('imageFile'); + if (imageFile.files.length == 0) return; + + const image = new Image(); + image.onload = () => { + URL.revokeObjectURL(image.src); + + const fieldsetRect = this.canvas.getBoundingClientRect(); + const scale = (image.width / fieldsetRect.width) / this.backgroundZoom; + + const sx = -this.backgroundPanX * scale; + const sy = -this.backgroundPanY * scale; + const sWidth = fieldsetRect.width * scale; + const sHeight = fieldsetRect.height * scale; + + fillCanvas('white'); + this.ctx.drawImage(image, sx, sy, sWidth, sHeight, 0, 0, this.canvas.width, this.canvas.height); + + this.exitCropMode(); + if (callback) callback(); + }; + image.src = URL.createObjectURL(imageFile.files[0]); + } + + handleTouchStart(e) { + e.preventDefault(); + if (e.touches.length === 1) { + this.handleBackgroundPanStart(e.touches[0]); + } else if (e.touches.length === 2) { + this.isPanning = false; // Stop panning when zooming + this.lastTouchDistance = this.getTouchDistance(e.touches); + } + } + + handleTouchMove(e) { + e.preventDefault(); + if (this.isPanning && e.touches.length === 1) { + this.handleBackgroundPan(e.touches[0]); + } else if (e.touches.length === 2) { + const newDist = this.getTouchDistance(e.touches); + if (this.lastTouchDistance > 0) { + const zoomFactor = newDist / this.lastTouchDistance; + this.backgroundZoom *= zoomFactor; + this.backgroundZoom = Math.max(0.1, Math.min(5, this.backgroundZoom)); // Limit zoom range + this.updateBackgroundTransform(); + } + this.lastTouchDistance = newDist; + } + } + + handleBackgroundZoom(e) { + e.preventDefault(); + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + this.backgroundZoom *= zoomFactor; + this.backgroundZoom = Math.max(0.1, Math.min(5, this.backgroundZoom)); // Limit zoom range + this.updateBackgroundTransform(); + } + + handleBackgroundPanStart(e) { + this.isPanning = true; + this.lastPanX = e.clientX; + this.lastPanY = e.clientY; + this.canvas.style.cursor = 'grabbing'; + } + + handleBackgroundPan(e) { + if (this.isPanning) { + const deltaX = e.clientX - this.lastPanX; + const deltaY = e.clientY - this.lastPanY; + this.backgroundPanX += deltaX; + this.backgroundPanY += deltaY; + this.lastPanX = e.clientX; + this.lastPanY = e.clientY; + this.updateBackgroundTransform(); + } + } + + handleBackgroundPanEnd() { + this.isPanning = false; + this.lastTouchDistance = 0; // Reset touch distance + this.canvas.style.cursor = 'grab'; + } + + updateBackgroundTransform() { + this.canvas.style.backgroundSize = `${100 * this.backgroundZoom}%`; + this.canvas.style.backgroundPosition = `${this.backgroundPanX}px ${this.backgroundPanY}px`; + } + + getTouchDistance(touches) { + const touch1 = touches[0]; + const touch2 = touches[1]; + return Math.sqrt( + Math.pow(touch2.clientX - touch1.clientX, 2) + + Math.pow(touch2.clientY - touch1.clientY, 2) + ); + } + + initCropTools() { + document.getElementById('crop-zoom-in').addEventListener('click', (e) => { + e.preventDefault(); + this.handleBackgroundZoom({ preventDefault: () => { }, deltaY: -1 }); + }); + + document.getElementById('crop-zoom-out').addEventListener('click', (e) => { + e.preventDefault(); + this.handleBackgroundZoom({ preventDefault: () => { }, deltaY: 1 }); + }); + + document.getElementById('crop-move-left').addEventListener('click', (e) => { + e.preventDefault(); + this.backgroundPanX -= 10; + this.updateBackgroundTransform(); + }); + + document.getElementById('crop-move-right').addEventListener('click', (e) => { + e.preventDefault(); + this.backgroundPanX += 10; + this.updateBackgroundTransform(); + }); + + document.getElementById('crop-move-up').addEventListener('click', (e) => { + e.preventDefault(); + this.backgroundPanY -= 10; + this.updateBackgroundTransform(); + }); + + document.getElementById('crop-move-down').addEventListener('click', (e) => { + e.preventDefault(); + this.backgroundPanY += 10; + this.updateBackgroundTransform(); + }); + } +} \ No newline at end of file diff --git a/gen-esp/js/dithering.js b/gen-esp/js/dithering.js new file mode 100644 index 0000000..f359700 --- /dev/null +++ b/gen-esp/js/dithering.js @@ -0,0 +1,716 @@ +// Ported from: https://e-paper-display.cn/usb2epd.html + +// 固定的六色调色板 +const rgbPalette = [ + { name: "黑色", r: 0, g: 0, b: 0, value: 0x00 }, + { name: "白色", r: 255, g: 255, b: 255, value: 0x01 }, + { name: "黄色", r: 255, g: 255, b: 0, value: 0x02 }, + { name: "红色", r: 255, g: 0, b: 0, value: 0x03 }, + { name: "蓝色", r: 0, g: 0, b: 255, value: 0x05 }, + { name: "绿色", r: 41, g: 204, b: 20, value: 0x06 } +]; + +// 四色调色板 +const fourColorPalette = [ + { name: "黑色", r: 0, g: 0, b: 0, value: 0x00 }, + { name: "白色", r: 255, g: 255, b: 255, value: 0x01 }, + { name: "红色", r: 255, g: 0, b: 0, value: 0x03 }, + { name: "黄色", r: 255, g: 255, b: 0, value: 0x02 } +]; + +// 三色调色板 +const threeColorPalette = [ + { name: "黑色", r: 0, g: 0, b: 0, value: 0x00 }, + { name: "白色", r: 255, g: 255, b: 255, value: 0x01 }, + { name: "红色", r: 255, g: 0, b: 0, value: 0x02 } +]; + +function adjustContrast(imageData, factor) { + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + data[i] = Math.min(255, Math.max(0, (data[i] - 128) * factor + 128)); + data[i + 1] = Math.min(255, Math.max(0, (data[i + 1] - 128) * factor + 128)); + data[i + 2] = Math.min(255, Math.max(0, (data[i + 2] - 128) * factor + 128)); + } + return imageData; +} + +function rgbToLab(r, g, b) { + r = r / 255; + g = g / 255; + b = b / 255; + + r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; + g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; + b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; + + r *= 100; + g *= 100; + b *= 100; + + let x = r * 0.4124 + g * 0.3576 + b * 0.1805; + let y = r * 0.2126 + g * 0.7152 + b * 0.0722; + let z = r * 0.0193 + g * 0.1192 + b * 0.9505; + + x /= 95.047; + y /= 100.0; + z /= 108.883; + + x = x > 0.008856 ? Math.pow(x, 1 / 3) : (7.787 * x) + (16 / 116); + y = y > 0.008856 ? Math.pow(y, 1 / 3) : (7.787 * y) + (16 / 116); + z = z > 0.008856 ? Math.pow(z, 1 / 3) : (7.787 * z) + (16 / 116); + + const l = (116 * y) - 16; + const a = 500 * (x - y); + const bLab = 200 * (y - z); + + return { l, a, b: bLab }; +} + +function labDistance(lab1, lab2) { + const dl = lab1.l - lab2.l; + const da = lab1.a - lab2.a; + const db = lab1.b - lab2.b; + return Math.sqrt(0.2 * dl * dl + 3 * da * da + 3 * db * db); +} + +function findClosestColor(r, g, b, mode) { + let palette; + + if (mode === 'fourColor') { + palette = fourColorPalette; + } else if (mode === 'threeColor') { + palette = threeColorPalette; + } else { + palette = rgbPalette; + } + + // 蓝色特殊情况(仅限非三色、四色模式) + if (mode !== 'fourColor' && mode !== 'threeColor' && r < 50 && g < 150 && b > 100) { + return rgbPalette[4]; // 蓝色 + } + + // 三色模式下优先检测红色 + if (mode === 'threeColor') { + // 如果红色通道显著高于绿色和蓝色,且强度足够 + if (r > 120 && r > g * 1.5 && r > b * 1.5) { + return threeColorPalette[2]; // 红色 + } + // 否则根据亮度选择黑或白 + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + return luminance < 128 ? threeColorPalette[0] : threeColorPalette[1]; // 黑色或白色 + } + + const inputLab = rgbToLab(r, g, b); + let minDistance = Infinity; + let closestColor = palette[0]; + + for (const color of palette) { + const colorLab = rgbToLab(color.r, color.g, color.b); + const distance = labDistance(inputLab, colorLab); + if (distance < minDistance) { + minDistance = distance; + closestColor = color; + } + } + + return closestColor; +} + +function floydSteinbergDither(imageData, strength, mode) { + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + const tempData = new Uint8ClampedArray(data); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = tempData[idx]; + const g = tempData[idx + 1]; + const b = tempData[idx + 2]; + + const closest = findClosestColor(r, g, b, mode); + + const errR = (r - closest.r) * strength; + const errG = (g - closest.g) * strength; + const errB = (b - closest.b) * strength; + + if (x + 1 < width) { + const idxRight = idx + 4; + tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * 7 / 16)); + tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * 7 / 16)); + tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * 7 / 16)); + } + if (y + 1 < height) { + if (x > 0) { + const idxDownLeft = idx + width * 4 - 4; + tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * 3 / 16)); + tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * 3 / 16)); + tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * 3 / 16)); + } + const idxDown = idx + width * 4; + tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * 5 / 16)); + tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * 5 / 16)); + tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * 5 / 16)); + if (x + 1 < width) { + const idxDownRight = idx + width * 4 + 4; + tempData[idxDownRight] = Math.min(255, Math.max(0, tempData[idxDownRight] + errR * 1 / 16)); + tempData[idxDownRight + 1] = Math.min(255, Math.max(0, tempData[idxDownRight + 1] + errG * 1 / 16)); + tempData[idxDownRight + 2] = Math.min(255, Math.max(0, tempData[idxDownRight + 2] + errB * 1 / 16)); + } + } + } + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = tempData[idx]; + const g = tempData[idx + 1]; + const b = tempData[idx + 2]; + + const closest = findClosestColor(r, g, b, mode); + data[idx] = closest.r; + data[idx + 1] = closest.g; + data[idx + 2] = closest.b; + } + } + + return imageData; +} + +function atkinsonDither(imageData, strength, mode) { + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + const tempData = new Uint8ClampedArray(data); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = tempData[idx]; + const g = tempData[idx + 1]; + const b = tempData[idx + 2]; + + const closest = findClosestColor(r, g, b, mode); + + data[idx] = closest.r; + data[idx + 1] = closest.g; + data[idx + 2] = closest.b; + + const errR = (r - closest.r) * strength; + const errG = (g - closest.g) * strength; + const errB = (b - closest.b) * strength; + + const fraction = 1 / 8; + + if (x + 1 < width) { + const idxRight = idx + 4; + tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * fraction)); + tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * fraction)); + tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * fraction)); + } + if (x + 2 < width) { + const idxRight2 = idx + 8; + tempData[idxRight2] = Math.min(255, Math.max(0, tempData[idxRight2] + errR * fraction)); + tempData[idxRight2 + 1] = Math.min(255, Math.max(0, tempData[idxRight2 + 1] + errG * fraction)); + tempData[idxRight2 + 2] = Math.min(255, Math.max(0, tempData[idxRight2 + 2] + errB * fraction)); + } + if (y + 1 < height) { + if (x > 0) { + const idxDownLeft = idx + width * 4 - 4; + tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * fraction)); + tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * fraction)); + tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * fraction)); + } + const idxDown = idx + width * 4; + tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * fraction)); + tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * fraction)); + tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * fraction)); + if (x + 1 < width) { + const idxDownRight = idx + width * 4 + 4; + tempData[idxDownRight] = Math.min(255, Math.max(0, tempData[idxDownRight] + errR * fraction)); + tempData[idxDownRight + 1] = Math.min(255, Math.max(0, tempData[idxDownRight + 1] + errG * fraction)); + tempData[idxDownRight + 2] = Math.min(255, Math.max(0, tempData[idxDownRight + 2] + errB * fraction)); + } + } + if (y + 2 < height) { + const idxDown2 = idx + width * 8; + tempData[idxDown2] = Math.min(255, Math.max(0, tempData[idxDown2] + errR * fraction)); + tempData[idxDown2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2 + 1] + errG * fraction)); + tempData[idxDown2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2 + 2] + errB * fraction)); + } + } + } + + return imageData; +} + +function stuckiDither(imageData, strength, mode) { + // 执行Stucki错误扩散算法以处理图像 + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + const tempData = new Uint8ClampedArray(data); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = tempData[idx]; + const g = tempData[idx + 1]; + const b = tempData[idx + 2]; + + const closest = findClosestColor(r, g, b, mode); + + const errR = (r - closest.r) * strength; + const errG = (g - closest.g) * strength; + const errB = (b - closest.b) * strength; + + const divisor = 42; + + if (x + 1 < width) { + const idxRight = idx + 4; + tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * 8 / divisor)); + tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * 8 / divisor)); + tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * 8 / divisor)); + } + if (x + 2 < width) { + const idxRight2 = idx + 8; + tempData[idxRight2] = Math.min(255, Math.max(0, tempData[idxRight2] + errR * 4 / divisor)); + tempData[idxRight2 + 1] = Math.min(255, Math.max(0, tempData[idxRight2 + 1] + errG * 4 / divisor)); + tempData[idxRight2 + 2] = Math.min(255, Math.max(0, tempData[idxRight2 + 2] + errB * 4 / divisor)); + } + if (y + 1 < height) { + if (x > 1) { + const idxDownLeft2 = idx + width * 4 - 8; + tempData[idxDownLeft2] = Math.min(255, Math.max(0, tempData[idxDownLeft2] + errR * 2 / divisor)); + tempData[idxDownLeft2 + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 1] + errG * 2 / divisor)); + tempData[idxDownLeft2 + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 2] + errB * 2 / divisor)); + } + if (x > 0) { + const idxDownLeft = idx + width * 4 - 4; + tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * 4 / divisor)); + tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * 4 / divisor)); + tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * 4 / divisor)); + } + const idxDown = idx + width * 4; + tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * 8 / divisor)); + tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * 8 / divisor)); + tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * 8 / divisor)); + if (x + 1 < width) { + const idxDownRight1 = idx + width * 4 + 4; + tempData[idxDownRight1] = Math.min(255, Math.max(0, tempData[idxDownRight1] + errR * 4 / divisor)); + tempData[idxDownRight1 + 1] = Math.min(255, Math.max(0, tempData[idxDownRight1 + 1] + errG * 4 / divisor)); + tempData[idxDownRight1 + 2] = Math.min(255, Math.max(0, tempData[idxDownRight1 + 2] + errB * 4 / divisor)); + } + if (x + 2 < width) { + const idxDownRight2 = idx + width * 4 + 8; + tempData[idxDownRight2] = Math.min(255, Math.max(0, tempData[idxDownRight2] + errR * 2 / divisor)); + tempData[idxDownRight2 + 1] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 1] + errG * 2 / divisor)); + tempData[idxDownRight2 + 2] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 2] + errB * 2 / divisor)); + } + } + if (y + 2 < height) { + if (x > 1) { + const idxDown2Left2 = idx + width * 8 - 8; + tempData[idxDown2Left2] = Math.min(255, Math.max(0, tempData[idxDown2Left2] + errR * 1 / divisor)); + tempData[idxDown2Left2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 1] + errG * 1 / divisor)); + tempData[idxDown2Left2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 2] + errB * 1 / divisor)); + } + if (x > 0) { + const idxDown2Left = idx + width * 8 - 4; + tempData[idxDown2Left] = Math.min(255, Math.max(0, tempData[idxDown2Left] + errR * 2 / divisor)); + tempData[idxDown2Left + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left + 1] + errG * 2 / divisor)); + tempData[idxDown2Left + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left + 2] + errB * 2 / divisor)); + } + const idxDown2 = idx + width * 8; + tempData[idxDown2] = Math.min(255, Math.max(0, tempData[idxDown2] + errR * 4 / divisor)); + tempData[idxDown2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2 + 1] + errG * 4 / divisor)); + tempData[idxDown2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2 + 2] + errB * 4 / divisor)); + if (x + 1 < width) { + const idxDown2Right = idx + width * 8 + 4; + tempData[idxDown2Right] = Math.min(255, Math.max(0, tempData[idxDown2Right] + errR * 2 / divisor)); + tempData[idxDown2Right + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right + 1] + errG * 2 / divisor)); + tempData[idxDown2Right + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right + 2] + errB * 2 / divisor)); + } + if (x + 2 < width) { + const idxDown2Right2 = idx + width * 8 + 8; + tempData[idxDown2Right2] = Math.min(255, Math.max(0, tempData[idxDown2Right2] + errR * 1 / divisor)); + tempData[idxDown2Right2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 1] + errG * 1 / divisor)); + tempData[idxDown2Right2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 2] + errB * 1 / divisor)); + } + } + } + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = tempData[idx]; + const g = tempData[idx + 1]; + const b = tempData[idx + 2]; + + const closest = findClosestColor(r, g, b, mode); + data[idx] = closest.r; + data[idx + 1] = closest.g; + data[idx + 2] = closest.b; + } + } + + return imageData; +} + +function jarvisDither(imageData, strength, mode) { + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + const tempData = new Uint8ClampedArray(data); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = tempData[idx]; + const g = tempData[idx + 1]; + const b = tempData[idx + 2]; + + const closest = findClosestColor(r, g, b, mode); + + data[idx] = closest.r; + data[idx + 1] = closest.g; + data[idx + 2] = closest.b; + + const errR = (r - closest.r) * strength; + const errG = (g - closest.g) * strength; + const errB = (b - closest.b) * strength; + + const divisor = 48; + + if (x + 1 < width) { + const idxRight = idx + 4; + tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * 7 / divisor)); + tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * 7 / divisor)); + tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * 7 / divisor)); + } + if (x + 2 < width) { + const idxRight2 = idx + 8; + tempData[idxRight2] = Math.min(255, Math.max(0, tempData[idxRight2] + errR * 5 / divisor)); + tempData[idxRight2 + 1] = Math.min(255, Math.max(0, tempData[idxRight2 + 1] + errG * 5 / divisor)); + tempData[idxRight2 + 2] = Math.min(255, Math.max(0, tempData[idxRight2 + 2] + errB * 5 / divisor)); + } + if (y + 1 < height) { + if (x > 1) { + const idxDownLeft2 = idx + width * 4 - 8; + tempData[idxDownLeft2] = Math.min(255, Math.max(0, tempData[idxDownLeft2] + errR * 3 / divisor)); + tempData[idxDownLeft2 + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 1] + errG * 3 / divisor)); + tempData[idxDownLeft2 + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 2] + errB * 3 / divisor)); + } + if (x > 0) { + const idxDownLeft = idx + width * 4 - 4; + tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * 5 / divisor)); + tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * 5 / divisor)); + tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * 5 / divisor)); + } + const idxDown = idx + width * 4; + tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * 7 / divisor)); + tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * 7 / divisor)); + tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * 7 / divisor)); + if (x + 1 < width) { + const idxDownRight = idx + width * 4 + 4; + tempData[idxDownRight] = Math.min(255, Math.max(0, tempData[idxDownRight] + errR * 5 / divisor)); + tempData[idxDownRight + 1] = Math.min(255, Math.max(0, tempData[idxDownRight + 1] + errG * 5 / divisor)); + tempData[idxDownRight + 2] = Math.min(255, Math.max(0, tempData[idxDownRight + 2] + errB * 5 / divisor)); + } + if (x + 2 < width) { + const idxDownRight2 = idx + width * 4 + 8; + tempData[idxDownRight2] = Math.min(255, Math.max(0, tempData[idxDownRight2] + errR * 3 / divisor)); + tempData[idxDownRight2 + 1] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 1] + errG * 3 / divisor)); + tempData[idxDownRight2 + 2] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 2] + errB * 3 / divisor)); + } + } + if (y + 2 < height) { + if (x > 1) { + const idxDown2Left2 = idx + width * 8 - 8; + tempData[idxDown2Left2] = Math.min(255, Math.max(0, tempData[idxDown2Left2] + errR * 1 / divisor)); + tempData[idxDown2Left2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 1] + errG * 1 / divisor)); + tempData[idxDown2Left2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 2] + errB * 1 / divisor)); + } + if (x > 0) { + const idxDown2Left = idx + width * 8 - 4; + tempData[idxDown2Left] = Math.min(255, Math.max(0, tempData[idxDown2Left] + errR * 3 / divisor)); + tempData[idxDown2Left + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left + 1] + errG * 3 / divisor)); + tempData[idxDown2Left + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left + 2] + errB * 3 / divisor)); + } + const idxDown2 = idx + width * 8; + tempData[idxDown2] = Math.min(255, Math.max(0, tempData[idxDown2] + errR * 5 / divisor)); + tempData[idxDown2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2 + 1] + errG * 5 / divisor)); + tempData[idxDown2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2 + 2] + errB * 5 / divisor)); + if (x + 1 < width) { + const idxDown2Right = idx + width * 8 + 4; + tempData[idxDown2Right] = Math.min(255, Math.max(0, tempData[idxDown2Right] + errR * 3 / divisor)); + tempData[idxDown2Right + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right + 1] + errG * 3 / divisor)); + tempData[idxDown2Right + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right + 2] + errB * 3 / divisor)); + } + if (x + 2 < width) { + const idxDown2Right2 = idx + width * 8 + 8; + tempData[idxDown2Right2] = Math.min(255, Math.max(0, tempData[idxDown2Right2] + errR * 1 / divisor)); + tempData[idxDown2Right2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 1] + errG * 1 / divisor)); + tempData[idxDown2Right2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 2] + errB * 1 / divisor)); + } + } + } + } + + return imageData; +} + +function bayerDither(imageData, strength, mode) { + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + + // 8x8 Bayer matrix (normalized to 0-1 range) + const bayerMatrix = [ + [0, 32, 8, 40, 2, 34, 10, 42], + [48, 16, 56, 24, 50, 18, 58, 26], + [12, 44, 4, 36, 14, 46, 6, 38], + [60, 28, 52, 20, 62, 30, 54, 22], + [3, 35, 11, 43, 1, 33, 9, 41], + [51, 19, 59, 27, 49, 17, 57, 25], + [15, 47, 7, 39, 13, 45, 5, 37], + [63, 31, 55, 23, 61, 29, 53, 21] + ]; + + const matrixSize = 8; + const maxThreshold = 64; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = data[idx]; + const g = data[idx + 1]; + const b = data[idx + 2]; + + // Get threshold from Bayer matrix + const matrixX = x % matrixSize; + const matrixY = y % matrixSize; + const threshold = (bayerMatrix[matrixY][matrixX] / maxThreshold) * 255; + + // Apply dithering with strength factor + const adjustedR = r + (threshold - 127.5) * strength; + const adjustedG = g + (threshold - 127.5) * strength; + const adjustedB = b + (threshold - 127.5) * strength; + + // Clamp values + const clampedR = Math.min(255, Math.max(0, adjustedR)); + const clampedG = Math.min(255, Math.max(0, adjustedG)); + const clampedB = Math.min(255, Math.max(0, adjustedB)); + + // Find closest color in palette + const closest = findClosestColor(clampedR, clampedG, clampedB, mode); + + data[idx] = closest.r; + data[idx + 1] = closest.g; + data[idx + 2] = closest.b; + } + } + + return imageData; +} + +function ditherImage(imageData, alg, strength, mode) { + switch (alg) { + case 'floydSteinberg': + return floydSteinbergDither(imageData, strength, mode); + case 'atkinson': + return atkinsonDither(imageData, strength, mode); + case 'stucki': + return stuckiDither(imageData, strength, mode); + case 'jarvis': + return jarvisDither(imageData, strength, mode); + case 'bayer': + return bayerDither(imageData, strength, mode); + case 'none': + default: + return imageData; + } +} + +function decodeProcessedData(processedData, width, height, mode) { + const imageData = new ImageData(width, height); + const data = imageData.data; + + if (mode === 'sixColor') { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const newIndex = (x * height) + (height - 1 - y); + const value = processedData[newIndex]; + const color = rgbPalette.find(c => c.value === value) || rgbPalette[1]; // 默认白色 + const index = (y * width + x) * 4; + data[index] = color.r; + data[index + 1] = color.g; + data[index + 2] = color.b; + data[index + 3] = 255; // Alpha 透明度 + } + } + } else if (mode === 'fourColor') { + const fourColorValues = [ + { value: 0x00, r: 0, g: 0, b: 0 }, // 黑色 + { value: 0x01, r: 255, g: 255, b: 255 }, // 白色 + { value: 0x03, r: 255, g: 0, b: 0 }, // 红色 + { value: 0x02, r: 255, g: 255, b: 0 } // 黄色 + ]; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const newIndex = (y * width + x) / 4 | 0; + const shift = 6 - ((x % 4) * 2); + const value = (processedData[newIndex] >> shift) & 0x03; + const color = fourColorValues.find(c => c.value === value) || fourColorValues[1]; // 默认白色 + const index = (y * width + x) * 4; + data[index] = color.r; + data[index + 1] = color.g; + data[index + 2] = color.b; + data[index + 3] = 255; + } + } + } else if (mode === 'blackWhiteColor') { + const byteWidth = Math.ceil(width / 8); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const byteIndex = y * byteWidth + Math.floor(x / 8); + const bitIndex = 7 - (x % 8); + const bit = (processedData[byteIndex] >> bitIndex) & 1; + const index = (y * width + x) * 4; + data[index] = bit ? 255 : 0; // 白或黑 + data[index + 1] = bit ? 255 : 0; + data[index + 2] = bit ? 255 : 0; + data[index + 3] = 255; + } + } + } else if (mode === 'threeColor') { + const byteWidth = Math.ceil(width / 8); + const blackWhiteData = processedData.slice(0, byteWidth * height); + const redWhiteData = processedData.slice(byteWidth * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const byteIndex = y * byteWidth + Math.floor(x / 8); + const bitIndex = 7 - (x % 8); + const blackWhiteBit = (blackWhiteData[byteIndex] >> bitIndex) & 1; + const redWhiteBit = (redWhiteData[byteIndex] >> bitIndex) & 1; + const index = (y * width + x) * 4; + if (!redWhiteBit) { + // 红色 + data[index] = 255; + data[index + 1] = 0; + data[index + 2] = 0; + } else { + // 黑或白 + data[index] = blackWhiteBit ? 255 : 0; + data[index + 1] = blackWhiteBit ? 255 : 0; + data[index + 2] = blackWhiteBit ? 255 : 0; + } + data[index + 3] = 255; + } + } + } + + return imageData; +} + +function processImageData(imageData, mode) { + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + + let processedData; + + if (mode === 'sixColor') { + processedData = new Uint8Array(width * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + + const closest = findClosestColor(r, g, b, mode); + const newIndex = (x * height) + (height - 1 - y); + processedData[newIndex] = closest.value; + } + } + } else if (mode === 'fourColor') { + processedData = new Uint8Array(Math.ceil((width * height) / 4)); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const closest = findClosestColor(r, g, b, mode); // 使用 fourColorPalette + const colorValue = closest.value; // 0x00 (黑), 0x01 (白), 0x02 (红), 0x03 (黄) + const newIndex = (y * width + x) / 4 | 0; + const shift = 6 - ((x % 4) * 2); + processedData[newIndex] |= (colorValue << shift); + } + } + } else if (mode === 'blackWhiteColor') { + const byteWidth = Math.ceil(width / 8); + processedData = new Uint8Array(byteWidth * height); + const threshold = 140; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const grayscale = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + const bit = grayscale >= threshold ? 1 : 0; + const byteIndex = y * byteWidth + Math.floor(x / 8); + const bitIndex = 7 - (x % 8); + processedData[byteIndex] |= (bit << bitIndex); + } + } + } else if (mode === 'threeColor') { + const byteWidth = Math.ceil(width / 8); + const blackWhiteThreshold = 140; + const redThreshold = 160; + + const blackWhiteData = new Uint8Array(height * byteWidth); + const redWhiteData = new Uint8Array(height * byteWidth); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * 4; + const r = data[index]; + const g = data[index + 1]; + const b = data[index + 2]; + const grayscale = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + + const blackWhiteBit = grayscale >= blackWhiteThreshold ? 1 : 0; + const blackWhiteByteIndex = y * byteWidth + Math.floor(x / 8); + const blackWhiteBitIndex = 7 - (x % 8); + if (blackWhiteBit) { + blackWhiteData[blackWhiteByteIndex] |= (0x01 << blackWhiteBitIndex); + } else { + blackWhiteData[blackWhiteByteIndex] &= ~(0x01 << blackWhiteBitIndex); + } + + const redWhiteBit = (r > redThreshold && r > g && r > b) ? 0 : 1; + const redWhiteByteIndex = y * byteWidth + Math.floor(x / 8); + const redWhiteBitIndex = 7 - (x % 8); + if (redWhiteBit) { + redWhiteData[redWhiteByteIndex] |= (0x01 << redWhiteBitIndex); + } else { + redWhiteData[redWhiteByteIndex] &= ~(0x01 << redWhiteBitIndex); + } + } + } + + processedData = new Uint8Array(blackWhiteData.length + redWhiteData.length); + processedData.set(blackWhiteData, 0); + processedData.set(redWhiteData, blackWhiteData.length); + } + + return processedData; +} diff --git a/gen-esp/js/main.js b/gen-esp/js/main.js new file mode 100644 index 0000000..56e6908 --- /dev/null +++ b/gen-esp/js/main.js @@ -0,0 +1,2101 @@ +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 = ` + ${date} + + `; + 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 = `${item.label}`; + 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 = ` + ${item.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 = ` + + + + + + + `; + 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 = ` + + + + + `; + 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(); +} diff --git a/gen-esp/js/paint.js b/gen-esp/js/paint.js new file mode 100644 index 0000000..2950187 --- /dev/null +++ b/gen-esp/js/paint.js @@ -0,0 +1,888 @@ +class PaintManager { + constructor(canvas, ctx) { + this.canvas = canvas; + this.ctx = ctx; + this.painting = false; + this.lastX = 0; + this.lastY = 0; + this.brushColor = "#000000"; + this.brushSize = 2; + this.currentTool = null; + this.textElements = []; + this.lineSegments = []; + this.isTextPlacementMode = false; + this.draggingCanvasContext = null; + this.selectedTextElement = null; + this.isDraggingText = false; + this.dragOffsetX = 0; + this.dragOffsetY = 0; + this.textBold = false; + this.textItalic = false; + + // Brush cursor indicator + this.brushCursor = null; + + // Undo/Redo functionality + this.historyStack = []; + this.historyStep = -1; + this.MAX_HISTORY = 50; + + // Bind event handlers + this.startPaint = this.startPaint.bind(this); + this.paint = this.paint.bind(this); + this.endPaint = this.endPaint.bind(this); + this.handleCanvasClick = this.handleCanvasClick.bind(this); + this.onTouchStart = this.onTouchStart.bind(this); + this.onTouchMove = this.onTouchMove.bind(this); + this.onTouchEnd = this.onTouchEnd.bind(this); + this.handleKeyboard = this.handleKeyboard.bind(this); + this.updateBrushCursor = this.updateBrushCursor.bind(this); + this.hideBrushCursor = this.hideBrushCursor.bind(this); + } + + buildFontString(fontFamily, fontSize, bold = false, italic = false) { + const fontParts = []; + if (italic) fontParts.push('italic'); + if (bold) fontParts.push('bold'); + fontParts.push(`${fontSize}px`); + fontParts.push(fontFamily); + return fontParts.join(' '); + } + + parseInlineMarkdown(text, baseStyle = {}) { + const patterns = [ + { + regex: /`([^`]+)`/g, + style: { code: true, fontFamily: 'monospace', background: 'rgba(0, 0, 0, 0.08)' } + }, + { regex: /\*\*([^*]+)\*\*/g, style: { bold: true } }, + { regex: /__([^_]+)__/g, style: { bold: true } }, + { regex: /\*([^*]+)\*/g, style: { italic: true } }, + { regex: /_([^_]+)_/g, style: { italic: true } }, + { regex: /~~([^~]+)~~/g, style: { strike: true, color: '#666666' } } + ]; + + let segments = [{ text, style: { ...baseStyle } }]; + + patterns.forEach(({ regex, style }) => { + const nextSegments = []; + segments.forEach((segment) => { + if (!segment.text) return; + regex.lastIndex = 0; + let lastIndex = 0; + let match = regex.exec(segment.text); + let hasMatch = false; + + while (match) { + hasMatch = true; + if (match.index > lastIndex) { + nextSegments.push({ + text: segment.text.slice(lastIndex, match.index), + style: { ...segment.style } + }); + } + nextSegments.push({ + text: match[1], + style: { ...segment.style, ...style } + }); + lastIndex = match.index + match[0].length; + match = regex.exec(segment.text); + } + + if (!hasMatch) { + nextSegments.push(segment); + return; + } + + if (lastIndex < segment.text.length) { + nextSegments.push({ + text: segment.text.slice(lastIndex), + style: { ...segment.style } + }); + } + }); + segments = nextSegments; + }); + + return segments.filter((segment) => segment.text); + } + + parseMarkdownText(markdownText, fontFamily, fontSize, color, defaultBold = false, defaultItalic = false) { + const normalizedText = String(markdownText || '').replace(/\r\n/g, '\n'); + const lines = normalizedText.split('\n'); + const blocks = []; + + lines.forEach((rawLine) => { + const line = rawLine || ''; + const trimmed = line.trim(); + + if (!trimmed) { + blocks.push({ type: 'blank', height: Math.max(12, Math.round(fontSize * 0.75)) }); + return; + } + + let type = 'paragraph'; + let content = line; + let level = 0; + let indent = 0; + let prefix = ''; + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + const quoteMatch = line.match(/^\s*>\s?(.*)$/); + const unorderedListMatch = line.match(/^(\s*)[-*+]\s+(.*)$/); + const orderedListMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); + + if (headingMatch) { + type = 'heading'; + level = headingMatch[1].length; + content = headingMatch[2]; + } else if (quoteMatch) { + type = 'quote'; + content = quoteMatch[1]; + } else if (unorderedListMatch) { + type = 'list'; + content = unorderedListMatch[2]; + indent = Math.floor((unorderedListMatch[1] || '').length / 2); + prefix = '•'; + } else if (orderedListMatch) { + type = 'ordered-list'; + content = orderedListMatch[3]; + indent = Math.floor((orderedListMatch[1] || '').length / 2); + prefix = `${orderedListMatch[2]}.`; + } + + const scale = type === 'heading' ? Math.max(1, 1.65 - (level - 1) * 0.16) : 1; + const blockFontSize = Math.max(10, Math.round(fontSize * scale)); + const baseStyle = { + color: type === 'quote' ? '#444444' : color, + fontFamily, + fontSize: blockFontSize, + bold: type === 'heading' || defaultBold, + italic: defaultItalic + }; + + blocks.push({ + type, + level, + indent, + prefix, + fontSize: blockFontSize, + lineHeight: Math.max(14, Math.round(blockFontSize * (type === 'heading' ? 1.45 : 1.35))), + segments: this.parseInlineMarkdown(content, baseStyle) + }); + }); + + return blocks; + } + + measureMarkdownBlock(block) { + if (block.type === 'blank') { + return { width: 0, height: block.height || 12 }; + } + + const indentWidth = block.indent ? block.indent * 18 : 0; + let prefixWidth = 0; + if (block.prefix) { + this.ctx.font = this.buildFontString('Arial', block.fontSize, true, false); + prefixWidth = this.ctx.measureText(block.prefix).width + 8; + } + + let contentWidth = 0; + block.segments.forEach((segment) => { + this.ctx.font = this.buildFontString( + segment.style.fontFamily || 'Arial', + segment.style.fontSize || block.fontSize, + !!segment.style.bold, + !!segment.style.italic + ); + contentWidth += this.ctx.measureText(segment.text).width; + }); + + return { + width: indentWidth + prefixWidth + contentWidth + (block.type === 'quote' ? 16 : 0), + height: block.lineHeight + }; + } + + measureTextElement(textElement) { + if (textElement.type === 'markdown' && Array.isArray(textElement.blocks)) { + let width = 0; + let height = 0; + + textElement.blocks.forEach((block) => { + const size = this.measureMarkdownBlock(block); + width = Math.max(width, size.width); + height += size.height; + }); + + return { + width, + height, + top: textElement.y - Math.max(14, textElement.baseFontSize || 16), + left: textElement.x + }; + } + + this.ctx.font = textElement.font; + const textWidth = this.ctx.measureText(textElement.text).width; + const fontSizeMatch = textElement.font.match(/(\d+)px/); + const textHeight = (fontSizeMatch ? parseInt(fontSizeMatch[1], 10) : 14) * 1.2; + + return { + width: textWidth, + height: textHeight, + top: textElement.y - textHeight, + left: textElement.x + }; + } + + renderTextElement(textElement) { + if (textElement.type === 'markdown' && Array.isArray(textElement.blocks)) { + let currentY = textElement.y; + + textElement.blocks.forEach((block) => { + if (block.type === 'blank') { + currentY += block.height || 12; + return; + } + + let currentX = textElement.x + (block.indent ? block.indent * 18 : 0); + if (block.type === 'quote') { + this.ctx.strokeStyle = '#999999'; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + this.ctx.moveTo(textElement.x + 6, currentY - block.lineHeight + 4); + this.ctx.lineTo(textElement.x + 6, currentY + 2); + this.ctx.stroke(); + currentX += 16; + } + + if (block.prefix) { + this.ctx.font = this.buildFontString('Arial', block.fontSize, true, false); + this.ctx.fillStyle = textElement.color; + this.ctx.fillText(block.prefix, currentX, currentY); + currentX += this.ctx.measureText(block.prefix).width + 8; + } + + block.segments.forEach((segment) => { + const fontSize = segment.style.fontSize || block.fontSize; + this.ctx.font = this.buildFontString( + segment.style.fontFamily || textElement.fontFamily || 'Arial', + fontSize, + !!segment.style.bold, + !!segment.style.italic + ); + + const segmentWidth = this.ctx.measureText(segment.text).width; + + if (segment.style.code) { + this.ctx.fillStyle = segment.style.background || 'rgba(0, 0, 0, 0.08)'; + this.ctx.fillRect(currentX - 2, currentY - fontSize, segmentWidth + 4, block.lineHeight - 2); + } + + this.ctx.fillStyle = segment.style.color || textElement.color; + this.ctx.fillText(segment.text, currentX, currentY); + + if (segment.style.strike) { + this.ctx.strokeStyle = segment.style.color || textElement.color; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(currentX, currentY - fontSize * 0.35); + this.ctx.lineTo(currentX + segmentWidth, currentY - fontSize * 0.35); + this.ctx.stroke(); + } + + currentX += segmentWidth; + }); + + currentY += block.lineHeight; + }); + + return; + } + + this.ctx.font = textElement.font; + this.ctx.fillStyle = textElement.color; + this.ctx.fillText(textElement.text, textElement.x, textElement.y); + } + + saveToHistory() { + // Remove any states after current step (when user drew something after undoing) + this.historyStack = this.historyStack.slice(0, this.historyStep + 1); + + // Save current canvas state along with text and line data + const canvasState = { + imageData: this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height), + textElements: JSON.parse(JSON.stringify(this.textElements)), + lineSegments: JSON.parse(JSON.stringify(this.lineSegments)) + }; + + this.historyStack.push(canvasState); + this.historyStep++; + + // Limit history size + if (this.historyStack.length > this.MAX_HISTORY) { + this.historyStack.shift(); + this.historyStep--; + } + + this.updateUndoRedoButtons(); + } + + clearHistory() { + this.historyStack = []; + this.historyStep = -1; + this.updateUndoRedoButtons(); + } + + undo() { + if (this.historyStep > 0) { + this.historyStep--; + this.restoreFromHistory(); + } + } + + redo() { + if (this.historyStep < this.historyStack.length - 1) { + this.historyStep++; + this.restoreFromHistory(); + } + } + + restoreFromHistory() { + if (this.historyStep >= 0 && this.historyStep < this.historyStack.length) { + const state = this.historyStack[this.historyStep]; + + // Restore canvas image + this.ctx.putImageData(state.imageData, 0, 0); + + // Restore text and line data + this.textElements = JSON.parse(JSON.stringify(state.textElements)); + this.lineSegments = JSON.parse(JSON.stringify(state.lineSegments)); + + this.updateUndoRedoButtons(); + } + } + + updateUndoRedoButtons() { + const undoBtn = document.getElementById('undo-btn'); + const redoBtn = document.getElementById('redo-btn'); + + if (undoBtn) { + undoBtn.disabled = this.historyStep <= 0; + } + + if (redoBtn) { + redoBtn.disabled = this.historyStep >= this.historyStack.length - 1; + } + } + + initPaintTools() { + document.getElementById('brush-mode').addEventListener('click', () => { + if (this.currentTool === 'brush') { + this.setActiveTool(null, ''); + } else { + this.setActiveTool('brush', '画笔模式'); + this.brushColor = document.getElementById('brush-color').value; + } + }); + + document.getElementById('eraser-mode').addEventListener('click', () => { + if (this.currentTool === 'eraser') { + this.setActiveTool(null, ''); + } else { + this.setActiveTool('eraser', '橡皮擦'); + this.brushColor = "#FFFFFF"; + } + }); + + document.getElementById('text-mode').addEventListener('click', () => { + if (this.currentTool === 'text') { + this.setActiveTool(null, ''); + } else { + this.setActiveTool('text', '插入文字'); + this.brushColor = document.getElementById('brush-color').value; + } + }); + + document.getElementById('brush-color').addEventListener('change', (e) => { + this.brushColor = e.target.value; + }); + + document.getElementById('brush-size').addEventListener('input', (e) => { + this.brushSize = parseInt(e.target.value); + this.updateBrushCursorSize(); + }); + + document.getElementById('add-text-btn').addEventListener('click', () => this.startTextPlacement()); + + // Add event listeners for bold and italic buttons + document.getElementById('text-bold').addEventListener('click', () => { + this.textBold = !this.textBold; + document.getElementById('text-bold').classList.toggle('primary', this.textBold); + }); + + document.getElementById('text-italic').addEventListener('click', () => { + this.textItalic = !this.textItalic; + document.getElementById('text-italic').classList.toggle('primary', this.textItalic); + }); + + // Add undo/redo button listeners + document.getElementById('undo-btn').addEventListener('click', () => this.undo()); + document.getElementById('redo-btn').addEventListener('click', () => this.redo()); + + this.canvas.addEventListener('mousedown', this.startPaint); + this.canvas.addEventListener('mousemove', this.paint); + this.canvas.addEventListener('mouseup', this.endPaint); + this.canvas.addEventListener('mouseleave', this.endPaint); + this.canvas.addEventListener('click', this.handleCanvasClick); + + // Touch support + this.canvas.addEventListener('touchstart', this.onTouchStart); + this.canvas.addEventListener('touchmove', this.onTouchMove); + this.canvas.addEventListener('touchend', this.onTouchEnd); + + // Keyboard shortcuts for undo/redo + document.addEventListener('keydown', this.handleKeyboard); + + // Mouse move for brush cursor + this.canvas.addEventListener('mouseenter', this.updateBrushCursor); + this.canvas.addEventListener('mousemove', this.updateBrushCursor); + + // Create brush cursor element + this.createBrushCursor(); + + // Initialize history with blank canvas state + this.saveToHistory(); + } + + handleKeyboard(e) { + // Ctrl+Z or Cmd+Z for undo + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + this.undo(); + } + // Ctrl+Y or Ctrl+Shift+Z or Cmd+Shift+Z for redo + else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { + e.preventDefault(); + this.redo(); + } + } + + setActiveTool(tool, title) { + setCanvasTitle(title); + this.currentTool = tool; + + this.canvas.parentNode.classList.toggle('brush-mode', this.currentTool === 'brush'); + this.canvas.parentNode.classList.toggle('eraser-mode', this.currentTool === 'eraser'); + this.canvas.parentNode.classList.toggle('text-mode', this.currentTool === 'text'); + + document.getElementById('brush-mode').classList.toggle('active', this.currentTool === 'brush'); + document.getElementById('eraser-mode').classList.toggle('active', this.currentTool === 'eraser'); + document.getElementById('text-mode').classList.toggle('active', this.currentTool === 'text'); + + document.getElementById('brush-color').disabled = this.currentTool === 'eraser'; + document.getElementById('brush-size').disabled = this.currentTool === 'text'; + + document.getElementById('undo-btn').classList.toggle('hide', this.currentTool === null); + document.getElementById('redo-btn').classList.toggle('hide', this.currentTool === null); + + // Cancel any pending text placement + this.cancelTextPlacement(); + } + + createBrushCursor() { + // Create a div element to show as brush cursor + this.brushCursor = document.createElement('div'); + this.brushCursor.id = 'brush-cursor'; + this.brushCursor.style.position = 'fixed'; + this.brushCursor.style.border = '2px solid rgba(0, 0, 0, 0.5)'; + this.brushCursor.style.borderRadius = '50%'; + this.brushCursor.style.pointerEvents = 'none'; + this.brushCursor.style.display = 'none'; + this.brushCursor.style.zIndex = '10000'; + this.brushCursor.style.transform = 'translate(-50%, -50%)'; + this.brushCursor.style.willChange = 'transform'; + this.brushCursor.style.left = '0'; + this.brushCursor.style.top = '0'; + document.body.appendChild(this.brushCursor); + this.updateBrushCursorSize(); + + // For requestAnimationFrame throttling + this.cursorUpdateScheduled = false; + this.pendingCursorX = 0; + this.pendingCursorY = 0; + } + + updateBrushCursorSize() { + if (!this.brushCursor) return; + + const rect = this.canvas.getBoundingClientRect(); + const scaleX = rect.width / this.canvas.width; + const scaleY = rect.height / this.canvas.height; + const scale = Math.min(scaleX, scaleY); + + const size = this.brushSize * scale; + this.brushCursor.style.width = size + 'px'; + this.brushCursor.style.height = size + 'px'; + } + + updateBrushCursor(e) { + if (!this.brushCursor) return; + + if (this.currentTool === 'brush' || this.currentTool === 'eraser') { + // Check if mouse is within canvas bounds + const rect = this.canvas.getBoundingClientRect(); + const isInCanvas = e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom; + + if (isInCanvas) { + this.brushCursor.style.display = 'block'; + this.canvas.style.cursor = 'none'; + + // Store the pending position + this.pendingCursorX = e.clientX; + this.pendingCursorY = e.clientY; + + // Schedule update using requestAnimationFrame for smooth movement + if (!this.cursorUpdateScheduled) { + this.cursorUpdateScheduled = true; + requestAnimationFrame(() => { + this.brushCursor.style.transform = `translate(${this.pendingCursorX}px, ${this.pendingCursorY}px) translate(-50%, -50%)`; + this.cursorUpdateScheduled = false; + }); + } + + // Update color to match brush or show white for eraser (only needs to happen once or when tool changes) + if (this.currentTool === 'eraser') { + if (this.brushCursor.getAttribute('data-tool') !== 'eraser') { + this.brushCursor.style.border = '2px solid rgba(255, 0, 0, 0.7)'; + this.brushCursor.style.backgroundColor = 'rgba(255, 255, 255, 0.2)'; + this.brushCursor.style.boxShadow = 'none'; + this.brushCursor.setAttribute('data-tool', 'eraser'); + } + } else { + if (this.brushCursor.getAttribute('data-tool') !== 'brush') { + // Use a contrasting border - white with black outline for visibility + this.brushCursor.style.border = '1px solid white'; + this.brushCursor.style.boxShadow = '0 0 0 1px black, inset 0 0 0 1px black'; + this.brushCursor.style.backgroundColor = 'transparent'; + this.brushCursor.setAttribute('data-tool', 'brush'); + } + } + } else { + // Hide cursor when outside canvas + this.brushCursor.style.display = 'none'; + } + } + } + + hideBrushCursor() { + if (this.brushCursor) { + this.brushCursor.style.display = 'none'; + } + this.canvas.style.cursor = 'default'; + } + + startPaint(e) { + if (!this.currentTool) return; + + if (this.currentTool === 'text') { + // Check if we're clicking on a text element to drag + const textElement = this.findTextElementAt(e); + if (textElement && textElement === this.selectedTextElement) { + this.isDraggingText = true; + + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + + // Calculate offset for smooth dragging + this.dragOffsetX = textElement.x - x; + this.dragOffsetY = textElement.y - y; + + return; // Don't start drawing + } + } else { + this.painting = true; + this.draw(e); + } + } + + endPaint() { + if (this.painting || this.isDraggingText) { + this.saveToHistory(); // Save state after drawing or dragging text + } + this.painting = false; + this.isDraggingText = false; + this.lastX = 0; + this.lastY = 0; + + this.hideBrushCursor(); + } + + paint(e) { + if (!this.currentTool) return; + + if (this.currentTool === 'text') { + if (this.isDraggingText && this.selectedTextElement) { + this.dragText(e); + } + } else { + if (this.painting) { + this.draw(e); + } + } + } + + draw(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + + this.ctx.lineJoin = 'round'; + this.ctx.lineCap = 'round'; + this.ctx.strokeStyle = this.brushColor; + this.ctx.lineWidth = this.brushSize; + + this.ctx.beginPath(); + + if (this.lastX === 0 && this.lastY === 0) { + // For the first point, just do a dot + this.ctx.moveTo(x, y); + this.ctx.lineTo(x + 0.1, y + 0.1); + + // Store the dot for redrawing + this.lineSegments.push({ + type: 'dot', + x: x, + y: y, + color: this.brushColor, + size: this.brushSize + }); + } else { + // Connect to the previous point + this.ctx.moveTo(this.lastX, this.lastY); + this.ctx.lineTo(x, y); + + // Store the line segment for redrawing + this.lineSegments.push({ + type: 'line', + x1: this.lastX, + y1: this.lastY, + x2: x, + y2: y, + color: this.brushColor, + size: this.brushSize + }); + } + + this.ctx.stroke(); + + this.lastX = x; + this.lastY = y; + } + + handleCanvasClick(e) { + if (this.currentTool === 'text' && this.isTextPlacementMode) { + this.placeText(e); + } + } + + onTouchStart(e) { + e.preventDefault(); + const touch = e.touches[0]; + + // If in text placement mode, handle as a click + if (this.currentTool === 'text' && this.isTextPlacementMode) { + const mouseEvent = new MouseEvent('click', { + clientX: touch.clientX, + clientY: touch.clientY + }); + this.canvas.dispatchEvent(mouseEvent); + return; + } + + // Otherwise handle as normal drawing + const mouseEvent = new MouseEvent('mousedown', { + clientX: touch.clientX, + clientY: touch.clientY + }); + this.canvas.dispatchEvent(mouseEvent); + } + + onTouchMove(e) { + e.preventDefault(); + const touch = e.touches[0]; + const mouseEvent = new MouseEvent('mousemove', { + clientX: touch.clientX, + clientY: touch.clientY + }); + this.canvas.dispatchEvent(mouseEvent); + } + + onTouchEnd(e) { + e.preventDefault(); + this.endPaint(); + } + + dragText(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + + // Update text position with offset + this.selectedTextElement.x = x + this.dragOffsetX; + this.selectedTextElement.y = y + this.dragOffsetY; + + // Redraw selected text element + if (this.draggingCanvasContext) { + this.ctx.putImageData(this.draggingCanvasContext, 0, 0); + } else { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } + this.renderTextElement(this.selectedTextElement); + } + + findTextElementAt(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + + // Search through text elements in reverse order (top-most first) + for (let i = this.textElements.length - 1; i >= 0; i--) { + const text = this.textElements[i]; + + const measurement = this.measureTextElement(text); + const textWidth = measurement.width; + const textHeight = measurement.height; + const top = measurement.top; + const left = measurement.left; + + // Check if click is within text bounds (allowing for some margin) + const margin = 5; + if (x >= left - margin && + x <= left + textWidth + margin && + y >= top - margin && + y <= top + textHeight + margin) { + return text; + } + } + + return null; + } + + startTextPlacement() { + const text = document.getElementById('text-input').value.trim(); + if (!text) { + alert('请输入文字内容'); + return; + } + + this.isTextPlacementMode = true; + + // Add visual feedback + setCanvasTitle('点击画布放置文字'); + this.canvas.classList.add('text-placement-mode'); + } + + cancelTextPlacement() { + this.isTextPlacementMode = false; + this.canvas.classList.remove('text-placement-mode'); + + // reset dragging state + this.isDraggingText = false; + this.dragOffsetX = 0; + this.dragOffsetY = 0; + this.selectedTextElement = null; + this.draggingCanvasContext = null; + } + + placeText(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + + const text = document.getElementById('text-input').value; + const fontFamily = document.getElementById('font-family').value; + const fontSize = parseInt(document.getElementById('font-size').value, 10); + const blocks = this.parseMarkdownText(text, fontFamily, fontSize, this.brushColor, this.textBold, this.textItalic); + + // Create a new text element + const newText = { + type: 'markdown', + text: text, + x: x, + y: y, + font: this.buildFontString(fontFamily, fontSize, this.textBold, this.textItalic), + color: this.brushColor, + fontFamily: fontFamily, + baseFontSize: fontSize, + blocks: blocks + }; + + // Add to our list of text elements + this.textElements.push(newText); + + // Select this text element for immediate dragging + this.selectedTextElement = newText; + this.draggingCanvasContext = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); + + // Draw text on canvas + this.renderTextElement(newText); + + // Save to history after placing text + this.saveToHistory(); + + // Reset + document.getElementById('text-input').value = ''; + this.isTextPlacementMode = false; + this.canvas.classList.remove('text-placement-mode'); + setCanvasTitle('拖动新添加文字可调整位置'); + } + + redrawTextElements() { + // Redraw all text elements after dithering + this.textElements.forEach(item => { + this.renderTextElement(item); + }); + } + + redrawLineSegments() { + // Redraw all line segments after dithering + this.lineSegments.forEach(segment => { + this.ctx.lineJoin = 'round'; + this.ctx.lineCap = 'round'; + this.ctx.strokeStyle = segment.color; + this.ctx.lineWidth = segment.size; + this.ctx.beginPath(); + + if (segment.type === 'dot') { + this.ctx.moveTo(segment.x, segment.y); + this.ctx.lineTo(segment.x + 0.1, segment.y + 0.1); + } else { + this.ctx.moveTo(segment.x1, segment.y1); + this.ctx.lineTo(segment.x2, segment.y2); + } + + this.ctx.stroke(); + }); + } + + clearElements() { + this.textElements = []; + this.lineSegments = []; + } +}