Skip to content

Commit c00e851

Browse files
committed
feat(editor): Docs-stijl blokstijl-dropdown, actieve-status-knoppen, codeblok
Vervangt de native select door een blokstijl-knop in de balk (zoals Google Docs): [T Paragraaf v] opent een menu met Paragraaf + Kop 1 t/m Kop 6 (met H-markers en een vinkje op het actieve blok). Koppen zijn nu direct H1..H6 (één hash = H1, zes = H6, standaard markdown); de document-afhankelijke verschuiving voor PDF-export volgt later, dus de baseHeadingLevel-offset is verwijderd. Verder: - Knoppen lichten op (is-active + aria-pressed) als hun mark/node actief staat bij de cursor, zodat duidelijk is dat een tweede klik het uitzet (o.a. citaat). - Codeblok-knop toegevoegd (toggleCodeBlock). - Knoppen gegroepeerd met dunne scheidingslijnen. - De dropdown doet mee in de roving-tabindex (één tab-stop); menu met pijltjes/Escape. Markdown blijft canoniek; 100% dekking behouden.
1 parent 54d8afa commit c00e851

8 files changed

Lines changed: 484 additions & 217 deletions

File tree

apps/boekhouding-frontend/src/views/ProjectDetail.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,6 @@ const formatDate = (dateStr: string) =>
380380
<div v-if="editingDescription" class="editable-field-group rvo-margin-block-end--md">
381381
<MarkdownEditor
382382
:model-value="editDescription"
383-
:base-heading-level="2"
384383
aria-label="Projectbeschrijving"
385384
@update:model-value="(value) => editDescription = value"
386385
/>

apps/boekhouding-frontend/src/views/ProjectList.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ const descriptionPreview = (markdown: string): string =>
8888
</div>
8989
<div class="rvo-form-field rvo-margin-block-end--md">
9090
<label class="rvo-form-field__label" id="projectDescLabel" for="projectDesc">Beschrijving (optioneel)</label>
91-
<MarkdownEditor :model-value="newProjectDescription" :base-heading-level="2"
91+
<MarkdownEditor :model-value="newProjectDescription"
9292
input-id="projectDesc" aria-labelledby="projectDescLabel"
9393
@update:model-value="(value) => newProjectDescription = value" />
9494
</div>

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

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@
175175
.rvo-theme .markdown-toolbar {
176176
display: flex;
177177
flex-wrap: wrap;
178+
align-items: center;
178179
gap: 0.15rem;
179180
}
180181

@@ -197,7 +198,16 @@
197198
background: var(--rvo-color-grijs-100, #f3f3f3);
198199
}
199200

200-
.rvo-theme .markdown-toolbar__button:focus-visible {
201+
/* An active mark/node is shown with a filled, tinted button so it is clear a
202+
second click toggles it off. */
203+
.rvo-theme .markdown-toolbar__button.is-active {
204+
background: var(--rvo-color-lichtblauw, #e5f1fb);
205+
border-color: var(--rvo-color-logoblauw, #007bc7);
206+
color: var(--rvo-color-logoblauw, #007bc7);
207+
}
208+
209+
.rvo-theme .markdown-toolbar__button:focus-visible,
210+
.rvo-theme .markdown-toolbar__block-button:focus-visible {
201211
outline: 2px solid var(--rvo-color-hemelblauw);
202212
outline-offset: 1px;
203213
}
@@ -206,14 +216,105 @@
206216
display: block;
207217
}
208218

209-
/* The heading-level dropdown sits in the footer toolbar, so keep it compact and
210-
aligned with the icon buttons rather than at the full form-field height. */
211-
.rvo-theme .markdown-toolbar__heading {
219+
/* Thin vertical separator between button groups. */
220+
.rvo-theme .markdown-toolbar__sep {
221+
align-self: stretch;
222+
width: 1px;
223+
min-height: 1.4rem;
224+
margin-inline: 0.25rem;
225+
background: var(--rvo-color-grijs-300, #ccc);
226+
}
227+
.rvo-theme .markdown-toolbar__sep:first-child {
228+
display: none;
229+
}
230+
231+
/* Block-style dropdown (paragraph / Kop 1..6), styled like a toolbar button. */
232+
.rvo-theme .markdown-toolbar__block {
233+
position: relative;
234+
}
235+
236+
.rvo-theme .markdown-toolbar__block-button {
237+
display: inline-flex;
238+
align-items: center;
239+
gap: 0.3rem;
212240
height: 1.75rem;
213-
min-height: 0;
214-
padding-block: 0;
215-
font-size: 0.8rem;
216-
margin-inline-end: 0.2rem;
241+
padding: 0 0.4rem;
242+
border: 1px solid transparent;
243+
border-radius: 3px;
244+
background: none;
245+
color: var(--rvo-color-zwart, #000);
246+
font-size: 0.85rem;
247+
cursor: pointer;
248+
}
249+
.rvo-theme .markdown-toolbar__block-button:hover {
250+
background: var(--rvo-color-grijs-100, #f3f3f3);
251+
}
252+
253+
.rvo-theme .markdown-toolbar__block-marker {
254+
display: inline-flex;
255+
align-items: center;
256+
justify-content: center;
257+
min-width: 1.4rem;
258+
font-weight: 700;
259+
font-size: 0.7rem;
260+
color: var(--rvo-color-grijs-700, #555);
261+
}
262+
263+
.rvo-theme .markdown-toolbar__block-label {
264+
min-width: 3.6rem;
265+
text-align: start;
266+
}
267+
268+
.rvo-theme .markdown-toolbar__caret {
269+
display: block;
270+
color: var(--rvo-color-grijs-700, #555);
271+
}
272+
273+
.rvo-theme .markdown-toolbar__menu {
274+
position: absolute;
275+
bottom: calc(100% + 0.3rem);
276+
inset-inline-start: 0;
277+
z-index: 20;
278+
min-width: 11rem;
279+
max-height: 16rem;
280+
overflow-y: auto;
281+
padding: 0.25rem;
282+
background: #fff;
283+
border: 1px solid var(--rvo-color-grijs-300, #ccc);
284+
border-radius: 4px;
285+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
286+
}
287+
288+
.rvo-theme .markdown-toolbar__menuitem {
289+
display: flex;
290+
align-items: center;
291+
gap: 0.5rem;
292+
width: 100%;
293+
padding: 0.35rem 0.5rem;
294+
border: 0;
295+
border-radius: 3px;
296+
background: none;
297+
color: var(--rvo-color-zwart, #000);
298+
font-size: 0.9rem;
299+
text-align: start;
300+
cursor: pointer;
301+
}
302+
.rvo-theme .markdown-toolbar__menuitem:hover,
303+
.rvo-theme .markdown-toolbar__menuitem:focus-visible {
304+
background: var(--rvo-color-grijs-100, #f3f3f3);
305+
outline: none;
306+
}
307+
.rvo-theme .markdown-toolbar__menuitem.is-active {
308+
background: var(--rvo-color-lichtblauw, #e5f1fb);
309+
}
310+
311+
.rvo-theme .markdown-toolbar__menuitem-label {
312+
flex: 1;
313+
}
314+
315+
.rvo-theme .markdown-toolbar__check {
316+
display: block;
317+
color: var(--rvo-color-logoblauw, #007bc7);
217318
}
218319

219320
/* WYSIWYG editor surface (Mattermost-style): one bordered shell with the

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

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,17 @@ const props = defineProps<{
2424
// A direct accessible name, for hosts without a separate visible label element
2525
// to point at via ariaLabelledby.
2626
ariaLabel?: string
27-
// The top heading level available inside the field. A field sits below the
28-
// document/section structure, so its first heading defaults to H2; a deeper
29-
// host can raise it (e.g. H3). Levels from here down to H6 are all available,
30-
// so an answer can have a small sub-heading hierarchy.
31-
baseHeadingLevel?: number
3227
}>()
3328
3429
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
3530
3631
// 1..6 matches TipTap's Level union without importing the heading extension type.
3732
type Level = 1 | 2 | 3 | 4 | 5 | 6
38-
// Clamp to a valid HTML heading level. An out-of-range or fractional prop would
39-
// otherwise build an invalid schema and emit <hundefined>/<h0> tags.
40-
const baseLevel = Math.min(Math.max(Math.round(props.baseHeadingLevel ?? 2), 1), 6) as Level
41-
// Every level from the base down to H6, so deeper markdown (## , ### ...) and
42-
// pasted content keep their depth instead of collapsing onto one level.
43-
const headingLevels = Array.from({ length: 6 - baseLevel + 1 }, (_, i) => baseLevel + i) as Level[]
33+
const headingLevels: Level[] = [1, 2, 3, 4, 5, 6]
4434
4535
// Input rules: typed `[text](url)` becomes a link, and a `#`..`######` shortcut
46-
// maps relatively onto the field's levels — one hash is the base level, each extra
47-
// hash one level deeper, capped at H6 (a field never has H1).
36+
// becomes the matching heading level (one hash = H1, six = H6) — standard markdown.
37+
// A document-aware offset for PDF export is applied later, not here.
4838
const MarkdownInputRules = Extension.create({
4939
name: 'markdownInputRules',
5040
addInputRules() {
@@ -53,7 +43,7 @@ const MarkdownInputRules = Extension.create({
5343
textblockTypeInputRule({
5444
find: /^(#{1,6})\s$/,
5545
type: this.editor.schema.nodes.heading,
56-
getAttributes: (match) => ({ level: Math.min(baseLevel - 1 + match[1].length, 6) }),
46+
getAttributes: (match) => ({ level: match[1].length }),
5747
}),
5848
]
5949
},
@@ -88,28 +78,32 @@ const editor = useEditor({
8878
},
8979
onUpdate: ({ editor }) => {
9080
emit('update:modelValue', editor.getMarkdown())
91-
syncActiveHeading()
81+
syncActiveState()
9282
},
93-
onSelectionUpdate: syncActiveHeading,
83+
onSelectionUpdate: syncActiveState,
9484
})
9585
96-
// The level dropdown reflects the block at the cursor. Tracked explicitly off the
97-
// editor's own events (selection + content) rather than relying on render-time
98-
// reactivity, so it stays correct as the cursor moves.
99-
const activeHeadingLevel = ref<number | null>(null)
100-
function syncActiveHeading() {
86+
// The toolbar reflects the block + marks at the cursor: the dropdown shows the
87+
// block type (paragraph or heading level) and each format button lights up when
88+
// its mark/node is active. Tracked explicitly off the editor's own events rather
89+
// than render-time reactivity, so it stays correct as the cursor moves.
90+
const activeBlock = ref<number | null>(null)
91+
const activeMarks = ref<Record<string, boolean>>({})
92+
function syncActiveState() {
10193
const instance = editor.value
10294
/* istanbul ignore if @preserve -- only invoked from the editor's own events, after creation. */
10395
if (!instance) return
104-
if (!instance.isActive('heading')) {
105-
activeHeadingLevel.value = null
106-
return
96+
activeBlock.value = instance.isActive('heading') ? (instance.getAttributes('heading').level as number) : null
97+
activeMarks.value = {
98+
bold: instance.isActive('bold'),
99+
italic: instance.isActive('italic'),
100+
strikethrough: instance.isActive('strike'),
101+
code: instance.isActive('code'),
102+
bulletList: instance.isActive('bulletList'),
103+
orderedList: instance.isActive('orderedList'),
104+
blockquote: instance.isActive('blockquote'),
105+
codeBlock: instance.isActive('codeBlock'),
107106
}
108-
// Clamp into the field's range so a legacy out-of-range heading (e.g. a stored
109-
// '# ' H1, which the editor renders at the base level) still maps to a real
110-
// dropdown option instead of silently showing "Gewone tekst".
111-
const level = instance.getAttributes('heading').level as number
112-
activeHeadingLevel.value = Math.min(Math.max(level, baseLevel), 6)
113107
}
114108
115109
// Apply external value changes (e.g. reference prefill) without clobbering what
@@ -207,6 +201,9 @@ function handleCommand(command: MarkdownCommand) {
207201
case 'code':
208202
instance.chain().focus().toggleCode().run()
209203
break
204+
case 'codeBlock':
205+
instance.chain().focus().toggleCodeBlock().run()
206+
break
210207
case 'divider':
211208
instance.chain().focus().setHorizontalRule().run()
212209
break
@@ -254,7 +251,7 @@ defineExpose({ editor })
254251
</div>
255252

256253
<div class="markdown-editor__footer">
257-
<MarkdownToolbar :heading-levels="headingLevels" :active-heading-level="activeHeadingLevel"
254+
<MarkdownToolbar :heading-levels="headingLevels" :active-block="activeBlock" :active-marks="activeMarks"
258255
@command="handleCommand" @heading="setHeading" />
259256
</div>
260257
</div>

0 commit comments

Comments
 (0)