@@ -1,308 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>墨水屏倒计时 Pro - 动态阵列版</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<style>
|
||||
:root { --ep-black: #000000; --ep-red: #ff0000; --ep-white: #ffffff; }
|
||||
body { font-family: "PingFang SC", "STHeiti", sans-serif; background: #f0f2f5; display: flex; flex-direction: column; align-items: center; padding: 20px; gap: 20px; }
|
||||
|
||||
/* --- 4.2寸预览区 (400x300) --- */
|
||||
#capture-area {
|
||||
width: 400px; height: 300px;
|
||||
background: var(--ep-white);
|
||||
border: 1px solid #000;
|
||||
display: flex; flex-direction: column;
|
||||
box-sizing: border-box; overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.top-date {
|
||||
position: absolute;
|
||||
top: 8px; right: 12px;
|
||||
font-size: 10px;
|
||||
color: var(--ep-black);
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: left;
|
||||
padding: 12px 0 8px 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#title-text {
|
||||
color: var(--ep-black);
|
||||
border-left: 5px solid var(--ep-red);
|
||||
display: inline-block;
|
||||
padding: 0 10px;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* --- 阵列布局容器 --- */
|
||||
.exam-grid-preview {
|
||||
padding: 0 12px 12px 12px;
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
/* --- 矩形卡片样式 --- */
|
||||
.preview-card {
|
||||
border: 2px solid var(--ep-black);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
background: white;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.is-important-card {
|
||||
border: 4px solid var(--ep-red) !important;
|
||||
}
|
||||
.is-important-card::after {
|
||||
content: "★";
|
||||
position: absolute;
|
||||
top: 4px; right: 6px;
|
||||
color: var(--ep-red);
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-weight: bold;
|
||||
color: var(--ep-black);
|
||||
margin-bottom: 5px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-days-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-days-val {
|
||||
font-weight: 900;
|
||||
color: var(--ep-red);
|
||||
font-family: 'Arial Black', sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
.preview-unit {
|
||||
font-weight: bold;
|
||||
color: var(--ep-black);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/* --- 管理区 --- */
|
||||
.admin-panel { width: 520px; background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
|
||||
.input-card { display: flex; gap: 8px; margin-bottom: 15px; align-items: center; background: #f8f9fa; padding: 12px; border-radius: 8px; }
|
||||
.input-card input { padding: 8px; border: 1px solid #ddd; border-radius: 4px; }
|
||||
|
||||
#sortable-list { list-style: none; padding: 0; margin: 0; }
|
||||
.manage-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: #fff; border: 1px solid #eee; margin-bottom: 8px;
|
||||
padding: 10px; border-radius: 6px; cursor: move;
|
||||
}
|
||||
.drag-handle { color: #ccc; cursor: grab; font-size: 20px; user-select: none; }
|
||||
.manage-info { flex-grow: 1; display: flex; gap: 8px; }
|
||||
.manage-info input[type="text"] { flex: 2; }
|
||||
.btn-del { background: #ff4d4f; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; }
|
||||
|
||||
.action-btns { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.btn-main { flex: 1; padding: 12px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; }
|
||||
.btn-save { background: var(--ep-red); color: white; }
|
||||
.btn-data { background: #555; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="capture-area">
|
||||
<div class="top-date" id="live-date"></div>
|
||||
<div class="header">
|
||||
<h1 id="title-text">倒计时看板</h1>
|
||||
</div>
|
||||
<div class="exam-grid-preview" id="preview-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel">
|
||||
<h3 style="margin-top:0; border-left: 4px solid var(--ep-red); padding-left: 10px;">项目管理</h3>
|
||||
|
||||
<div class="input-card">
|
||||
<input type="text" id="name-in" placeholder="项目名称">
|
||||
<input type="date" id="date-in">
|
||||
<label style="font-size:13px; cursor:pointer"><input type="checkbox" id="imp-in"> 重要</label>
|
||||
<button onclick="addItem()" style="background:#000; color:white; border:none; padding:8px 15px; border-radius:4px; cursor:pointer">添加</button>
|
||||
</div>
|
||||
|
||||
<ul id="sortable-list"></ul>
|
||||
|
||||
<div class="action-btns">
|
||||
<button class="btn-main btn-data" onclick="exportJSON()">备份</button>
|
||||
<button class="btn-main btn-data" onclick="document.getElementById('file-in').click()">恢复</button>
|
||||
<input type="file" id="file-in" style="display:none" onchange="importJSON(this)">
|
||||
<button class="btn-main btn-save" onclick="downloadImage()">保存 400x300 图片</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const getTodayStr = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
let exams = JSON.parse(localStorage.getItem('dragExamGridV2')) || [
|
||||
{ name: "期末大考", date: "2026-06-20", imp: true },
|
||||
{ name: "驾照预约", date: getTodayStr(), imp: false }
|
||||
];
|
||||
|
||||
function updateLiveDate() {
|
||||
const now = new Date();
|
||||
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
document.getElementById('live-date').innerHTML = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日<br>${weekDays[now.getDay()]}`;
|
||||
}
|
||||
|
||||
Sortable.create(document.getElementById('sortable-list'), {
|
||||
animation: 150, handle: '.drag-handle', onEnd: () => saveOrder()
|
||||
});
|
||||
|
||||
function calculateDays(targetDate) {
|
||||
const today = new Date().setHours(0,0,0,0);
|
||||
const target = new Date(targetDate).setHours(0,0,0,0);
|
||||
const diff = target - today;
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderPreview();
|
||||
renderManageList();
|
||||
updateLiveDate();
|
||||
localStorage.setItem('dragExamGridV2', JSON.stringify(exams));
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const container = document.getElementById('preview-container');
|
||||
container.innerHTML = '';
|
||||
const count = exams.length;
|
||||
|
||||
// 1. 动态确定网格布局
|
||||
let cols = 2;
|
||||
if (count === 1) cols = 1;
|
||||
else if (count > 4) cols = 3;
|
||||
container.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
||||
|
||||
// 2. 根据数量动态计算字体大小 (单位 px)
|
||||
// 逻辑:项目越少,字号越大
|
||||
let baseNameSize, baseNumSize, starSize;
|
||||
if (count <= 1) {
|
||||
baseNameSize = 64; baseNumSize = 80; starSize = 36;
|
||||
} else if (count <= 2) {
|
||||
baseNameSize = 24; baseNumSize = 64; starSize = 20;
|
||||
} else if (count <= 4) {
|
||||
baseNameSize = 18; baseNumSize = 48; starSize = 16;
|
||||
} else {
|
||||
baseNameSize = 14; baseNumSize = 32; starSize = 14;
|
||||
}
|
||||
|
||||
exams.forEach(item => {
|
||||
const days = calculateDays(item.date);
|
||||
let dayHTML = '';
|
||||
|
||||
if (days === 0) {
|
||||
dayHTML = `<span class="preview-days-val" style="font-size:${baseNumSize * 0.6}px">今天</span>`;
|
||||
} else if (days < 0) {
|
||||
dayHTML = `<span class="preview-days-val" style="font-size:${baseNumSize * 0.6}px; color:#666">已过</span>`;
|
||||
} else {
|
||||
dayHTML = `<span class="preview-days-val" style="font-size:${baseNumSize}px">${days}</span>
|
||||
<span class="preview-unit" style="font-size:${baseNumSize * 0.3}px">天</span>`;
|
||||
}
|
||||
|
||||
container.innerHTML += `
|
||||
<div class="preview-card ${item.imp ? 'is-important-card' : ''}">
|
||||
${item.imp ? `<style>.is-important-card::after{font-size:${starSize}px}</style>` : ''}
|
||||
<div class="preview-name" style="font-size:${baseNameSize}px">${item.name}</div>
|
||||
<div class="preview-days-info">
|
||||
${dayHTML}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderManageList() {
|
||||
const list = document.getElementById('sortable-list');
|
||||
list.innerHTML = '';
|
||||
exams.forEach((item, index) => {
|
||||
list.innerHTML += `
|
||||
<li class="manage-item" data-index="${index}">
|
||||
<span class="drag-handle">☰</span>
|
||||
<div class="manage-info">
|
||||
<input type="text" value="${item.name}" onchange="updateItem(${index}, 'name', this.value)">
|
||||
<input type="date" value="${item.date}" onchange="updateItem(${index}, 'date', this.value)">
|
||||
<label><input type="checkbox" ${item.imp?'checked':''} onchange="updateItem(${index}, 'imp', this.checked)"> 重要</label>
|
||||
</div>
|
||||
<button class="btn-del" onclick="removeItem(${index})">×</button>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
}
|
||||
function updateItem(index, key, value) {
|
||||
exams[index][key] = value;
|
||||
renderPreview();
|
||||
localStorage.setItem('dragExamGridV2', JSON.stringify(exams));
|
||||
}
|
||||
function saveOrder() {
|
||||
const newExams = [];
|
||||
document.querySelectorAll('.manage-item').forEach(el => {
|
||||
newExams.push(exams[el.getAttribute('data-index')]);
|
||||
});
|
||||
exams = newExams;
|
||||
renderAll();
|
||||
}
|
||||
function addItem() {
|
||||
const n = document.getElementById('name-in').value;
|
||||
const d = document.getElementById('date-in').value;
|
||||
const i = document.getElementById('imp-in').checked;
|
||||
if(n && d) {
|
||||
exams.push({name:n, date:d, imp:i});
|
||||
renderAll();
|
||||
document.getElementById('name-in').value = '';
|
||||
}
|
||||
}
|
||||
function removeItem(index) {
|
||||
exams.splice(index, 1);
|
||||
renderAll();
|
||||
}
|
||||
function downloadImage() {
|
||||
html2canvas(document.getElementById('capture-area'), { width: 400, height: 300, scale: 2 })
|
||||
.then(canvas => {
|
||||
const link = document.createElement('a');
|
||||
link.download = `Grid_Countdown.png`;
|
||||
link.href = canvas.toDataURL();
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
function exportJSON() {
|
||||
const blob = new Blob([JSON.stringify(exams)], {type: 'application/json'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = 'exams.json'; a.click();
|
||||
}
|
||||
function importJSON(input) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => { exams = JSON.parse(e.target.result); renderAll(); };
|
||||
reader.readAsText(input.files[0]);
|
||||
}
|
||||
renderAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,218 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>墨水屏标语倒计时 - 红黑版</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<style>
|
||||
:root { --ep-black: #000000; --ep-red: #ff0000; --ep-white: #ffffff; }
|
||||
body { font-family: "PingFang SC", "Microsoft YaHei", sans-serif; background: #f4f4f4; display: flex; flex-direction: column; align-items: center; padding: 30px; }
|
||||
|
||||
/* --- 4.2寸预览区 (400x300) --- */
|
||||
#capture-area {
|
||||
width: 400px; height: 300px;
|
||||
background: var(--ep-white);
|
||||
border: 2px solid #000;
|
||||
display: flex; flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box; overflow: hidden;
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 顶部日期栏 */
|
||||
.top-date {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: var(--ep-black);
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--ep-black);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 核心标语区 - 居中特大号 */
|
||||
.motto-section {
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
#editable-motto {
|
||||
font-size: 42px; /* 进一步放大 */
|
||||
font-weight: 900;
|
||||
color: var(--ep-black);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
line-height: 1.1;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 分割点 */
|
||||
.dot-divider {
|
||||
margin: 15px 0;
|
||||
font-size: 20px;
|
||||
color: var(--ep-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 倒计时展示区 */
|
||||
.countdown-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-label {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #444;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.days-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.days-num {
|
||||
font-size: 85px; /* 巨型数字 */
|
||||
font-family: 'Arial Black', sans-serif;
|
||||
font-weight: 900;
|
||||
line-height: 0.85;
|
||||
color: var(--ep-red); /* 改为红色 */
|
||||
}
|
||||
|
||||
.days-unit {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: var(--ep-black);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* 管理面板 */
|
||||
.admin-panel {
|
||||
width: 400px; background: white; padding: 25px;
|
||||
border-radius: 12px; margin-top: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.input-item { margin-bottom: 15px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.input-item label { font-size: 13px; font-weight: bold; color: #666; }
|
||||
.input-item input { padding: 10px; border: 1px solid #ccc; border-radius: 6px; }
|
||||
|
||||
.btn-save {
|
||||
width: 100%; padding: 12px; border: none; border-radius: 6px;
|
||||
font-weight: bold; cursor: pointer; background: var(--ep-red); color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="capture-area">
|
||||
<div class="top-date" id="live-date">2026 / 04 / 17 FRIDAY</div>
|
||||
|
||||
<div class="motto-section">
|
||||
<h1 id="editable-motto" onclick="editMotto()" title="点击修改标语">新婚快乐</h1>
|
||||
</div>
|
||||
|
||||
<div class="dot-divider">✦ ✦ ✦</div>
|
||||
|
||||
<div class="countdown-section" id="countdown-display">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-panel">
|
||||
<div class="input-item">
|
||||
<label>目标事件描述</label>
|
||||
<input type="text" id="event-name" placeholder="例如:距离考研/周年庆">
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<label>目标日期</label>
|
||||
<input type="date" id="target-date">
|
||||
</div>
|
||||
|
||||
<button class="btn-save" onclick="saveImage()">下载 400x300 图片</button>
|
||||
<p style="font-size:12px; color:#999; text-align:center; margin-top:12px">提示:直接点击图片中的“黑色大字”可改写标语</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let config = JSON.parse(localStorage.getItem('ep_red_cfg')) || {
|
||||
motto: "保持热爱",
|
||||
label: "距离考研",
|
||||
date: "2026-12-21"
|
||||
};
|
||||
|
||||
function init() {
|
||||
// 填充表单默认值
|
||||
document.getElementById('event-name').value = config.label;
|
||||
document.getElementById('target-date').value = config.date;
|
||||
render();
|
||||
updateDate();
|
||||
}
|
||||
|
||||
function updateDate() {
|
||||
const n = new Date();
|
||||
const weeks = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||
document.getElementById('live-date').innerText = `${n.getFullYear()} / ${String(n.getMonth()+1).padStart(2,'0')} / ${String(n.getDate()).padStart(2,'0')} ${weeks[n.getDay()]}`;
|
||||
}
|
||||
|
||||
function editMotto() {
|
||||
const val = prompt("请输入顶端大标语:", config.motto);
|
||||
if (val) {
|
||||
config.motto = val;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const label = document.getElementById('event-name').value || "距离目标";
|
||||
const date = document.getElementById('target-date').value || "2026-12-31";
|
||||
|
||||
// 更新配置
|
||||
config.label = label;
|
||||
config.date = date;
|
||||
document.getElementById('editable-motto').innerText = config.motto;
|
||||
|
||||
const target = new Date(date).setHours(0,0,0,0);
|
||||
const today = new Date().setHours(0,0,0,0);
|
||||
const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let displayNum = diff;
|
||||
let unit = "DAYS";
|
||||
|
||||
if (diff === 0) { displayNum = "0"; }
|
||||
else if (diff < 0) { displayNum = "!"; unit = "OVER"; }
|
||||
|
||||
document.getElementById('countdown-display').innerHTML = `
|
||||
<div class="event-label">${label}</div>
|
||||
<div class="days-container">
|
||||
<span class="days-num">${displayNum}</span>
|
||||
<span class="days-unit">${unit}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
localStorage.setItem('ep_red_cfg', JSON.stringify(config));
|
||||
}
|
||||
|
||||
// 监听输入
|
||||
document.getElementById('event-name').addEventListener('input', render);
|
||||
document.getElementById('target-date').addEventListener('input', render);
|
||||
|
||||
function saveImage() {
|
||||
html2canvas(document.getElementById('capture-area'), {
|
||||
width: 400,
|
||||
height: 300,
|
||||
scale: 2
|
||||
}).then(canvas => {
|
||||
const a = document.createElement('a');
|
||||
a.download = `EInk_Countdown_Red.png`;
|
||||
a.href = canvas.toDataURL();
|
||||
a.click();
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
+337
-101
@@ -1,106 +1,342 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>项目导航</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #1e1e1e;
|
||||
--card-bg: #2d2d2d;
|
||||
--text-color: #e0e0e0;
|
||||
--accent-color: #ff7b00; /* 匹配你截图中的橙色调 */
|
||||
--hover-color: #ff9533;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 300;
|
||||
margin-bottom: 30px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
background: var(--card-bg);
|
||||
padding: 18px 25px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
transform: translateX(10px);
|
||||
background: #383838;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 15px;
|
||||
color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex-grow: 1;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
<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>
|
||||
|
||||
<h1>目录:</h1>
|
||||
|
||||
<div class="nav-container">
|
||||
<a href="countdown_s.html" class="nav-item">
|
||||
<span class="icon"></></span>
|
||||
<span class="file-name">特殊倒计时</span>
|
||||
<span class="arrow">→</span>
|
||||
</a>
|
||||
|
||||
<a href="countdown.html" class="nav-item">
|
||||
<span class="icon"></></span>
|
||||
<span class="file-name">普通倒计时</span>
|
||||
<span class="arrow">→</span>
|
||||
</a>
|
||||
|
||||
<a href="resum.html" class="nav-item">
|
||||
<span class="icon"></></span>
|
||||
<span class="file-name">简历生成器</span>
|
||||
<span class="arrow">→</span>
|
||||
</a>
|
||||
|
||||
<a href="todo.html" class="nav-item">
|
||||
<span class="icon"></></span>
|
||||
<span class="file-name">待办事项</span>
|
||||
<span class="arrow">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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="01" data-color="blackWhiteColor" data-size="4.2_400_300">4.2寸 (黑白, UC8176)</option>
|
||||
<option value="03" data-color="threeColor" data-size="4.2_400_300">4.2寸 (三色, UC8176)</option>
|
||||
<option value="04" data-color="blackWhiteColor" data-size="4.2_400_300">4.2寸 (黑白, SSD1619)</option>
|
||||
<option value="02" data-color="threeColor" data-size="4.2_400_300">4.2寸 (三色, SSD1619)</option>
|
||||
<option value="05" data-color="fourColor" data-size="4.2_400_300">4.2寸 (四色, JD79668)</option>
|
||||
<option value="0d" data-color="fourColor" data-size="5.83_648_480">5.83寸 (四色, JD79665)</option>
|
||||
<option value="06" data-color="blackWhiteColor" data-size="7.5_800_480">7.5寸 (黑白, UC8179)</option>
|
||||
<option value="07" data-color="threeColor" data-size="7.5_800_480">7.5寸 (三色, UC8179)</option>
|
||||
<option value="0c" data-color="fourColor" data-size="7.5_800_480">7.5寸 (四色, JD79665)</option>
|
||||
<option value="08" data-color="blackWhiteColor" data-size="7.5_640_384">7.5寸低分 (黑白, UC8159)</option>
|
||||
<option value="09" data-color="threeColor" data-size="7.5_640_384">7.5寸低分 (三色, UC8159)</option>
|
||||
<option value="0a" data-color="blackWhiteColor" data-size="7.5_880_528">7.5寸HD (黑白, SSD1677)</option>
|
||||
<option value="0b" data-color="threeColor" data-size="7.5_880_528">7.5寸HD (三色, SSD1677)</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="clockmodebutton" type="button" class="primary" onclick="syncTime(2)">时钟模式</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>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>蓝牙传图</legend>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<canvas id="canvas" width="400" height="300"></canvas>
|
||||
<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">
|
||||
<input type="text" id="text-input" placeholder="输入文字" style="width:150px">
|
||||
<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">© 2025 tsl0922.</span>
|
||||
<span class="links">
|
||||
<a href="https://github.com/tsl0922/EPD-nRF5">Github</a>
|
||||
<a href="https://qm.qq.com/q/SckzhfDxuu" onclick="return confirm('本群是此开源固件作者的技术交流群\n如果你购买了成品,请找卖家提供售后!')">交流群</a>
|
||||
<a href="?debug=true" id="debug-toggle">开发模式</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="js/dithering.js?v=20251109"></script>
|
||||
<script type="text/javascript" src="js/paint.js?v=20251109"></script>
|
||||
<script type="text/javascript" src="js/crop.js?v=20251109"></script>
|
||||
<script type="text/javascript" src="js/main.js?v=20251109"></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
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+1428
File diff suppressed because it is too large
Load Diff
+631
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
-248
@@ -1,248 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>简介生成器</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--ink-red: #ff0000;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Source Han Sans CN", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background-color: #f4f4f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 墨水屏预览区 400x300 */
|
||||
#screen-wrap {
|
||||
padding: 10px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
#screen-canvas {
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
padding: 25px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 布局样式 */
|
||||
.info-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-right: 2px solid var(--ink-red);
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
#disp-name {
|
||||
font-size: 36px;
|
||||
font-weight: 900;
|
||||
margin-bottom: 5px;
|
||||
color: var(--ink-red);
|
||||
}
|
||||
|
||||
#disp-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
display: inline-block;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.qr-side {
|
||||
width: 130px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
#qr-img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
padding: 5px;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
#disp-qr-label {
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--ink-red);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 编辑面板 */
|
||||
.editor-panel {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
width: 420px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus { border-color: var(--ink-red); }
|
||||
|
||||
.btn-download {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: var(--ink-red);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-download:hover { opacity: 0.9; }
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="screen-wrap">
|
||||
<div id="screen-canvas">
|
||||
<div class="info-side">
|
||||
<div id="disp-name">张三</div>
|
||||
<div id="disp-title">全栈开发工程师</div>
|
||||
<div class="details" id="disp-details">
|
||||
📍 坐标:北京 · 朝阳<br>
|
||||
📧 邮箱:zhangsan@dev.com<br>
|
||||
🔗 博客:blog.zhangsan.me
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-side">
|
||||
<img id="qr-img" src="https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=https://github.com" alt="QR" crossOrigin="anonymous">
|
||||
<div id="disp-qr-label">扫码获取简历</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="input-group">
|
||||
<label>姓名 (红色)</label>
|
||||
<input type="text" id="in-name" value="张三" oninput="update()">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>职业标签 (黑底白字)</label>
|
||||
<input type="text" id="in-title" value="全栈开发工程师" oninput="update()">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>个人简介 (支持换行)</label>
|
||||
<textarea id="in-details" rows="3" oninput="update()">📍 坐标:北京 · 朝阳 📧 邮箱:zhangsan@dev.com 🔗 博客:blog.zhangsan.me</textarea>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>二维码链接</label>
|
||||
<input type="text" id="in-qr-data" value="https://github.com" onchange="update()">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>二维码下方文案 (红色)</label>
|
||||
<input type="text" id="in-qr-label" value="扫码获取简历" oninput="update()">
|
||||
</div>
|
||||
|
||||
<button class="btn-download" onclick="downloadImage()">保存图片到本地</button>
|
||||
|
||||
<div class="hint">
|
||||
生成的图片尺寸固定为 400x300,完美适配 4.2" 墨水屏。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 更新预览内容
|
||||
function update() {
|
||||
document.getElementById('disp-name').innerText = document.getElementById('in-name').value;
|
||||
document.getElementById('disp-title').innerText = document.getElementById('in-title').value;
|
||||
|
||||
const details = document.getElementById('in-details').value;
|
||||
document.getElementById('disp-details').innerHTML = details.replace(/\n/g, '<br>');
|
||||
|
||||
document.getElementById('disp-qr-label').innerText = document.getElementById('in-qr-label').value;
|
||||
|
||||
// 二维码更新
|
||||
const qrData = encodeURIComponent(document.getElementById('in-qr-data').value);
|
||||
// 注意:qrserver支持跨域,html2canvas 才能捕获它
|
||||
document.getElementById('qr-img').src = `https://api.qrserver.com/v1/create-qr-code/?size=100x100&data=${qrData}`;
|
||||
}
|
||||
|
||||
// 下载图片功能
|
||||
function downloadImage() {
|
||||
const screen = document.getElementById('screen-canvas');
|
||||
|
||||
// 使用 html2canvas 捕捉指定节点
|
||||
html2canvas(screen, {
|
||||
width: 400,
|
||||
height: 300,
|
||||
scale: 1, // 保持 1:1 像素
|
||||
useCORS: true, // 允许加载跨域二维码图片
|
||||
backgroundColor: "#ffffff"
|
||||
}).then(canvas => {
|
||||
const link = document.createElement('a');
|
||||
link.download = `eink_profile_${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
// 初始运行一次
|
||||
window.onload = update;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>4.2寸墨水屏看板 (红色提醒版)</title>
|
||||
<style>
|
||||
:root { --ink-red: #ff0000; --ink-black: #000000; }
|
||||
body { font-family: sans-serif; background: #f0f2f5; display: flex; flex-direction: column; align-items: center; padding: 20px; }
|
||||
.container { display: flex; gap: 20px; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.editor { width: 300px; }
|
||||
.input-box { display: flex; gap: 5px; margin-bottom: 15px; }
|
||||
input { flex: 1; padding: 8px; border: 1px solid #ddd; }
|
||||
.task-item { display: flex; align-items: center; padding: 8px; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||
canvas { border: 1px solid #000; background: #fff; width: 400px; height: 300px; }
|
||||
.btn-dl { width: 100%; margin-top: 15px; padding: 10px; background: var(--ink-red); color: white; border: none; cursor: pointer; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>三色墨水屏待办生成器</h2>
|
||||
<p style="color: #666;">提示:红色代表<strong>未完成</strong>(急需处理),黑色代表<strong>已完成</strong>。</p>
|
||||
|
||||
<div class="container">
|
||||
<div class="editor">
|
||||
<div class="input-box">
|
||||
<input type="text" id="taskInput" placeholder="添加任务...">
|
||||
<button onclick="addTask()">添加</button>
|
||||
</div>
|
||||
<div id="listUI"></div>
|
||||
<button class="btn-dl" onclick="download()">下载 400x300 图片</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<canvas id="canvas" width="400" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let tasks = [
|
||||
{ text: "Class 612 网站数据库备份", done: false },
|
||||
{ text: "Surveying 测量平差作业", done: false },
|
||||
{ text: "已完成的演示任务", done: true }
|
||||
];
|
||||
|
||||
function addTask() {
|
||||
const val = document.getElementById('taskInput').value;
|
||||
if(val) { tasks.push({text: val, done: false}); render(); }
|
||||
}
|
||||
|
||||
function render() {
|
||||
// UI 渲染
|
||||
const listUI = document.getElementById('listUI');
|
||||
listUI.innerHTML = '';
|
||||
tasks.forEach((t, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'task-item';
|
||||
div.innerHTML = `<input type="checkbox" ${t.done?'checked':''} onchange="tasks[${i}].done=!tasks[${i}].done;render()">
|
||||
<span style="flex:1; margin-left:8px; ${t.done?'text-decoration:line-through;color:#999':''}">${t.text}</span>
|
||||
<button onclick="tasks.splice(${i},1);render()">×</button>`;
|
||||
listUI.appendChild(div);
|
||||
});
|
||||
|
||||
// Canvas 绘图
|
||||
const ctx = document.getElementById('canvas').getContext('2d');
|
||||
ctx.fillStyle = "#fff"; ctx.fillRect(0,0,400,300);
|
||||
|
||||
// 标题栏 (黑色)
|
||||
ctx.fillStyle = "#000"; ctx.font = "bold 24px 'Microsoft YaHei'";
|
||||
ctx.fillText("Focus Tasks", 20, 45);
|
||||
ctx.fillRect(20, 55, 360, 2); // 黑线下划线
|
||||
|
||||
// 绘制列表
|
||||
tasks.forEach((t, i) => {
|
||||
const y = 90 + i * 38;
|
||||
if(y > 270) return;
|
||||
|
||||
if(!t.done) {
|
||||
// --- 未完成任务:红色强调 ---
|
||||
ctx.strokeStyle = "#f00"; ctx.fillStyle = "#f00"; ctx.lineWidth = 2;
|
||||
// 空心框
|
||||
ctx.strokeRect(20, y - 16, 18, 18);
|
||||
// 粗体文字
|
||||
ctx.font = "bold 19px 'Microsoft YaHei'";
|
||||
ctx.fillText(t.text, 50, y);
|
||||
} else {
|
||||
// --- 已完成任务:黑色弱化 ---
|
||||
ctx.strokeStyle = "#000"; ctx.fillStyle = "#000"; ctx.lineWidth = 1;
|
||||
// 打钩框
|
||||
ctx.strokeRect(20, y - 16, 18, 18);
|
||||
ctx.beginPath(); ctx.moveTo(22, y-8); ctx.lineTo(28, y); ctx.lineTo(36, y-12); ctx.stroke();
|
||||
// 普通文字 + 删除线
|
||||
ctx.font = "17px 'Microsoft YaHei'";
|
||||
ctx.fillText(t.text, 50, y);
|
||||
ctx.beginPath(); ctx.moveTo(50, y-6); ctx.lineTo(380, y-6); ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
// 底部提示 (黑色)
|
||||
ctx.fillStyle = "#000"; ctx.font = "12px monospace";
|
||||
ctx.fillText(`Update: ${new Date().toLocaleTimeString()}`, 20, 290);
|
||||
ctx.fillText(`Pending: ${tasks.filter(x=>!x.done).length}`, 310, 290);
|
||||
}
|
||||
|
||||
function download() {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'eink_todo.png';
|
||||
link.href = document.getElementById('canvas').toDataURL();
|
||||
link.click();
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>4.2 寸电子墨水屏蓝牙控制器</title>
|
||||
<link rel="stylesheet" href="css/main.css?v=20250412">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main">
|
||||
<h3>4.2 寸电子墨水屏蓝牙控制器</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 debug">
|
||||
<label for="epddriver">驱动</label>
|
||||
<select id="epddriver" onchange="filterDitheringOptions()">
|
||||
<option value="01">UC8176(黑白屏)</option>
|
||||
<option value="03">UC8176(三色屏)</option>
|
||||
<option value="05">UC8276(三色屏)</option>
|
||||
<option value="04">SSD1619(黑白屏)</option>
|
||||
<option value="02">SSD1619(三色屏)</option>
|
||||
<option value="04">SSD1683(黑白屏)</option>
|
||||
<option value="02">SSD1683(三色屏)</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 class="flex-group">
|
||||
<button id="calendarmodebutton" type="button" class="primary" onclick="syncTime(1)">日历模式</button>
|
||||
<button id="clockmodebutton" type="button" class="primary" onclick="syncTime(2)">时钟模式</button>
|
||||
<button id="clearscreenbutton" type="button" class="secondary" onclick="clearScreen()">清除屏幕</button>
|
||||
</div>
|
||||
<div class="flex-group debug">
|
||||
<input type="text" id="cmdTXT" value="">
|
||||
<button id="sendcmdbutton" type="button" class="primary" onclick="sendcmd()">发送命令</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="log"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>蓝牙传图</legend>
|
||||
<div class="flex-container">
|
||||
<input type="file" id="image_file" onchange="update_image()" accept=".png,.jpg,.bmp,.webp,.jpeg">
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<div class="flex-group">
|
||||
<label for="dithering">取模算法</label>
|
||||
<select id="dithering" title="取模算法" onchange="update_image()">
|
||||
<optgroup data-driver="01|04" label="黑白">
|
||||
<option value="none">二值化</option>
|
||||
<option value="bayer">bayer</option>
|
||||
<option value="floydsteinberg">floydsteinberg</option>
|
||||
<option value="Atkinson">Atkinson</option>
|
||||
</optgroup>
|
||||
<optgroup id="dithering-bwr" data-driver="02|03|05" label="三色">
|
||||
<option value="bwr_floydsteinberg">黑白红floydsteinberg</option>
|
||||
<option value="bwr_Atkinson">黑白红Atkinson</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-group">
|
||||
<label for="threshold">阈值</label>
|
||||
<input type="number" max="255" min="0" value="125" id="threshold" onchange="update_image()">
|
||||
</div>
|
||||
<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 id="clearcanvasbutton" type="button" class="secondary" onclick="clear_canvas()">清除画布</button>
|
||||
<button id="sendimgbutton" type="button" class="primary" onclick="sendimg()">发送图片</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="canvas" width="400" height="300"></canvas>
|
||||
</fieldset>
|
||||
<div class="footer">
|
||||
<span class="copy">© 2025 tsl0922.</span>
|
||||
<span class="links">
|
||||
<a href="https://github.com/tsl0922/EPD-nRF5">Github</a>
|
||||
<a href="https://qm.qq.com/q/SckzhfDxuu">交流群</a>
|
||||
<a href="?debug=true" id="debug-toggle">开发模式</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="js/dithering.js?v=20250318"></script>
|
||||
<script type="text/javascript" src="js/main.js?v=20250412"></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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(`<span class="action">⇑</span> ${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(`<span class="action">⇓</span> ${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 += '<span class="time">' + time + '</span>' + logTXT + '<br>';
|
||||
log.scrollTop = log.scrollHeight;
|
||||
while ((log.innerHTML.match(/<br>/g) || []).length > 20) {
|
||||
var logs_br_position = log.innerHTML.search("<br>");
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user