Skip to content

Commit c4548d3

Browse files
committed
feat(editor): onderstrepen, knopvolgorde, foreground-link, compactere balk
- Onderstrepen toegevoegd (StarterKit-mark; serialiseert als `++..++` en round-trip in de editor). marked kent `++` niet, dus een kleine inline-extensie rendert het als <u> in de HTML-preview én in de PDF-export (decoration: underline). Opslag blijft markdown — geen raw HTML. - Knopvolgorde per wens: blokstijl | vet/cursief/onderstrepen/doorhalen | opsomming/ genummerd/citaat | link/scheidingslijn/code/codeblok. - Link 'Openen' opent nu op de **voorgrond** (blanco tab, opener losgekoppeld vóór navigatie, dan focus) i.p.v. een verborgen tabblad; mailto via window.open(noopener). - P/Hx-markers even groot als de andere icoontjes; balk iets naar links (minder padding). 100% dekking behouden.
1 parent c00e851 commit c4548d3

8 files changed

Lines changed: 100 additions & 27 deletions

File tree

packages/assessment-core/src/assets/base.css

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,9 @@
236236
.rvo-theme .markdown-toolbar__block-button {
237237
display: inline-flex;
238238
align-items: center;
239-
gap: 0.3rem;
239+
gap: 0.25rem;
240240
height: 1.75rem;
241-
padding: 0 0.4rem;
241+
padding: 0 0.4rem 0 0.25rem;
242242
border: 1px solid transparent;
243243
border-radius: 3px;
244244
background: none;
@@ -250,13 +250,14 @@
250250
background: var(--rvo-color-grijs-100, #f3f3f3);
251251
}
252252

253+
/* The P / Hx marker reads as an icon, so size it like the other toolbar icons. */
253254
.rvo-theme .markdown-toolbar__block-marker {
254255
display: inline-flex;
255256
align-items: center;
256257
justify-content: center;
257-
min-width: 1.4rem;
258+
min-width: 1.25rem;
258259
font-weight: 700;
259-
font-size: 0.7rem;
260+
font-size: 0.95rem;
260261
color: var(--rvo-color-grijs-700, #555);
261262
}
262263

@@ -345,7 +346,7 @@
345346
.rvo-theme .markdown-editor__footer {
346347
display: flex;
347348
align-items: center;
348-
padding: 0.2rem 0.4rem;
349+
padding: 0.2rem 0.4rem 0.2rem 0.25rem;
349350
border-top: 1px solid var(--rvo-color-grijs-200, #e6e6e6);
350351
background: var(--rvo-color-grijs-100, #f3f3f3);
351352
border-radius: 0 0 4px 4px;

packages/assessment-core/src/components/task/MarkdownEditor.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function syncActiveState() {
9797
activeMarks.value = {
9898
bold: instance.isActive('bold'),
9999
italic: instance.isActive('italic'),
100+
underline: instance.isActive('underline'),
100101
strikethrough: instance.isActive('strike'),
101102
code: instance.isActive('code'),
102103
bulletList: instance.isActive('bulletList'),
@@ -157,7 +158,20 @@ function applyLink() {
157158
158159
function openLink() {
159160
const url = linkUrl.value.trim()
160-
if (url) window.open(url, '_blank', 'noopener,noreferrer')
161+
if (/^https?:\/\//i.test(url)) {
162+
// Open in the FOREGROUND: a blank same-origin tab whose opener we null before
163+
// navigating, so the user lands on the link without the reverse-tabnabbing
164+
// risk of an opener reference (noopener would block the foreground focus).
165+
const tab = window.open('about:blank', '_blank')
166+
/* istanbul ignore else @preserve -- a popup blocker can return null. */
167+
if (tab) {
168+
tab.opener = null
169+
tab.location.replace(url)
170+
tab.focus()
171+
}
172+
} else if (/^mailto:/i.test(url)) {
173+
window.open(url, '_blank', 'noopener,noreferrer')
174+
}
161175
}
162176
163177
function removeLink() {
@@ -186,6 +200,9 @@ function handleCommand(command: MarkdownCommand) {
186200
case 'italic':
187201
instance.chain().focus().toggleItalic().run()
188202
break
203+
case 'underline':
204+
instance.chain().focus().toggleUnderline().run()
205+
break
189206
case 'strikethrough':
190207
instance.chain().focus().toggleStrike().run()
191208
break

packages/assessment-core/src/components/task/MarkdownToolbar.vue

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,19 @@ const groups: Btn[][] = [
4444
[
4545
{ command: 'bold', label: 'Vet', toggle: true, shortcut: { key: 'B' }, paths: ['M7 5h6a3.5 3.5 0 0 1 0 7h-6z', 'M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7'] },
4646
{ command: 'italic', label: 'Cursief', toggle: true, shortcut: { key: 'I' }, paths: ['M11 5h6', 'M7 19h6', 'M14 5l-4 14'] },
47+
{ command: 'underline', label: 'Onderstrepen', toggle: true, shortcut: { key: 'U' }, paths: ['M7 5v5a5 5 0 0 0 10 0v-5', 'M5 19h14'] },
4748
{ command: 'strikethrough', label: 'Doorhalen', toggle: true, shortcut: { key: 'S', shift: true }, paths: ['M5 12h14', 'M16 6.5a4 2 0 0 0 -4 -1.5h-1a3.5 3.5 0 0 0 0 7', 'M8.5 17.5a4 2 0 0 0 4 1.5h1a3.5 3.5 0 0 0 .5 -6.95'] },
48-
{ command: 'code', label: 'Code', toggle: true, shortcut: { key: 'E' }, paths: ['M7 8l-4 4l4 4', 'M17 8l4 4l-4 4', 'M14 4l-4 16'] },
4949
],
5050
[
5151
{ command: 'bulletList', label: 'Opsommingslijst', toggle: true, shortcut: { key: '8', shift: true }, paths: ['M9 6h11', 'M9 12h11', 'M9 18h11', 'M5 6h.01', 'M5 12h.01', 'M5 18h.01'] },
5252
{ command: 'orderedList', label: 'Genummerde lijst', toggle: true, shortcut: { key: '7', shift: true }, paths: ['M11 6h9', 'M11 12h9', 'M12 18h8', 'M4 16a2 2 0 1 1 4 0c0 .591 -.602 1.46 -1 2l-3 3h4', 'M6 10v-6l-2 2'] },
5353
{ command: 'blockquote', label: 'Citaat', toggle: true, shortcut: { key: 'B', shift: true }, paths: ['M6 15h15', 'M21 19h-15', 'M15 11h6', 'M21 7h-6', 'M9 9h1a1 1 0 0 1 -1 1v-2.5a2 2 0 0 1 2 -2', 'M3 9h1a1 1 0 0 1 -1 1v-2.5a2 2 0 0 1 2 -2'] },
54-
{ command: 'codeBlock', label: 'Codeblok', toggle: true, paths: ['M7 4a2 2 0 0 0 -2 2v3a2 2 0 0 1 -2 2a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2', 'M17 4a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2v3a2 2 0 0 1 -2 2'] },
55-
{ command: 'divider', label: 'Scheidingslijn', paths: ['M4 12h16'] },
5654
],
5755
[
5856
{ command: 'link', label: 'Link', paths: ['M9 15l6 -6', 'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464', 'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'] },
57+
{ command: 'divider', label: 'Scheidingslijn', paths: ['M4 12h16'] },
58+
{ command: 'code', label: 'Code', toggle: true, shortcut: { key: 'E' }, paths: ['M7 8l-4 4l4 4', 'M17 8l4 4l-4 4', 'M14 4l-4 16'] },
59+
{ command: 'codeBlock', label: 'Codeblok', toggle: true, paths: ['M7 4a2 2 0 0 0 -2 2v3a2 2 0 0 1 -2 2a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2', 'M17 4a2 2 0 0 1 2 2v3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2v3a2 2 0 0 1 -2 2'] },
5960
],
6061
]
6162

packages/assessment-core/src/utils/markdown.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1-
import { Marked, type Tokens, type Token, type MarkedToken } from 'marked'
1+
import { Marked, type Tokens, type Token, type MarkedToken, type TokenizerAndRendererExtension } from 'marked'
22
import type { Content } from 'pdfmake/interfaces'
33
import { escapeHtml } from './escapeHtml'
44

5+
// `++text++` underline, matching what @tiptap/markdown serialises (marked has no
6+
// built-in underline). A small inline extension renders it for both the HTML
7+
// preview and the PDF lexer. The rendered output is a fixed <u> tag whose inner
8+
// content goes through the safe inline renderers, so no raw HTML can leak in.
9+
const underlineExtension: TokenizerAndRendererExtension = {
10+
name: 'underline',
11+
level: 'inline',
12+
start(src) { return src.indexOf('++') },
13+
tokenizer(src) {
14+
const match = /^\+\+([\s\S]+?)\+\+/.exec(src)
15+
if (!match) return undefined
16+
return { type: 'underline', raw: match[0], text: match[1], tokens: this.lexer.inlineTokens(match[1]) }
17+
},
18+
renderer(token) {
19+
// The tokenizer always sets `tokens`, so it is present here.
20+
return `<u>${this.parser.parseInline(token.tokens!)}</u>`
21+
},
22+
}
23+
524
// Marked instance with a custom renderer that acts as an allowlist.
625
// Only safe HTML tags are produced; raw HTML input and images are stripped.
726
// Links are rendered with target="_blank" and rel="noopener noreferrer".
@@ -27,10 +46,11 @@ const safeMarked = new Marked({
2746
// All remaining methods use the default renderer (safe tags only)
2847
},
2948
})
49+
safeMarked.use({ extensions: [underlineExtension] })
3050

3151
/**
3252
* Parse markdown to sanitized HTML for preview rendering.
33-
* Uses an allowlist renderer: only safe tags (p, strong, em, ul, ol, li, h1-h6,
53+
* Uses an allowlist renderer: only safe tags (p, strong, em, u, ul, ol, li, h1-h6,
3454
* code, pre, blockquote, hr, br, del, a) are produced. Raw HTML and images
3555
* are stripped. Links open in a new tab.
3656
*/
@@ -58,6 +78,11 @@ function processInlineTokens(tokens: Token[]): PdfText[] {
5878
const parts: PdfText[] = []
5979
for (const token of tokens) {
6080
const t = token as MarkedToken
81+
// `underline` is our custom inline token (not part of MarkedToken's union).
82+
if ((t as { type: string }).type === 'underline') {
83+
parts.push({ text: processInlineTokens((t as unknown as { tokens: Token[] }).tokens), decoration: 'underline' })
84+
continue
85+
}
6186
switch (t.type) {
6287
case 'strong':
6388
parts.push({ text: processInlineTokens(t.tokens), bold: true })
@@ -189,7 +214,9 @@ function processBlockTokens(tokens: Token[]): Content[] {
189214
export function markdownToPdfContent(markdown: string): Content {
190215
if (!markdown) return { text: '' }
191216

192-
const tokens = new Marked({ breaks: true }).lexer(markdown)
217+
const pdfMarked = new Marked({ breaks: true })
218+
pdfMarked.use({ extensions: [underlineExtension] })
219+
const tokens = pdfMarked.lexer(markdown)
193220
const content = processBlockTokens(tokens)
194221

195222
if (content.length === 0) return { text: '' }

packages/assessment-core/src/utils/markdownCommands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export type MarkdownCommand =
77
| 'bold'
88
| 'italic'
9+
| 'underline'
910
| 'strikethrough'
1011
| 'bulletList'
1112
| 'orderedList'

packages/assessment-core/test/cov/components-task-MarkdownEditor.cov.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('MarkdownEditor.vue (WYSIWYG)', () => {
5858

5959
it('applies every formatting command and emits markdown on a document change', async () => {
6060
const wrapper = await mountEditor({ modelValue: 'Hallo' })
61-
const labels = ['Vet', 'Cursief', 'Doorhalen', 'Opsommingslijst', 'Genummerde lijst', 'Citaat', 'Code', 'Scheidingslijn']
61+
const labels = ['Vet', 'Cursief', 'Onderstrepen', 'Doorhalen', 'Opsommingslijst', 'Genummerde lijst', 'Citaat', 'Code', 'Scheidingslijn']
6262
for (const label of labels) {
6363
await wrapper.find(`button[aria-label="${label}"]`).trigger('click')
6464
await flushPromises()
@@ -93,8 +93,9 @@ describe('MarkdownEditor.vue (WYSIWYG)', () => {
9393
wrapper.unmount()
9494
})
9595

96-
it('edits an existing link: pre-fills the url, opens it, and removes it', async () => {
97-
const openSpy = vi.fn()
96+
it('edits an existing link: pre-fills the url, opens it in the foreground, and removes it', async () => {
97+
const tab = { opener: {} as unknown, location: { replace: vi.fn() }, focus: vi.fn() }
98+
const openSpy = vi.fn(() => tab)
9899
vi.stubGlobal('open', openSpy)
99100
const wrapper = await mountEditor({ modelValue: 'tekst' })
100101
const editor = getEditor(wrapper)
@@ -107,14 +108,23 @@ describe('MarkdownEditor.vue (WYSIWYG)', () => {
107108
expect((wrapper.find('.markdown-editor__linkinput').element as HTMLInputElement).value).toBe('https://x.org')
108109
expect(linkButton(wrapper, 'Opslaan').exists()).toBe(true)
109110

110-
// Openen → opens in a new tab.
111+
// Openen → foreground tab: a blank tab whose opener is severed before navigating.
112+
await linkButton(wrapper, 'Openen').trigger('click')
113+
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank')
114+
expect(tab.opener).toBeNull()
115+
expect(tab.location.replace).toHaveBeenCalledWith('https://x.org')
116+
expect(tab.focus).toHaveBeenCalled()
117+
118+
// A mailto link opens via window.open with noopener.
119+
await wrapper.find('.markdown-editor__linkinput').setValue('mailto:a@b.nl')
111120
await linkButton(wrapper, 'Openen').trigger('click')
112-
expect(openSpy).toHaveBeenCalledWith('https://x.org', '_blank', 'noopener,noreferrer')
121+
expect(openSpy).toHaveBeenCalledWith('mailto:a@b.nl', '_blank', 'noopener,noreferrer')
113122

114-
// With an empty url, Openen is a no-op.
123+
// A non-openable url (empty) is a no-op.
124+
openSpy.mockClear()
115125
await wrapper.find('.markdown-editor__linkinput').setValue('')
116126
await linkButton(wrapper, 'Openen').trigger('click')
117-
expect(openSpy).toHaveBeenCalledTimes(1)
127+
expect(openSpy).not.toHaveBeenCalled()
118128

119129
// Verwijderen removes the link and closes the bar.
120130
await linkButton(wrapper, 'Verwijderen').trigger('click')

packages/assessment-core/test/cov/components-task-MarkdownToolbar.cov.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,19 @@ function blockButton(wrapper: ReturnType<typeof mount>) {
1717
return wrapper.find('.markdown-toolbar__block-button')
1818
}
1919

20-
// block dropdown (index 0) + 10 buttons.
21-
const TEN_MINUS = Array(10).fill('-1')
20+
// block dropdown (index 0) + 11 buttons.
21+
const BUTTONS_MINUS = Array(11).fill('-1')
2222

2323
describe('MarkdownToolbar.vue', () => {
2424
it('renders a labelled toolbar: block dropdown (tab stop) + grouped buttons with separators', () => {
2525
const wrapper = mountToolbar()
2626
const toolbar = wrapper.find('[role="toolbar"]')
2727
expect(toolbar.attributes('aria-label')).toBe('Tekstopmaak')
28-
expect(wrapper.findAll('.markdown-toolbar__button')).toHaveLength(10)
28+
expect(wrapper.findAll('.markdown-toolbar__button')).toHaveLength(11)
2929
expect(wrapper.findAll('.markdown-toolbar__sep')).toHaveLength(3)
30-
expect(controlTabindexes(wrapper)).toEqual(['0', ...TEN_MINUS]) // dropdown holds the tab stop
30+
expect(controlTabindexes(wrapper)).toEqual(['0', ...BUTTONS_MINUS]) // dropdown holds the tab stop
3131
expect(wrapper.findAll('.markdown-toolbar__button').map((b) => b.attributes('aria-label'))).toEqual([
32-
'Vet', 'Cursief', 'Doorhalen', 'Code', 'Opsommingslijst', 'Genummerde lijst', 'Citaat', 'Codeblok', 'Scheidingslijn', 'Link',
32+
'Vet', 'Cursief', 'Onderstrepen', 'Doorhalen', 'Opsommingslijst', 'Genummerde lijst', 'Citaat', 'Link', 'Scheidingslijn', 'Code', 'Codeblok',
3333
])
3434
})
3535

@@ -171,11 +171,11 @@ describe('MarkdownToolbar.vue', () => {
171171
await toolbar.trigger('keydown', { key: 'ArrowLeft' }) // Vet(1) -> dropdown(0)
172172
expect(controlTabindexes(wrapper)[0]).toBe('0')
173173
await toolbar.trigger('keydown', { key: 'ArrowLeft' }) // dropdown(0) -> last (wrap)
174-
expect(controlTabindexes(wrapper)[10]).toBe('0')
174+
expect(controlTabindexes(wrapper)[11]).toBe('0')
175175
await toolbar.trigger('keydown', { key: 'ArrowRight' }) // last -> dropdown(0) (wrap)
176176
expect(controlTabindexes(wrapper)[0]).toBe('0')
177177
await toolbar.trigger('keydown', { key: 'End' })
178-
expect(controlTabindexes(wrapper)[10]).toBe('0')
178+
expect(controlTabindexes(wrapper)[11]).toBe('0')
179179
await toolbar.trigger('keydown', { key: 'Home' })
180180
expect(controlTabindexes(wrapper)[0]).toBe('0')
181181
})
@@ -184,7 +184,7 @@ describe('MarkdownToolbar.vue', () => {
184184
const wrapper = mountToolbar()
185185
const toolbar = wrapper.find('[role="toolbar"]')
186186
await toolbar.trigger('keydown', { key: 'ArrowDown' }) // not a roving key
187-
expect(controlTabindexes(wrapper)).toEqual(['0', ...TEN_MINUS])
187+
expect(controlTabindexes(wrapper)).toEqual(['0', ...BUTTONS_MINUS])
188188
// A key bubbling from the menu must not move the toolbar tab stop.
189189
await blockButton(wrapper).trigger('click')
190190
await flushPromises()

packages/assessment-core/test/cov/utils-markdown.cov.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ describe('renderMarkdownToHtml', () => {
1515
expect(renderMarkdownToHtml('Hello world')).toContain('<p>Hello world</p>')
1616
})
1717

18+
it('renders ++text++ as <u> underline, and leaves an unterminated ++ literal', () => {
19+
expect(renderMarkdownToHtml('++onder++ streep')).toContain('<u>onder</u>')
20+
// Nested inline marks inside the underline are still parsed.
21+
expect(renderMarkdownToHtml('++**vet**++')).toContain('<u><strong>vet</strong></u>')
22+
// No closing ++ -> the tokenizer returns undefined and the text stays literal.
23+
expect(renderMarkdownToHtml('++incompleet')).toContain('++incompleet')
24+
})
25+
1826
it('strips raw HTML via the html() renderer', () => {
1927
const html = renderMarkdownToHtml('<script>alert("xss")</script>')
2028
expect(html).not.toContain('<script>')
@@ -110,6 +118,14 @@ describe('markdownToPdfContent — inline token handling (processInlineTokens)',
110118
expect(delItem).toBeDefined()
111119
})
112120

121+
it('renders ++underline++ with an underline decoration', () => {
122+
const content = markdownToPdfContent('++onder++') as any
123+
const item = textArray(content).find(
124+
(t: any) => typeof t === 'object' && t.decoration === 'underline',
125+
)
126+
expect(item).toBeDefined()
127+
})
128+
113129
it('handles codespan with background colour', () => {
114130
const content = markdownToPdfContent('`code`') as any
115131
const span = textArray(content).find((t: any) => typeof t === 'object' && t.background === '#e8e8e8')

0 commit comments

Comments
 (0)