diff --git a/api/config/packages/exercise_html_purifier.yaml b/api/config/packages/exercise_html_purifier.yaml
index 4c787a58ee3..7416e519dc8 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 a385e147c5e..c8b77981811 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -29,6 +29,10 @@
"@tiptap/extension-paragraph": "2.2.4",
"@tiptap/extension-placeholder": "2.2.4",
"@tiptap/extension-strike": "2.2.4",
+ "@tiptap/extension-table": "^2.2.4",
+ "@tiptap/extension-table-cell": "^2.2.4",
+ "@tiptap/extension-table-header": "^2.2.4",
+ "@tiptap/extension-table-row": "^2.2.4",
"@tiptap/extension-text": "2.2.4",
"@tiptap/extension-underline": "2.2.4",
"@tiptap/pm": "2.2.4",
@@ -3763,6 +3767,55 @@
"@tiptap/core": "^2.0.0"
}
},
+ "node_modules/@tiptap/extension-table": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.2.4.tgz",
+ "integrity": "sha512-9aEFitlcSi33I6a8nGXQ2uNBEx0urYw/C9W4Ygl49YiMzLXtXDBTqSIzVpas1KkKOSN8yaOqB2UiQdbtqGV8fw==",
+ "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.2.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.2.4.tgz",
+ "integrity": "sha512-Dt3FjNjM1Mh2BgEjvx5+s96DiJpC82BdMtqicO3z/Pk0X1bn70ocMuURNR7upfRYI+9YbE3+3wBk/vY1yf7ydw==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0"
+ }
+ },
+ "node_modules/@tiptap/extension-table-header": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.2.4.tgz",
+ "integrity": "sha512-epRrB/468yGvKb/n6lW3VXWUpjMp3+mKxGWfsXLQncGb1leRbqkgQgsUUYuIEosk+70bjzz6lbfHKQBz408s3g==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0"
+ }
+ },
+ "node_modules/@tiptap/extension-table-row": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.2.4.tgz",
+ "integrity": "sha512-VItZ0byY5CVMrcSRrdBjhElHxIq1JQAAli+o3UNYM5rLKHKx4ezeBCUh80wIKvmaAxWsLMs8h/t4crxUE8dyHA==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0"
+ }
+ },
"node_modules/@tiptap/extension-text": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.2.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 99a89d453a5..8bccf06cf1d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -41,6 +41,10 @@
"@tiptap/extension-paragraph": "2.2.4",
"@tiptap/extension-placeholder": "2.2.4",
"@tiptap/extension-strike": "2.2.4",
+ "@tiptap/extension-table": "^2.2.4",
+ "@tiptap/extension-table-cell": "^2.2.4",
+ "@tiptap/extension-table-header": "^2.2.4",
+ "@tiptap/extension-table-row": "^2.2.4",
"@tiptap/extension-text": "2.2.4",
"@tiptap/extension-underline": "2.2.4",
"@tiptap/pm": "2.2.4",
diff --git a/frontend/src/components/form/tiptap/TiptapEditor.vue b/frontend/src/components/form/tiptap/TiptapEditor.vue
index fda24817a5d..06b1f9ad60c 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 b38b0795cd4..e3850eb7b35 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;
+}