diff --git a/lib/migrator.js b/lib/migrator.js index f42722e..28b3b89 100644 --- a/lib/migrator.js +++ b/lib/migrator.js @@ -1,17 +1,16 @@ 'use strict'; -const posts = require.main.require('./src/posts'); - const MarkdownIt = require('markdown-it'); const markdown = new MarkdownIt(); const { QuillDeltaToHtmlConverter } = require('quill-delta-to-html'); +const { TableParser } = require('quill-v1-table/TableDeltaToHtml'); const isHtml = require('is-html'); -const winston = require.main.require('winston'); - const Migrator = module.exports; +Migrator.resolveNbb = id => require.main.require(id); + Migrator.detect = (postObj) => { const isHtml = Migrator.isHtml(postObj); @@ -37,21 +36,90 @@ Migrator.isHtml = postObj => isHtml(postObj.content); Migrator.isMarkdown = postObj => !Migrator.isHTML(postObj); -Migrator.toHtml = (content) => { - try { - content = JSON.parse(content); - const converter = new QuillDeltaToHtmlConverter(content.ops, {}); - - // Quill plugin should fire a hook here, passing converter.renderCustomWith - // Emoji plugin should take that method and register a listener. - // Also toHtml is probably going to end up being asynchronous, then... awaited? - converter.renderCustomWith((customOp) => { - if (customOp.insert.type === 'emoji') { - return `${customOp.attributes.alt}`; +function registerEmojiOnConverter(converter) { + converter.renderCustomWith((customOp) => { + if (customOp.insert.type === 'emoji') { + return `${customOp.attributes.alt}`; + } + }); +} + +function segmentDeltaOps(ops) { + const segments = []; + let i = 0; + while (i < ops.length) { + const op = ops[i]; + const next = ops[i + 1]; + const opTd = !!(op.attributes && op.attributes.td); + const nextTd = !!(next && next.attributes && next.attributes.td); + + if (opTd || nextTd) { + const tableOps = []; + while (i < ops.length) { + const o = ops[i]; + const n = ops[i + 1]; + const oTd = !!(o.attributes && o.attributes.td); + const nTd = !!(n && n.attributes && n.attributes.td); + if (oTd || nTd) { + tableOps.push(o); + i += 1; + } else { + break; + } + } + segments.push({ type: 'table', ops: tableOps }); + } else { + const textOps = []; + while (i < ops.length) { + const o = ops[i]; + const n = ops[i + 1]; + const oTd = !!(o.attributes && o.attributes.td); + const nTd = !!(n && n.attributes && n.attributes.td); + if (oTd || nTd) { + break; + } + textOps.push(o); + i += 1; } - }); + segments.push({ type: 'text', ops: textOps }); + } + } + return segments; +} - return posts.sanitize(converter.convert()); +Migrator.convertMixedDeltaToHtml = (delta) => { + const {ops} = delta; + if (!Array.isArray(ops)) { + return ''; + } + const parts = []; + const segments = segmentDeltaOps(ops); + for (let s = 0; s < segments.length; s += 1) { + const seg = segments[s]; + if (seg.type === 'table') { + const parser = new TableParser(seg.ops); + parser.parse(); + parts.push(parser.toHTML()); + } else if (seg.ops.length > 0) { + const converter = new QuillDeltaToHtmlConverter(seg.ops, {}); + registerEmojiOnConverter(converter); + parts.push(converter.convert()); + } + } + return parts.join(''); +}; + +Migrator.toHtml = (content) => { + const posts = Migrator.resolveNbb('./src/posts'); + const winston = Migrator.resolveNbb('winston'); + try { + const delta = JSON.parse(content); + if (!delta || !Array.isArray(delta.ops)) { + winston.verbose('[plugin/composer-quill (toHtml)] Input not in expected format, skipping.'); + return false; + } + const html = Migrator.convertMixedDeltaToHtml(delta); + return posts.sanitize(html); } catch (e) { // Do nothing winston.verbose('[plugin/composer-quill (toHtml)] Input not in expected format, skipping.'); diff --git a/package.json b/package.json index 8ed5769..4f38a15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodebb-plugin-composer-quill", - "version": "4.1.0", + "version": "4.2.0", "description": "Quill Composer for NodeBB", "main": "library.js", "repository": { @@ -32,6 +32,7 @@ "node-quill-converter": "^0.3.2", "quill": "^1.3.7", "quill-delta-to-html": "^0.12.0", + "quill-v1-table": "^1.7.4", "quill-magic-url": "^4.0.0", "quill-markdown-shortcuts": "^0.0.10", "screenfull": "^5.0.0" diff --git a/plugin.json b/plugin.json index 2ac8777..db872c1 100644 --- a/plugin.json +++ b/plugin.json @@ -25,6 +25,7 @@ "quill.js": "./node_modules/quill/dist/quill.js", "quill-magic-url.js": "./node_modules/quill-magic-url/dist/index.js", "quill-markdown-shortcuts.js": "./node_modules/quill-markdown-shortcuts/dist/markdownShortcuts.js", + "quill-table.js": "./node_modules/quill-v1-table/dist/quill1-table.js", "quill-emoji.js": "./static/lib/emoji.js", "composer.js": "../nodebb-plugin-composer-default/static/lib/composer.js", "composer/categoryList.js": "../nodebb-plugin-composer-default/static/lib/composer/categoryList.js", @@ -46,4 +47,4 @@ "./node_modules/screenfull/dist/screenfull.js" ], "templates": "static/templates" -} +} \ No newline at end of file diff --git a/screenshots/table in editor.png b/screenshots/table in editor.png new file mode 100644 index 0000000..a917bfd Binary files /dev/null and b/screenshots/table in editor.png differ diff --git a/screenshots/table in post.png b/screenshots/table in post.png new file mode 100644 index 0000000..5ded1d0 Binary files /dev/null and b/screenshots/table in post.png differ diff --git a/static/lib/quill-nbb.js b/static/lib/quill-nbb.js index 157eccb..c20c02e 100644 --- a/static/lib/quill-nbb.js +++ b/static/lib/quill-nbb.js @@ -241,20 +241,24 @@ $(window).on('action:chat.loaded', (evt, containerEl) => { window.quill.init = function (targetEl, data, callback) { require([ - 'quill', 'quill-magic-url', 'quill-emoji', 'quill-markdown-shortcuts', + 'quill', 'quill-magic-url', 'quill-emoji', 'quill-markdown-shortcuts', 'quill-table', 'composer/autocomplete', 'composer/drafts', - ], (Quill, MagicUrl, Emoji, MarkdownShortcuts, autocomplete, drafts) => { + ], (Quill, MagicUrl, Emoji, MarkdownShortcuts, TableModule, autocomplete, drafts) => { const textDirection = $('html').attr('data-dir'); const textareaEl = targetEl.siblings('textarea'); - window.quill.configureToolbar(targetEl, data).then(({ toolbar }) => { + window.quill.configureToolbar(targetEl, data, TableModule).then(({ toolbar }) => { // Quill... Quill.register('modules/magicUrl', MagicUrl.default); Quill.register('modules/markdownShortcuts', MarkdownShortcuts); + Quill.register('modules/table', TableModule); const quill = new Quill(targetEl.get(0), { theme: data.theme || 'snow', modules: { toolbar, + table: { + cellSelectionOnClick: false, + }, magicUrl: { normalizeUrlOptions: { sortQueryParameters: false, @@ -383,7 +387,7 @@ window.quill.init = function (targetEl, data, callback) { return window.quill; }; -window.quill.configureToolbar = async (targetEl, data) => { +window.quill.configureToolbar = async (targetEl, data, TableModule) => { const textareaEl = targetEl.siblings('textarea'); const [formatting, hooks] = await new Promise((resolve) => { require(['composer/formatting', 'hooks'], (...libs) => resolve(libs)); @@ -395,6 +399,26 @@ window.quill.configureToolbar = async (targetEl, data) => { ['bold', 'italic', 'underline', 'strike'], // toggled buttons ['link', 'blockquote', 'code-block'], [{ list: 'ordered' }, { list: 'bullet' }], + [{ table: TableModule.tableOptions() }, { + table: [ + 'insert', + 'remove-table', + 'split-cell', + 'merge-selection', + 'append-row-above', + 'append-row-below', + 'append-col-before', + 'append-col-after', + 'remove-col', + 'remove-row', + 'remove-cell', + 'remove-selection', + 'hide-border', + 'show-border', + 'undo', + 'redo', + ], + }], [{ script: 'sub' }, { script: 'super' }], // superscript/subscript [{ color: [] }, { background: [] }], // dropdown with defaults from theme [{ align: [] }], diff --git a/static/scss/post.scss b/static/scss/post.scss index 0583550..dd99f72 100644 --- a/static/scss/post.scss +++ b/static/scss/post.scss @@ -16,4 +16,100 @@ .ql-font-monospace { font-family: Monaco, "Courier New", monospace; } + + /* 表格视觉变量:默认浅色;仅当祖先带 .dark 或 .dark 时切换深色描边(不与系统 prefers-color-scheme 联动) */ + table { + /* 原 0.14 在白底上极易被看成「无边框」;0.18 仍为浅边,可读性更好;可用变量改回 0.14 */ + --ql-table-border: rgba(23, 26, 29, 0.18); + --ql-table-bg: transparent; + --ql-table-cell-bg: transparent; + --ql-table-selected-bg: rgba(59, 130, 246, 0.14); + --ql-table-font-size: 16px; + --ql-table-line-height: 1.7; + --ql-table-padding: 8px; + width: 100%; + border-collapse: collapse; + table-layout: fixed; + /* 勿与 border-radius 同用:在 Chromium/WebKit 下常导致 collapse 表格的格线被裁掉或完全不画 */ + overflow: visible; + white-space: nowrap; + font-size: var(--ql-table-font-size); + line-height: var(--ql-table-line-height); + background-color: var(--ql-table-bg); + border-radius: 8px; + /* 外轮廓:collapse 下仅 td 边线在部分浏览器不可靠,表格外框保证浅色模式下可见 */ + border: 1px solid var(--ql-table-border); + } + + /* @media (prefers-color-scheme: dark) { + table { + --ql-table-border: rgba(255, 255, 255, 0.12); + --ql-table-selected-bg: rgba(96, 165, 250, 0.18); + } + } */ + + .dark table, + .dark table { + --ql-table-border: rgba(255, 255, 255, 0.12); + --ql-table-selected-bg: rgba(96, 165, 250, 0.18); + } + + table td { + border: 1px solid var(--ql-table-border); + padding: var(--ql-table-padding); + min-height: calc(var(--ql-table-padding) * 2 + var(--ql-table-line-height) * 1em); + vertical-align: top; + white-space: pre-wrap; /* https://github.com/quilljs/quill/issues/1760 */ + font-size: inherit; + line-height: inherit; + background-color: var(--ql-table-cell-bg); + } + + .ql-editor__table--hideBorder td { + border: none !important; + } + + .ql-editor__table--hideBorder { + border: none !important; + } + + table td[rowspan="2"] { + min-height: calc(var(--ql-table-padding) * 2 + 2 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="3"] { + min-height: calc(var(--ql-table-padding) * 2 + 3 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="4"] { + min-height: calc(var(--ql-table-padding) * 2 + 4 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="5"] { + min-height: calc(var(--ql-table-padding) * 2 + 5 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="6"] { + min-height: calc(var(--ql-table-padding) * 2 + 6 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="7"] { + min-height: calc(var(--ql-table-padding) * 2 + 7 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="8"] { + min-height: calc(var(--ql-table-padding) * 2 + 8 * var(--ql-table-line-height) * 1em); + } + + table td[rowspan="9"] { + min-height: calc(var(--ql-table-padding) * 2 + 9 * var(--ql-table-line-height) * 1em); + } + + table td.ql-cell-selected { + background-color: var(--ql-table-selected-bg); + } + + table td[merge_id] { + display: none; + } } \ No newline at end of file