-
-
+
diff --git a/frontend/src/utils/noteRichText.ts b/frontend/src/utils/noteRichText.ts
new file mode 100644
index 0000000..f4bb873
--- /dev/null
+++ b/frontend/src/utils/noteRichText.ts
@@ -0,0 +1,78 @@
+const RICH_NOTE_PREFIX = 'sky-note-html-v1:'
+
+const HTML_ENTITIES: Record
= {
+ 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 ''
+
+ return body
+ .split(/\n{2,}/)
+ .map(
+ (paragraph) => `${escapeHtml(paragraph).split('\n').join('
')}
`,
+ )
+ .join('')
+}
+
+export function noteBodyToPlainText(body: string): string {
+ if (!isRichNoteBody(body)) return body
+
+ return decodeHtmlEntities(
+ body
+ .slice(RICH_NOTE_PREFIX.length)
+ .replace(/
/gi, '\n')
+ .replace(/<\/p\s*>/gi, '\n')
+ .replace(/<\/h[1-6]\s*>/gi, '\n')
+ .replace(/]*)?>/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}`
+}
diff --git a/frontend/src/utils/notes.test.ts b/frontend/src/utils/notes.test.ts
index 340d5d4..3073d85 100644
--- a/frontend/src/utils/notes.test.ts
+++ b/frontend/src/utils/notes.test.ts
@@ -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\nnot markup')).toBe(
+ 'First line
<b>not markup</b>
',
+ )
+ })
+
+ it('stores formatted notes and derives a clean preview', () => {
+ const body = serializeRichNoteBody(
+ 'Briefing
Meet outside.
',
+ )
+
+ expect(noteBodyToEditorHtml(body)).toContain('Meet')
+ expect(noteBodyToPlainText(body)).toBe(
+ 'Briefing\nMeet outside.\n• Radio\n• Vest',
+ )
+ })
+})
diff --git a/frontend/src/views/apps/NotesApp.vue b/frontend/src/views/apps/NotesApp.vue
index a4c600e..06fd6ea 100644
--- a/frontend/src/views/apps/NotesApp.vue
+++ b/frontend/src/views/apps/NotesApp.vue
@@ -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(null)
const editorOpened = ref(false)
const draftTitle = ref('')
const draftBody = ref('')
-const menuButton = ref(null)
const menuOpened = ref(false)
-const menuTarget = computed(
- () => menuButton.value?.$el as HTMLElement | undefined,
-)
-const menuTargetStyle = ref({})
-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 {
+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),
})
}
-
@@ -204,11 +186,7 @@ function shareNote(): void {
class="!pt-[44px] !pb-[25px]"
:aria-label="phone.t('Apps.notes.name')"
>
-
+
-
+
@@ -287,49 +263,46 @@ function shareNote(): void {
-
-
-
+
+
+
+
-
+
-