diff --git a/frontend/src/components/NotesRichTextEditor.vue b/frontend/src/components/NotesRichTextEditor.vue new file mode 100644 index 0000000..0be467e --- /dev/null +++ b/frontend/src/components/NotesRichTextEditor.vue @@ -0,0 +1,601 @@ + + + + + diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 7a54938..e0c4f99 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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', diff --git a/frontend/src/ui/overlays.css b/frontend/src/ui/overlays.css index 72ea447..be82670 100644 --- a/frontend/src/ui/overlays.css +++ b/frontend/src/ui/overlays.css @@ -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; } } diff --git a/frontend/src/ui/overlays/SkyActionSheet.vue b/frontend/src/ui/overlays/SkyActionSheet.vue index 7bc423f..a529a7e 100644 --- a/frontend/src/ui/overlays/SkyActionSheet.vue +++ b/frontend/src/ui/overlays/SkyActionSheet.vue @@ -55,27 +55,29 @@ useOverlayFocusTrap({ 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.

  • Radio
  • Vest
', + ) + + 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), }) } -