From 0ce95688d5898bd3aea7c60745baa8cc010d094c Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Apr 2026 10:21:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A5=97=E7=94=A8tsl0922=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gen/countdown.html | 308 -------- gen/countdown_s.html | 218 ------ gen/css/main.css | 619 +++++++++++++++++ gen/favicon.png | Bin 0 -> 2873 bytes gen/index.html | 440 +++++++++--- gen/js/crop.js | 219 ++++++ gen/js/dithering.js | 716 +++++++++++++++++++ gen/js/main.js | 1428 ++++++++++++++++++++++++++++++++++++++ gen/js/paint.js | 631 +++++++++++++++++ gen/resum.html | 248 ------- gen/todo.html | 114 --- gen/v1.5/css/main.css | 370 ++++++++++ gen/v1.5/index.html | 114 +++ gen/v1.5/js/dithering.js | 212 ++++++ gen/v1.5/js/main.js | 369 ++++++++++ 15 files changed, 5016 insertions(+), 990 deletions(-) delete mode 100644 gen/countdown.html delete mode 100644 gen/countdown_s.html create mode 100644 gen/css/main.css create mode 100644 gen/favicon.png create mode 100644 gen/js/crop.js create mode 100644 gen/js/dithering.js create mode 100644 gen/js/main.js create mode 100644 gen/js/paint.js delete mode 100644 gen/resum.html delete mode 100644 gen/todo.html create mode 100644 gen/v1.5/css/main.css create mode 100644 gen/v1.5/index.html create mode 100644 gen/v1.5/js/dithering.js create mode 100644 gen/v1.5/js/main.js diff --git a/gen/countdown.html b/gen/countdown.html deleted file mode 100644 index 1c14130..0000000 --- a/gen/countdown.html +++ /dev/null @@ -1,308 +0,0 @@ - - - - - 墨水屏倒计时 Pro - 动态阵列版 - - - - - - -
-
-
-

倒计时看板

-
-
-
- -
-

项目管理

- -
- - - - -
- - - -
- - - - -
-
- - - - \ No newline at end of file diff --git a/gen/countdown_s.html b/gen/countdown_s.html deleted file mode 100644 index 7738077..0000000 --- a/gen/countdown_s.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - - 墨水屏标语倒计时 - 红黑版 - - - - - -
-
2026 / 04 / 17 FRIDAY
- -
-

新婚快乐

-
- -
✦ ✦ ✦
- -
-
-
- -
-
- - -
-
- - -
- - -

提示:直接点击图片中的“黑色大字”可改写标语

-
- - - - \ No newline at end of file diff --git a/gen/css/main.css b/gen/css/main.css new file mode 100644 index 0000000..2836512 --- /dev/null +++ b/gen/css/main.css @@ -0,0 +1,619 @@ +:root { + --primary-color: #0d6efd; + --primary-hover: #0b5ed7; + --secondary-color: #6c757d; + --secondary-hover: #5c636a; + + --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: system-ui, -apple-system, sans-serif; +} + +button { + padding: 0.375rem 0.75rem; + border: 1px solid var(--primary-color); + border-radius: 0.375rem; + margin-bottom: 5px; + white-space: nowrap; + cursor: pointer; + font-size: 0.9rem; +} + +button:disabled { + opacity: 0.65; +} + +button.primary { + color: #fff; + background-color: var(--primary-color); +} + +button.primary:hover { + color: #fff; + border-color: var(--primary-hover); + background-color: var(--primary-hover); +} + +button.secondary { + color: #fff; + background-color: var(--secondary-color); + border-color: var(--secondary-color); +} + +button.secondary:hover { + color: #fff; + border-color: var(--secondary-hover); + background-color: var(--secondary-hover); +} + +h3 { + padding-bottom: .3em; + border-bottom: 1px solid #ccc; + text-align: center; +} + +fieldset { + border: none; + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.2); + background-color: #f8f9fa; + padding: 10px; + margin-bottom: 16px; + border-radius: 4px; +} + +fieldset legend { + font-weight: bold; + color: rgba(0, 0, 255, 0.6); +} + +code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background: #ccc; + border-radius: 3px; +} + +input[type=text], +input[type=date], +input[type=number], +select { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: .2rem .75rem; + max-width: 100%; + box-sizing: border-box; +} + +input[type=file] { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + max-width: 100%; +} + +input::file-selector-button { + font-size: 0.9rem; + font-weight: 400; + line-height: 1.5; + border: 1px solid var(--primary-color); + border-radius: 0.375rem; + cursor: pointer; +} + +select { + padding: .3rem 2.25rem .3rem .75rem; +} + +input:focus, +select:focus { + border: 1px solid #86b7fe; + box-shadow: 0 0 4px rgba(0, 120, 215, 0.8); + outline: 0; +} + +input[type=text]: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; +} + +.main { + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0 1rem; + background: #fff; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + box-sizing: border-box; +} + +.footer { + display: flex; + gap: 10px; + font-size: 0.8rem; + color: #666; + flex-wrap: wrap; + margin: 1rem 0; +} + +.footer .links { + display: flex; + align-items: center; +} + +.footer .links a { + color: #666; + 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: #0d6efd; + 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: 5px; + background: #ddd; + 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; +} + +.log-container .log-line { + padding: 2px 0; +} + +.log-container .time, +.log-container .action { + display: inline-block; + white-space: nowrap; +} + +.log-container .time { + color: #333; + margin-right: 0.5em; +} + +.log-container .action { + color: #666; + margin-right: 0.5em; +} + +.template-panel { + border: 1px solid #dee2e6; + border-radius: 0.5rem; + background: #fff; + padding: 12px; + margin-bottom: 10px; +} + +.panel-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.panel-tab { + color: #495057; + background: #eef2f6; + border-color: #d0d7de; +} + +.panel-tab:hover { + background: #dde5ee; + border-color: #c1c9d2; +} + +.panel-tab.active { + color: #fff; + background: var(--primary-color); + border-color: var(--primary-color); +} + +.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: #666; + 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 #dee2e6; + border-radius: 0.375rem; + background: #f8f9fa; +} + +.template-item input[type="text"] { + min-width: 120px; + flex: 1 1 160px; +} + +.template-item input[type="date"] { + min-width: 150px; +} + +.template-item label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.canvas-container canvas { + border: black solid 1px; + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} + +.canvas-container.crop-mode canvas { + border: 2px dashed var(--primary-color); + cursor: grab; +} + +.status-bar { + display: none; + font-size: 85%; + color: #666; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dotted #AAA; +} + +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-tools { + margin-top: 10px; + justify-content: center; +} + +.brush-tools, +.text-tools, +.crop-tools { + display: none; +} + +.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-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; +} + +.tool-button:hover { + background-color: #e9ecef; + border-color: #ced4da; +} + +.tool-button.active { + background-color: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +.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], + 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 fieldset { + background-color: var(--dark-fieldset-bg); + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.5); +} + +body.dark-mode h3 { + border-bottom: 1px solid var(--dark-border); + color: var(--dark-text); +} + +body.dark-mode code { + background: var(--dark-code-bg); + color: #ff9800; +} + +body.dark-mode input[type=text], +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 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 .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; +} diff --git a/gen/favicon.png b/gen/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..57da876ddd1d9cf60873cf8809a531c7a9cc82b3 GIT binary patch literal 2873 zcmV-93&!+`P)e-M^!0Ydb09d{y_@=KqtuTp(> z^9L(m{l}Wue>afizV-Kw0G?dE#DC~O%`?e#*J;MtlO?fOz_uNj`8;yz1i)Mf$3ZX@ zfn}L+T^FuxLo@sc^@s*EO?{LL_t^TCFDA6Wh#{pFrrcEjj= zY|tnr+I#TANVsxmTU&hO^)P%5!2Vq|1KPVg)?4v>MY26>WRtEhpVN&@ifOh8>!url zjS?l54$x;mulj-&ZlE%qF(#g235v3t*MeAaM*<~`WHbt`s$vIB<>9K_KhwK#G7I8M=- zlP6B#XzdXkt38U23k_(d{ifq5;n)td(y0>7cHZ4Z`E>=uRRH~}3ZKg7tl65bkrRTY zPn|&ebR914*@gerY{jO%d-48;5ApuTTd`*C8f@6G9-B9B!m`)?jMtVd!P_g}#G>E7 zj76_3!dq{xz_LI60o#A`3@$BShQ?Q3Bmfqp2TLZ?!Qdv2ur0Wb zD*>SF(VwII$)_>m`QPG+UoXTj7A(MH3m(UV4?m0tA9~26nX_i&$Mff7?!0*zb<1en zHD@Mf%$SBTW5(jviFcrK!bF6IjX-qNEpQx{>8dUm<47fvPt>eiGC+dh@{#RIK5bhW zJ)7*Ls}yhrgc1%zEh$B)yc|_kRTwaE0B#&S7(<2+!-!EMF?#G6jKB3(+&*~{rcAvP z(`L-T>{)X#ckaEo`|i1@o_`;1dhijH+&vF5a>fxZ0>J=_M55qa(=(~`v(h$Sx9(qI zBVgQb<#I^HFTu4<5DF$F3aV{JGZQ3AGx6mfm=NH^W1t7E<-|xt%6a{O&I#ebHci-G zR2_w2)E9 z19RrgY4Y|mfX2oK;<<1sI?40rJsAO)xn>@mt57u!p-2P;{_q_<7!KS__MoUL1bOHZ z(K$HuV1XcnU~mWoS2&~>QYKIBSSl~1$e8UNo&jC=QA@V2f|F6mC)42JAZ*g zfux+^+&iNv3Vc2vfpq{)1$4ucH*J$b;!>(8o*u?E=(=9PeZD}Eswz+@3@Syz^HK=9 z>kNdhgET==ID)|t#C} zh)@Q*l$g2%9m+VT7ZQy&y{;HD<=z&u!bS@#zNLiw>EDoLyFKr!%mU zmjR?xX>@j6F1O~UCZvcjl^#BP$Sb?NY2q-w2_-;LUV*OB3du&H{omzq5%7^hDt zF5tQjipnY=8=IX=Zs(oZ)JF>ANgqW)qw-OxB1j4ftvMwVIS7N2G$0d_4o3?q?DSt6 ztL)Q9ZhIb9R94bmyA%tCXi-m!pbMMinW~XG;=4AN4l0yuGRI5GtCHu=H+{&fs~=B+ z^Ob(X4|x?Dh0UeLVP`YQwKRF?v$VG8yU(HAh(rqDDWM!FDk=g6jEEsjU~S8$J;lor zDbGkJ2(XM+KATZ;nIsrzU<4DY`bPE7p6%w+1y}IJy5`Fp{s8d_F=e|&{oGev>Y+C& z#GG>i;}SU@$xzBfl*%2FmupE&=oM(@~^BdsuW!dA>PpnuJFYp zN6z%>)&n4sn7r$ypwBp{=|0rgokW~oI{qFZC;=5l*bl`|kx>A8rGR=GOxFUnS0W1* z^^6=(AxrDsC8tk&11y^Jr$RRDM0^{c$8*!TK;Mr&+fU-a%* zjvmEDL_HAc8TF{JvB`L_@{edQy0`!y)~b4@98^fQtk`;xC%frs;d{AgC3n;TvF6RVM2R*CptRfh{qG? z?oJ|+NFv#tLMoX-Q)4qOwYH(NJx-q4uuSsMw2>!r1fWqB1*v@(mlk8lm{BN>^}di5 z?$jywJah)v41UepTRyvR;o{&y{QY$Dpc4qMi*#isRZux;Zi zG#}lCwz|V;{Cq!-9oUV7hxX$XeG%86JB!AK29GW_HV_u#&eO&(DX=5>O`WVdr(Re&CYXauqGaXZ>O~HiQeu&ZJ z-N>5;VQAkHlmrC=A`L5kkv_@maB%mQ^RNH&FN?N*ws*pu>C?|i-Cw2a0+2%0)z#*V zxj%Vc_vtrK3I0u0)TGbwA(hHdsWQknVOd!W{rdN(57vPgK5RI~jT?vC#*f9FlW%XG zJ7>n?g$sUq%f_|qp8MldPo<^K>(Ms?xFS4Z%6;`yW - + + + - - - 项目导航 - + + + 墨水屏日历 + + + - -

目录:

- - - +
+

墨水屏日历

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

三色墨水屏待办生成器

-

提示:红色代表未完成(急需处理),黑色代表已完成

- -
-
-
- - -
-
- -
- -
- -
-
- - - - \ No newline at end of file diff --git a/gen/v1.5/css/main.css b/gen/v1.5/css/main.css new file mode 100644 index 0000000..e6e4441 --- /dev/null +++ b/gen/v1.5/css/main.css @@ -0,0 +1,370 @@ +:root { + --primary-color: #0d6efd; + --primary-hover: #0b5ed7; + --secondary-color: #6c757d; + --secondary-hover: #5c636a; + + --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: system-ui, -apple-system, sans-serif; + overflow-x: hidden; +} + +.debug { + display: none !important; +} + +body.debug-mode .debug { + display: flex !important; +} + +body.debug-mode { + background-color: var(--dark-bg); + color: var(--dark-text); +} + +body.debug-mode .main { + background-color: var(--dark-bg); + color: var(--dark-text); +} + +body.debug-mode fieldset { + background-color: var(--dark-fieldset-bg); + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.5); +} + +body.debug-mode h3 { + border-bottom: 1px solid var(--dark-border); + color: var(--dark-text); +} + +body.debug-mode code { + background: var(--dark-code-bg); + color: #ff9800; +} + +body.debug-mode #log { + background: var(--dark-log-bg); + border: 1px solid var(--dark-border); +} + +body.debug-mode #log .time { + color: #8bc34a; +} + +body.debug-mode #log .action { + color: #03a9f4; +} + +body.debug-mode input[type=text], +body.debug-mode input[type=number], +body.debug-mode select { + background-color: var(--dark-input-bg); + color: var(--dark-input-text); + border-color: var(--dark-border); +} + +body.debug-mode input[type=file] { + color: var(--dark-input-text); + background-color: transparent; + border-color: var(--dark-border); +} + +body.debug-mode input[type=file]::file-selector-button { + background-color: var(--dark-fieldset-bg); + color: var(--dark-input-text); + border-color: var(--dark-border); +} + +body.debug-mode input[type=file]::file-selector-button:hover { + background-color: #333; + border-color: #444; +} + +body.debug-mode fieldset legend { + color: #64b5f6; +} + +.main { + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0 1rem; + background: #fff; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + box-sizing: border-box; +} + +.footer { + display: flex; + gap: 10px; + font-size: 0.8rem; + color: #666; + flex-wrap: wrap; + margin: 1rem 0; +} + +.footer .links { + display: flex; + align-items: center; +} + +.footer .links a { + color: #666; + 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: #0d6efd; + text-decoration: underline; +} + +body.debug-mode .footer .links a:not(:last-child)::after { + color: #666; +} + +body.debug-mode .footer { + color: #999; +} + +body.debug-mode .footer a { + color: #999; +} + +body.debug-mode .footer a:hover { + color: #64b5f6; +} + +h3 { + padding-bottom: .3em; + border-bottom: 1px solid #CCC; + text-align: center; +} + +fieldset { + border: none; + box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.2); + background-color: #f8f9fa; + padding: 10px; + margin-bottom: 16px; + border-radius: 4px; +} + +fieldset legend { + font-weight: bold; + color: rgba(0, 0, 255, 0.6); +} + +code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background: #CCC; + border-radius: 3px; +} + +.flex-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.flex-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +#status { + margin: 10px 0; +} + +#log { + width: 100%; + min-height: 100px; + max-height: 300px; + margin: 0; + padding: 5px; + background: #DDD; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + box-sizing: border-box; +} + +#log .time { + color: #333; +} + +#log .action { + color: #666; +} + +#canvas-box { + margin-top: 10px; + width: 100%; +} + +#canvas { + border: black solid 1px; + max-width: 100%; + height: auto; + display: block; + margin: 0 auto; +} + +button { + padding: 0.375rem 0.75rem; + border: 1px solid var(--primary-color); + border-radius: 0.375rem; + margin-bottom: 5px; + white-space: nowrap; + cursor: pointer; + font-size: 0.9rem; +} + +button:disabled { + opacity: 0.65; +} + +button.primary { + color: #fff; + background-color: var(--primary-color); +} + +button.primary:hover { + color: #fff; + border-color: var(--primary-hover); + background-color: var(--primary-hover); +} + +button.secondary { + color: #fff; + background-color: var(--secondary-color); + border-color: var(--secondary-color); +} + +button.secondary:hover { + color: #fff; + border-color: var(--secondary-hover); + background-color: var(--secondary-hover); +} + +input[type=text], +input[type=number], +select { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: .2rem .75rem; + max-width: 100%; + box-sizing: border-box; +} + +input[type=file] { + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: #212529; + max-width: 100%; +} + +input::file-selector-button { + font-size: 0.9rem; + font-weight: 400; + line-height: 1.5; + border: 1px solid var(--primary-color); + border-radius: 0.375rem; + cursor: pointer; +} + +select { + padding: .3rem 2.25rem .3rem .75rem; +} + +input:focus, +select:focus { + border: 1px solid #86b7fe; + box-shadow: 0 0 4px rgba(0, 120, 215, 0.8); + outline: 0; +} + +label { + margin-right: 4px; + white-space: nowrap; +} + +.status-bar { + display: none; + font-size: 85%; + color: #666; + margin-bottom: 10px; + padding-bottom: 10px; + border-bottom: 1px dotted #AAA; +} + +@media (max-width: 768px) { + .flex-container { + flex-direction: column; + } + + .left-controls, + .right-controls { + margin-left: 0; + width: 100%; + } + + .canvas-log-container { + flex-direction: column; + } + + #log { + height: 150px; + margin-top: 10px; + } + + fieldset { + padding: 8px; + } + + button { + width: auto; + } + + input[type=text], + input[type=number], + select { + max-width: 100%; + margin-bottom: 5px; + } +} \ No newline at end of file diff --git a/gen/v1.5/index.html b/gen/v1.5/index.html new file mode 100644 index 0000000..3d7ce39 --- /dev/null +++ b/gen/v1.5/index.html @@ -0,0 +1,114 @@ + + + + + + 4.2 寸电子墨水屏蓝牙控制器 + + + + +
+

4.2 寸电子墨水屏蓝牙控制器

+
+ 蓝牙连接 +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + + +
+
+ + +
+
+
+
+ +
+ 蓝牙传图 +
+ +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
状态:
+
+
+ + +
+
+ +
+ +
+ + + + + + \ No newline at end of file diff --git a/gen/v1.5/js/dithering.js b/gen/v1.5/js/dithering.js new file mode 100644 index 0000000..9be0141 --- /dev/null +++ b/gen/v1.5/js/dithering.js @@ -0,0 +1,212 @@ +const bwrPalette = [ + [0, 0, 0, 255], + [255, 255, 255, 255], + [255, 0, 0, 255] +] + +const bwPalette = [ + [0, 0, 0, 255], + [255, 255, 255, 255], +] + +function dithering(ctx, width, height, threshold, type) { + const bayerThresholdMap = [ + [ 15, 135, 45, 165 ], + [ 195, 75, 225, 105 ], + [ 60, 180, 30, 150 ], + [ 240, 120, 210, 90 ] + ]; + + const lumR = []; + const lumG = []; + const lumB = []; + for (let i=0; i<256; i++) { + lumR[i] = i*0.299; + lumG[i] = i*0.587; + lumB[i] = i*0.114; + } + const imageData = ctx.getImageData(0, 0, width, height); + + const imageDataLength = imageData.data.length; + + // Greyscale luminance (sets r pixels to luminance of rgb) + for (let i = 0; i <= imageDataLength; i += 4) { + imageData.data[i] = Math.floor(lumR[imageData.data[i]] + lumG[imageData.data[i+1]] + lumB[imageData.data[i+2]]); + } + + const w = imageData.width; + let newPixel, err; + + for (let currentPixel = 0; currentPixel <= imageDataLength; currentPixel+=4) { + if (type === "gray") { + const factor = 255 / (threshold - 1); + imageData.data[currentPixel] = Math.round(imageData.data[currentPixel] / factor) * factor; + } else if (type ==="none") { + // No dithering + imageData.data[currentPixel] = imageData.data[currentPixel] < threshold ? 0 : 255; + } else if (type ==="bayer") { + // 4x4 Bayer ordered dithering algorithm + var x = currentPixel/4 % w; + var y = Math.floor(currentPixel/4 / w); + var map = Math.floor( (imageData.data[currentPixel] + bayerThresholdMap[x%4][y%4]) / 2 ); + imageData.data[currentPixel] = (map < threshold) ? 0 : 255; + } else if (type ==="floydsteinberg") { + // Floyda€"Steinberg dithering algorithm + newPixel = imageData.data[currentPixel] < 129 ? 0 : 255; + err = Math.floor((imageData.data[currentPixel] - newPixel) / 16); + imageData.data[currentPixel] = newPixel; + + imageData.data[currentPixel + 4 ] += err*7; + imageData.data[currentPixel + 4*w - 4 ] += err*3; + imageData.data[currentPixel + 4*w ] += err*5; + imageData.data[currentPixel + 4*w + 4 ] += err*1; + } else { + // Bill Atkinson's dithering algorithm + newPixel = imageData.data[currentPixel] < threshold ? 0 : 255; + err = Math.floor((imageData.data[currentPixel] - newPixel) / 8); + imageData.data[currentPixel] = newPixel; + + imageData.data[currentPixel + 4 ] += err; + imageData.data[currentPixel + 8 ] += err; + imageData.data[currentPixel + 4*w - 4 ] += err; + imageData.data[currentPixel + 4*w ] += err; + imageData.data[currentPixel + 4*w + 4 ] += err; + imageData.data[currentPixel + 8*w ] += err; + } + + // Set g and b pixels equal to r + imageData.data[currentPixel + 1] = imageData.data[currentPixel + 2] = imageData.data[currentPixel]; + } + + ctx.putImageData(imageData, 0, 0); +} + +// white: 1, black/red: 0 +function canvas2bytes(canvas, type='bw', invert = false) { + const ctx = canvas.getContext("2d"); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + const arr = []; + let buffer = []; + + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const i = (canvas.width * y + x) * 4; + if (type !== 'red') { + buffer.push(imageData.data[i] === 0 && imageData.data[i+1] === 0 && imageData.data[i+2] === 0 ? 0 : 1); + } else { + buffer.push(imageData.data[i] > 0 && imageData.data[i+1] === 0 && imageData.data[i+2] === 0 ? 0 : 1); + } + + if (buffer.length === 8) { + const data = parseInt(buffer.join(''), 2); + arr.push(invert ? ~data : data); + buffer = []; + } + } + } + return arr; +} + +function getColorDistance(rgba1, rgba2) { + const [r1, b1, g1] = rgba1; + const [r2, b2, g2] = rgba2; + + const rm = (r1 + r2 ) / 2; + + const r = r1 - r2; + const g = g1 - g2; + const b = b1 - b2; + + return Math.sqrt((2 + rm / 256) * r * r + 4 * g * g + (2 + (255 - rm) / 256) * b * b); +} + +function getNearColor(pixel, palette) { + let minDistance = 255 * 255 * 3 + 1; + let paletteIndex = 0; + + for (let i = 0; i < palette.length; i++) { + const targetColor = palette[i]; + const distance = getColorDistance(pixel, targetColor); + if (distance < minDistance) { + minDistance = distance; + paletteIndex = i; + } + } + + return palette[paletteIndex]; +} + + +function getNearColorV2(color, palette) { + let minDistanceSquared = 255*255 + 255*255 + 255*255 + 1; + + let bestIndex = 0; + for (let i = 0; i < palette.length; i++) { + let rdiff = (color[0] & 0xff) - (palette[i][0] & 0xff); + let gdiff = (color[1] & 0xff) - (palette[i][1] & 0xff); + let bdiff = (color[2] & 0xff) - (palette[i][2] & 0xff); + let distanceSquared = rdiff*rdiff + gdiff*gdiff + bdiff*bdiff; + if (distanceSquared < minDistanceSquared) { + minDistanceSquared = distanceSquared; + bestIndex = i; + } + } + return palette[bestIndex]; + +} + + +function updatePixel(imageData, index, color) { + imageData[index] = color[0]; + imageData[index+1] = color[1]; + imageData[index+2] = color[2]; + imageData[index+3] = color[3]; +} + +function getColorErr(color1, color2, rate) { + const res = []; + for (let i = 0; i < 3; i++) { + res.push(Math.floor((color1[i] - color2[i]) / rate)); + } + return res; +} + +function updatePixelErr(imageData, index, err, rate) { + imageData[index] += err[0] * rate; + imageData[index+1] += err[1] * rate; + imageData[index+2] += err[2] * rate; +} + +function ditheringCanvasByPalette(canvas, palette, type) { + palette = palette || bwrPalette; + + const ctx = canvas.getContext('2d'); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const w = imageData.width; + + for (let currentPixel = 0; currentPixel <= imageData.data.length; currentPixel+=4) { + const newColor = getNearColorV2(imageData.data.slice(currentPixel, currentPixel+4), palette); + + if (type === "bwr_floydsteinberg") { + const err = getColorErr(imageData.data.slice(currentPixel, currentPixel+4), newColor, 16); + + updatePixel(imageData.data, currentPixel, newColor); + updatePixelErr(imageData.data, currentPixel +4, err, 7); + updatePixelErr(imageData.data, currentPixel + 4*w - 4, err, 3); + updatePixelErr(imageData.data, currentPixel + 4*w, err, 5); + updatePixelErr(imageData.data, currentPixel + 4*w + 4, err, 1); + } else { + const err = getColorErr(imageData.data.slice(currentPixel, currentPixel+4), newColor, 8); + + updatePixel(imageData.data, currentPixel, newColor); + updatePixelErr(imageData.data, currentPixel +4, err, 1); + updatePixelErr(imageData.data, currentPixel +8, err, 1); + updatePixelErr(imageData.data, currentPixel +4 * w - 4, err, 1); + updatePixelErr(imageData.data, currentPixel +4 * w, err, 1); + updatePixelErr(imageData.data, currentPixel +4 * w + 4, err, 1); + updatePixelErr(imageData.data, currentPixel +8 * w, err, 1); + } + } + ctx.putImageData(imageData, 0, 0); +} \ No newline at end of file diff --git a/gen/v1.5/js/main.js b/gen/v1.5/js/main.js new file mode 100644 index 0000000..236df20 --- /dev/null +++ b/gen/v1.5/js/main.js @@ -0,0 +1,369 @@ +let bleDevice, gattServer; +let epdService, epdCharacteristic; +let startTime, msgIndex; +let canvas, ctx, textDecoder; + +const EpdCmd = { + SET_PINS: 0x00, + INIT: 0x01, + CLEAR: 0x02, + SEND_CMD: 0x03, + SEND_DATA: 0x04, + REFRESH: 0x05, + SLEEP: 0x06, + + SET_TIME: 0x20, + + SET_CONFIG: 0x90, + SYS_RESET: 0x91, + SYS_SLEEP: 0x92, + CFG_ERASE: 0x99, +}; + +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 epdWrite(cmd, data) { + const chunkSize = document.getElementById('mtusize').value - 1; + const interleavedCount = document.getElementById('interleavedcount').value; + const count = Math.round(data.length / chunkSize); + let chunkIdx = 0; + let noReplyCount = interleavedCount; + + if (typeof data == 'string') data = hex2bytes(data); + + await write(EpdCmd.SEND_CMD, [cmd]); + for (let i = 0; i < data.length; i += chunkSize) { + let currentTime = (new Date().getTime() - startTime) / 1000.0; + setStatus(`命令:0x${cmd.toString(16)}, 数据块: ${chunkIdx+1}/${count+1}, 总用时: ${currentTime}s`); + if (noReplyCount > 0) { + await write(EpdCmd.SEND_DATA, data.slice(i, i + chunkSize), false); + noReplyCount--; + } else { + await write(EpdCmd.SEND_DATA, data.slice(i, i + chunkSize), true); + noReplyCount = interleavedCount; + } + chunkIdx++; + } +} + +async function setDriver() { + await write(EpdCmd.SET_PINS, document.getElementById("epdpins").value); + await write(EpdCmd.INIT, document.getElementById("epddriver").value); +} + +async function syncTime(mode) { + const timestamp = new Date().getTime() / 1000; + const data = new Uint8Array([ + (timestamp >> 24) & 0xFF, + (timestamp >> 16) & 0xFF, + (timestamp >> 8) & 0xFF, + timestamp & 0xFF, + -(new Date().getTimezoneOffset() / 60), + mode + ]); + if(await write(EpdCmd.SET_TIME, data)) { + addLog("时间已同步!"); + } +} + +async function clearScreen() { + if(confirm('确认清除屏幕内容?')) { + await write(EpdCmd.CLEAR); + } +} + +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); +} + +async function sendimg() { + const status = document.getElementById("status"); + const driver = document.getElementById("epddriver").value; + const mode = document.getElementById('dithering').value; + + if (mode === '') { + alert('请选择一种取模算法!'); + return; + } + + startTime = new Date().getTime(); + status.parentElement.style.display = "block"; + + if (mode.startsWith('bwr')) { + const invert = (driver === '02') || (driver === '05'); + await epdWrite(driver === "02" ? 0x24 : 0x10, canvas2bytes(canvas, 'bw')); + await epdWrite(driver === "02" ? 0x26 : 0x13, canvas2bytes(canvas, 'red', invert)); + } else { + await epdWrite(driver === "04" ? 0x24 : 0x13, canvas2bytes(canvas, 'bw')); + } + + await write(EpdCmd.REFRESH); + + const sendTime = (new Date().getTime() - startTime) / 1000.0; + addLog(`发送完成!耗时: ${sendTime}s`); + setStatus(`发送完成!耗时: ${sendTime}s`); + setTimeout(() => { + status.parentElement.style.display = "none"; + }, 5000); +} + +function updateButtonStatus() { + const connected = gattServer != null && gattServer.connected; + const status = connected ? null : 'disabled'; + document.getElementById("reconnectbutton").disabled = (gattServer == null || gattServer.connected) ? 'disabled' : null; + document.getElementById("sendcmdbutton").disabled = status; + document.getElementById("calendarmodebutton").disabled = status; + document.getElementById("clockmodebutton").disabled = status; + document.getElementById("clearscreenbutton").disabled = status; + document.getElementById("sendimgbutton").disabled = status; + document.getElementById("setDriverbutton").disabled = status; +} + +function disconnect() { + updateButtonStatus(); + resetVariables(); + addLog('已断开连接.'); + document.getElementById("connectbutton").innerHTML = '连接'; +} + +async function preConnect() { + if (gattServer != null && gattServer.connected) { + if (bleDevice != null && bleDevice.gatt.connected) { + bleDevice.gatt.disconnect(); + } + } + else { + resetVariables(); + try { + bleDevice = await navigator.bluetooth.requestDevice({ + optionalServices: ['62750001-d828-918d-fb46-b6c11c675aec'], + acceptAllDevices: true + }); + } catch (e) { + console.error(e); + if (e.message) addLog("requestDevice: " + e.message); + addLog("请检查蓝牙是否已开启,且使用的浏览器支持蓝牙!建议使用以下浏览器:"); + addLog("• 电脑: Chrome/Edge"); + addLog("• Android: Chrome/Edge"); + addLog("• iOS: Bluefy 浏览器"); + return; + } + + await bleDevice.addEventListener('gattserverdisconnected', disconnect); + setTimeout(async function () { await connect(); }, 300); + } +} + +async function reConnect() { + if (bleDevice != null && bleDevice.gatt.connected) + bleDevice.gatt.disconnect(); + resetVariables(); + addLog("正在重连"); + setTimeout(async function () { await connect(); }, 300); +} + +function handleNotify(value, idx) { + const data = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + if (idx == 0) { + addLog(`收到配置:${bytes2hex(data)}`); + const epdpins = document.getElementById("epdpins"); + const epddriver = document.getElementById("epddriver"); + epdpins.value = bytes2hex(data.slice(0, 7)); + if (data.length > 10) epdpins.value += bytes2hex(data.slice(10, 11)); + epddriver.value = bytes2hex(data.slice(7, 8)); + filterDitheringOptions(); + } else { + if (textDecoder == null) textDecoder = new TextDecoder(); + const msg = textDecoder.decode(data); + addLog(` ${msg}`); + } +} + +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 { + await epdCharacteristic.startNotifications(); + epdCharacteristic.addEventListener('characteristicvaluechanged', (event) => { + handleNotify(event.target.value, msgIndex++); + }); + } catch (e) { + console.error(e); + if (e.message) addLog("startNotifications: " + e.message); + } + + await write(EpdCmd.INIT); + + document.getElementById("connectbutton").innerHTML = '断开'; + updateButtonStatus(); +} + +function setStatus(statusText) { + document.getElementById("status").innerHTML = statusText; +} + +function addLog(logTXT) { + 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') + " "; + log.innerHTML += '' + time + '' + logTXT + '
'; + log.scrollTop = log.scrollHeight; + while ((log.innerHTML.match(/
/g) || []).length > 20) { + var logs_br_position = log.innerHTML.search("
"); + log.innerHTML = log.innerHTML.substring(logs_br_position + 4); + log.scrollTop = log.scrollHeight; + } +} + +function clearLog() { + document.getElementById("log").innerHTML = ''; +} + +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); +} + +async function update_image() { + const image_file = document.getElementById('image_file'); + if (image_file.files.length == 0) return; + + let image = new Image();; + const file = image_file.files[0]; + image.src = URL.createObjectURL(file); + + image.onload = function(event) { + URL.revokeObjectURL(this.src); + ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height); + convert_dithering() + } +} + +function clear_canvas() { + if(confirm('确认清除画布内容?')) { + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } +} + +function convert_dithering() { + const mode = document.getElementById('dithering').value; + if (mode === '') return; + + if (mode.startsWith('bwr')) { + ditheringCanvasByPalette(canvas, bwrPalette, mode); + } else { + dithering(ctx, canvas.width, canvas.height, parseInt(document.getElementById('threshold').value), mode); + } +} + +function filterDitheringOptions() { + const driver = document.getElementById('epddriver').value; + const dithering = document.getElementById('dithering'); + for (let optgroup of dithering.getElementsByTagName('optgroup')) { + const drivers = optgroup.getAttribute('data-driver').split('|'); + const show = drivers.includes(driver); + for (option of optgroup.getElementsByTagName('option')) { + if (show) + option.removeAttribute('disabled'); + else + option.setAttribute('disabled', 'disabled'); + } + } + dithering.value = ''; +} + +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('debug-mode'); + link.innerHTML = '正常模式'; + link.setAttribute('href', window.location.pathname); + addLog("注意:开发模式功能已开启!不懂请不要随意修改,否则后果自负!"); + } else { + document.body.classList.remove('debug-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); + + updateButtonStatus(); + filterDitheringOptions(); + + checkDebugMode(); +} \ No newline at end of file