From 81cdc8fb87e2be7c05810e69dbef638802b4fdcf Mon Sep 17 00:00:00 2001 From: Type <79042381+TypeFor@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:36:30 +0200 Subject: [PATCH] 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. --- .../src/components/NotesRichTextEditor.vue | 47 ++++++-- .../views/apps/NotesApp.menu.contract.test.ts | 20 ++++ frontend/src/views/apps/NotesApp.vue | 110 ++++++++++-------- frontend/src/voiceProviders.contract.test.ts | 10 ++ sky_phone/source/bridge/shared.lua | 8 ++ 5 files changed, 136 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/NotesRichTextEditor.vue b/frontend/src/components/NotesRichTextEditor.vue index 311e78c..4ae25a9 100644 --- a/frontend/src/components/NotesRichTextEditor.vue +++ b/frontend/src/components/NotesRichTextEditor.vue @@ -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 `

${html}` + + return `${match[1]}

${match[3]}

${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; } diff --git a/frontend/src/views/apps/NotesApp.menu.contract.test.ts b/frontend/src/views/apps/NotesApp.menu.contract.test.ts index e347608..d94f1f1 100644 --- a/frontend/src/views/apps/NotesApp.menu.contract.test.ts +++ b/frontend/src/views/apps/NotesApp.menu.contract.test.ts @@ -7,6 +7,26 @@ const menuSource = source.slice( source.indexOf('') + ''.length, ) +const listSource = source.slice( + source.indexOf(' { + it('places the Sky searchbar and create action together at the bottom', () => { + const composerSource = listSource.slice( + listSource.indexOf('') + ''.length, + ) + + expect(composerSource).toContain('') + }) +}) describe('NotesApp more menu', () => { it('uses the shared Feather-style action sheet', () => { diff --git a/frontend/src/views/apps/NotesApp.vue b/frontend/src/views/apps/NotesApp.vue index 4e54d72..20b10a0 100644 --- a/frontend/src/views/apps/NotesApp.vue +++ b/frontend/src/views/apps/NotesApp.vue @@ -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(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 { + +
+ + + + +
@@ -264,17 +258,6 @@ function shareNote(): void {
- - -