mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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(')
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user