From 94c27b6c6d388330879542b67a93079490a6afd4 Mon Sep 17 00:00:00 2001 From: Carlo Beltrame Date: Tue, 26 Mar 2024 21:31:11 +0100 Subject: [PATCH] WIP simple table support --- .../packages/exercise_html_purifier.yaml | 4 +- frontend/package-lock.json | 53 +++++++ frontend/package.json | 4 + .../components/form/tiptap/TiptapEditor.vue | 133 ++++++++++++++++-- pdf/src/campPrint/RichText.vue | 86 ++++++++++- 5 files changed, 264 insertions(+), 16 deletions(-) diff --git a/api/config/packages/exercise_html_purifier.yaml b/api/config/packages/exercise_html_purifier.yaml index 4c787a58ee..7416e519dc 100644 --- a/api/config/packages/exercise_html_purifier.yaml +++ b/api/config/packages/exercise_html_purifier.yaml @@ -12,7 +12,9 @@ exercise_html_purifier: # to know how to whitelist elements # # whitelist attributes by tag -# attributes: [] + attributes: + td: + colwidth: Length # # whitelist elements by name # elements: [] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f3ba7345a..0c73b14184 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,6 +29,10 @@ "@tiptap/extension-paragraph": "2.3.0", "@tiptap/extension-placeholder": "2.3.0", "@tiptap/extension-strike": "2.3.0", + "@tiptap/extension-table": "^2.3.0", + "@tiptap/extension-table-cell": "^2.3.0", + "@tiptap/extension-table-header": "^2.3.0", + "@tiptap/extension-table-row": "^2.3.0", "@tiptap/extension-text": "2.3.0", "@tiptap/extension-underline": "2.3.0", "@tiptap/pm": "2.3.0", @@ -3789,6 +3793,55 @@ "@tiptap/core": "^2.0.0" } }, + "node_modules/@tiptap/extension-table": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.3.0.tgz", + "integrity": "sha512-1cU0C5LFyF7Nm8K2X6sntuunpLDE11XV8gV4ALbXv0FNKx2rG9Wq0tQlGHAjhYZMhWt386T21N7o13aMAov1GA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0", + "@tiptap/pm": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.3.0.tgz", + "integrity": "sha512-jsFp5lc+be04AsuMiTGlluLnsmJl/51+sv0DewYHeidh7iyvk3R5y2pyA+Bk1V/txFdaH5GxOQvSH3RonEVMAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.3.0.tgz", + "integrity": "sha512-wLvJqDBaXc/xs+NBJZoSIfO7fVYqcrIlsdtQRlBec3vTpSG0w0zlrM/JY4mjQKHzWsDk6hb9mvbK2scChOu5TA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.3.0.tgz", + "integrity": "sha512-i2o/S8Mggw1GDxF5N5i8SvDvmOvbHu8MuWpdhFwfOkbrnEdtHlU/GjWIEstPymg4QyrfAEQa/KDffkrX0T7RNw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0" + } + }, "node_modules/@tiptap/extension-text": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 75afac5111..0923fd604e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,10 @@ "@tiptap/extension-paragraph": "2.3.0", "@tiptap/extension-placeholder": "2.3.0", "@tiptap/extension-strike": "2.3.0", + "@tiptap/extension-table": "^2.3.0", + "@tiptap/extension-table-cell": "^2.3.0", + "@tiptap/extension-table-header": "^2.3.0", + "@tiptap/extension-table-row": "^2.3.0", "@tiptap/extension-text": "2.3.0", "@tiptap/extension-underline": "2.3.0", "@tiptap/pm": "2.3.0", diff --git a/frontend/src/components/form/tiptap/TiptapEditor.vue b/frontend/src/components/form/tiptap/TiptapEditor.vue index fda24817a5..06b1f9ad60 100644 --- a/frontend/src/components/form/tiptap/TiptapEditor.vue +++ b/frontend/src/components/form/tiptap/TiptapEditor.vue @@ -64,8 +64,61 @@ @click="editor.chain().focus().sinkListItem('listItem').run()" /> + + + + + + + + + + + + + + + + { - const { doc, selection } = state - const { empty } = selection - - // Sometime check for `empty` is not enough. - // Doubleclick an empty paragraph returns a node size of 2. - // So we check also for an empty text size. - const isEmptyTextBlock = - !doc.textBetween(from, to).length && isTextSelection(state.selection) - // Don't show if selection is within of an autolink if (this.withExtensions) { const links = AutoLinkKey.getState(state).find( @@ -227,12 +282,19 @@ export default { const hasEditorFocus = view.hasFocus() || isChildOfMenu - if (!hasEditorFocus || empty || isEmptyTextBlock || !this.editor.isEditable) { + if (!hasEditorFocus || !this.editor.isEditable) { return false } return true }, + shouldShowTableOptions: ({ from, to }) => { + if (this.withExtensions && this.from > -1 && this.to > -1) { + return this.editor.can().addColumnBefore() + } + + return false + }, } }, computed: { @@ -328,6 +390,57 @@ div.editor:deep(.editor__content .ProseMirror) { line-height: 1.5; } +div.editor:deep(.editor__content) .resize-cursor { + cursor: ew-resize; + cursor: col-resize; +} + +div.editor:deep(.editor__content table) { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + margin: 0; + overflow: hidden; + + td, th { + padding: 0.2rem 0.5rem 0; + min-width: 1em; + border: 1px solid rgba(0, 0, 0, 0.38); + vertical-align: top; + text-align: left; + box-sizing: border-box; + position: relative; + + > * { + margin-bottom: 0; + } + } + + th { + font-weight: bold; + background-color: #f1f3f5; + } + + .selectedCell:after { + z-index: 2; + position: absolute; + content: ""; + left: 0; right: 0; top: 0; bottom: 0; + background: rgba(200, 200, 255, 0.4); + pointer-events: none; + } + + .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: -2px; + width: 4px; + background-color: #adf; + pointer-events: none; + } +} + .theme--light.v-input--is-disabled div.editor:deep(.editor__content .ProseMirror) { color: rgba(0, 0, 0, 0.38); } diff --git a/pdf/src/campPrint/RichText.vue b/pdf/src/campPrint/RichText.vue index b38b0795cd..e3850eb7b3 100644 --- a/pdf/src/campPrint/RichText.vue +++ b/pdf/src/campPrint/RichText.vue @@ -5,22 +5,24 @@ import { decode } from 'html-entities' // eslint-disable-next-line vue/prefer-import-from-vue import { h } from '@vue/runtime-core' -function visit(node, parent = null) { +function visit(node, parent = null, index = 0) { const rule = rules.find((rule) => rule.shouldProcessNode(node, parent)) if (!rule) { console.log('unknown HTML node type', node) return null } - return rule.processNode(node, parent) + return rule.processNode(node, parent, index) } function visitChildren(children, parent) { return children.length - ? children.map((child) => visit(child, parent)) - : [visit({ type: 'text', content: ' ' }, parent)] + ? children.map((child, idx) => visit(child, parent, idx)) + : [visit({ type: 'text', content: ' ' }, parent, 0)] } +const tableContextStack = [] + const rules = [ { shouldProcessNode: (node) => node.type === 'text', @@ -95,6 +97,54 @@ const rules = [ ) }, }, + { + shouldProcessNode: (node) => node.type === 'tag' && node.name === 'table', + processNode: (node) => { + tableContextStack.push([]) + const result = h('View', { class: 'table' }, visitChildren(node.children, node)) + tableContextStack.pop() + return result + }, + }, + { + shouldProcessNode: (node) => node.type === 'tag' && node.name === 'colgroup', + processNode: (node) => { + visitChildren(node.children, node) + return null + }, + }, + { + shouldProcessNode: (node) => node.type === 'tag' && node.name === 'col', + processNode: (node) => { + const width = Math.floor( + parseInt(node.attrs.style?.match(/width:\s*(\d+)px;/)[1]) / 1.33 + ) + const tableContext = tableContextStack.pop() + tableContext.push(width) + tableContextStack.push(tableContext) + return null + }, + }, + { + shouldProcessNode: (node) => node.type === 'tag' && node.name === 'tbody', + processNode: (node) => + h('View', { class: 'tbody' }, visitChildren(node.children, node)), + }, + { + shouldProcessNode: (node) => node.type === 'tag' && node.name === 'tr', + processNode: (node) => h('View', { class: 'tr' }, visitChildren(node.children, node)), + }, + { + shouldProcessNode: (node) => + node.type === 'tag' && (node.name === 'td' || node.name === 'th'), + processNode: (node, _, index) => { + const width = tableContextStack[tableContextStack.length - 1][index] + const style = width + ? { flexBasis: width, flexGrow: 0, flexShrink: 0 } + : { flexBasis: 1.33, flexGrow: 1 } + return h('View', { class: node.name, style }, visitChildren(node.children, node)) + }, + }, ] function calculateListNumber(node, parent) { @@ -120,7 +170,7 @@ export default { }, }, render() { - return [this.parsed].flat().map((node) => visit(node)) + return [this.parsed].flat().map((node, idx) => visit(node, null, idx)) }, } @@ -140,4 +190,30 @@ export default { .strikethrough { text-decoration: line-through; } +.table { + borderLeft: 1pt solid black; + borderTop: 1pt solid black; + width: 100%; +} +.tr { + flex-direction: row; + align-items: stretch; + width: 100%; +} +.th { + font-weight: bold; + background-color: #f1f3f5; + border-right: 1pt solid black; + border-bottom: 1pt solid black; + padding: 2pt 4pt 0; + flex-grow: 1; + flex-basis: 1; +} +.td { + border-right: 1pt solid black; + border-bottom: 1pt solid black; + padding: 2pt 4pt 0; + flex-grow: 1; + flex-basis: 1; +}