添加esp支持
Vercel Deploy / deploy (push) Successful in 1m3s

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

After

Width:  |  Height:  |  Size: 2.8 KiB

+358
View File
@@ -0,0 +1,358 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>墨水屏日历</title>
<link rel="shortcut icon" type="image/png" href="favicon.png">
<link rel="stylesheet" href="css/main.css?v=20251109">
</head>
<body>
<div class="main">
<h3>墨水屏日历</h3>
<fieldset>
<legend>蓝牙连接</legend>
<div class="flex-container">
<div class="flex-group">
<button id="connectbutton" type="button" class="primary" onclick="preConnect()">连接</button>
<button id="reconnectbutton" type="button" class="secondary" onclick="reConnect()">重连</button>
<button type="button" class="secondary" onclick="clearLog()">清空日志</button>
</div>
<div class="flex-group right debug">
<label for="epddriver">驱动</label>
<select id="epddriver" onchange="updateDitcherOptions()">
<option value="03" data-color="threeColor" data-size="4.2_400_300">4.2寸 (三色, UC8176)</option>
</select>
</div>
<div class="flex-group debug">
<label for="epdpins">引脚</label>
<input id="epdpins" type="text" value="">
<button id="setDriverbutton" type="button" class="primary" onclick="setDriver()">确定</button>
</div>
</div>
<div class="log-container" id="log"></div>
</fieldset>
<fieldset>
<legend>设备控制</legend>
<div class="flex-container">
<div class="flex-group">
<button id="calendarmodebutton" type="button" class="primary" onclick="syncTime(1)">日历模式</button>
<button id="clearscreenbutton" type="button" class="secondary" onclick="clearScreen()">清除屏幕</button>
</div>
<div class="flex-group right debug">
<input type="text" id="cmdTXT" value="">
<button id="sendcmdbutton" type="button" class="primary" onclick="sendcmd()">发送命令</button>
</div>
</div>
<div class="template-panel">
<div class="template-header">
<strong>重要日期</strong>
<span>在月历上标注自定义日期</span>
</div>
<div class="flex-container">
<div class="flex-group">
<input type="date" id="calendar-mark-date">
<button type="button" class="secondary" id="calendar-mark-add">添加标注</button>
</div>
</div>
<div class="template-list" id="calendar-mark-items"></div>
</div>
<div class="panel-tabs" id="image-source-tabs">
<button type="button" class="panel-tab active" data-tab-target="image-upload-panel">图片上传</button>
<button type="button" class="panel-tab" data-tab-target="countdown-template-panel">倒计时模板</button>
<button type="button" class="panel-tab" data-tab-target="todo-template-panel">待办任务</button>
</div>
<div class="panel-tab-content active" id="image-upload-panel">
<div class="flex-container">
<input type="file" id="imageFile" accept=".png,.jpg,.bmp,.webp,.jpeg" onchange="updateImage()">
</div>
</div>
<div class="panel-tab-content" id="countdown-template-panel">
<div class="template-panel">
<div class="template-header">
<strong>倒计时模板</strong>
<span>将 `countdown.html` 和 `countdown_s.html` 的核心样式渲染到当前画布</span>
</div>
<div class="flex-container">
<div class="flex-group">
<label for="countdown-template-mode">模式:</label>
<select id="countdown-template-mode">
<option value="single">单个倒计时</option>
<option value="grid">网格倒计时</option>
</select>
<button type="button" class="secondary" id="apply-countdown-template">渲染到画布</button>
<button type="button" class="primary" id="countdownmodebutton" onclick="activateCountdownMode()">设备自动倒计时</button>
</div>
<div class="flex-group right">
<input type="file" id="countdown-import-file" accept=".json,application/json" hidden>
<button type="button" class="secondary" id="import-countdown-template">导入</button>
<button type="button" class="secondary" id="export-countdown-template">导出</button>
</div>
</div>
<div class="template-mode-panel" id="countdown-single-panel">
<div class="flex-container">
<div class="flex-group">
<label for="countdown-single-motto">标语:</label>
<input type="text" id="countdown-single-motto" value="保持专注">
</div>
<div class="flex-group">
<label for="countdown-single-label">事件:</label>
<input type="text" id="countdown-single-label" value="目标日">
</div>
<div class="flex-group">
<label for="countdown-single-date">日期:</label>
<input type="date" id="countdown-single-date">
</div>
</div>
</div>
<div class="template-mode-panel" id="countdown-grid-panel">
<div class="flex-container">
<div class="flex-group">
<label for="countdown-grid-title">标题:</label>
<input type="text" id="countdown-grid-title" value="倒计时看板">
</div>
<div class="flex-group">
<button type="button" class="secondary" id="countdown-grid-add">添加项目</button>
</div>
</div>
<div class="template-list" id="countdown-grid-items"></div>
</div>
<div class="template-panel">
<div class="template-header">
<strong>数字覆盖区域</strong>
<span>上传自动模板前可手动微调 x/y/w/h</span>
</div>
<div class="flex-container">
<div class="flex-group">
<button type="button" class="secondary" id="countdown-regions-refresh">自动生成区域</button>
</div>
</div>
<div class="template-list" id="countdown-region-items"></div>
</div>
</div>
</div>
<div class="panel-tab-content" id="todo-template-panel">
<div class="template-panel">
<div class="template-header">
<strong>待办任务</strong>
<span>创建简洁的待办清单并渲染到当前画布</span>
</div>
<div class="flex-container">
<div class="flex-group">
<label for="todo-template-title">标题:</label>
<input type="text" id="todo-template-title" value="今日重点">
<button type="button" class="secondary" id="apply-todo-template">渲染到画布</button>
</div>
<div class="flex-group right">
<input type="file" id="todo-import-file" accept=".json,application/json" hidden>
<button type="button" class="secondary" id="import-todo-template">导入</button>
<button type="button" class="secondary" id="export-todo-template">导出</button>
</div>
</div>
<div class="flex-container">
<div class="flex-group">
<label for="todo-template-note">备注:</label>
<input type="text" id="todo-template-note" value="一次做好一件事">
</div>
<div class="flex-group">
<button type="button" class="secondary" id="todo-list-add">添加任务</button>
</div>
</div>
<div class="template-list" id="todo-list-items"></div>
</div>
</div>
<div class="flex-container options">
<div class="flex-group debug">
<label for="canvasSize">画布尺寸:</label>
<select id="canvasSize" onchange="updateCanvasSize()">
<option value="1.54_152_152">1.54 (152x152)</option>
<option value="1.54_200_200">1.54 (200x200)</option>
<option value="2.13_104_212">2.13 (104x212)</option>
<option value="2.13_122_250">2.13 (122x250)</option>
<option value="2.66_152_296">2.66 (152x296)</option>
<option value="2.66_184_360">2.66 (184x360)</option>
<option value="2.9_128_296">2.9 (128x296)</option>
<option value="2.9_168_384">2.9 (168x384)</option>
<option value="3.5_184_384">3.5 (184x384)</option>
<option value="3.5_360_600">3.5 (360x600)</option>
<option value="3.7_240_416">3.7 (240x416)</option>
<option value="3.7_280_480">3.7 (280x480)</option>
<option value="3.97_800_480">3.97 (800x480)</option>
<option value="3.98_768_552">3.98 (768x552)</option>
<option value="4.2_400_300" selected>4.2 (400x300)</option>
<option value="5.79_792_272">5.79 (792x272)</option>
<option value="5.83_600_448">5.83 (600x448)</option>
<option value="5.83_648_480">5.83 (648x480)</option>
<option value="7.5_640_384">7.5 (640x384)</option>
<option value="7.5_800_480">7.5 (800x480)</option>
<option value="7.5_880_528">7.5 (880x528)</option>
<option value="10.2_960_640">10.2 (960x640)</option>
<option value="10.85_1360_480">10.85 (1360x480)</option>
<option value="11.6_960_640">11.6 (960x640)</option>
<option value="4.0E6_600_400">4.0E6 (600x400)</option>
<option value="7.3E6_800_480">7.3E6 (800x480)</option>
</select>
</div>
<div class="flex-group debug">
<label for="ditherMode">颜色模式:</label>
<select id="ditherMode" onchange="applyDither()">
<option value="blackWhiteColor">双色(黑白)</option>
<option value="threeColor">三色(黑白红)</option>
<option value="fourColor">四色(黑白红黄)</option>
<option value="sixColor">六色(黑白红黄蓝绿)</option>
</select>
</div>
<div class="flex-group">
<label for="ditherAlg">抖动算法:</label>
<select id="ditherAlg" onchange="applyDither()">
<option value="floydSteinberg">Floyd-Steinberg</option>
<option value="atkinson">Atkinson</option>
<option value="bayer">Bayer</option>
<option value="stucki">Stucki</option>
<option value="jarvis">Jarvis-Judice-Ninke</option>
<option value="none">无抖动</option>
</select>
</div>
<div class="flex-group">
<label for="ditherStrength">抖动强度:</label>
<input type="range" min="0" max="5" step="0.1" value="1.0" id="ditherStrength">
<label id="ditherStrengthValue">1.0</label>
</div>
<div class="flex-group">
<label for="ditherContrast">对比度:</label>
<input type="range" min="0.5" max="2" step="0.1" value="1.2" id="ditherContrast">
<label id="ditherContrastValue">1.2</label>
</div>
</div>
<div class="flex-container options">
<div class="flex-group debug">
<label for="mtusize">MTU:</label>
<input type="number" id="mtusize" value="20" min="0" max="255">
<label for="interleavedcount">确认间隔:</label>
<input type="number" id="interleavedcount" value="50" min="0" max="500">
</div>
</div>
<div class="status-bar"><b>状态:</b><span id="status"></span></div>
<div class="flex-container">
<div class="flex-group">
<button type="button" class="secondary debug" onclick="rotateCanvas()">旋转画布</button>
<button type="button" class="secondary" onclick="clearCanvas()">清除画布</button>
<button type="button" class="secondary debug" onclick="downloadDataArray()">下载数组</button>
<button id="sendimgbutton" type="button" class="primary" onclick="sendimg()">发送图片</button>
</div>
</div>
<div class="canvas-container">
<div class="canvas-title"></div>
<div class="canvas-frame">
<canvas id="canvas" width="400" height="300"></canvas>
<div id="countdown-region-overlay" class="region-overlay" aria-hidden="true"></div>
</div>
<div class="flex-container canvas-tools">
<div class="flex-group tool-buttons">
<button id="brush-mode" title="画笔" class="tool-button">✏️</button>
<button id="eraser-mode" title="橡皮擦" class="tool-button">🧽</button>
<button id="text-mode" title="添加文字" class="tool-button">T</button>
<button id="undo-btn" title="撤销 (Ctrl+Z)" class="tool-button hide"></button>
<button id="redo-btn" title="重做 (Ctrl+Y)" class="tool-button hide"></button>
</div>
</div>
<div class="flex-container canvas-tools">
<div class="flex-group brush-tools">
<label for="brush-color">颜色:</label>
<select id="brush-color">
<option value="#000000">黑色</option>
<option value="#FF0000">红色</option>
<option value="#FFFF00">黄色</option>
<option value="#00FF00">绿色</option>
<option value="#0000FF">蓝色</option>
<option value="#FFFFFF">白色</option>
</select>
<label for="brush-size">粗细:</label>
<input type="number" id="brush-size" value="2" min="1" max="100">
</div>
</div>
<div class="flex-container canvas-tools">
<div class="flex-group text-tools">
<label for="font-family">字体:</label>
<select id="font-family">
<option value="Arial">Arial</option>
<option value="sans-serif">Sans-serif</option>
<option value="monospace">Monospace</option>
<option value="SimSun">宋体</option>
<option value="SimHei">黑体</option>
<option value="Microsoft Yahei">微软雅黑</option>
<option value="Microsoft JhengHei">微软正黑体</option>
<option value="KaiTi">楷体</option>
<option value="NSimSun">新宋体</option>
<option value="FangSong">仿宋</option>
<option value="YouYuan">幼圆</option>
<option value="LiSu">隶书</option>
<option value="STHeiti">华文黑体</option>
<option value="STXihei">华文细黑</option>
<option value="STKaiti">华文楷体</option>
<option value="STSong">华文宋体</option>
<option value="STFangsong">华文仿宋</option>
<option value="STZhongsong">华文中宋</option>
<option value="STHupo">华文琥珀</option>
<option value="STXinwei">华文新魏</option>
<option value="STLiti">华文隶书</option>
<option value="STXingkai">华文行楷</option>
<option value="FZShuTi">方正舒体</option>
<option value="FZYaoti">方正姚体</option>
<option value="PingFang SC">苹方</option>
<option value="Source Han Sans CN">思源黑体</option>
<option value="Source Han Serif SC">思源宋体</option>
<option value="WenQuanYi Micro Hei">文泉驿微米黑</option>
</optgroup>
</select>
<label for="font-size">大小:</label>
<input type="number" id="font-size" value="16" min="1" max="100">
</div>
<div class="flex-group text-tools">
<div class="markdown-text-input-group">
<textarea id="text-input" rows="6" placeholder="输入 Markdown 文本,例如:&#10;# 标题&#10;- 列表项&#10;**加粗** 和 *斜体*&#10;`代码`"></textarea>
<div class="markdown-text-hint">支持标题、列表、引用、粗体、斜体、删除线和行内代码</div>
</div>
<button id="text-bold" title="粗体">B</button>
<button id="text-italic" title="斜体">I</button>
<button id="add-text-btn" class="primary">添加文字</button>
</div>
<div class="flex-group crop-tools">
<button id="crop-zoom-in" title="放大" class="secondary">+</button>
<button id="crop-zoom-out" title="缩小" class="secondary">-</button>
<button id="crop-move-left" title="左移"></button>
<button id="crop-move-up" title="上移"></button>
<button id="crop-move-down" title="下移"></button>
<button id="crop-move-right" title="右移"></button>
<button class="primary" onclick="applyDither()">完成</button>
</div>
</div>
</div>
</fieldset>
<div class="footer">
<span class="copy">&copy; 2025 tsl0922.</span>
<span class="links">
<a href="https://github.com/tsl0922/EPD-nRF5">Github</a>
<a href="?debug=true" id="debug-toggle">开发模式</a>
</span>
</div>
</div>
<script type="text/javascript" src="js/dithering.js?v=20260504n"></script>
<script type="text/javascript" src="js/paint.js?v=20260504n"></script>
<script type="text/javascript" src="js/crop.js?v=20260504n"></script>
<script type="text/javascript" src="js/main.js?v=20260504n"></script>
<script src="https://cdn.jsdmirror.cn/gh/bishshi/wechat-detect@main/wechat-detect.js"></script>
<script>
var _hmt = _hmt || [];
(function () {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?c5949ebea1f35f725f7b05fcce462b61";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
</body>
</html>
+219
View File
@@ -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();
});
}
}
+716
View File
@@ -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;
}
+2101
View File
File diff suppressed because it is too large Load Diff
+888
View File
@@ -0,0 +1,888 @@
class PaintManager {
constructor(canvas, ctx) {
this.canvas = canvas;
this.ctx = ctx;
this.painting = false;
this.lastX = 0;
this.lastY = 0;
this.brushColor = "#000000";
this.brushSize = 2;
this.currentTool = null;
this.textElements = [];
this.lineSegments = [];
this.isTextPlacementMode = false;
this.draggingCanvasContext = null;
this.selectedTextElement = null;
this.isDraggingText = false;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.textBold = false;
this.textItalic = false;
// Brush cursor indicator
this.brushCursor = null;
// Undo/Redo functionality
this.historyStack = [];
this.historyStep = -1;
this.MAX_HISTORY = 50;
// Bind event handlers
this.startPaint = this.startPaint.bind(this);
this.paint = this.paint.bind(this);
this.endPaint = this.endPaint.bind(this);
this.handleCanvasClick = this.handleCanvasClick.bind(this);
this.onTouchStart = this.onTouchStart.bind(this);
this.onTouchMove = this.onTouchMove.bind(this);
this.onTouchEnd = this.onTouchEnd.bind(this);
this.handleKeyboard = this.handleKeyboard.bind(this);
this.updateBrushCursor = this.updateBrushCursor.bind(this);
this.hideBrushCursor = this.hideBrushCursor.bind(this);
}
buildFontString(fontFamily, fontSize, bold = false, italic = false) {
const fontParts = [];
if (italic) fontParts.push('italic');
if (bold) fontParts.push('bold');
fontParts.push(`${fontSize}px`);
fontParts.push(fontFamily);
return fontParts.join(' ');
}
parseInlineMarkdown(text, baseStyle = {}) {
const patterns = [
{
regex: /`([^`]+)`/g,
style: { code: true, fontFamily: 'monospace', background: 'rgba(0, 0, 0, 0.08)' }
},
{ regex: /\*\*([^*]+)\*\*/g, style: { bold: true } },
{ regex: /__([^_]+)__/g, style: { bold: true } },
{ regex: /\*([^*]+)\*/g, style: { italic: true } },
{ regex: /_([^_]+)_/g, style: { italic: true } },
{ regex: /~~([^~]+)~~/g, style: { strike: true, color: '#666666' } }
];
let segments = [{ text, style: { ...baseStyle } }];
patterns.forEach(({ regex, style }) => {
const nextSegments = [];
segments.forEach((segment) => {
if (!segment.text) return;
regex.lastIndex = 0;
let lastIndex = 0;
let match = regex.exec(segment.text);
let hasMatch = false;
while (match) {
hasMatch = true;
if (match.index > lastIndex) {
nextSegments.push({
text: segment.text.slice(lastIndex, match.index),
style: { ...segment.style }
});
}
nextSegments.push({
text: match[1],
style: { ...segment.style, ...style }
});
lastIndex = match.index + match[0].length;
match = regex.exec(segment.text);
}
if (!hasMatch) {
nextSegments.push(segment);
return;
}
if (lastIndex < segment.text.length) {
nextSegments.push({
text: segment.text.slice(lastIndex),
style: { ...segment.style }
});
}
});
segments = nextSegments;
});
return segments.filter((segment) => segment.text);
}
parseMarkdownText(markdownText, fontFamily, fontSize, color, defaultBold = false, defaultItalic = false) {
const normalizedText = String(markdownText || '').replace(/\r\n/g, '\n');
const lines = normalizedText.split('\n');
const blocks = [];
lines.forEach((rawLine) => {
const line = rawLine || '';
const trimmed = line.trim();
if (!trimmed) {
blocks.push({ type: 'blank', height: Math.max(12, Math.round(fontSize * 0.75)) });
return;
}
let type = 'paragraph';
let content = line;
let level = 0;
let indent = 0;
let prefix = '';
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
const quoteMatch = line.match(/^\s*>\s?(.*)$/);
const unorderedListMatch = line.match(/^(\s*)[-*+]\s+(.*)$/);
const orderedListMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (headingMatch) {
type = 'heading';
level = headingMatch[1].length;
content = headingMatch[2];
} else if (quoteMatch) {
type = 'quote';
content = quoteMatch[1];
} else if (unorderedListMatch) {
type = 'list';
content = unorderedListMatch[2];
indent = Math.floor((unorderedListMatch[1] || '').length / 2);
prefix = '•';
} else if (orderedListMatch) {
type = 'ordered-list';
content = orderedListMatch[3];
indent = Math.floor((orderedListMatch[1] || '').length / 2);
prefix = `${orderedListMatch[2]}.`;
}
const scale = type === 'heading' ? Math.max(1, 1.65 - (level - 1) * 0.16) : 1;
const blockFontSize = Math.max(10, Math.round(fontSize * scale));
const baseStyle = {
color: type === 'quote' ? '#444444' : color,
fontFamily,
fontSize: blockFontSize,
bold: type === 'heading' || defaultBold,
italic: defaultItalic
};
blocks.push({
type,
level,
indent,
prefix,
fontSize: blockFontSize,
lineHeight: Math.max(14, Math.round(blockFontSize * (type === 'heading' ? 1.45 : 1.35))),
segments: this.parseInlineMarkdown(content, baseStyle)
});
});
return blocks;
}
measureMarkdownBlock(block) {
if (block.type === 'blank') {
return { width: 0, height: block.height || 12 };
}
const indentWidth = block.indent ? block.indent * 18 : 0;
let prefixWidth = 0;
if (block.prefix) {
this.ctx.font = this.buildFontString('Arial', block.fontSize, true, false);
prefixWidth = this.ctx.measureText(block.prefix).width + 8;
}
let contentWidth = 0;
block.segments.forEach((segment) => {
this.ctx.font = this.buildFontString(
segment.style.fontFamily || 'Arial',
segment.style.fontSize || block.fontSize,
!!segment.style.bold,
!!segment.style.italic
);
contentWidth += this.ctx.measureText(segment.text).width;
});
return {
width: indentWidth + prefixWidth + contentWidth + (block.type === 'quote' ? 16 : 0),
height: block.lineHeight
};
}
measureTextElement(textElement) {
if (textElement.type === 'markdown' && Array.isArray(textElement.blocks)) {
let width = 0;
let height = 0;
textElement.blocks.forEach((block) => {
const size = this.measureMarkdownBlock(block);
width = Math.max(width, size.width);
height += size.height;
});
return {
width,
height,
top: textElement.y - Math.max(14, textElement.baseFontSize || 16),
left: textElement.x
};
}
this.ctx.font = textElement.font;
const textWidth = this.ctx.measureText(textElement.text).width;
const fontSizeMatch = textElement.font.match(/(\d+)px/);
const textHeight = (fontSizeMatch ? parseInt(fontSizeMatch[1], 10) : 14) * 1.2;
return {
width: textWidth,
height: textHeight,
top: textElement.y - textHeight,
left: textElement.x
};
}
renderTextElement(textElement) {
if (textElement.type === 'markdown' && Array.isArray(textElement.blocks)) {
let currentY = textElement.y;
textElement.blocks.forEach((block) => {
if (block.type === 'blank') {
currentY += block.height || 12;
return;
}
let currentX = textElement.x + (block.indent ? block.indent * 18 : 0);
if (block.type === 'quote') {
this.ctx.strokeStyle = '#999999';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.moveTo(textElement.x + 6, currentY - block.lineHeight + 4);
this.ctx.lineTo(textElement.x + 6, currentY + 2);
this.ctx.stroke();
currentX += 16;
}
if (block.prefix) {
this.ctx.font = this.buildFontString('Arial', block.fontSize, true, false);
this.ctx.fillStyle = textElement.color;
this.ctx.fillText(block.prefix, currentX, currentY);
currentX += this.ctx.measureText(block.prefix).width + 8;
}
block.segments.forEach((segment) => {
const fontSize = segment.style.fontSize || block.fontSize;
this.ctx.font = this.buildFontString(
segment.style.fontFamily || textElement.fontFamily || 'Arial',
fontSize,
!!segment.style.bold,
!!segment.style.italic
);
const segmentWidth = this.ctx.measureText(segment.text).width;
if (segment.style.code) {
this.ctx.fillStyle = segment.style.background || 'rgba(0, 0, 0, 0.08)';
this.ctx.fillRect(currentX - 2, currentY - fontSize, segmentWidth + 4, block.lineHeight - 2);
}
this.ctx.fillStyle = segment.style.color || textElement.color;
this.ctx.fillText(segment.text, currentX, currentY);
if (segment.style.strike) {
this.ctx.strokeStyle = segment.style.color || textElement.color;
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.moveTo(currentX, currentY - fontSize * 0.35);
this.ctx.lineTo(currentX + segmentWidth, currentY - fontSize * 0.35);
this.ctx.stroke();
}
currentX += segmentWidth;
});
currentY += block.lineHeight;
});
return;
}
this.ctx.font = textElement.font;
this.ctx.fillStyle = textElement.color;
this.ctx.fillText(textElement.text, textElement.x, textElement.y);
}
saveToHistory() {
// Remove any states after current step (when user drew something after undoing)
this.historyStack = this.historyStack.slice(0, this.historyStep + 1);
// Save current canvas state along with text and line data
const canvasState = {
imageData: this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height),
textElements: JSON.parse(JSON.stringify(this.textElements)),
lineSegments: JSON.parse(JSON.stringify(this.lineSegments))
};
this.historyStack.push(canvasState);
this.historyStep++;
// Limit history size
if (this.historyStack.length > this.MAX_HISTORY) {
this.historyStack.shift();
this.historyStep--;
}
this.updateUndoRedoButtons();
}
clearHistory() {
this.historyStack = [];
this.historyStep = -1;
this.updateUndoRedoButtons();
}
undo() {
if (this.historyStep > 0) {
this.historyStep--;
this.restoreFromHistory();
}
}
redo() {
if (this.historyStep < this.historyStack.length - 1) {
this.historyStep++;
this.restoreFromHistory();
}
}
restoreFromHistory() {
if (this.historyStep >= 0 && this.historyStep < this.historyStack.length) {
const state = this.historyStack[this.historyStep];
// Restore canvas image
this.ctx.putImageData(state.imageData, 0, 0);
// Restore text and line data
this.textElements = JSON.parse(JSON.stringify(state.textElements));
this.lineSegments = JSON.parse(JSON.stringify(state.lineSegments));
this.updateUndoRedoButtons();
}
}
updateUndoRedoButtons() {
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
if (undoBtn) {
undoBtn.disabled = this.historyStep <= 0;
}
if (redoBtn) {
redoBtn.disabled = this.historyStep >= this.historyStack.length - 1;
}
}
initPaintTools() {
document.getElementById('brush-mode').addEventListener('click', () => {
if (this.currentTool === 'brush') {
this.setActiveTool(null, '');
} else {
this.setActiveTool('brush', '画笔模式');
this.brushColor = document.getElementById('brush-color').value;
}
});
document.getElementById('eraser-mode').addEventListener('click', () => {
if (this.currentTool === 'eraser') {
this.setActiveTool(null, '');
} else {
this.setActiveTool('eraser', '橡皮擦');
this.brushColor = "#FFFFFF";
}
});
document.getElementById('text-mode').addEventListener('click', () => {
if (this.currentTool === 'text') {
this.setActiveTool(null, '');
} else {
this.setActiveTool('text', '插入文字');
this.brushColor = document.getElementById('brush-color').value;
}
});
document.getElementById('brush-color').addEventListener('change', (e) => {
this.brushColor = e.target.value;
});
document.getElementById('brush-size').addEventListener('input', (e) => {
this.brushSize = parseInt(e.target.value);
this.updateBrushCursorSize();
});
document.getElementById('add-text-btn').addEventListener('click', () => this.startTextPlacement());
// Add event listeners for bold and italic buttons
document.getElementById('text-bold').addEventListener('click', () => {
this.textBold = !this.textBold;
document.getElementById('text-bold').classList.toggle('primary', this.textBold);
});
document.getElementById('text-italic').addEventListener('click', () => {
this.textItalic = !this.textItalic;
document.getElementById('text-italic').classList.toggle('primary', this.textItalic);
});
// Add undo/redo button listeners
document.getElementById('undo-btn').addEventListener('click', () => this.undo());
document.getElementById('redo-btn').addEventListener('click', () => this.redo());
this.canvas.addEventListener('mousedown', this.startPaint);
this.canvas.addEventListener('mousemove', this.paint);
this.canvas.addEventListener('mouseup', this.endPaint);
this.canvas.addEventListener('mouseleave', this.endPaint);
this.canvas.addEventListener('click', this.handleCanvasClick);
// Touch support
this.canvas.addEventListener('touchstart', this.onTouchStart);
this.canvas.addEventListener('touchmove', this.onTouchMove);
this.canvas.addEventListener('touchend', this.onTouchEnd);
// Keyboard shortcuts for undo/redo
document.addEventListener('keydown', this.handleKeyboard);
// Mouse move for brush cursor
this.canvas.addEventListener('mouseenter', this.updateBrushCursor);
this.canvas.addEventListener('mousemove', this.updateBrushCursor);
// Create brush cursor element
this.createBrushCursor();
// Initialize history with blank canvas state
this.saveToHistory();
}
handleKeyboard(e) {
// Ctrl+Z or Cmd+Z for undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
this.undo();
}
// Ctrl+Y or Ctrl+Shift+Z or Cmd+Shift+Z for redo
else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) {
e.preventDefault();
this.redo();
}
}
setActiveTool(tool, title) {
setCanvasTitle(title);
this.currentTool = tool;
this.canvas.parentNode.classList.toggle('brush-mode', this.currentTool === 'brush');
this.canvas.parentNode.classList.toggle('eraser-mode', this.currentTool === 'eraser');
this.canvas.parentNode.classList.toggle('text-mode', this.currentTool === 'text');
document.getElementById('brush-mode').classList.toggle('active', this.currentTool === 'brush');
document.getElementById('eraser-mode').classList.toggle('active', this.currentTool === 'eraser');
document.getElementById('text-mode').classList.toggle('active', this.currentTool === 'text');
document.getElementById('brush-color').disabled = this.currentTool === 'eraser';
document.getElementById('brush-size').disabled = this.currentTool === 'text';
document.getElementById('undo-btn').classList.toggle('hide', this.currentTool === null);
document.getElementById('redo-btn').classList.toggle('hide', this.currentTool === null);
// Cancel any pending text placement
this.cancelTextPlacement();
}
createBrushCursor() {
// Create a div element to show as brush cursor
this.brushCursor = document.createElement('div');
this.brushCursor.id = 'brush-cursor';
this.brushCursor.style.position = 'fixed';
this.brushCursor.style.border = '2px solid rgba(0, 0, 0, 0.5)';
this.brushCursor.style.borderRadius = '50%';
this.brushCursor.style.pointerEvents = 'none';
this.brushCursor.style.display = 'none';
this.brushCursor.style.zIndex = '10000';
this.brushCursor.style.transform = 'translate(-50%, -50%)';
this.brushCursor.style.willChange = 'transform';
this.brushCursor.style.left = '0';
this.brushCursor.style.top = '0';
document.body.appendChild(this.brushCursor);
this.updateBrushCursorSize();
// For requestAnimationFrame throttling
this.cursorUpdateScheduled = false;
this.pendingCursorX = 0;
this.pendingCursorY = 0;
}
updateBrushCursorSize() {
if (!this.brushCursor) return;
const rect = this.canvas.getBoundingClientRect();
const scaleX = rect.width / this.canvas.width;
const scaleY = rect.height / this.canvas.height;
const scale = Math.min(scaleX, scaleY);
const size = this.brushSize * scale;
this.brushCursor.style.width = size + 'px';
this.brushCursor.style.height = size + 'px';
}
updateBrushCursor(e) {
if (!this.brushCursor) return;
if (this.currentTool === 'brush' || this.currentTool === 'eraser') {
// Check if mouse is within canvas bounds
const rect = this.canvas.getBoundingClientRect();
const isInCanvas = e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (isInCanvas) {
this.brushCursor.style.display = 'block';
this.canvas.style.cursor = 'none';
// Store the pending position
this.pendingCursorX = e.clientX;
this.pendingCursorY = e.clientY;
// Schedule update using requestAnimationFrame for smooth movement
if (!this.cursorUpdateScheduled) {
this.cursorUpdateScheduled = true;
requestAnimationFrame(() => {
this.brushCursor.style.transform = `translate(${this.pendingCursorX}px, ${this.pendingCursorY}px) translate(-50%, -50%)`;
this.cursorUpdateScheduled = false;
});
}
// Update color to match brush or show white for eraser (only needs to happen once or when tool changes)
if (this.currentTool === 'eraser') {
if (this.brushCursor.getAttribute('data-tool') !== 'eraser') {
this.brushCursor.style.border = '2px solid rgba(255, 0, 0, 0.7)';
this.brushCursor.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
this.brushCursor.style.boxShadow = 'none';
this.brushCursor.setAttribute('data-tool', 'eraser');
}
} else {
if (this.brushCursor.getAttribute('data-tool') !== 'brush') {
// Use a contrasting border - white with black outline for visibility
this.brushCursor.style.border = '1px solid white';
this.brushCursor.style.boxShadow = '0 0 0 1px black, inset 0 0 0 1px black';
this.brushCursor.style.backgroundColor = 'transparent';
this.brushCursor.setAttribute('data-tool', 'brush');
}
}
} else {
// Hide cursor when outside canvas
this.brushCursor.style.display = 'none';
}
}
}
hideBrushCursor() {
if (this.brushCursor) {
this.brushCursor.style.display = 'none';
}
this.canvas.style.cursor = 'default';
}
startPaint(e) {
if (!this.currentTool) return;
if (this.currentTool === 'text') {
// Check if we're clicking on a text element to drag
const textElement = this.findTextElementAt(e);
if (textElement && textElement === this.selectedTextElement) {
this.isDraggingText = true;
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Calculate offset for smooth dragging
this.dragOffsetX = textElement.x - x;
this.dragOffsetY = textElement.y - y;
return; // Don't start drawing
}
} else {
this.painting = true;
this.draw(e);
}
}
endPaint() {
if (this.painting || this.isDraggingText) {
this.saveToHistory(); // Save state after drawing or dragging text
}
this.painting = false;
this.isDraggingText = false;
this.lastX = 0;
this.lastY = 0;
this.hideBrushCursor();
}
paint(e) {
if (!this.currentTool) return;
if (this.currentTool === 'text') {
if (this.isDraggingText && this.selectedTextElement) {
this.dragText(e);
}
} else {
if (this.painting) {
this.draw(e);
}
}
}
draw(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
this.ctx.lineJoin = 'round';
this.ctx.lineCap = 'round';
this.ctx.strokeStyle = this.brushColor;
this.ctx.lineWidth = this.brushSize;
this.ctx.beginPath();
if (this.lastX === 0 && this.lastY === 0) {
// For the first point, just do a dot
this.ctx.moveTo(x, y);
this.ctx.lineTo(x + 0.1, y + 0.1);
// Store the dot for redrawing
this.lineSegments.push({
type: 'dot',
x: x,
y: y,
color: this.brushColor,
size: this.brushSize
});
} else {
// Connect to the previous point
this.ctx.moveTo(this.lastX, this.lastY);
this.ctx.lineTo(x, y);
// Store the line segment for redrawing
this.lineSegments.push({
type: 'line',
x1: this.lastX,
y1: this.lastY,
x2: x,
y2: y,
color: this.brushColor,
size: this.brushSize
});
}
this.ctx.stroke();
this.lastX = x;
this.lastY = y;
}
handleCanvasClick(e) {
if (this.currentTool === 'text' && this.isTextPlacementMode) {
this.placeText(e);
}
}
onTouchStart(e) {
e.preventDefault();
const touch = e.touches[0];
// If in text placement mode, handle as a click
if (this.currentTool === 'text' && this.isTextPlacementMode) {
const mouseEvent = new MouseEvent('click', {
clientX: touch.clientX,
clientY: touch.clientY
});
this.canvas.dispatchEvent(mouseEvent);
return;
}
// Otherwise handle as normal drawing
const mouseEvent = new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY
});
this.canvas.dispatchEvent(mouseEvent);
}
onTouchMove(e) {
e.preventDefault();
const touch = e.touches[0];
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY
});
this.canvas.dispatchEvent(mouseEvent);
}
onTouchEnd(e) {
e.preventDefault();
this.endPaint();
}
dragText(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Update text position with offset
this.selectedTextElement.x = x + this.dragOffsetX;
this.selectedTextElement.y = y + this.dragOffsetY;
// Redraw selected text element
if (this.draggingCanvasContext) {
this.ctx.putImageData(this.draggingCanvasContext, 0, 0);
} else {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
this.renderTextElement(this.selectedTextElement);
}
findTextElementAt(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Search through text elements in reverse order (top-most first)
for (let i = this.textElements.length - 1; i >= 0; i--) {
const text = this.textElements[i];
const measurement = this.measureTextElement(text);
const textWidth = measurement.width;
const textHeight = measurement.height;
const top = measurement.top;
const left = measurement.left;
// Check if click is within text bounds (allowing for some margin)
const margin = 5;
if (x >= left - margin &&
x <= left + textWidth + margin &&
y >= top - margin &&
y <= top + textHeight + margin) {
return text;
}
}
return null;
}
startTextPlacement() {
const text = document.getElementById('text-input').value.trim();
if (!text) {
alert('请输入文字内容');
return;
}
this.isTextPlacementMode = true;
// Add visual feedback
setCanvasTitle('点击画布放置文字');
this.canvas.classList.add('text-placement-mode');
}
cancelTextPlacement() {
this.isTextPlacementMode = false;
this.canvas.classList.remove('text-placement-mode');
// reset dragging state
this.isDraggingText = false;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.selectedTextElement = null;
this.draggingCanvasContext = null;
}
placeText(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const text = document.getElementById('text-input').value;
const fontFamily = document.getElementById('font-family').value;
const fontSize = parseInt(document.getElementById('font-size').value, 10);
const blocks = this.parseMarkdownText(text, fontFamily, fontSize, this.brushColor, this.textBold, this.textItalic);
// Create a new text element
const newText = {
type: 'markdown',
text: text,
x: x,
y: y,
font: this.buildFontString(fontFamily, fontSize, this.textBold, this.textItalic),
color: this.brushColor,
fontFamily: fontFamily,
baseFontSize: fontSize,
blocks: blocks
};
// Add to our list of text elements
this.textElements.push(newText);
// Select this text element for immediate dragging
this.selectedTextElement = newText;
this.draggingCanvasContext = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// Draw text on canvas
this.renderTextElement(newText);
// Save to history after placing text
this.saveToHistory();
// Reset
document.getElementById('text-input').value = '';
this.isTextPlacementMode = false;
this.canvas.classList.remove('text-placement-mode');
setCanvasTitle('拖动新添加文字可调整位置');
}
redrawTextElements() {
// Redraw all text elements after dithering
this.textElements.forEach(item => {
this.renderTextElement(item);
});
}
redrawLineSegments() {
// Redraw all line segments after dithering
this.lineSegments.forEach(segment => {
this.ctx.lineJoin = 'round';
this.ctx.lineCap = 'round';
this.ctx.strokeStyle = segment.color;
this.ctx.lineWidth = segment.size;
this.ctx.beginPath();
if (segment.type === 'dot') {
this.ctx.moveTo(segment.x, segment.y);
this.ctx.lineTo(segment.x + 0.1, segment.y + 0.1);
} else {
this.ctx.moveTo(segment.x1, segment.y1);
this.ctx.lineTo(segment.x2, segment.y2);
}
this.ctx.stroke();
});
}
clearElements() {
this.textElements = [];
this.lineSegments = [];
}
}