FIX - use standard tabbar for note formatting

Migrates the note editor formatting controls to the shared SkyTabBar and SkyTabButton primitives. Removes the app-local toolbar geometry and adds a contract test for the shared component usage.
This commit is contained in:
Type
2026-08-16 00:30:08 +02:00
parent 6fba5bcc10
commit 96cffcf4d1
2 changed files with 124 additions and 188 deletions
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./NotesRichTextEditor.vue', import.meta.url),
'utf8',
)
describe('NotesRichTextEditor formatting tabbar', () => {
it('lists every formatting action through shared tabbar primitives', () => {
expect(source.match(/<SkyTabBar\b/g)).toHaveLength(1)
expect(source.match(/<SkyTabButton\b/g)).toHaveLength(11)
expect(source).toContain(
"import { SkyIcon, SkyTabBar, SkyTabButton } from '@/ui'",
)
expect(source).not.toContain('notes-rich-editor__toolbar-row')
expect(source).not.toContain('scrollToolbar')
})
})
+104 -188
View File
@@ -18,6 +18,7 @@ import {
} from 'lucide-vue-next'
import { onBeforeUnmount, watch } from 'vue'
import { SkyIcon, SkyTabBar, SkyTabButton } from '@/ui'
import {
noteBodyToEditorHtml,
serializeRichNoteBody,
@@ -206,16 +207,6 @@ function toggleSelectionQuote(): void {
editor.value.view.focus()
}
function scrollToolbar(event: WheelEvent): void {
const toolbar = event.currentTarget as HTMLElement
if (toolbar.scrollWidth <= toolbar.clientWidth) return
event.preventDefault()
toolbar.scrollLeft +=
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY
}
watch(
() => props.modelValue,
(body) => {
@@ -241,128 +232,150 @@ onBeforeUnmount(() => editor.value?.destroy())
:editor="editor"
/>
<nav
v-if="editor"
class="notes-rich-editor__toolbar"
:aria-label="labels.toolbar"
@wheel="scrollToolbar"
>
<button
type="button"
:disabled="!editor.can().chain().focus().undo().run()"
<SkyTabBar v-if="editor" :label="labels.toolbar">
<SkyTabButton
:active="
['tiny', 'small', 'compact'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.undo"
:title="labels.undo"
:disabled="!editor.can().chain().focus().undo().run()"
@click="editor.chain().focus().undo().run()"
>
<Undo2 :size="19" />
</button>
<button
type="button"
:disabled="!editor.can().chain().focus().redo().run()"
<template #icon
><SkyIcon :size="19"><Undo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="
['medium', 'large', 'huge'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.redo"
:title="labels.redo"
:disabled="!editor.can().chain().focus().redo().run()"
@click="editor.chain().focus().redo().run()"
>
<Redo2 :size="19" />
</button>
<span class="notes-rich-editor__separator" aria-hidden="true"></span>
<button
type="button"
<template #icon
><SkyIcon :size="19"><Redo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bold')"
:class="{
'is-active': ['tiny', 'small', 'compact'].includes(
editor.getAttributes('noteTextSize').size,
),
'is-unavailable': editor.state.selection.empty,
'notes-rich-editor__tool--active': [
'tiny',
'small',
'compact',
].includes(editor.getAttributes('noteTextSize').size),
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.decreaseText"
:title="labels.decreaseText"
@pointerdown.prevent="adjustTextSize(-1)"
>
<span class="notes-rich-editor__text-tool">A</span>
</button>
<button
type="button"
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('italic')"
:class="{
'is-active': ['medium', 'large', 'huge'].includes(
editor.getAttributes('noteTextSize').size,
),
'is-unavailable': editor.state.selection.empty,
'notes-rich-editor__tool--active': [
'medium',
'large',
'huge',
].includes(editor.getAttributes('noteTextSize').size),
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.increaseText"
:title="labels.increaseText"
@pointerdown.prevent="adjustTextSize(1)"
>
<span
class="notes-rich-editor__text-tool notes-rich-editor__text-tool--large"
>A+</span
>
</button>
<span class="notes-rich-editor__separator" aria-hidden="true"></span>
<button
type="button"
:class="{ 'is-active': editor.isActive('bold') }"
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--active': editor.isActive('bold'),
}"
:aria-label="labels.bold"
:title="labels.bold"
@click="editor.chain().focus().toggleBold().run()"
>
<Bold :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('italic') }"
<template #icon
><SkyIcon :size="19"><Bold /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--active': editor.isActive('italic'),
}"
:aria-label="labels.italic"
:title="labels.italic"
@click="editor.chain().focus().toggleItalic().run()"
>
<Italic :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('underline') }"
<template #icon
><SkyIcon :size="19"><Italic /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('underline')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('underline'),
}"
:aria-label="labels.underline"
:title="labels.underline"
@click="editor.chain().focus().toggleUnderline().run()"
>
<Underline :size="19" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('strike') }"
<template #icon
><SkyIcon :size="19"><Underline /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('strike')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('strike'),
}"
:aria-label="labels.strike"
:title="labels.strike"
@click="editor.chain().focus().toggleStrike().run()"
>
<Strikethrough :size="19" />
</button>
<span class="notes-rich-editor__separator" aria-hidden="true"></span>
<button
type="button"
:class="{ 'is-active': editor.isActive('bulletList') }"
<template #icon
><SkyIcon :size="19"><Strikethrough /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bulletList')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('bulletList'),
}"
:aria-label="labels.bulletList"
:title="labels.bulletList"
@click="editor.chain().focus().toggleBulletList().run()"
>
<List :size="20" />
</button>
<button
type="button"
:class="{ 'is-active': editor.isActive('orderedList') }"
<template #icon
><SkyIcon :size="20"><List /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('orderedList')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('orderedList'),
}"
:aria-label="labels.numberedList"
:title="labels.numberedList"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<ListOrdered :size="20" />
</button>
<button
type="button"
:class="{ 'is-unavailable': editor.state.selection.empty }"
<template #icon
><SkyIcon :size="20"><ListOrdered /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.quote"
:title="labels.quote"
@pointerdown.prevent="toggleSelectionQuote"
>
<Quote :size="19" />
</button>
</nav>
<template #icon
><SkyIcon :size="19"><Quote /></SkyIcon
></template>
</SkyTabButton>
</SkyTabBar>
</section>
</template>
@@ -390,8 +403,7 @@ onBeforeUnmount(() => editor.value?.destroy())
scrollbar-width: none;
}
.notes-rich-editor__content::-webkit-scrollbar,
.notes-rich-editor__toolbar::-webkit-scrollbar {
.notes-rich-editor__content::-webkit-scrollbar {
display: none;
}
@@ -494,89 +506,6 @@ onBeforeUnmount(() => editor.value?.destroy())
pointer-events: none;
}
.notes-rich-editor__toolbar {
width: 100%;
min-height: 59px;
padding: 7px var(--sky-page-gutter) calc(var(--sky-safe-area-bottom) + 7px);
display: flex;
align-items: center;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
border-top: 1px solid rgb(60 60 67 / 18%);
background: rgb(246 246 248 / 96%);
box-shadow: 0 -9px 28px rgb(0 0 0 / 7%);
scrollbar-width: none;
touch-action: pan-x;
}
.notes-rich-editor--dark .notes-rich-editor__toolbar {
border-top-color: rgb(255 255 255 / 12%);
background: rgb(27 27 29 / 97%);
box-shadow: 0 -10px 30px rgb(0 0 0 / 38%);
}
.notes-rich-editor__toolbar button {
width: 42px;
height: 42px;
flex: 0 0 42px;
border: 1px solid rgb(60 60 67 / 14%);
border-radius: 14px;
display: grid;
place-items: center;
background: rgb(255 255 255 / 72%);
box-shadow: inset 0 1px rgb(255 255 255 / 65%);
color: inherit;
cursor: pointer;
transition:
transform 130ms ease,
background-color 150ms ease,
color 150ms ease;
}
.notes-rich-editor--dark .notes-rich-editor__toolbar button {
border-color: rgb(255 255 255 / 10%);
background: linear-gradient(145deg, #303034, #202023);
box-shadow: inset 0 1px rgb(255 255 255 / 8%);
}
.notes-rich-editor__toolbar button.is-active {
border-color: #ffcc00;
background: #ffcc00;
color: #171719;
}
.notes-rich-editor__toolbar button:disabled {
opacity: 0.3;
cursor: default;
}
.notes-rich-editor__toolbar button.is-unavailable {
opacity: 0.3;
cursor: default;
}
.notes-rich-editor__toolbar button:active:not(:disabled) {
transform: scale(0.94);
}
.notes-rich-editor__toolbar button:focus-visible {
outline: 2px solid #ffcc00;
outline-offset: 2px;
}
.notes-rich-editor__separator {
width: 1px;
height: 24px;
flex: 0 0 1px;
margin: 0 1px;
background: rgb(60 60 67 / 20%);
}
.notes-rich-editor--dark .notes-rich-editor__separator {
background: rgb(255 255 255 / 16%);
}
.notes-rich-editor__text-tool {
font-size: 14px;
font-weight: 650;
@@ -585,17 +514,4 @@ onBeforeUnmount(() => editor.value?.destroy())
.notes-rich-editor__text-tool--large {
font-size: 17px;
}
@media (hover: hover) {
.notes-rich-editor__toolbar button:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.notes-rich-editor__toolbar button {
transition: none;
}
}
</style>