Skip to content

Commit 73531e4

Browse files
committed
feat(editor): Cmd+K, link-actief, voorgrond cmd-klik, slim plakken, koppen >= tekst
- Cmd/Ctrl+K opent de inline link-editor (nieuw of bewerken van de link bij de cursor). - De Link-knop licht op (is-active) als de cursor op een link staat. - Cmd/Ctrl-klik op een link opent nu op de **voorgrond** (zelfde blank-tab-patroon als de 'Openen'-knop) i.p.v. een achtergrond-tabblad. - Slim plakken: een URL plakken terwijl tekst geselecteerd is maakt van die tekst een link naar de URL i.p.v. de tekst te vervangen. - Kop-groottes em-gebaseerd (h1 1.5em .. h6 1em): een kop is nooit kleiner dan de paragraaf-tekst; de diepste kop is body-grootte, onderscheiden door vet. 100% dekking behouden.
1 parent c4548d3 commit 73531e4

8 files changed

Lines changed: 113 additions & 35 deletions

File tree

apps/boekhouding-frontend/src/assets/app.css

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,11 +1475,13 @@ a.card-link:hover {
14751475
margin-block-end: 0;
14761476
}
14771477

1478-
.markdown-content h2 { font-size: 1.3rem; }
1479-
.markdown-content h3 { font-size: 1.17rem; }
1480-
.markdown-content h4 { font-size: 1.05rem; }
1481-
.markdown-content h5 { font-size: 0.95rem; }
1482-
.markdown-content h6 { font-size: 0.88rem; }
1478+
/* em-based so a heading is never smaller than the body text. */
1479+
.markdown-content h1 { font-size: 1.5em; }
1480+
.markdown-content h2 { font-size: 1.36em; }
1481+
.markdown-content h3 { font-size: 1.24em; }
1482+
.markdown-content h4 { font-size: 1.14em; }
1483+
.markdown-content h5 { font-size: 1.06em; }
1484+
.markdown-content h6 { font-size: 1em; }
14831485

14841486
.markdown-content h2,
14851487
.markdown-content h3,

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -384,14 +384,15 @@
384384
margin-block: 0.7em 0.1em;
385385
}
386386

387-
/* Distinct, shrinking sizes for every level so a multi-level sub-heading
388-
hierarchy (H2..H6) stays visually distinguishable inside the field. */
389-
.rvo-theme .markdown-editor .ProseMirror h1 { font-size: 1.45rem; }
390-
.rvo-theme .markdown-editor .ProseMirror h2 { font-size: 1.3rem; }
391-
.rvo-theme .markdown-editor .ProseMirror h3 { font-size: 1.17rem; }
392-
.rvo-theme .markdown-editor .ProseMirror h4 { font-size: 1.05rem; }
393-
.rvo-theme .markdown-editor .ProseMirror h5 { font-size: 0.95rem; }
394-
.rvo-theme .markdown-editor .ProseMirror h6 { font-size: 0.88rem; color: var(--rvo-color-grijs-700, #555); }
387+
/* Sizes are em-based (relative to the body text), so every heading level stays
388+
distinct yet never smaller than a paragraph — the deepest level is exactly the
389+
body size, set apart by its bold weight. */
390+
.rvo-theme .markdown-editor .ProseMirror h1 { font-size: 1.5em; }
391+
.rvo-theme .markdown-editor .ProseMirror h2 { font-size: 1.36em; }
392+
.rvo-theme .markdown-editor .ProseMirror h3 { font-size: 1.24em; }
393+
.rvo-theme .markdown-editor .ProseMirror h4 { font-size: 1.14em; }
394+
.rvo-theme .markdown-editor .ProseMirror h5 { font-size: 1.06em; }
395+
.rvo-theme .markdown-editor .ProseMirror h6 { font-size: 1em; }
395396

396397
.rvo-theme .markdown-editor .ProseMirror p {
397398
margin-block: 0.25rem;

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,27 @@ const editor = useEditor({
7575
// Cmd/Ctrl+click opens a link in a focused tab and stops the editor from
7676
// moving the selection (the logic lives in openLinkOnModifierClick).
7777
handleClick: (_view, _pos, event) => openLinkOnModifierClick(event as MouseEvent),
78+
// Cmd/Ctrl+K opens the inline link editor (add a new link or edit the one at
79+
// the cursor), matching the common editor shortcut.
80+
handleKeyDown: (_view, event) => {
81+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
82+
event.preventDefault()
83+
openLinkEditor()
84+
return true
85+
}
86+
return false
87+
},
88+
// Pasting a URL over a non-empty selection links that text instead of
89+
// replacing it with the raw URL.
90+
handlePaste: (view, event) => {
91+
const text = event.clipboardData?.getData('text/plain')?.trim()
92+
if (text && !view.state.selection.empty && /^https?:\/\/\S+$/i.test(text)) {
93+
// The editor exists while the user is pasting into it.
94+
editor.value!.chain().focus().setLink({ href: text }).run()
95+
return true
96+
}
97+
return false
98+
},
7899
},
79100
onUpdate: ({ editor }) => {
80101
emit('update:modelValue', editor.getMarkdown())
@@ -104,6 +125,7 @@ function syncActiveState() {
104125
orderedList: instance.isActive('orderedList'),
105126
blockquote: instance.isActive('blockquote'),
106127
codeBlock: instance.isActive('codeBlock'),
128+
link: instance.isActive('link'),
107129
}
108130
}
109131

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ function onKeydown(event: KeyboardEvent) {
179179
<span class="markdown-toolbar__sep" role="separator" aria-orientation="vertical"></span>
180180
<button v-for="button in group" :key="button.command" type="button"
181181
class="markdown-toolbar__control markdown-toolbar__button"
182-
:class="{ 'is-active': button.toggle && activeMarks[button.command] }"
182+
:class="{ 'is-active': activeMarks[button.command] }"
183183
:tabindex="rovingIndex[button.command] === activeIndex ? 0 : -1"
184184
:aria-label="button.label" :title="buttonTitle(button)"
185185
:aria-pressed="button.toggle ? Boolean(activeMarks[button.command]) : undefined"

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,22 +34,27 @@ export function markdownLinkInputRule(link: MarkType): InputRule {
3434
}
3535

3636
// While editing, a plain click on a link just places the cursor. Holding the
37-
// platform modifier (Cmd on macOS, Ctrl elsewhere) opens the link in a new tab,
38-
// matching common editors. Used as a ProseMirror handleClick: it preventDefaults
39-
// the native handling and returns true so the editor does not also move the
40-
// selection (which would otherwise select the clicked line). Returns false (lets
41-
// the editor place the cursor) for a plain click or a non-openable link.
37+
// platform modifier (Cmd on macOS, Ctrl elsewhere) opens the link in a focused
38+
// new tab, matching common editors. Used as a ProseMirror handleClick: it
39+
// preventDefaults the native handling and returns true so the editor does not
40+
// also move the selection (which would otherwise select the clicked line).
41+
// Returns false (lets the editor place the cursor) for a plain click or a
42+
// non-openable link.
4243
//
43-
// Security: only http(s) hrefs are opened, blocking javascript:/data: that could
44-
// hide in user-entered link markdown. noopener,noreferrer severs window.opener so
45-
// the opened page cannot navigate this tab back to a phishing page (reverse
46-
// tabnabbing). A programmatic window.open already lands in the foreground, so no
47-
// .focus() is needed (and noopener makes the return value null anyway).
44+
// Foreground without reverse-tabnabbing: open a blank same-origin tab, null its
45+
// opener before navigating to the (possibly cross-origin) href, then focus it.
46+
// Only http(s) is opened, blocking javascript:/data: hidden in link markdown.
4847
export function openLinkOnModifierClick(event: MouseEvent): boolean {
4948
if (!(event.metaKey || event.ctrlKey)) return false
5049
const anchor = (event.target as HTMLElement | null)?.closest('a') as HTMLAnchorElement | null
5150
if (!anchor || !/^https?:\/\//i.test(anchor.href)) return false
5251
event.preventDefault()
53-
window.open(anchor.href, '_blank', 'noopener,noreferrer')
52+
const tab = window.open('about:blank', '_blank')
53+
/* istanbul ignore else @preserve -- a popup blocker can return null. */
54+
if (tab) {
55+
tab.opener = null
56+
tab.location.replace(anchor.href)
57+
tab.focus()
58+
}
5459
return true
5560
}

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, beforeAll, vi } from 'vitest'
22
import { mount, flushPromises } from '@vue/test-utils'
33
import type { Editor } from '@tiptap/core'
4+
import { Slice } from '@tiptap/pm/model'
45
import MarkdownEditor from '../../src/components/task/MarkdownEditor.vue'
56

67
// jsdom has no layout engine; ProseMirror measures the DOM via Range.getClientRects
@@ -145,17 +146,56 @@ describe('MarkdownEditor.vue (WYSIWYG)', () => {
145146
wrapper.unmount()
146147
})
147148

148-
it('opens a link in a new tab on Cmd/Ctrl+click via the editor handleClick', async () => {
149+
it('opens the link editor on Cmd/Ctrl+K and ignores other modifier keys', async () => {
149150
const wrapper = await mountEditor({ modelValue: 'tekst' })
150151
const editor = getEditor(wrapper)
151-
const openSpy = vi.fn()
152+
const press = (key: string, mods: Record<string, boolean> = {}) =>
153+
editor.view.someProp('handleKeyDown', (h: (...a: unknown[]) => boolean) =>
154+
h(editor.view, new KeyboardEvent('keydown', { key, cancelable: true, ...mods })))
155+
press('k', { metaKey: true })
156+
await flushPromises()
157+
expect(wrapper.find('.markdown-editor__linkbar').exists()).toBe(true)
158+
// The handler runs for another Cmd shortcut but declines it.
159+
press('q', { ctrlKey: true })
160+
wrapper.unmount()
161+
})
162+
163+
it('links the selected text when a URL is pasted, and otherwise pastes normally', async () => {
164+
const wrapper = await mountEditor({ modelValue: 'Mijn site' })
165+
const editor = getEditor(wrapper)
166+
// someProp also runs other extensions' paste handlers (Link reads the slice,
167+
// CodeBlock JSON-parses a clipboard key); pass an empty slice and only return
168+
// text for text/plain so those handlers no-op instead of throwing.
169+
const paste = (text: string) =>
170+
editor.view.someProp('handlePaste', (h: (...a: unknown[]) => boolean) =>
171+
h(editor.view, { clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') } }, Slice.empty))
172+
173+
editor.commands.selectAll()
174+
paste('https://example.org') // URL over a selection → link the text
175+
await flushPromises()
176+
expect(wrapper.find('.ProseMirror').html()).toContain('href="https://example.org"')
177+
178+
expect(paste('gewone tekst')).toBeFalsy() // non-URL → default paste
179+
expect(paste('')).toBeFalsy() // empty clipboard → default paste
180+
editor.commands.setTextSelection(1) // collapse the selection
181+
expect(paste('https://x.org')).toBeFalsy() // URL but nothing selected → default paste
182+
wrapper.unmount()
183+
})
184+
185+
it('opens a focused tab on Cmd/Ctrl+click via the editor handleClick', async () => {
186+
const wrapper = await mountEditor({ modelValue: 'tekst' })
187+
const editor = getEditor(wrapper)
188+
const tab = { opener: {} as unknown, location: { replace: vi.fn() }, focus: vi.fn() }
189+
const openSpy = vi.fn(() => tab)
152190
vi.stubGlobal('open', openSpy)
153191
const anchor = document.createElement('a')
154192
anchor.href = 'https://x.org'
155193
const event = new MouseEvent('click', { cancelable: true, metaKey: true })
156194
Object.defineProperty(event, 'target', { value: anchor })
157195
editor.view.someProp('handleClick', (handler: (...args: unknown[]) => boolean) => handler(editor.view, 1, event))
158-
expect(openSpy).toHaveBeenCalledWith('https://x.org/', '_blank', 'noopener,noreferrer')
196+
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank')
197+
expect(tab.location.replace).toHaveBeenCalledWith('https://x.org/')
198+
expect(tab.focus).toHaveBeenCalled()
159199
vi.unstubAllGlobals()
160200
wrapper.unmount()
161201
})

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,17 +132,21 @@ describe('MarkdownToolbar.vue', () => {
132132
expect((document.activeElement as HTMLElement).textContent).toContain('Paragraaf')
133133
})
134134

135-
it('lights up active toggle buttons (is-active + aria-pressed) and leaves inserts unpressed', () => {
136-
const wrapper = mountToolbar({ activeMarks: { bold: true, blockquote: false } })
135+
it('lights up active toggle buttons (is-active + aria-pressed) and the link button when on a link', () => {
136+
const wrapper = mountToolbar({ activeMarks: { bold: true, blockquote: false, link: true } })
137137
const bold = wrapper.find('button[aria-label="Vet"]')
138138
expect(bold.classes()).toContain('is-active')
139139
expect(bold.attributes('aria-pressed')).toBe('true')
140140
const quote = wrapper.find('button[aria-label="Citaat"]')
141141
expect(quote.classes()).not.toContain('is-active')
142142
expect(quote.attributes('aria-pressed')).toBe('false')
143-
// Inserts (divider, link) are not toggles -> no aria-pressed, never active.
143+
// Link highlights when the cursor is on a link, but it is not a toggle (no aria-pressed).
144+
const link = wrapper.find('button[aria-label="Link"]')
145+
expect(link.classes()).toContain('is-active')
146+
expect(link.attributes('aria-pressed')).toBeUndefined()
147+
// Divider is neither active nor pressed.
148+
expect(wrapper.find('button[aria-label="Scheidingslijn"]').classes()).not.toContain('is-active')
144149
expect(wrapper.find('button[aria-label="Scheidingslijn"]').attributes('aria-pressed')).toBeUndefined()
145-
expect(wrapper.find('button[aria-label="Link"]').attributes('aria-pressed')).toBeUndefined()
146150
})
147151

148152
it('shows the keyboard shortcut in the tooltip, or just the label when there is none', () => {

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,20 @@ describe('markdownLinkRule', () => {
6666
})
6767

6868
describe('openLinkOnModifierClick', () => {
69-
it('opens a noopener tab and prevents the default on Cmd/Ctrl+click of an http(s) link', () => {
70-
const openSpy = vi.fn()
69+
it('opens a focused tab (opener severed before navigating) on Cmd/Ctrl+click of an http(s) link', () => {
70+
const tab = { opener: {} as unknown, location: { replace: vi.fn() }, focus: vi.fn() }
71+
const openSpy = vi.fn(() => tab)
7172
vi.stubGlobal('open', openSpy)
7273
const anchor = document.createElement('a')
7374
anchor.href = 'https://x.org'
7475

7576
const event = clickEvent(anchor, { metaKey: true })
7677
expect(openLinkOnModifierClick(event)).toBe(true)
7778
expect(event.defaultPrevented).toBe(true)
78-
expect(openSpy).toHaveBeenCalledWith('https://x.org/', '_blank', 'noopener,noreferrer')
79+
expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank')
80+
expect(tab.opener).toBeNull()
81+
expect(tab.location.replace).toHaveBeenCalledWith('https://x.org/')
82+
expect(tab.focus).toHaveBeenCalled()
7983

8084
expect(openLinkOnModifierClick(clickEvent(anchor, { ctrlKey: true }))).toBe(true) // Ctrl variant
8185
vi.unstubAllGlobals()

0 commit comments

Comments
 (0)