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 @@
+
+
+
+
+
+
+ 墨水屏日历
+
+
+
+
+
+
+
墨水屏日历
+
+
+
+
+
+
+
+
+
+
+
+
+
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 = [];
+ }
+}