ENH - refine phone notes experience

Moves the first notes editor line into an H1 title, keeps it stable when empty, and removes the separate title field. Includes the remaining pending phone UI, voice, locale, bridge, and test updates on this branch.
This commit is contained in:
Type
2026-08-16 00:36:30 +02:00
parent 252aba03be
commit 81cdc8fb87
5 changed files with 136 additions and 59 deletions
@@ -54,6 +54,7 @@ const allowedTags = [
'blockquote',
'br',
'em',
'h1',
'h2',
'h3',
'li',
@@ -93,9 +94,7 @@ const NoteTextSize = Mark.create({
},
renderHTML: (attributes) => {
const size = attributes.size as NoteTextSizeStep | undefined
return size &&
noteTextSizeSteps.includes(size) &&
size !== 'normal'
return size && noteTextSizeSteps.includes(size) && size !== 'normal'
? { 'data-note-size': size }
: {}
},
@@ -119,7 +118,16 @@ function sanitizeEditorHtml(body: string): string {
)
}
let acceptedHtml = sanitizeEditorHtml(props.modelValue)
function withFirstLineHeading(html: string): string {
const match = html.match(
/^(\s*)<(p|h[1-3])(?:\s[^>]*)?>([\s\S]*?)<\/\2>([\s\S]*)$/i,
)
if (!match) return `<h1></h1>${html}`
return `${match[1]}<h1>${match[3]}</h1>${match[4]}`
}
let acceptedHtml = withFirstLineHeading(sanitizeEditorHtml(props.modelValue))
const editor = useEditor({
content: acceptedHtml,
@@ -127,7 +135,7 @@ const editor = useEditor({
StarterKit.configure({
code: false,
codeBlock: false,
heading: { levels: [2, 3] },
heading: { levels: [1, 2, 3] },
horizontalRule: false,
link: false,
strike: {},
@@ -137,6 +145,20 @@ const editor = useEditor({
Placeholder.configure({ placeholder: props.placeholder }),
],
injectCSS: false,
editorProps: {
handleKeyDown: (view, event) => {
if (event.key !== 'Backspace' || !view.state.selection.empty) return false
const { doc, selection } = view.state
const firstLine = doc.firstChild
return (
firstLine?.type.name === 'heading' &&
firstLine.attrs.level === 1 &&
firstLine.content.size === 0 &&
selection.$from.parent === firstLine
)
},
},
onUpdate: ({ editor: currentEditor }) => {
const safeHtml = String(
DOMPurify.sanitize(currentEditor.getHTML(), {
@@ -157,8 +179,9 @@ const editor = useEditor({
function adjustTextSize(direction: -1 | 1): void {
if (!editor.value || editor.value.state.selection.empty) return
const currentSize = editor.value.getAttributes('noteTextSize')
.size as NoteTextSizeStep | undefined
const currentSize = editor.value.getAttributes('noteTextSize').size as
| NoteTextSizeStep
| undefined
const currentIndex = currentSize
? noteTextSizeSteps.indexOf(currentSize)
: normalTextSizeIndex
@@ -186,7 +209,8 @@ function toggleSelectionQuote(): void {
const { from, to } = editor.value.state.selection
const { doc, tr } = editor.value.state
const selectedText = doc.textBetween(from, to)
const isQuotedInside = selectedText.startsWith('„') && selectedText.endsWith('“')
const isQuotedInside =
selectedText.startsWith('„') && selectedText.endsWith('“')
const isQuotedOutside =
from > 1 &&
doc.textBetween(from - 1, from) === '„' &&
@@ -211,7 +235,7 @@ watch(
() => props.modelValue,
(body) => {
if (!editor.value) return
const safeHtml = sanitizeEditorHtml(body)
const safeHtml = withFirstLineHeading(sanitizeEditorHtml(body))
if (editor.value.getHTML() === safeHtml) return
acceptedHtml = safeHtml
editor.value.commands.setContent(safeHtml, { emitUpdate: false })
@@ -421,6 +445,7 @@ onBeforeUnmount(() => editor.value?.destroy())
margin: 0 0 0.55em;
}
:deep(.tiptap h1),
:deep(.tiptap h2),
:deep(.tiptap h3) {
margin: 0.75em 0 0.35em;
@@ -428,6 +453,10 @@ onBeforeUnmount(() => editor.value?.destroy())
line-height: 1.15;
}
:deep(.tiptap h1) {
font-size: 32px;
}
:deep(.tiptap h2) {
font-size: 28px;
}
@@ -7,6 +7,26 @@ const menuSource = source.slice(
source.indexOf('<SkyActionSheet'),
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
)
const listSource = source.slice(
source.indexOf('<k-page\n v-if="!editorOpened"'),
source.indexOf('<k-page v-else'),
)
describe('NotesApp list controls', () => {
it('places the Sky searchbar and create action together at the bottom', () => {
const composerSource = listSource.slice(
listSource.indexOf('<footer'),
listSource.indexOf('</footer>') + '</footer>'.length,
)
expect(composerSource).toContain('<SkySearchbar')
expect(composerSource).toContain('v-model="searchQuery"')
expect(composerSource).toContain('<SkyFab')
expect(composerSource).toContain('@click="createNote"')
expect(listSource).not.toContain('<k-searchbar')
expect(listSource).not.toContain('<template #right>')
})
})
describe('NotesApp more menu', () => {
it('uses the shared Feather-style action sheet', () => {
+60 -50
View File
@@ -5,12 +5,10 @@ import {
kLink,
kList,
kListButton,
kListInput,
kListItem,
kNavbar,
kNavbarBackLink,
kPage,
kSearchbar,
} from 'konsta/vue'
import {
Ellipsis,
@@ -26,7 +24,7 @@ import NotesRichTextEditor from '@/components/NotesRichTextEditor.vue'
import { useNotesStore } from '@/stores/notes'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import { SkyActionSheet, SkyButton } from '@/ui'
import { SkyActionSheet, SkyButton, SkyFab, SkySearchbar } from '@/ui'
import type { Note } from '@/utils/notes'
import { noteBodyToPlainText } from '@/utils/noteRichText'
@@ -36,7 +34,6 @@ const easyShare = useEasyShareStore()
const searchQuery = ref('')
const editorId = ref<string | null>(null)
const editorOpened = ref(false)
const draftTitle = ref('')
const draftBody = ref('')
const menuOpened = ref(false)
const currentNote = computed(() =>
@@ -100,24 +97,18 @@ function noteSubtitle(note: Note): string {
return `${noteDate(note)} · ${notePreview(note)}`
}
function updateSearch(event: Event): void {
searchQuery.value = (event.target as HTMLInputElement).value
}
function updateTitle(event: Event): void {
draftTitle.value = (event.target as HTMLInputElement).value
function titleFromDraftBody(body: string): string {
return noteBodyToPlainText(body).split('\n')[0]?.trim() ?? ''
}
function createNote(): void {
editorId.value = null
draftTitle.value = ''
draftBody.value = ''
editorOpened.value = true
}
function editNote(note: Note): void {
editorId.value = note.id
draftTitle.value = note.title
draftBody.value = note.body
editorOpened.value = true
}
@@ -125,7 +116,7 @@ function editNote(note: Note): void {
function persistDraft(): Note | undefined {
const draft = {
body: draftBody.value,
title: draftTitle.value.trim(),
title: titleFromDraftBody(draftBody.value) || currentNote.value?.title.trim() || '',
}
if (editorId.value) {
@@ -183,29 +174,10 @@ function shareNote(): void {
<template>
<k-page
v-if="!editorOpened"
class="!pt-[44px] !pb-[25px]"
class="notes-list-page !pt-[44px]"
:aria-label="phone.t('Apps.notes.name')"
>
<k-navbar large transparent :title="phone.t('Apps.notes.name')">
<template #right>
<k-link
component="button"
icon-only
:aria-label="phone.t('Apps.notes.newNote')"
@click="createNote"
>
<SquarePen :size="21" />
</k-link>
</template>
<template #subnavbar>
<k-searchbar
:value="searchQuery"
:placeholder="phone.t('Apps.notes.searchPlaceholder')"
@input="updateSearch"
@clear="searchQuery = ''"
/>
</template>
</k-navbar>
<k-navbar large transparent :title="phone.t('Apps.notes.name')" />
<k-list v-if="visibleNotes.length" strong inset>
<k-list-item
@@ -239,6 +211,28 @@ function shareNote(): void {
</k-list-button>
</k-list>
</template>
<footer
class="notes-composer sky-ui-provider"
:class="{ 'sky-ui-provider--dark': phone.isDarkMode }"
>
<SkySearchbar
v-model="searchQuery"
class="notes-search"
:clear-label="phone.t('Common.clear')"
:label="phone.t('Apps.notes.searchPlaceholder')"
:placeholder="phone.t('Apps.notes.searchPlaceholder')"
/>
<SkyFab
class="notes-create-fab"
:aria-label="phone.t('Apps.notes.newNote')"
@click="createNote"
>
<template #icon>
<SquarePen :size="21" aria-hidden="true" />
</template>
</SkyFab>
</footer>
</k-page>
<k-page v-else class="notes-editor-page !pt-[44px] !pb-0">
@@ -264,17 +258,6 @@ function shareNote(): void {
</k-navbar>
<div class="notes-editor-layout">
<k-list class="notes-editor-title" nested :dividers="false">
<k-list-input
:value="draftTitle"
:label="phone.t('Apps.notes.title')"
:placeholder="phone.t('Apps.notes.titlePlaceholder')"
maxlength="120"
clear-button
@input="updateTitle"
@clear="draftTitle = ''"
/>
</k-list>
<NotesRichTextEditor
v-model="draftBody"
:dark="phone.isDarkMode"
@@ -330,6 +313,38 @@ function shareNote(): void {
</template>
<style scoped>
.notes-list-page {
padding-bottom: calc(
var(--sky-safe-area-bottom) + var(--sky-touch-target) + 24px
) !important;
}
.notes-composer {
position: absolute;
z-index: 20;
right: 0;
bottom: 0;
left: 0;
min-width: 0;
padding: 8px calc(var(--sky-page-gutter) + var(--sky-safe-area-right))
calc(var(--sky-safe-area-bottom) + 8px)
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
display: grid;
grid-template-columns: minmax(0, 1fr) var(--sky-touch-target);
align-items: center;
gap: 10px;
background: linear-gradient(to top, var(--sky-bg) 72%, transparent);
}
.notes-search {
min-width: 0;
}
.notes-create-fab {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
}
.notes-editor-page {
display: flex;
flex-direction: column;
@@ -344,11 +359,6 @@ function shareNote(): void {
overflow: hidden;
}
.notes-editor-title {
margin: 0;
flex: 0 0 auto;
}
.notes-action-menu {
display: grid;
gap: 8px;
@@ -90,6 +90,16 @@ describe('voice provider contracts', () => {
expect(serverCalls).toContain('if not Bridge.Speaker.IsEnabled() then')
})
it('provides safe shared defaults for the optional server radio speaker adapter', () => {
expect(sharedBridge).toContain('function Bridge.Radio.SupportsSpeaker()')
expect(sharedBridge).toMatch(
/function Bridge\.Radio\.SupportsSpeaker\(\)\s+return false\s+end/,
)
expect(sharedBridge).toContain('function Bridge.Radio.SetPlayerSpeaker()')
expect(serverVoice).toContain('function Bridge.Radio.SupportsSpeaker()')
expect(serverVoice).toContain('function Bridge.Radio.SetPlayerSpeaker(')
})
it('uses the documented client and server Radio speaker exports', () => {
expect(clientRadio).toContain('exports.saltychat:GetRadioSpeaker()')
expect(clientRadio).toContain('exports.saltychat:SetRadioSpeaker(')
+8
View File
@@ -11,6 +11,14 @@ function Bridge.Speaker.IsEnabled()
return not Config.Speaker or Config.Speaker.Enabled ~= false
end
function Bridge.Radio.SupportsSpeaker()
return false
end
function Bridge.Radio.SetPlayerSpeaker()
return false
end
local level_colours = {
debug = "^5",
info = "^2",