mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
ENH - expand Notes rich text editing
This commit is contained in:
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('&')
|
||||
.split('<')
|
||||
.join('<')
|
||||
.split('>')
|
||||
.join('>')
|
||||
.split('"')
|
||||
.join('"')
|
||||
.split("'")
|
||||
.join(''')
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -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><b>not markup</b></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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(" ", " ")
|
||||
:gsub("<", "<")
|
||||
:gsub(">", ">")
|
||||
:gsub(""", '"')
|
||||
:gsub("'", "'")
|
||||
:gsub("&", "&")
|
||||
: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)
|
||||
|
||||
Reference in New Issue
Block a user