diff --git a/public/todo-public.html b/public/todo-public.html index ecc61fa..4c144d3 100644 --- a/public/todo-public.html +++ b/public/todo-public.html @@ -180,6 +180,57 @@ .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); } + /* ===== 富文本编辑器(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 .grow { flex: 1; min-width: 200px; } .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; } .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; } .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) } + /* ===== 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 function dueState(due) { if (!due) return '' @@ -769,6 +871,81 @@ return sel } + /* ===== 富文本编辑器(自研,contenteditable + execCommand) ===== */ + var RTE_BAR = + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + 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, '
 
') + } 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) ===== */ function confirmDialog(cfg) { return new Promise(function (resolve) { @@ -869,7 +1046,12 @@ html += '' html += '
' html += '
' + esc(item.title) + '
' - if (item.remark) html += '
' + esc(item.remark) + '
' + if (item.remark && stripHtml(item.remark)) { + html += '
' + + '
' + esc(stripHtml(item.remark)) + '
' + + '
' + sanitizeHtml(item.remark) + '
' + + '
' + } html += '
' html += '' + priorityLabel(p) + '' if (item.dueDate) html += '📅 ' + esc(dueText(item.dueDate)) + '' @@ -1002,7 +1184,9 @@ + '
' + dateSelHtml('mDue', item.dueDate || '') + '
' + '
' + '
' - + '
' + + '
' + RTE_BAR + + '
' + + '
' + '