From e808d052bd3968de038d4a1e53d3372ba900b8e7 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 25 Sep 2026 00:08:37 +0200 Subject: [PATCH] feat: move note title and url into note document. Issue 83 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the note title from the first line of the body and the URL from an optional `url:` line, removing the dedicated Title and URL inputs. The API payload contract is unchanged; the client parses and re-synthesizes the document on load/save, migrates old-shaped drafts, and updates the card title live per keystroke. 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- client/src/__test__/views/NoteAdd.test.tsx | 261 +++++++++++++++++---- client/src/constants/english.ts | 1 + client/src/constants/portuguese.ts | 1 + client/src/constants/russian.ts | 1 + client/src/constants/spanish.ts | 1 + client/src/views/NoteAdd/index.tsx | 147 ++++++------ 6 files changed, 295 insertions(+), 117 deletions(-) diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 2550de5d..247376f3 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -1,6 +1,5 @@ import React, { act } from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter } from 'react-router'; import { I18nextProvider } from 'react-i18next'; @@ -109,42 +108,62 @@ describe('NoteAdd Component', () => { vi.clearAllMocks(); }); - it('should render the NoteAdd component', async () => { + it('should render the NoteAdd component without title and url inputs', async () => { let result: any; await act(async () => { result = renderNoteAdd(); }); - const { getByText } = result; - expect(getByText('note_form_title_label')).toBeDefined(); - expect(getByText('task_form_url_label')).toBeDefined(); + const { getByText, queryByLabelText } = result; + expect(getByText('note_form_untitled')).toBeDefined(); expect(getByText('note_form_content_label')).toBeDefined(); expect(getByText('note_form_submit')).toBeDefined(); + expect(queryByLabelText('note_form_title_label')).toBeNull(); + expect(queryByLabelText('task_form_url_label')).toBeNull(); + }); + + it('should show the helper text mentioning title line, url line and tags footer', async () => { + const { getByText } = renderNoteAdd(); + expect(getByText(/The first line is the note title/)).toBeDefined(); + expect(getByText(/url: /)).toBeDefined(); + expect(getByText(/tags: a, b/)).toBeDefined(); }); it('should show error message when form is invalid', async () => { const { getByText, getByRole } = renderNoteAdd(); const submitButton = getByRole('button', { name: 'note_form_submit' }); - + + fireEvent.click(submitButton); + + await waitFor(() => { + expect(getByText('Please fill in all the fields')).toBeDefined(); + }); + }); + + it('should block save when the first line is blank', async () => { + const { getByText, getByTestId, getByRole } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + const submitButton = getByRole('button', { name: 'note_form_submit' }); + + fireEvent.change(noteContentInput, { target: { value: '\nBody without a title' } }); fireEvent.click(submitButton); await waitFor(() => { expect(getByText('Please fill in all the fields')).toBeDefined(); }); + expect(api.postJSON).not.toHaveBeenCalled(); }); - it('should add a new note when form is valid', async () => { + it('should add a new note deriving title from the first line', async () => { mockedUseSearchParams.mockReturnValue([ - new URLSearchParams("backTo=home"), + new URLSearchParams('backTo=home'), vi.fn(), ]); - const { getByLabelText, getByTestId, getByRole } = renderNoteAdd(); - const descriptionInput = getByLabelText('note_form_title_label') as HTMLInputElement; - const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement; + const { getByTestId, getByRole } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; const submitButton = getByRole('button', { name: 'note_form_submit' }); - fireEvent.change(descriptionInput, { target: { value: 'New Note' } }); - fireEvent.change(noteContentInput, { target: { value: 'Note content' } }); + fireEvent.change(noteContentInput, { target: { value: 'New Note\n\nNote content' } }); fireEvent.click(submitButton); await waitFor(() => { @@ -158,11 +177,98 @@ describe('NoteAdd Component', () => { shared: false, shareToken: null, archived: false - } + }; expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote); }); }); + it('should strip markdown heading markers from the title line', async () => { + const { getByTestId, getByRole } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + const submitButton = getByRole('button', { name: 'note_form_submit' }); + + fireEvent.change(noteContentInput, { target: { value: '# My Title\n\nBody' } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ + title: 'My Title', + description: 'Body' + })); + }); + }); + + it('should parse the url line case-insensitively and strip it from the description', async () => { + const { getByTestId, getByRole } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + const submitButton = getByRole('button', { name: 'note_form_submit' }); + + fireEvent.change(noteContentInput, { + target: { value: 'Titled\nURL: https://example.com/page, extra\n\nBody\n\ntags: dev' } + }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ + title: 'Titled', + url: 'https://example.com/page', + description: 'Body', + tags: ['dev'] + })); + }); + }); + + it('should use the first url line when multiple are present', async () => { + const { getByTestId, getByRole } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + const submitButton = getByRole('button', { name: 'note_form_submit' }); + + fireEvent.change(noteContentInput, { + target: { value: 'Titled\nurl: https://first\nurl: https://second\n\nBody' } + }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ + url: 'https://first', + description: 'url: https://second\n\nBody' + })); + }); + }); + + it('should update the card title live with the normalized first line', async () => { + const { getByText, getByTestId } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + + expect(getByText('note_form_untitled')).toBeDefined(); + + fireEvent.change(noteContentInput, { target: { value: '## Live Title\n\nBody' } }); + + await waitFor(() => { + expect(getByText('Live Title')).toBeDefined(); + }); + }); + + it('should hide the title, url and tags lines from the markdown preview', async () => { + const { getByText, getByTestId } = renderNoteAdd(); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + + fireEvent.change(noteContentInput, { + target: { value: 'My Title\nurl: https://example.com\n\nBody text\n\ntags: dev' } + }); + fireEvent.click(getByText('Preview Markdown')); + + await waitFor(() => { + expect(getByTestId('modal-header-title').textContent).toBe('My Title'); + }); + + fireEvent.click(getByTestId('modal-source-button')); + + await waitFor(() => { + expect(getByTestId('markdown-source-view').textContent).toBe('Body text'); + }); + }); + it('should render text based on new contentHeader component', async () => { let result: any; await act(async () => { @@ -177,7 +283,7 @@ describe('NoteAdd Component', () => { expect(getByText('Them')).toBeDefined(); }); - it('should render a note to edit', async () => { + it('should render a note to edit with title and url synthesized into the body', async () => { mockedUseParams.mockReturnValue({ id: '1' }); const toEdit: NoteResponse = { @@ -190,18 +296,56 @@ describe('NoteAdd Component', () => { shared: false, shareToken: null }; - + vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit); - const { getByLabelText, getByTestId } = renderNoteAdd(); + const { getByTestId } = renderNoteAdd(); + + await waitFor(() => { + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + expect(noteContentInput.innerHTML).toBe( + 'Note one\nurl: http://notes.domain.com\n\nDescription of note one\n\ntags: dev' + ); + }); + }); + + it('should round-trip title, url, body and tags when saving an edited note', async () => { + mockedUseParams.mockReturnValue({ id: '1' }); + + const toEdit: NoteResponse = { + id: 1, + title: 'Note one', + description: 'Description of note one', + url: 'http://notes.domain.com', + tags: ['dev'], + lastUpdate: '3 minutes ago', + shared: false, + shareToken: null + }; + + vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit); + + const { getByTestId, getByRole } = renderNoteAdd(); + + await waitFor(() => { + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + expect(noteContentInput.innerHTML).toContain('Note one'); + }); + + fireEvent.click(getByRole('button', { name: 'note_form_submit' })); await waitFor(() => { - const noteTitle = getByLabelText('note_form_title_label') as HTMLInputElement; - const noteUrl = getByLabelText('task_form_url_label') as HTMLInputElement; - const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement; - expect(noteTitle.value).toBe(toEdit.title); - expect(noteUrl.value).toBe(toEdit.url); - expect(noteContentInput.innerHTML).toBe(`${toEdit.description}\n\ntags: dev`); + expect(api.patchJSON).toHaveBeenCalledWith(`${ApiConfig.notesUrl}/1`, { + id: 1, + title: 'Note one', + description: 'Description of note one', + url: 'http://notes.domain.com', + tags: ['dev'], + lastUpdate: '', + shared: false, + shareToken: null, + archived: false + }); }); }); @@ -218,45 +362,41 @@ describe('NoteAdd Component', () => { shared: false, shareToken: null }; - + vi.spyOn(api, 'getJSON').mockResolvedValue(toClone); - const { getByLabelText, getByTestId } = renderNoteAdd(); + const { getByTestId } = renderNoteAdd(); await waitFor(() => { - const noteTitle = getByLabelText('note_form_title_label') as HTMLInputElement; - const noteUrl = getByLabelText('task_form_url_label') as HTMLInputElement; - const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement; - expect(noteTitle.value).toBe(toClone.title); - expect(noteUrl.value).toBe(toClone.url); - expect(noteContentInput.innerHTML).toBe(`${toClone.description}\n\ntags: dev`); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + expect(noteContentInput.innerHTML).toBe( + 'Old title\nurl: http://notes.domain.com\n\nOld description\n\ntags: dev' + ); }); + + window.history.pushState({}, '', '/'); }); it('should not render a cloned note when user is trying to mess up', async () => { window.history.pushState({}, '', '?cloneFrom=12345'); - const { getByLabelText, getByTestId } = renderNoteAdd(); + const { getByTestId } = renderNoteAdd(); await waitFor(() => { - const noteTitle = getByLabelText('note_form_title_label') as HTMLInputElement; - const noteUrl = getByLabelText('task_form_url_label') as HTMLInputElement; - const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement; - expect(noteTitle.value).toBe(''); - expect(noteUrl.value).toBe(''); + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; expect(noteContentInput.innerHTML).toBe(''); }); + + window.history.pushState({}, '', '/'); }); it('should parse the tags footer, strip it from the description and send tags on save', async () => { - const { getByLabelText, getByTestId, getByRole } = renderNoteAdd(); - const titleInput = getByLabelText('note_form_title_label') as HTMLInputElement; + const { getByTestId, getByRole } = renderNoteAdd(); const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; const submitButton = getByRole('button', { name: 'note_form_submit' }); - fireEvent.change(titleInput, { target: { value: 'Tagged Note' } }); fireEvent.change(noteContentInput, { - target: { value: 'Note content\n\nTags: Foo, bar , foo,,' } + target: { value: 'Tagged Note\n\nNote content\n\nTags: Foo, bar , foo,,' } }); fireEvent.click(submitButton); @@ -276,13 +416,11 @@ describe('NoteAdd Component', () => { }); it('should save an untagged note when the footer has an empty value', async () => { - const { getByLabelText, getByTestId, getByRole } = renderNoteAdd(); - const titleInput = getByLabelText('note_form_title_label') as HTMLInputElement; + const { getByTestId, getByRole } = renderNoteAdd(); const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; const submitButton = getByRole('button', { name: 'note_form_submit' }); - fireEvent.change(titleInput, { target: { value: 'Untagged Note' } }); - fireEvent.change(noteContentInput, { target: { value: 'Note content\n\ntags:' } }); + fireEvent.change(noteContentInput, { target: { value: 'Untagged Note\n\nNote content\n\ntags:' } }); fireEvent.click(submitButton); await waitFor(() => { @@ -335,7 +473,9 @@ describe('NoteAdd Component', () => { await waitFor(() => { const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; - expect(noteContentInput.innerHTML).toBe('Description of note one\n\ntags: body-tag'); + expect(noteContentInput.innerHTML).toBe( + 'Note one\nurl: http://notes.domain.com\n\nDescription of note one\n\ntags: body-tag' + ); }); await waitFor(() => { @@ -344,4 +484,33 @@ describe('NoteAdd Component', () => { expect(preview.textContent).not.toContain('#server-tag'); }); }); + + it('should restore a content-only draft', async () => { + localStorage.setItem('draft:note:new', JSON.stringify({ content: 'Draft Title\n\nDraft body' })); + + const { getByTestId, getByText } = renderNoteAdd(); + + await waitFor(() => { + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + expect(noteContentInput.value).toBe('Draft Title\n\nDraft body'); + }); + expect(getByText(/Draft restored/)).toBeDefined(); + }); + + it('should migrate an old-shaped draft into the content once', async () => { + localStorage.setItem( + 'draft:note:new', + JSON.stringify({ title: 'Old Title', content: 'Old body', noteUrl: 'http://x.com' }) + ); + + const { getByTestId } = renderNoteAdd(); + + await waitFor(() => { + const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + expect(noteContentInput.value).toBe('Old Title\nurl: http://x.com\n\nOld body'); + }); + + const stored = JSON.parse(localStorage.getItem('draft:note:new')!); + expect(stored).toEqual({ content: 'Old Title\nurl: http://x.com\n\nOld body' }); + }); }); diff --git a/client/src/constants/english.ts b/client/src/constants/english.ts index cc20951a..9cb3ad7d 100644 --- a/client/src/constants/english.ts +++ b/client/src/constants/english.ts @@ -117,6 +117,7 @@ const enTranslations = { delete_modal_confirm: 'Delete', note_form_title: 'Add note', + note_form_untitled: 'Untitled note', note_form_title_label: 'Title', note_form_title_placeholder: 'Enter the note title', note_form_content_label: 'Note content', diff --git a/client/src/constants/portuguese.ts b/client/src/constants/portuguese.ts index 326a45ab..3f61a345 100644 --- a/client/src/constants/portuguese.ts +++ b/client/src/constants/portuguese.ts @@ -117,6 +117,7 @@ const ptBrTranslations = { delete_modal_confirm: 'Excluir', note_form_title: 'Adicionar nota', + note_form_untitled: 'Nota sem título', note_form_title_label: 'Título', note_form_title_placeholder: 'Digite o título da nota', note_form_content_label: 'Conteúdo da nota', diff --git a/client/src/constants/russian.ts b/client/src/constants/russian.ts index a49057ec..915f2702 100644 --- a/client/src/constants/russian.ts +++ b/client/src/constants/russian.ts @@ -117,6 +117,7 @@ const ruTranslations = { delete_modal_confirm: 'Удалить', note_form_title: 'Добавить примечание', + note_form_untitled: 'Заметка без названия', note_form_title_label: 'Заголовок', note_form_title_placeholder: 'Введите название заметки', note_form_content_label: 'Примечание к содержанию', diff --git a/client/src/constants/spanish.ts b/client/src/constants/spanish.ts index 4fda3cd1..ef94782f 100644 --- a/client/src/constants/spanish.ts +++ b/client/src/constants/spanish.ts @@ -117,6 +117,7 @@ const esTranslations = { delete_modal_confirm: 'Eliminar', note_form_title: 'Añadir nota', + note_form_untitled: 'Nota sin título', note_form_title_label: 'Título', note_form_title_placeholder: 'Introduce el título de la nota', note_form_content_label: 'Contenido de la nota', diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index 7b27e301..f1bcaf25 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -15,7 +15,6 @@ import { NoteResponse } from '../../types/NoteResponse'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import { translateServerResponse } from '../../utils/TranslatorUtils'; -import FormInput from '../../components/FormInput'; import ModalMarkdown from '../../components/ModalMarkdown'; import AlertError from '../../components/AlertError'; import ContentHeader from '../../components/ContentHeader'; @@ -23,9 +22,19 @@ import ContentHeader from '../../components/ContentHeader'; type NoteAction = 'add' | 'edit'; interface NoteDraft { - title: string; content: string; - noteUrl: string; +} + +interface LegacyNoteDraft { + title?: string; + content: string; + noteUrl?: string; +} + +interface ParsedNoteDocument { + title: string; + url: string; + body: string; } interface ParsedTagsFooter { @@ -58,16 +67,48 @@ const parseTagsFooter = (content: string): ParsedTagsFooter | null => { return null; }; -const stripTagsFooter = (content: string): string => { - const parsed = parseTagsFooter(content); - return parsed ? parsed.bodyWithoutFooter : content; -}; - const synthesizeTagsFooter = (content: string, tags: string[]): string => { if (parseTagsFooter(content) || tags.length === 0) return content; return `${content.replace(/\s+$/, '')}\n\ntags: ${tags.join(', ')}`; }; +const URL_LINE_PATTERN = /^url:\s*(.*)$/i; + +const normalizeTitleLine = (line: string): string => line.replace(/^#+\s+/, '').trim(); + +const parseNoteDocument = (content: string): ParsedNoteDocument => { + const lines = content.split('\n'); + const title = lines.length > 0 ? normalizeTitleLine(lines[0].replace(/\r$/, '')) : ''; + const footer = parseTagsFooter(content); + const footerLineIndex = footer ? footer.footerLineIndex : lines.length; + let url = ''; + let urlLineIndex = -1; + for (let i = 1; i < footerLineIndex; i += 1) { + const match = lines[i].replace(/\r$/, '').match(URL_LINE_PATTERN); + if (match) { + urlLineIndex = i; + url = match[1].split(/[\s,]+/).filter(token => token.length > 0)[0] ?? ''; + break; + } + } + const body = lines + .slice(1, footerLineIndex) + .filter((_, index) => index + 1 !== urlLineIndex) + .join('\n') + .replace(/^\s+/, '') + .replace(/\s+$/, ''); + return { title, url, body }; +}; + +const synthesizeNoteDocument = (noteData: NoteResponse): string => { + const headerLines = [noteData.title]; + if (noteData.url) { + headerLines.push(`url: ${noteData.url}`); + } + const body = synthesizeTagsFooter(noteData.description, noteData.tags ?? []); + return `${headerLines.join('\n')}\n\n${body}`; +}; + const getFooterCaretContext = (content: string, caret: number): FooterCaretContext | null => { const parsed = parseTagsFooter(content); if (!parsed) return null; @@ -99,9 +140,7 @@ function NoteAdd(): React.ReactNode { const [validated, setValidated] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [noteId, setNoteId] = useState(0); - const [noteTitle, setNoteTitle] = useState(''); const [noteContent, setNoteContent] = useState(''); - const [noteUrl, setNoteUrl] = useState(''); const [tags, setTags] = useState([]); const [footerCtx, setFooterCtx] = useState(null); const [showFooterDropdown, setShowFooterDropdown] = useState(false); @@ -119,6 +158,7 @@ function NoteAdd(): React.ReactNode { const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new'; + const parsedDocument = parseNoteDocument(noteContent); const parsedFooter = parseTagsFooter(noteContent); const footerTags = parsedFooter ? parsedFooter.tags : []; const footerSuggestions = showFooterDropdown && footerCtx @@ -191,8 +231,6 @@ function NoteAdd(): React.ReactNode { */ const resetInputs = () => { setNoteId(0); - setNoteTitle(''); - setNoteUrl(''); setNoteContent(''); setFooterCtx(null); setShowFooterDropdown(false); @@ -200,11 +238,11 @@ function NoteAdd(): React.ReactNode { setValidated(false); }; - const saveDraft = (title: string, content: string, noteUrl: string): void => { + const saveDraft = (content: string): void => { if (!hasUserEdited.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - const draft: NoteDraft = { title, content, noteUrl }; + const draft: NoteDraft = { content }; localStorage.setItem(draftKey, JSON.stringify(draft)); }, 1500); }; @@ -218,10 +256,18 @@ function NoteAdd(): React.ReactNode { const raw = localStorage.getItem(draftKey); if (!raw) return; try { - const draft: NoteDraft = JSON.parse(raw); - setNoteTitle(draft.title ?? ''); - setNoteContent(draft.content ?? ''); - setNoteUrl(draft.noteUrl ?? ''); + const draft: LegacyNoteDraft = JSON.parse(raw); + let content = draft.content ?? ''; + if (typeof draft.title === 'string' || typeof draft.noteUrl === 'string') { + const headerLines = [draft.title ?? '']; + if (draft.noteUrl) { + headerLines.push(`url: ${draft.noteUrl}`); + } + content = `${headerLines.join('\n')}\n\n${content}`; + const migrated: NoteDraft = { content }; + localStorage.setItem(draftKey, JSON.stringify(migrated)); + } + setNoteContent(content); setDraftBanner(true); } catch { @@ -273,7 +319,7 @@ function NoteAdd(): React.ReactNode { const newCaret = footerCtx.tokenStart + replacement.length; hasUserEdited.current = true; setNoteContent(newContent); - saveDraft(noteTitle, newContent, noteUrl); + saveDraft(newContent); setTimeout(() => { if (contentInputRef.current) { contentInputRef.current.focus(); @@ -314,17 +360,17 @@ function NoteAdd(): React.ReactNode { const saveNote = async (): Promise => { setValidated(true); - if (!noteTitle.trim() || !noteContent.trim()) { + if (!parsedDocument.title || !noteContent.trim()) { setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language)); return false; } const payload: NoteResponse = { id: action === 'edit' ? noteId : 0, - title: noteTitle, - description: stripTagsFooter(noteContent), - url: noteUrl, - tags: parseTagsFooter(noteContent)?.tags ?? [], + title: parsedDocument.title, + description: parsedDocument.body, + url: parsedDocument.url, + tags: parsedFooter?.tags ?? [], lastUpdate: '', shared: false, shareToken: null, @@ -406,11 +452,7 @@ function NoteAdd(): React.ReactNode { const setNoteFromServer = (noteData: NoteResponse) => { setNoteId(noteData.id); - setNoteTitle(noteData.title); - if (noteData.url) { - setNoteUrl(noteData.url); - } - setNoteContent(synthesizeTagsFooter(noteData.description, noteData.tags ?? [])); + setNoteContent(synthesizeNoteDocument(noteData)); }; /** @@ -465,7 +507,7 @@ function NoteAdd(): React.ReactNode { - {t('note_form_title')} + {parsedDocument.title || t('note_form_untitled')} - - - {/* Note title */} - ) => { - setNoteTitle(e.target.value); - hasUserEdited.current = true; - saveDraft(e.target.value, noteContent, noteUrl); - }} - /> - - - {/* Note URL */} - ) => { - setNoteUrl(e.target.value); - hasUserEdited.current = true; - saveDraft(noteTitle, noteContent, e.target.value); - }} - /> - - - {t('note_form_content_label')} @@ -556,7 +561,7 @@ function NoteAdd(): React.ReactNode { onChange={(e: React.ChangeEvent) => { setNoteContent(e.target.value); hasUserEdited.current = true; - saveDraft(noteTitle, e.target.value, noteUrl); + saveDraft(e.target.value); refreshFooterAutocomplete( e.target.value, e.target.selectionStart ?? e.target.value.length @@ -608,7 +613,7 @@ function NoteAdd(): React.ReactNode { )} - Add a final line `tags: a, b` to tag this note. + {'The first line is the note title. To link a URL to this note, add a line `url: ` before the tags. Add a final line `tags: a, b` to tag this note.'} {footerTags.length > 0 && (
@@ -656,8 +661,8 @@ function NoteAdd(): React.ReactNode {