ENH - expand Notes rich text editing

This commit is contained in:
smx.pusha
2026-08-13 17:25:37 +02:00
parent 223cb4a600
commit b4b341d202
9 changed files with 929 additions and 134 deletions
@@ -0,0 +1,601 @@
<script setup lang="ts">
import Placeholder from '@tiptap/extension-placeholder'
import StarterKit from '@tiptap/starter-kit'
import { Mark, mergeAttributes } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import DOMPurify from 'dompurify'
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Redo2,
Strikethrough,
Underline,
Undo2,
} from 'lucide-vue-next'
import { onBeforeUnmount, watch } from 'vue'
import {
noteBodyToEditorHtml,
serializeRichNoteBody,
} from '@/utils/noteRichText'
export type NotesEditorLabels = {
bold: string
bulletList: string
decreaseText: string
increaseText: string
italic: string
numberedList: string
quote: string
redo: string
strike: string
toolbar: string
underline: string
undo: string
}
const props = defineProps<{
dark: boolean
labels: NotesEditorLabels
modelValue: string
placeholder: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const allowedTags = [
'blockquote',
'br',
'em',
'h2',
'h3',
'li',
'ol',
'p',
's',
'strong',
'span',
'u',
'ul',
]
const noteTextSizeSteps = [
'tiny',
'small',
'compact',
'normal',
'medium',
'large',
'huge',
] as const
type NoteTextSizeStep = (typeof noteTextSizeSteps)[number]
const normalTextSizeIndex = noteTextSizeSteps.indexOf('normal')
const NoteTextSize = Mark.create({
name: 'noteTextSize',
addAttributes() {
return {
size: {
default: null,
parseHTML: (element) => {
const size = element.getAttribute('data-note-size')
return noteTextSizeSteps.includes(size as NoteTextSizeStep) &&
size !== 'normal'
? size
: null
},
renderHTML: (attributes) => {
const size = attributes.size as NoteTextSizeStep | undefined
return size &&
noteTextSizeSteps.includes(size) &&
size !== 'normal'
? { 'data-note-size': size }
: {}
},
},
}
},
parseHTML() {
return [{ tag: 'span[data-note-size]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), 0]
},
})
function sanitizeEditorHtml(body: string): string {
return String(
DOMPurify.sanitize(noteBodyToEditorHtml(body), {
ALLOWED_ATTR: ['data-note-size'],
ALLOWED_TAGS: allowedTags,
}),
)
}
let acceptedHtml = sanitizeEditorHtml(props.modelValue)
const editor = useEditor({
content: acceptedHtml,
extensions: [
StarterKit.configure({
code: false,
codeBlock: false,
heading: { levels: [2, 3] },
horizontalRule: false,
link: false,
strike: {},
underline: {},
}),
NoteTextSize,
Placeholder.configure({ placeholder: props.placeholder }),
],
injectCSS: false,
onUpdate: ({ editor: currentEditor }) => {
const safeHtml = String(
DOMPurify.sanitize(currentEditor.getHTML(), {
ALLOWED_ATTR: ['data-note-size'],
ALLOWED_TAGS: allowedTags,
}),
)
const serializedBody = serializeRichNoteBody(safeHtml)
if (serializedBody.length > 20_000) {
currentEditor.commands.setContent(acceptedHtml, { emitUpdate: false })
return
}
acceptedHtml = safeHtml
emit('update:modelValue', serializedBody)
},
})
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 currentIndex = currentSize
? noteTextSizeSteps.indexOf(currentSize)
: normalTextSizeIndex
const nextIndex = Math.max(
0,
Math.min(
noteTextSizeSteps.length - 1,
(currentIndex >= 0 ? currentIndex : normalTextSizeIndex) + direction,
),
)
const nextSize = noteTextSizeSteps[nextIndex]
const chain = editor.value.chain().focus()
if (nextSize === 'normal') {
chain.unsetMark('noteTextSize').run()
return
}
chain.setMark('noteTextSize', { size: nextSize }).run()
}
function toggleSelectionQuote(): void {
if (!editor.value || editor.value.state.selection.empty) return
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 isQuotedOutside =
from > 1 &&
doc.textBetween(from - 1, from) === '„' &&
doc.textBetween(to, to + 1) === '“'
if (isQuotedInside) {
tr.delete(to - 1, to).delete(from, from + 1)
tr.setSelection(TextSelection.create(tr.doc, from, to - 2))
} else if (isQuotedOutside) {
tr.delete(to, to + 1).delete(from - 1, from)
tr.setSelection(TextSelection.create(tr.doc, from - 1, to - 1))
} else {
tr.insertText('“', to).insertText('„', from)
tr.setSelection(TextSelection.create(tr.doc, from + 1, to + 1))
}
editor.value.view.dispatch(tr)
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) => {
if (!editor.value) return
const safeHtml = sanitizeEditorHtml(body)
if (editor.value.getHTML() === safeHtml) return
acceptedHtml = safeHtml
editor.value.commands.setContent(safeHtml, { emitUpdate: false })
},
)
onBeforeUnmount(() => editor.value?.destroy())
</script>
<template>
<section
class="notes-rich-editor"
:class="{ 'notes-rich-editor--dark': dark }"
>
<EditorContent
v-if="editor"
class="notes-rich-editor__content"
: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()"
:aria-label="labels.undo"
:title="labels.undo"
@click="editor.chain().focus().undo().run()"
>
<Undo2 :size="19" />
</button>
<button
type="button"
:disabled="!editor.can().chain().focus().redo().run()"
:aria-label="labels.redo"
:title="labels.redo"
@click="editor.chain().focus().redo().run()"
>
<Redo2 :size="19" />
</button>
<span class="notes-rich-editor__separator" aria-hidden="true"></span>
<button
type="button"
:class="{
'is-active': ['tiny', 'small', 'compact'].includes(
editor.getAttributes('noteTextSize').size,
),
'is-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"
:class="{
'is-active': ['medium', 'large', 'huge'].includes(
editor.getAttributes('noteTextSize').size,
),
'is-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') }"
: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') }"
: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') }"
: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') }"
: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') }"
: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') }"
: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 }"
:aria-label="labels.quote"
:title="labels.quote"
@pointerdown.prevent="toggleSelectionQuote"
>
<Quote :size="19" />
</button>
</nav>
</section>
</template>
<style scoped>
.notes-rich-editor {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
background: #fff;
color: #171719;
}
.notes-rich-editor--dark {
background: #000;
color: #f5f5f7;
}
.notes-rich-editor__content {
min-height: 0;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.notes-rich-editor__content::-webkit-scrollbar,
.notes-rich-editor__toolbar::-webkit-scrollbar {
display: none;
}
:deep(.tiptap) {
min-height: 100%;
padding: 13px var(--sky-page-gutter) 90px;
outline: none;
font-size: 17px;
line-height: 1.48;
word-break: break-word;
}
:deep(.tiptap p) {
min-height: 1.48em;
margin: 0 0 0.55em;
}
:deep(.tiptap h2),
:deep(.tiptap h3) {
margin: 0.75em 0 0.35em;
letter-spacing: -0.02em;
line-height: 1.15;
}
:deep(.tiptap h2) {
font-size: 28px;
}
:deep(.tiptap h3) {
font-size: 22px;
}
:deep(.tiptap span[data-note-size='tiny']) {
font-size: 11px;
}
:deep(.tiptap span[data-note-size='small']) {
font-size: 13px;
}
:deep(.tiptap span[data-note-size='compact']) {
font-size: 15px;
}
:deep(.tiptap span[data-note-size='medium']) {
font-size: 20px;
}
:deep(.tiptap span[data-note-size='large']) {
font-size: 23px;
}
:deep(.tiptap span[data-note-size='huge']) {
font-size: 28px;
}
:deep(.tiptap ul),
:deep(.tiptap ol) {
margin: 0.45em 0 0.75em;
padding-left: 1.45em;
list-style-position: outside;
}
:deep(.tiptap ul) {
list-style-type: disc;
}
:deep(.tiptap ol) {
list-style-type: decimal;
}
:deep(.tiptap ul ul) {
list-style-type: circle;
}
:deep(.tiptap ol ol) {
list-style-type: lower-alpha;
}
:deep(.tiptap li) {
margin: 0.2em 0;
}
:deep(.tiptap blockquote) {
margin: 0.65em 0;
padding-left: 0.85em;
border-left: 3px solid #ffcc00;
color: #636366;
}
.notes-rich-editor--dark :deep(.tiptap blockquote) {
color: #a1a1a6;
}
:deep(.tiptap p.is-editor-empty:first-child::before) {
float: left;
height: 0;
color: #8e8e93;
content: attr(data-placeholder);
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;
}
.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>
+14
View File
@@ -3433,6 +3433,20 @@ const defaultLocales: LocaleTree = {
pin: 'Pin note',
unpin: 'Unpin note',
deleteNote: 'Delete note',
tools: {
bold: 'Bold',
bulletList: 'Bullet list',
decreaseText: 'Smaller text',
increaseText: 'Larger text',
italic: 'Italic',
numberedList: 'Numbered list',
quote: 'Quote',
redo: 'Redo',
strike: 'Strikethrough',
toolbar: 'Formatting tools',
underline: 'Underline',
undo: 'Undo',
},
},
photos: {
name: 'Gallery',
+9 -3
View File
@@ -22,7 +22,9 @@
}
.sky-sheet-rise-enter-active .sky-sheet__panel,
.sky-sheet-rise-leave-active .sky-sheet__panel {
.sky-sheet-rise-leave-active .sky-sheet__panel,
.sky-sheet-rise-enter-active .sky-action-sheet__panel,
.sky-sheet-rise-leave-active .sky-action-sheet__panel {
transition:
opacity 220ms ease,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
@@ -35,7 +37,9 @@
}
.sky-sheet-rise-enter-from .sky-sheet__panel,
.sky-sheet-rise-leave-to .sky-sheet__panel {
.sky-sheet-rise-leave-to .sky-sheet__panel,
.sky-sheet-rise-enter-from .sky-action-sheet__panel,
.sky-sheet-rise-leave-to .sky-action-sheet__panel {
opacity: 0;
transform: translateY(100%);
}
@@ -293,7 +297,9 @@
.sky-sheet-rise-enter-active .sky-overlay-backdrop,
.sky-sheet-rise-leave-active .sky-overlay-backdrop,
.sky-sheet-rise-enter-active .sky-sheet__panel,
.sky-sheet-rise-leave-active .sky-sheet__panel {
.sky-sheet-rise-leave-active .sky-sheet__panel,
.sky-sheet-rise-enter-active .sky-action-sheet__panel,
.sky-sheet-rise-leave-active .sky-action-sheet__panel {
transition-duration: 0.01ms !important;
}
}
+24 -22
View File
@@ -55,27 +55,29 @@ useOverlayFocusTrap({
</script>
<template>
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-action-sheet">
<div
class="sky-overlay-backdrop"
aria-hidden="true"
@click="emit('backdropclick')"
></div>
<div
ref="panel"
class="sky-action-sheet__panel"
:role="inferredRole"
:aria-modal="
inferredRole === 'dialog' || inferredRole === 'alertdialog'
? ariaModal
: undefined
"
:aria-label="inferredRole ? ariaLabel || label || undefined : undefined"
:aria-labelledby="inferredRole ? ariaLabelledby : undefined"
:aria-describedby="inferredRole ? ariaDescribedby : undefined"
:tabindex="tabindex"
>
<slot />
<Transition name="sky-sheet-rise">
<div v-if="opened" ref="root" v-bind="$attrs" class="sky-action-sheet">
<div
class="sky-overlay-backdrop"
aria-hidden="true"
@click="emit('backdropclick')"
></div>
<div
ref="panel"
class="sky-action-sheet__panel"
:role="inferredRole"
:aria-modal="
inferredRole === 'dialog' || inferredRole === 'alertdialog'
? ariaModal
: undefined
"
:aria-label="inferredRole ? ariaLabel || label || undefined : undefined"
:aria-labelledby="inferredRole ? ariaLabelledby : undefined"
:aria-describedby="inferredRole ? ariaDescribedby : undefined"
:tabindex="tabindex"
>
<slot />
</div>
</div>
</div>
</Transition>
</template>
+78
View File
@@ -0,0 +1,78 @@
const RICH_NOTE_PREFIX = 'sky-note-html-v1:'
const HTML_ENTITIES: Record<string, string> = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
nbsp: ' ',
quot: '"',
}
function decodeHtmlEntities(value: string): string {
return value.replace(/&(#x?[\da-f]+|[a-z]+);/gi, (entity, code: string) => {
if (code[0] === '#') {
const hexadecimal = code[1]?.toLowerCase() === 'x'
const numeric = Number.parseInt(
code.slice(hexadecimal ? 2 : 1),
hexadecimal ? 16 : 10,
)
return Number.isFinite(numeric) ? String.fromCodePoint(numeric) : entity
}
return HTML_ENTITIES[code.toLowerCase()] ?? entity
})
}
function escapeHtml(value: string): string {
return value
.split('&')
.join('&amp;')
.split('<')
.join('&lt;')
.split('>')
.join('&gt;')
.split('"')
.join('&quot;')
.split("'")
.join('&#39;')
}
export function isRichNoteBody(body: string): boolean {
return body.startsWith(RICH_NOTE_PREFIX)
}
export function noteBodyToEditorHtml(body: string): string {
if (isRichNoteBody(body)) return body.slice(RICH_NOTE_PREFIX.length)
if (!body) return '<p></p>'
return body
.split(/\n{2,}/)
.map(
(paragraph) => `<p>${escapeHtml(paragraph).split('\n').join('<br>')}</p>`,
)
.join('')
}
export function noteBodyToPlainText(body: string): string {
if (!isRichNoteBody(body)) return body
return decodeHtmlEntities(
body
.slice(RICH_NOTE_PREFIX.length)
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p\s*>/gi, '\n')
.replace(/<\/h[1-6]\s*>/gi, '\n')
.replace(/<li(?:\s[^>]*)?>/gi, '• ')
.replace(/<\/li\s*>/gi, '\n')
.replace(/<\/blockquote\s*>/gi, '\n')
.replace(/<[^>]*>/g, ''),
)
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
export function serializeRichNoteBody(html: string): string {
return `${RICH_NOTE_PREFIX}${html}`
}
+24
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { parseNotes } from './notes'
import {
noteBodyToEditorHtml,
noteBodyToPlainText,
serializeRichNoteBody,
} from './noteRichText'
describe('notes persistence', () => {
it('returns valid notes and ignores malformed entries', () => {
@@ -24,3 +29,22 @@ describe('notes persistence', () => {
expect(parseNotes('{}')).toEqual([])
})
})
describe('notes rich text', () => {
it('keeps legacy plain text notes editable without interpreting HTML', () => {
expect(noteBodyToEditorHtml('First line\n<b>not markup</b>')).toBe(
'<p>First line<br>&lt;b&gt;not markup&lt;/b&gt;</p>',
)
})
it('stores formatted notes and derives a clean preview', () => {
const body = serializeRichNoteBody(
'<h2>Briefing</h2><p><strong>Meet</strong> outside.</p><ul><li>Radio</li><li>Vest</li></ul>',
)
expect(noteBodyToEditorHtml(body)).toContain('<strong>Meet</strong>')
expect(noteBodyToPlainText(body)).toBe(
'Briefing\nMeet outside.\n• Radio\n• Vest',
)
})
})
+147 -108
View File
@@ -10,22 +10,25 @@ import {
kNavbar,
kNavbarBackLink,
kPage,
kPopover,
kSearchbar,
} from 'konsta/vue'
import { Ellipsis, Pin, PinOff, Share2, SquarePen, Trash2 } from 'lucide-vue-next'
import {
computed,
type ComponentPublicInstance,
type CSSProperties,
nextTick,
ref,
} from 'vue'
Ellipsis,
Pin,
PinOff,
Share2,
SquarePen,
Trash2,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import NotesRichTextEditor from '@/components/NotesRichTextEditor.vue'
import { useNotesStore } from '@/stores/notes'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import { SkyActionButton, SkyActionGroup, SkyActionSheet } from '@/ui'
import type { Note } from '@/utils/notes'
import { noteBodyToPlainText } from '@/utils/noteRichText'
const phone = usePhoneStore()
const notes = useNotesStore()
@@ -35,25 +38,7 @@ const editorId = ref<string | null>(null)
const editorOpened = ref(false)
const draftTitle = ref('')
const draftBody = ref('')
const menuButton = ref<ComponentPublicInstance | null>(null)
const menuOpened = ref(false)
const menuTarget = computed(
() => menuButton.value?.$el as HTMLElement | undefined,
)
const menuTargetStyle = ref<CSSProperties>({})
const pinActionColors = computed(() => ({
textIos: phone.isDarkMode ? 'text-white' : 'text-black',
textMaterial: phone.isDarkMode ? 'text-white' : 'text-black',
}))
const deleteActionColors = {
textIos: 'text-red-500',
textMaterial: 'text-red-500',
}
const noteBodyStyle: CSSProperties = {
height: '617px',
maxHeight: 'calc(100% - 210px)',
resize: 'none',
}
const currentNote = computed(() =>
editorId.value
? notes.notes.find((note) => note.id === editorId.value)
@@ -65,7 +50,7 @@ const visibleNotes = computed(() => {
return [...notes.notes]
.filter((note) => {
if (!query) return true
return `${note.title}\n${note.body}`
return `${note.title}\n${noteBodyToPlainText(note.body)}`
.toLocaleLowerCase(phone.lang)
.includes(query)
})
@@ -75,13 +60,30 @@ const visibleNotes = computed(() => {
right.updatedAt - left.updatedAt,
)
})
const editorLabels = computed(() => ({
bold: phone.t('Apps.notes.tools.bold'),
bulletList: phone.t('Apps.notes.tools.bulletList'),
decreaseText: phone.t('Apps.notes.tools.decreaseText'),
increaseText: phone.t('Apps.notes.tools.increaseText'),
italic: phone.t('Apps.notes.tools.italic'),
numberedList: phone.t('Apps.notes.tools.numberedList'),
quote: phone.t('Apps.notes.tools.quote'),
redo: phone.t('Apps.notes.tools.redo'),
strike: phone.t('Apps.notes.tools.strike'),
toolbar: phone.t('Apps.notes.tools.toolbar'),
underline: phone.t('Apps.notes.tools.underline'),
undo: phone.t('Apps.notes.tools.undo'),
}))
function noteTitle(note: Note): string {
return note.title.trim() || phone.t('Apps.notes.untitled')
}
function notePreview(note: Note): string {
return note.body.trim().replace(/\s+/g, ' ') || phone.t('Apps.notes.noText')
return (
noteBodyToPlainText(note.body).trim().replace(/\s+/g, ' ') ||
phone.t('Apps.notes.noText')
)
}
function noteDate(note: Note): string {
@@ -106,10 +108,6 @@ function updateTitle(event: Event): void {
draftTitle.value = (event.target as HTMLInputElement).value
}
function updateBody(event: Event): void {
draftBody.value = (event.target as HTMLTextAreaElement).value
}
function createNote(): void {
editorId.value = null
draftTitle.value = ''
@@ -126,7 +124,7 @@ function editNote(note: Note): void {
function persistDraft(): Note | undefined {
const draft = {
body: draftBody.value.trim(),
body: draftBody.value,
title: draftTitle.value.trim(),
}
@@ -134,7 +132,7 @@ function persistDraft(): Note | undefined {
notes.updateNote(editorId.value, draft)
return notes.notes.find((note) => note.id === editorId.value)
}
if (!draft.title && !draft.body) return undefined
if (!draft.title && !noteBodyToPlainText(draft.body).trim()) return undefined
const note = notes.createNote(draft)
editorId.value = note.id
@@ -147,23 +145,8 @@ function saveAndClose(): void {
editorOpened.value = false
}
async function openMenu(): Promise<void> {
function openMenu(): void {
if (!persistDraft()) return
const target = menuTarget.value
const screen = target?.closest('.phone-screen')
if (target && screen) {
const screenRect = screen.getBoundingClientRect()
menuTargetStyle.value = {
'--k-safe-area-left': `${Math.round(screenRect.left + 2)}px`,
'--k-safe-area-right': `${Math.round(
document.body.offsetWidth - screenRect.right + 2,
)}px`,
'--k-safe-area-top': `${Math.round(screenRect.top + 8)}px`,
}
await nextTick()
}
menuOpened.value = true
}
@@ -188,14 +171,13 @@ function shareNote(): void {
menuOpened.value = false
easyShare.open({
appId: 'notes',
copyText: note.body || note.title,
copyText: noteBodyToPlainText(note.body) || note.title,
id: note.id,
kind: 'note',
subtitle: notePreview(note),
title: noteTitle(note),
})
}
</script>
<template>
@@ -204,11 +186,7 @@ function shareNote(): void {
class="!pt-[44px] !pb-[25px]"
:aria-label="phone.t('Apps.notes.name')"
>
<k-navbar
large
transparent
:title="phone.t('Apps.notes.name')"
>
<k-navbar large transparent :title="phone.t('Apps.notes.name')">
<template #right>
<k-link
component="button"
@@ -263,7 +241,7 @@ function shareNote(): void {
</template>
</k-page>
<k-page v-else class="!pt-[44px] !pb-[25px]">
<k-page v-else class="notes-editor-page !pt-[44px] !pb-0">
<k-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<k-navbar-back-link
@@ -275,10 +253,8 @@ function shareNote(): void {
</template>
<template #right>
<k-link
ref="menuButton"
component="button"
icon-only
:style="menuTargetStyle"
:aria-label="phone.t('Apps.notes.actions')"
@click="openMenu"
>
@@ -287,49 +263,46 @@ function shareNote(): void {
</template>
</k-navbar>
<k-list 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-input
type="textarea"
:value="draftBody"
:label="phone.t('Apps.notes.body')"
<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"
:labels="editorLabels"
:placeholder="phone.t('Apps.notes.bodyPlaceholder')"
:input-style="noteBodyStyle"
maxlength="20000"
@input="updateBody"
/>
</k-list>
</div>
<Teleport to="body">
<k-popover
:opened="menuOpened"
:target="menuTarget"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
angle
@backdropclick="menuOpened = false"
>
<k-list nested>
<k-list-button link-component="button" :colors="pinActionColors" @click="shareNote">
<SkyActionSheet
class="notes-action-sheet"
:opened="menuOpened"
:label="phone.t('Apps.notes.actions')"
@backdropclick="menuOpened = false"
@escape="menuOpened = false"
>
<div class="notes-action-sheet__handle" aria-hidden="true"></div>
<p class="notes-action-sheet__title">
{{ phone.t('Apps.notes.actions') }}
</p>
<SkyActionGroup>
<SkyActionButton @click="shareNote">
<span class="notes-action-button__content">
<Share2 :size="18" />
{{ phone.t('Apps.easyShare.name') }}
</k-list-button>
<k-list-button
link-component="button"
:colors="pinActionColors"
@click="togglePinned"
>
</span>
</SkyActionButton>
<SkyActionButton @click="togglePinned">
<span class="notes-action-button__content">
<PinOff v-if="currentNote?.pinned" :size="18" />
<Pin v-else :size="18" />
{{
@@ -337,17 +310,83 @@ function shareNote(): void {
currentNote?.pinned ? 'Apps.notes.unpin' : 'Apps.notes.pin',
)
}}
</k-list-button>
<k-list-button
link-component="button"
:colors="deleteActionColors"
@click="deleteNote"
>
</span>
</SkyActionButton>
<SkyActionButton class="notes-action-button--danger" @click="deleteNote">
<span class="notes-action-button__content">
<Trash2 :size="18" />
{{ phone.t('Apps.notes.deleteNote') }}
</k-list-button>
</k-list>
</k-popover>
</Teleport>
</span>
</SkyActionButton>
</SkyActionGroup>
<SkyActionGroup>
<SkyActionButton bold @click="menuOpened = false">
{{ phone.t('Common.cancel') }}
</SkyActionButton>
</SkyActionGroup>
</SkyActionSheet>
</k-page>
</template>
<style scoped>
.notes-editor-page {
display: flex;
flex-direction: column;
overflow: hidden;
}
.notes-editor-layout {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.notes-editor-title {
margin: 0;
flex: 0 0 auto;
}
.notes-action-button__content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
}
.notes-action-button--danger {
color: var(--sky-danger);
}
.notes-action-sheet :deep(.sky-action-sheet__panel) {
padding: 8px var(--sky-page-gutter)
calc(var(--sky-safe-area-bottom) + 10px);
border: 1px solid var(--sky-hairline);
border-bottom: 0;
border-radius: 30px 30px 0 0;
background: var(--sky-surface);
box-shadow: 0 -18px 50px rgb(0 0 0 / 32%);
}
.notes-action-sheet :deep(.sky-action-group) {
margin-top: 9px;
background: var(--sky-surface-muted);
}
.notes-action-sheet__handle {
width: 38px;
height: 5px;
margin: 0 auto 7px;
border-radius: 999px;
background: var(--sky-hairline-strong, rgb(142 142 147 / 65%));
}
.notes-action-sheet__title {
margin: 0;
color: var(--sky-muted);
font-size: 12px;
font-weight: 600;
text-align: center;
}
</style>
+5
View File
@@ -1527,6 +1527,11 @@ Locales["en"] = {
emptyBody = "Create a note to keep important details close at hand.", noResults = "No Results",
noResultsBody = "Try searching for a different word or phrase.", pin = "Pin note", unpin = "Unpin note",
deleteNote = "Delete note",
tools = {
bold = "Bold", bulletList = "Bullet list", decreaseText = "Smaller text", increaseText = "Larger text",
italic = "Italic", numberedList = "Numbered list", quote = "Quote", redo = "Redo",
strike = "Strikethrough", toolbar = "Formatting tools", underline = "Underline", undo = "Undo",
},
},
easyShare = {
name = "EasyShare", incoming = "Incoming Share", recentChats = "Contacts and Chats",
+27 -1
View File
@@ -55,6 +55,32 @@ local function uuid()
return id
end
local rich_note_prefix = "sky-note-html-v1:"
local function plain_note_body(body)
if body:sub(1, #rich_note_prefix) ~= rich_note_prefix then
return body
end
return body:sub(#rich_note_prefix + 1)
:gsub("<[bB][rR]%s*/?>", "\n")
:gsub("</[pP]%s*>", "\n")
:gsub("</[hH][1-6]%s*>", "\n")
:gsub("<[lL][iI][^>]*>", "- ")
:gsub("</[lL][iI]%s*>", "\n")
:gsub("</[bB][lL][oO][cC][kK][qQ][uU][oO][tT][eE]%s*>", "\n")
:gsub("<[^>]*>", "")
:gsub("&nbsp;", " ")
:gsub("&lt;", "<")
:gsub("&gt;", ">")
:gsub("&quot;", '"')
:gsub("&#39;", "'")
:gsub("&amp;", "&")
:gsub("[ \t]+\n", "\n")
:gsub("\n\n\n+", "\n\n")
:match("^%s*(.-)%s*$")
end
local function trim(value, maximum)
if type(value) ~= "string" then
return nil
@@ -646,7 +672,7 @@ local function sanitize_payload(source, device, data)
return nil, "not_owned"
end
payload.title = note.title ~= "" and note.title or title
payload.copyText = note.body
payload.copyText = plain_note_body(note.body)
payload.meta = { body = note.body, title = note.title }
elseif data.kind == "photo" or data.kind == "video" then
local url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, payload.id, data.kind)