- 备注字段升级为富文本(库表 remark 已由 VARCHAR(1000) 改 TEXT)
- 自研轻量编辑器:contenteditable + execCommand,零依赖,保持单文件风格
工具栏:加粗/斜体/下划线/删除线/标题/正文/引用/代码块/
无序/有序列表/待办项/链接/清除格式
- 编辑弹窗右上角「放大」切换大面板(880px 宽,正文 48vh)
- 卡片备注默认纯文本摘要(2 行截断),点击展开完整富文本
- HTML 白名单净化:保存前与渲染前各一次,防存储型 XSS
- 兼容旧的纯文本备注数据
This commit is contained in:
+198
-5
@@ -180,6 +180,57 @@
|
|||||||
.dp-quick button:hover { background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
|
.dp-quick button:hover { background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
|
||||||
.dp-quick button.dp-clear:hover { background: #fff1f2; color: var(--rose); }
|
.dp-quick button.dp-clear:hover { background: #fff1f2; color: var(--rose); }
|
||||||
|
|
||||||
|
/* ===== 富文本编辑器(contenteditable + execCommand 自研) ===== */
|
||||||
|
.rte { border: 1.5px solid var(--line); border-radius: 12px; background: #fbfcfe; overflow: hidden; transition: border-color .18s, box-shadow .18s, background .18s; }
|
||||||
|
.rte:focus-within { border-color: var(--indigo); background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, .11); }
|
||||||
|
.rte-bar { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; padding: 7px 8px; border-bottom: 1px solid var(--line); background: #fff; }
|
||||||
|
.rte-bar .spacer { flex: 1; }
|
||||||
|
.rte-btn {
|
||||||
|
min-width: 28px; height: 28px; padding: 0 4px; border: none; background: transparent; border-radius: 7px;
|
||||||
|
color: var(--text-sub); cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; font-weight: 600; transition: background .14s, color .14s;
|
||||||
|
}
|
||||||
|
.rte-btn:hover { background: #eef1f8; color: var(--text); }
|
||||||
|
.rte-btn.on { background: rgba(99, 102, 241, .13); color: var(--indigo-dark); }
|
||||||
|
.rte-btn.txt { padding: 0 10px; font-size: 12.5px; gap: 4px; }
|
||||||
|
.rte-btn.warn:hover { background: #fff1f2; color: var(--rose); }
|
||||||
|
.rte-sep { width: 1px; height: 16px; background: var(--line); margin: 0 4px; }
|
||||||
|
.rte-body {
|
||||||
|
padding: 12px 14px; min-height: 150px; max-height: 44vh; overflow-y: auto;
|
||||||
|
font-size: 14px; line-height: 1.75; color: var(--text); outline: none; word-break: break-word;
|
||||||
|
}
|
||||||
|
.rte-body:empty::before { content: attr(data-ph); color: var(--text-muted); pointer-events: none; }
|
||||||
|
|
||||||
|
/* 放大书写面板:编辑弹窗右上角按钮切换 */
|
||||||
|
.modal.lg { max-width: 880px; }
|
||||||
|
.modal.lg .rte-body { min-height: 48vh; max-height: none; }
|
||||||
|
|
||||||
|
/* 富文本排版(编辑器与卡片展开共用 .fmt) */
|
||||||
|
.fmt h2 { font-size: 17px; font-weight: 700; margin: 10px 0 5px; letter-spacing: -.2px; }
|
||||||
|
.fmt p { margin: 4px 0; }
|
||||||
|
.fmt blockquote { border-left: 3px solid var(--indigo); margin: 8px 0; padding: 5px 12px; background: #f5f6ff; border-radius: 0 8px 8px 0; color: var(--text-sub); }
|
||||||
|
.fmt pre { background: #1e293b; color: #e2e8f0; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; font-family: Consolas, Monaco, monospace; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; }
|
||||||
|
.fmt code { background: #eef1f8; border-radius: 5px; padding: 1px 5px; font-size: .92em; color: var(--indigo-dark); font-family: Consolas, Monaco, monospace; }
|
||||||
|
.fmt pre code { background: transparent; color: inherit; padding: 0; }
|
||||||
|
.fmt ul, .fmt ol { padding-left: 22px; margin: 6px 0; }
|
||||||
|
.fmt a { color: var(--indigo); text-decoration: underline; }
|
||||||
|
.fmt .ck-line { display: flex; align-items: flex-start; gap: 7px; margin: 3px 0; }
|
||||||
|
.fmt .ck-box {
|
||||||
|
flex-shrink: 0; width: 16px; height: 16px; margin-top: 4px; border: 1.6px solid #cbd5e1;
|
||||||
|
border-radius: 5px; background: #fff; cursor: pointer; user-select: none;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.fmt .ck-box.ck-checked { background: linear-gradient(135deg, var(--indigo), var(--violet)); border-color: transparent; }
|
||||||
|
.fmt .ck-box.ck-checked::after { content: '✓'; color: #fff; font-size: 11px; font-weight: 800; }
|
||||||
|
|
||||||
|
/* 卡片备注:默认纯文本摘要,点击展开完整富文本 */
|
||||||
|
.note-box { margin-top: 6px; cursor: pointer; font-size: 13px; color: var(--text-sub); word-break: break-word; }
|
||||||
|
.note-box .plain { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.note-box .full { display: none; }
|
||||||
|
.note-box.open .plain { display: none; }
|
||||||
|
.note-box.open .full { display: block; padding: 10px 13px; background: #fafbff; border: 1px solid var(--line); border-radius: 10px; line-height: 1.75; cursor: default; }
|
||||||
|
.note-box .full .ck-box { pointer-events: none; }
|
||||||
|
|
||||||
.add-bar { padding: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
|
.add-bar { padding: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
.add-bar .grow { flex: 1; min-width: 200px; }
|
.add-bar .grow { flex: 1; min-width: 200px; }
|
||||||
.add-bar .grow input[type="text"] { border: none; background: transparent; font-size: 15px; padding-left: 4px; }
|
.add-bar .grow input[type="text"] { border: none; background: transparent; font-size: 15px; padding-left: 4px; }
|
||||||
@@ -247,7 +298,6 @@
|
|||||||
|
|
||||||
.body { flex: 1; min-width: 0; }
|
.body { flex: 1; min-width: 0; }
|
||||||
.title { font-size: 15px; font-weight: 600; word-break: break-word; }
|
.title { font-size: 15px; font-weight: 600; word-break: break-word; }
|
||||||
.note { font-size: 13px; color: var(--text-sub); margin-top: 5px; word-break: break-word; white-space: pre-wrap; }
|
|
||||||
.meta { display: flex; align-items: center; gap: 8px; margin-top: 9px; flex-wrap: wrap; }
|
.meta { display: flex; align-items: center; gap: 8px; margin-top: 9px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: #f1f4fa; color: var(--text-sub); }
|
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: #f1f4fa; color: var(--text-sub); }
|
||||||
@@ -531,6 +581,58 @@
|
|||||||
return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
|
return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== HTML 净化 =====
|
||||||
|
公开页面任何人可写,富文本存 HTML 再渲染即存储型 XSS。
|
||||||
|
保存前与渲染前各净化一次,仅放行白名单标签与属性。 */
|
||||||
|
var ALLOW_TAGS = { P: 1, BR: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, UL: 1, OL: 1, LI: 1, H2: 1, BLOCKQUOTE: 1, CODE: 1, PRE: 1, A: 1, SPAN: 1, DIV: 1 }
|
||||||
|
var ALLOW_CLASSES = ['ck-box', 'ck-checked', 'ck-line']
|
||||||
|
|
||||||
|
function sanitizeHtml(html) {
|
||||||
|
if (!html) return ''
|
||||||
|
// 直接解析 body 并整体净化,避免外层包裹被内容里的闭合标签截断
|
||||||
|
var doc = new DOMParser().parseFromString(html, 'text/html')
|
||||||
|
cleanNode(doc.body)
|
||||||
|
return doc.body.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanNode(node) {
|
||||||
|
Array.prototype.slice.call(node.childNodes).forEach(function (child) {
|
||||||
|
if (child.nodeType === 3) return // 文本节点保留
|
||||||
|
if (child.nodeType !== 1) { node.removeChild(child); return }
|
||||||
|
var tag = child.tagName
|
||||||
|
if (!ALLOW_TAGS[tag]) { // 白名单外:剥标签,只留文字
|
||||||
|
while (child.firstChild) node.insertBefore(child.firstChild, child)
|
||||||
|
node.removeChild(child)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Array.prototype.slice.call(child.attributes).forEach(function (a) {
|
||||||
|
var n = a.name.toLowerCase()
|
||||||
|
if (n === 'class') {
|
||||||
|
var keep = String(child.className || '').split(/\s+/).filter(function (c) { return ALLOW_CLASSES.indexOf(c) > -1 })
|
||||||
|
if (keep.length) child.className = keep.join(' ')
|
||||||
|
else child.removeAttribute('class')
|
||||||
|
} else if (tag === 'A' && n === 'href') {
|
||||||
|
if (!/^(https?:|mailto:)/i.test(a.value)) child.removeAttribute('href')
|
||||||
|
} else if (n === 'contenteditable') {
|
||||||
|
child.setAttribute('contenteditable', 'false')
|
||||||
|
} else {
|
||||||
|
child.removeAttribute(n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (tag === 'A') {
|
||||||
|
child.setAttribute('target', '_blank')
|
||||||
|
child.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
cleanNode(child)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html) {
|
||||||
|
var d = document.createElement('div')
|
||||||
|
d.innerHTML = html || ''
|
||||||
|
return (d.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
// 截止日期状态:overdue / today / soon / normal
|
// 截止日期状态:overdue / today / soon / normal
|
||||||
function dueState(due) {
|
function dueState(due) {
|
||||||
if (!due) return ''
|
if (!due) return ''
|
||||||
@@ -769,6 +871,81 @@
|
|||||||
return sel
|
return sel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 富文本编辑器(自研,contenteditable + execCommand) ===== */
|
||||||
|
var RTE_BAR =
|
||||||
|
'<div class="rte-bar" contenteditable="false">'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="bold" title="加粗"><b>B</b></button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="italic" title="斜体"><i>I</i></button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="underline" title="下划线"><u>U</u></button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="strikeThrough" title="删除线"><s>S</s></button>'
|
||||||
|
+ '<span class="rte-sep"></span>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="P" title="正文">¶</button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="H2" title="标题">H</button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">❝</button>'
|
||||||
|
+ '<button type="button" class="rte-btn txt" data-cmd="formatBlock" data-val="PRE" title="代码块"></></button>'
|
||||||
|
+ '<span class="rte-sep"></span>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="insertUnorderedList" title="无序列表">☰</button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="insertOrderedList" title="有序列表">≣</button>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="insertCheckbox" title="待办项">☑</button>'
|
||||||
|
+ '<span class="rte-sep"></span>'
|
||||||
|
+ '<button type="button" class="rte-btn" data-cmd="createLink" title="插入链接">🔗</button>'
|
||||||
|
+ '<button type="button" class="rte-btn warn" data-cmd="removeFormat" title="清除格式">✕</button>'
|
||||||
|
+ '<span class="spacer"></span>'
|
||||||
|
+ '<button type="button" class="rte-btn txt" id="rteExpand" title="放大书写面板">⤢ 放大</button>'
|
||||||
|
+ '</div>'
|
||||||
|
|
||||||
|
function initEditor(root) {
|
||||||
|
var body = root.querySelector('.rte-body')
|
||||||
|
var bar = root.querySelector('.rte-bar')
|
||||||
|
|
||||||
|
// 点工具栏时阻止焦点跳走,保住正文里的文字选区
|
||||||
|
bar.addEventListener('mousedown', function (e) { e.preventDefault() })
|
||||||
|
|
||||||
|
bar.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.rte-btn')
|
||||||
|
if (!btn) return
|
||||||
|
if (btn.id === 'rteExpand') {
|
||||||
|
var modal = root.closest('.modal')
|
||||||
|
var lg = modal.classList.toggle('lg')
|
||||||
|
btn.innerHTML = lg ? '⤡ 收起' : '⤢ 放大'
|
||||||
|
btn.title = lg ? '恢复默认面板' : '放大书写面板'
|
||||||
|
body.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
execCmd(btn.dataset.cmd, btn.dataset.val)
|
||||||
|
refreshBarState()
|
||||||
|
})
|
||||||
|
|
||||||
|
function execCmd(cmd, val) {
|
||||||
|
body.focus()
|
||||||
|
if (cmd === 'insertCheckbox') {
|
||||||
|
document.execCommand('insertHTML', false, '<div class="ck-line"><span class="ck-box" contenteditable="false"></span> </div>')
|
||||||
|
} else if (cmd === 'createLink') {
|
||||||
|
var url = prompt('链接地址(http(s):// 或 mailto:):')
|
||||||
|
if (url && /^(https?:|mailto:)/i.test(url)) document.execCommand('createLink', false, url)
|
||||||
|
} else {
|
||||||
|
document.execCommand(cmd, false, val || null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 光标所在处的格式高亮到工具栏按钮(B/I/U/S 有效,块级命令不支持 state 时忽略)
|
||||||
|
function refreshBarState() {
|
||||||
|
Array.prototype.forEach.call(bar.querySelectorAll('.rte-btn[data-cmd]'), function (b) {
|
||||||
|
var on = false
|
||||||
|
try { on = document.queryCommandState(b.dataset.cmd) } catch (e) { /* 忽略 */ }
|
||||||
|
b.classList.toggle('on', !!on)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
body.addEventListener('keyup', refreshBarState)
|
||||||
|
body.addEventListener('mouseup', refreshBarState)
|
||||||
|
|
||||||
|
// 编辑器内点击待办项方框切换勾选
|
||||||
|
body.addEventListener('click', function (e) {
|
||||||
|
var box = e.target.closest('.ck-box')
|
||||||
|
if (box) box.classList.toggle('ck-checked')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 危险操作确认弹窗(替代原生 confirm) ===== */
|
/* ===== 危险操作确认弹窗(替代原生 confirm) ===== */
|
||||||
function confirmDialog(cfg) {
|
function confirmDialog(cfg) {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
@@ -869,7 +1046,12 @@
|
|||||||
html += '</div>'
|
html += '</div>'
|
||||||
html += '<div class="body">'
|
html += '<div class="body">'
|
||||||
html += '<div class="title">' + esc(item.title) + '</div>'
|
html += '<div class="title">' + esc(item.title) + '</div>'
|
||||||
if (item.remark) html += '<div class="note">' + esc(item.remark) + '</div>'
|
if (item.remark && stripHtml(item.remark)) {
|
||||||
|
html += '<div class="note-box" data-act="peek" title="点击展开 / 收起">'
|
||||||
|
+ '<div class="plain">' + esc(stripHtml(item.remark)) + '</div>'
|
||||||
|
+ '<div class="full fmt">' + sanitizeHtml(item.remark) + '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
}
|
||||||
html += '<div class="meta">'
|
html += '<div class="meta">'
|
||||||
html += '<span class="pri p' + p + '">' + priorityLabel(p) + '</span>'
|
html += '<span class="pri p' + p + '">' + priorityLabel(p) + '</span>'
|
||||||
if (item.dueDate) html += '<span class="' + chipCls + '">📅 ' + esc(dueText(item.dueDate)) + '</span>'
|
if (item.dueDate) html += '<span class="' + chipCls + '">📅 ' + esc(dueText(item.dueDate)) + '</span>'
|
||||||
@@ -1002,7 +1184,9 @@
|
|||||||
+ '<div class="field"><label>截止日期</label>' + dateSelHtml('mDue', item.dueDate || '') + '</div>'
|
+ '<div class="field"><label>截止日期</label>' + dateSelHtml('mDue', item.dueDate || '') + '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="field"><label>备注</label>'
|
+ '<div class="field"><label>备注</label>'
|
||||||
+ '<textarea id="mRemark" maxlength="1000" placeholder="补充说明(可选)">' + esc(item.remark || '') + '</textarea></div>'
|
+ '<div class="rte">' + RTE_BAR
|
||||||
|
+ '<div class="rte-body fmt" id="mRemark" contenteditable="true" data-ph="补充说明,支持加粗 / 列表 / 待办项 / 代码块…"></div>'
|
||||||
|
+ '</div></div>'
|
||||||
+ '<div class="modal-foot">'
|
+ '<div class="modal-foot">'
|
||||||
+ '<button class="btn btn-ghost" id="mCancel">取消</button>'
|
+ '<button class="btn btn-ghost" id="mCancel">取消</button>'
|
||||||
+ '<button class="btn btn-primary" id="mSave">保存</button>'
|
+ '<button class="btn btn-primary" id="mSave">保存</button>'
|
||||||
@@ -1011,6 +1195,9 @@
|
|||||||
elModalRoot.innerHTML = html
|
elModalRoot.innerHTML = html
|
||||||
initSel($('mPriority'))
|
initSel($('mPriority'))
|
||||||
initDate($('mDue'))
|
initDate($('mDue'))
|
||||||
|
var rteBody = $('mRemark')
|
||||||
|
rteBody.innerHTML = sanitizeHtml(item.remark || '')
|
||||||
|
initEditor(rteBody.closest('.rte'))
|
||||||
var mask = $('mask')
|
var mask = $('mask')
|
||||||
var mTitle = $('mTitle')
|
var mTitle = $('mTitle')
|
||||||
setTimeout(function () { mTitle.focus() }, 60)
|
setTimeout(function () { mTitle.focus() }, 60)
|
||||||
@@ -1024,11 +1211,13 @@
|
|||||||
$('mSave').addEventListener('click', function () {
|
$('mSave').addEventListener('click', function () {
|
||||||
var title = (mTitle.value || '').trim()
|
var title = (mTitle.value || '').trim()
|
||||||
if (!title) { toast('标题不能为空'); mTitle.focus(); return }
|
if (!title) { toast('标题不能为空'); mTitle.focus(); return }
|
||||||
|
var remarkHtml = sanitizeHtml($('mRemark').innerHTML)
|
||||||
|
if (!stripHtml(remarkHtml) && remarkHtml.indexOf('ck-box') === -1) remarkHtml = ''
|
||||||
var data = {
|
var data = {
|
||||||
title: title,
|
title: title,
|
||||||
priority: getSel('mPriority'),
|
priority: getSel('mPriority'),
|
||||||
dueDate: getSel('mDue'),
|
dueDate: getSel('mDue'),
|
||||||
remark: $('mRemark').value
|
remark: remarkHtml
|
||||||
}
|
}
|
||||||
var req
|
var req
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -1080,7 +1269,7 @@
|
|||||||
title: it.title,
|
title: it.title,
|
||||||
priority: it.priority || 2,
|
priority: it.priority || 2,
|
||||||
dueDate: it.dueDate || '',
|
dueDate: it.dueDate || '',
|
||||||
remark: it.remark || ''
|
remark: sanitizeHtml(it.remark || '')
|
||||||
}).then(function (r) { if (r && r.code === 200) okCount++ })
|
}).then(function (r) { if (r && r.code === 200) okCount++ })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1177,6 +1366,10 @@
|
|||||||
if (act === 'toggle') toggleStatus(item.id, item.status)
|
if (act === 'toggle') toggleStatus(item.id, item.status)
|
||||||
else if (act === 'edit') openEdit(item)
|
else if (act === 'edit') openEdit(item)
|
||||||
else if (act === 'del') removeTodo(item.id, item.title)
|
else if (act === 'del') removeTodo(item.id, item.title)
|
||||||
|
else if (act === 'peek') {
|
||||||
|
if (e.target.closest('a')) return // 点展开内容里的链接时不折叠
|
||||||
|
actEl.classList.toggle('open')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
$('exportBtn').addEventListener('click', exportJson)
|
$('exportBtn').addEventListener('click', exportJson)
|
||||||
|
|||||||
Reference in New Issue
Block a user