From 4b192e47ab4de3ad5e1438849c08d350908523ba Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Fri, 25 Sep 2026 16:17:39 +0200 Subject: [PATCH] feat: split note editor into hybrid document form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the note editor into dedicated title, URL, and tags fields arranged to read as one document, replacing the in-text title/url/tags line syntax. This prepares the editor for a live markdown body (spec 003) without any API or storage changes, and migrates existing localStorage drafts to the new field-based shape. The markdown toolbar spec (002) is superseded and removed. 💘 Generated with Crush Assisted-by: Crush:kimi-k3 --- ai/specs/002-markdown-formatting-toolbar.md | 36 -- ai/specs/003-live-markdown-editor.md | 132 ++++++ client/src/__test__/views/NoteAdd.test.tsx | 251 ++++++----- client/src/components/NoteForm/index.tsx | 215 +++++++++ client/src/constants/english.ts | 4 +- client/src/constants/portuguese.ts | 4 +- client/src/constants/russian.ts | 4 +- client/src/constants/spanish.ts | 4 +- client/src/styles/custom.scss | 26 ++ client/src/utils/noteDocumentParser.ts | 60 +++ client/src/views/NoteAdd/index.tsx | 471 +++++++------------- 11 files changed, 747 insertions(+), 460 deletions(-) delete mode 100644 ai/specs/002-markdown-formatting-toolbar.md create mode 100644 ai/specs/003-live-markdown-editor.md create mode 100644 client/src/components/NoteForm/index.tsx create mode 100644 client/src/utils/noteDocumentParser.ts diff --git a/ai/specs/002-markdown-formatting-toolbar.md b/ai/specs/002-markdown-formatting-toolbar.md deleted file mode 100644 index 2b11a600..00000000 --- a/ai/specs/002-markdown-formatting-toolbar.md +++ /dev/null @@ -1,36 +0,0 @@ -# Spec 002: Markdown Formatting Toolbar for Notes - -## Goal -Enhance the Note editing experience by adding a Markdown formatting toolbar to the note creation and editing interface. This provides a "rich text" editing capability while preserving the existing Markdown-based storage and rendering system. - -## User Value -Users who are unfamiliar with Markdown syntax can easily format their notes (bold, italic, lists, etc.) using familiar UI controls. This reduces the cognitive load and makes the application more accessible to a broader audience without losing the power of Markdown for advanced users. - -## Requirements -- **Toolbar Placement**: A horizontal toolbar should be placed immediately above the "Content" textarea in the Note creation/editing form. -- **Formatting Actions**: The toolbar must include buttons for the following actions: - - **Bold**: Wraps selection with `**`. - - **Italic**: Wraps selection with `_`. - - **Heading**: Prepends `### ` to the current line or selection. - - **Bullet List**: Prepends `- ` to the current line or selection. - - **Link**: Inserts a Markdown link template `[text](url)`. -- **Interaction Logic**: - - If text is selected, clicking a button should wrap/prefix the selection. - - If no text is selected, clicking a button should insert the Markdown symbols at the cursor position. - - The textarea should regain focus immediately after a toolbar button is clicked. -- **Styling**: The toolbar should use standard Bootstrap components (e.g., `ButtonGroup`) and icons (e.g., `react-bootstrap-icons`) to match the existing project aesthetic. - -## Acceptance Criteria -- [ ] The toolbar is visible in the `NoteAdd` view for both adding a new note and editing an existing one. -- [ ] Clicking the **Bold** button wraps the selected text in the textarea with `**`. -- [ ] Clicking the **Italic** button wraps the selected text with `_`. -- [ ] Clicking the **Heading** button adds `### ` at the cursor or selection start. -- [ ] Clicking the **Bullet List** button adds `- ` at the start of the line. -- [ ] Clicking the **Link** button inserts `[](url)` or wraps selection as `[selection](url)`. -- [ ] The note can be saved successfully with the newly formatted content. -- [ ] The "Preview Markdown" modal correctly renders the formatted content. - -## Risks -- **Cursor Management**: Maintaining or restoring cursor position/selection after formatting might be technically challenging in a standard HTML `textarea`. -- **Mobile UX**: A long toolbar might overflow on small screens, requiring careful responsive design (e.g., horizontal scrolling or wrapping). -- **Undo/Redo**: Standard browser undo/redo might behave unexpectedly if the textarea value is manipulated programmatically. diff --git a/ai/specs/003-live-markdown-editor.md b/ai/specs/003-live-markdown-editor.md new file mode 100644 index 00000000..c742eccb --- /dev/null +++ b/ai/specs/003-live-markdown-editor.md @@ -0,0 +1,132 @@ +# Spec: Live Markdown Editor + +## Goal + +Replace the plain-textarea note editor and the "Preview Markdown" toggle with a +Notion-style live markdown editor (Milkdown). As the user types markdown syntax +(`#`, `-`, `**bold**`, `*italic*`, etc.), it renders inline immediately. The note +title, URL, and tags move out of the document body into dedicated, label-less +form fields that visually read as one continuous document. + +--- + +## User Value + +- No mode switching between writing and previewing; what you see is what you get. +- Notes stay stored as plain markdown: zero data migration, existing notes and + public shared-note links keep working unchanged. + +--- + +## Decisions (agreed during design review) + +| Decision | Choice | +|---|---| +| Interaction model | Notion-style WYSIWYG; markdown syntax is hidden once applied | +| Library | Milkdown (`@milkdown/kit`, `@milkdown/react`) with CommonMark + GFM presets | +| Storage format | Plain markdown in `notes.description`; serializer normalization accepted | +| Form layout | Hybrid: title input on top (heading-styled), URL + tags inputs below the body, placeholders only, no labels, visually one document | +| Markdown scope | Full GFM: headers, bullets, numbered lists, bold, italic, inline code, fenced code, links, strikethrough, task lists, tables, blockquotes | +| Paste | Rich content converts to GFM; pasted markdown text auto-renders | +| Read views | Home note modal and SharedNote page keep `react-markdown` (v1) | +| Preview | "Preview Markdown" removed from the note editor; `ModalMarkdown` remains for Home | +| Size limit | Live character count past 45k; save blocked client-side at 50k | +| Theming | Hand-rolled editor CSS on `--bs-*` variables; light and dark themes | +| Mobile | Desktop-first; must not break on mobile, polish is follow-up | +| Delivery | PR 1: hybrid form; PR 2: Milkdown swap | + +--- + +## Requirements + +### PR 1: Hybrid note form + +- Title, URL, and tags become dedicated inputs; the body stays a plain textarea. +- Title input is styled as a document heading with placeholder `Untitled note`; + pressing Enter moves focus into the body. +- URL input (`Add a URL`) and tags input (`Add tags`) sit below the body with + muted, label-less styling; tags keep the existing autocomplete dropdown and + Enter-to-commit behavior. +- The in-document `title` / `url:` / `tags:` line syntax is removed from the + editing path; the API payload (`title`, `description`, `url`, `tags`) is + unchanged. +- Draft autosave (1.5s localStorage debounce) covers all fields. + +### PR 2: Milkdown live editor + +- Body textarea is replaced by a Milkdown editor component with input rules for + the full GFM set. +- Markdown is serialized on change (for autosave and save) and parsed on load. +- "Preview Markdown" link, its handler, and the editor's `ModalMarkdown` usage + are removed. +- Live character count appears past 45k chars; saving is blocked with an inline + error at 50k (server limit stays the source of truth). +- Editor styling maps to Bootstrap theme variables for both light and dark. + +### Explicitly out of scope + +- Backend or database changes (none required). +- Formatting toolbar (supersedes deleted spec 002). +- Mobile-specific editor polish. +- Migrating read views (Home modal, SharedNote) to Milkdown. +- Image embedding. + +--- + +## Acceptance Criteria + +### PR 1 + +- [ ] Title, URL, tags are separate inputs; body textarea unchanged. +- [ ] Enter in the title input focuses the body. +- [ ] Tag autocomplete works from the tags input below the body. +- [ ] Saving a note sends the same API payload shape as before. +- [ ] Draft autosave restores title/body/URL/tags after reload. +- [ ] Existing notes load correctly into the split fields. + +### PR 2 + +- [ ] Typing `# `, `- `, `**bold**`, `*italic*`, `` `code` ``, `> `, `[]` renders + live without showing raw syntax. +- [ ] Pasted markdown and pasted rich content render as GFM. +- [ ] Saved `description` is valid markdown that renders identically in the Home + modal and SharedNote page (react-markdown). +- [ ] "Preview Markdown" no longer exists in the editor. +- [ ] Character counter appears past 45k; save blocked at 50k. +- [ ] Editor is readable and correctly themed in light and dark modes. +- [ ] Cypress e2e: create a note with header, bold, and a list; verify saved + markdown via API and rendering in the Home modal. + +--- + +## Dependencies + +- None (spec 002, markdown toolbar, was deleted; this spec supersedes it). + +--- + +## Risks + +- **Markdown normalization**: Milkdown's serializer normalizes formatting + (e.g. `** bold **` becomes `**bold**`), so merely opening and saving an old + note may reformat untouched sections. Accepted; mention in changelog. +- **Round-trip fidelity**: syntax outside the enabled presets (raw inline HTML) + can be dropped by the editor schema on save. Accepted for v1. +- **Mobile quirks**: ProseMirror/contentEditable issues with virtual keyboards + and autocorrect. Mitigated by desktop-first scope. +- **jsdom testing**: keystroke-level input-rule simulation is brittle; editor + is tested through its markdown in/out API instead, with one Cypress flow for + real-browser coverage. + +--- + +## Notes + +- Library facts (checked 2026-09): Milkdown 7.22.x, actively maintained, small + issue backlog, first-class React 19 support, markdown-native via remark-based + transformer (`getMarkdown()`, `markdownUpdated` events). TipTap rejected + because markdown round-tripping relies on a deprecated community package or a + young official one. +- Do not adopt Crepe (Milkdown's prebuilt UI); its theming conflicts with + Bootstrap and the design has no toolbar. +- `ModalMarkdown` and `react-markdown` remain dependencies for Home/SharedNote. diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 247376f3..73b5e1c8 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -108,26 +108,23 @@ describe('NoteAdd Component', () => { vi.clearAllMocks(); }); - it('should render the NoteAdd component without title and url inputs', async () => { + it('should render the hybrid form with title, body, url and tags inputs and no labels', async () => { let result: any; await act(async () => { result = renderNoteAdd(); }); - const { getByText, queryByLabelText } = result; + const { getByTestId, getByText, queryByLabelText } = result; expect(getByText('note_form_untitled')).toBeDefined(); - expect(getByText('note_form_content_label')).toBeDefined(); + expect(getByTestId('note-title-input')).toBeDefined(); + expect(getByTestId('note-content-input-area')).toBeDefined(); + expect(getByTestId('note-url-input')).toBeDefined(); + expect(getByTestId('note-tags-input')).toBeDefined(); expect(getByText('note_form_submit')).toBeDefined(); expect(queryByLabelText('note_form_title_label')).toBeNull(); + expect(queryByLabelText('note_form_content_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' }); @@ -139,12 +136,12 @@ describe('NoteAdd Component', () => { }); }); - it('should block save when the first line is blank', async () => { + it('should block save when the title is blank even with a body', 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.change(noteContentInput, { target: { value: 'Body without a title' } }); fireEvent.click(submitButton); await waitFor(() => { @@ -153,17 +150,17 @@ describe('NoteAdd Component', () => { expect(api.postJSON).not.toHaveBeenCalled(); }); - it('should add a new note deriving title from the first line', async () => { + it('should add a new note from the split fields', async () => { mockedUseSearchParams.mockReturnValue([ new URLSearchParams('backTo=home'), vi.fn(), ]); 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: 'New Note\n\nNote content' } }); + fireEvent.change(getByTestId('note-title-input'), { target: { value: 'New Note' } }); + fireEvent.change(getByTestId('note-content-input-area'), { target: { value: 'Note content' } }); fireEvent.click(submitButton); await waitFor(() => { @@ -182,80 +179,71 @@ describe('NoteAdd Component', () => { }); }); - it('should strip markdown heading markers from the title line', async () => { + it('should use the title verbatim without stripping markdown markers', 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.change(getByTestId('note-title-input'), { target: { value: '# My Title' } }); + fireEvent.change(getByTestId('note-content-input-area'), { target: { value: 'Body' } }); fireEvent.click(submitButton); await waitFor(() => { expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ - title: 'My Title', + title: '# My Title', description: 'Body' })); }); }); - it('should parse the url line case-insensitively and strip it from the description', async () => { + it('should send the url input value with the note', 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.change(getByTestId('note-title-input'), { target: { value: 'Titled' } }); + fireEvent.change(getByTestId('note-content-input-area'), { target: { value: 'Body' } }); + fireEvent.change(getByTestId('note-url-input'), { target: { value: 'https://example.com/page' } }); fireEvent.click(submitButton); await waitFor(() => { expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ title: 'Titled', url: 'https://example.com/page', - description: 'Body', - tags: ['dev'] + description: 'Body' })); }); }); - 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' }); + it('should update the card title live with the title input', async () => { + const { getByText, getByTestId } = renderNoteAdd(); - fireEvent.change(noteContentInput, { - target: { value: 'Titled\nurl: https://first\nurl: https://second\n\nBody' } - }); - fireEvent.click(submitButton); + expect(getByText('note_form_untitled')).toBeDefined(); + + fireEvent.change(getByTestId('note-title-input'), { target: { value: 'Live Title' } }); await waitFor(() => { - expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ - url: 'https://first', - description: 'url: https://second\n\nBody' - })); + expect(getByText('Live Title')).toBeDefined(); }); }); - 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(); + it('should move focus to the body when pressing Enter in the title', async () => { + const { getByTestId } = renderNoteAdd(); + const titleInput = getByTestId('note-title-input') as HTMLInputElement; + const bodyInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; - fireEvent.change(noteContentInput, { target: { value: '## Live Title\n\nBody' } }); + titleInput.focus(); + fireEvent.keyDown(titleInput, { key: 'Enter' }); await waitFor(() => { - expect(getByText('Live Title')).toBeDefined(); + expect(document.activeElement).toBe(bodyInput); }); }); - it('should hide the title, url and tags lines from the markdown preview', async () => { + it('should hide the url and tags 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.change(getByTestId('note-title-input'), { target: { value: 'My Title' } }); + fireEvent.change(getByTestId('note-content-input-area'), { target: { value: 'Body text' } }); + fireEvent.change(getByTestId('note-url-input'), { target: { value: 'https://example.com' } }); fireEvent.click(getByText('Preview Markdown')); await waitFor(() => { @@ -283,7 +271,7 @@ describe('NoteAdd Component', () => { expect(getByText('Them')).toBeDefined(); }); - it('should render a note to edit with title and url synthesized into the body', async () => { + it('should render a note to edit with title, url and tags in their own fields', async () => { mockedUseParams.mockReturnValue({ id: '1' }); const toEdit: NoteResponse = { @@ -302,11 +290,18 @@ describe('NoteAdd Component', () => { 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' + expect((getByTestId('note-title-input') as HTMLInputElement).value).toBe('Note one'); + expect((getByTestId('note-content-input-area') as HTMLTextAreaElement).value).toBe( + 'Description of note one' + ); + expect((getByTestId('note-url-input') as HTMLInputElement).value).toBe( + 'http://notes.domain.com' ); }); + + await waitFor(() => { + expect(getByTestId('note-tags-preview').textContent).toContain('#dev'); + }); }); it('should round-trip title, url, body and tags when saving an edited note', async () => { @@ -328,8 +323,7 @@ describe('NoteAdd Component', () => { const { getByTestId, getByRole } = renderNoteAdd(); await waitFor(() => { - const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; - expect(noteContentInput.innerHTML).toContain('Note one'); + expect((getByTestId('note-title-input') as HTMLInputElement).value).toBe('Note one'); }); fireEvent.click(getByRole('button', { name: 'note_form_submit' })); @@ -368,9 +362,12 @@ describe('NoteAdd Component', () => { const { getByTestId } = renderNoteAdd(); await waitFor(() => { - 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' + expect((getByTestId('note-title-input') as HTMLInputElement).value).toBe('Old title'); + expect((getByTestId('note-content-input-area') as HTMLTextAreaElement).value).toBe( + 'Old description' + ); + expect((getByTestId('note-url-input') as HTMLInputElement).value).toBe( + 'http://notes.domain.com' ); }); @@ -383,21 +380,35 @@ describe('NoteAdd Component', () => { const { getByTestId } = renderNoteAdd(); await waitFor(() => { - const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; - expect(noteContentInput.innerHTML).toBe(''); + expect((getByTestId('note-title-input') as HTMLInputElement).value).toBe(''); + expect((getByTestId('note-content-input-area') as HTMLTextAreaElement).value).toBe(''); }); window.history.pushState({}, '', '/'); }); - it('should parse the tags footer, strip it from the description and send tags on save', async () => { + it('should commit a tag chip with Enter and send tags on save', 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: 'Tagged Note\n\nNote content\n\nTags: Foo, bar , foo,,' } + fireEvent.change(getByTestId('note-title-input'), { target: { value: 'Tagged Note' } }); + fireEvent.change(getByTestId('note-content-input-area'), { target: { value: 'Note content' } }); + + const tagsInput = getByTestId('note-tags-input') as HTMLInputElement; + fireEvent.change(tagsInput, { target: { value: 'Foo' } }); + fireEvent.keyDown(tagsInput, { key: 'Enter' }); + fireEvent.change(tagsInput, { target: { value: 'bar' } }); + fireEvent.keyDown(tagsInput, { key: 'Enter' }); + fireEvent.change(tagsInput, { target: { value: 'foo' } }); + fireEvent.keyDown(tagsInput, { key: 'Enter' }); + + await waitFor(() => { + const preview = getByTestId('note-tags-preview'); + expect(preview.textContent).toContain('#foo'); + expect(preview.textContent).toContain('#bar'); + expect(preview.textContent).not.toContain('#Foo'); }); + fireEvent.click(submitButton); await waitFor(() => { @@ -415,89 +426,78 @@ describe('NoteAdd Component', () => { }); }); - it('should save an untagged note when the footer has an empty value', async () => { + it('should commit a pending tag left in the input on save', 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: 'Untagged Note\n\nNote content\n\ntags:' } }); + fireEvent.change(getByTestId('note-title-input'), { target: { value: 'Pending Tag' } }); + fireEvent.change(getByTestId('note-content-input-area'), { target: { value: 'Body' } }); + fireEvent.change(getByTestId('note-tags-input'), { target: { value: 'dev' } }); fireEvent.click(submitButton); await waitFor(() => { - expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, { - id: 0, - title: 'Untagged Note', - description: 'Note content', - url: '', - tags: [], - lastUpdate: '', - shared: false, - shareToken: null, - archived: false - }); + expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, expect.objectContaining({ + tags: ['dev'] + })); }); }); - it('should show live tag chips while typing the footer', async () => { - const { getByTestId, queryByTestId } = renderNoteAdd(); - const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement; + it('should remove a tag chip when clicking it', async () => { + const { getByTestId, getByText, queryByTestId } = renderNoteAdd(); - expect(queryByTestId('note-tags-preview')).toBeNull(); + const tagsInput = getByTestId('note-tags-input') as HTMLInputElement; + fireEvent.change(tagsInput, { target: { value: 'dev' } }); + fireEvent.keyDown(tagsInput, { key: 'Enter' }); - fireEvent.change(noteContentInput, { target: { value: 'Content\n\ntags: dev, react' } }); + await waitFor(() => { + expect(getByTestId('note-tags-preview').textContent).toContain('#dev'); + }); + + fireEvent.click(getByText(/#dev/)); await waitFor(() => { - const preview = getByTestId('note-tags-preview'); - expect(preview.textContent).toContain('#dev'); - expect(preview.textContent).toContain('#react'); + expect(queryByTestId('note-tags-preview')).toBeNull(); }); }); - it('should keep the server footer over note.tags when editing', async () => { - mockedUseParams.mockReturnValue({ id: '1' }); + it('should show tag suggestions and accept one on click', async () => { + vi.spyOn(api, 'getJSON').mockResolvedValue(['dev', 'design']); - const toEdit: NoteResponse = { - id: 1, - title: 'Note one', - description: 'Description of note one\n\ntags: body-tag', - url: 'http://notes.domain.com', - tags: ['server-tag'], - lastUpdate: '3 minutes ago', - shared: false, - shareToken: null - }; + const { getByTestId, getByText } = renderNoteAdd(); - vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit); + await waitFor(() => { + expect(api.getJSON).toHaveBeenCalled(); + }); - const { getByTestId } = renderNoteAdd(); + const tagsInput = getByTestId('note-tags-input') as HTMLInputElement; + fireEvent.focus(tagsInput); + fireEvent.change(tagsInput, { target: { value: 'de' } }); 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: body-tag' - ); + expect(getByTestId('tag-suggestion-dropdown')).toBeDefined(); }); + fireEvent.mouseDown(getByText('#dev')); + await waitFor(() => { - const preview = getByTestId('note-tags-preview'); - expect(preview.textContent).toContain('#body-tag'); - expect(preview.textContent).not.toContain('#server-tag'); + expect(getByTestId('note-tags-preview').textContent).toContain('#dev'); + expect(tagsInput.value).toBe(''); }); }); - it('should restore a content-only draft', async () => { + it('should restore a legacy content-only draft into the split fields', 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((getByTestId('note-title-input') as HTMLInputElement).value).toBe('Draft Title'); + expect((getByTestId('note-content-input-area') as HTMLTextAreaElement).value).toBe('Draft body'); }); expect(getByText(/Draft restored/)).toBeDefined(); }); - it('should migrate an old-shaped draft into the content once', async () => { + it('should migrate an old-shaped draft into the split fields once', async () => { localStorage.setItem( 'draft:note:new', JSON.stringify({ title: 'Old Title', content: 'Old body', noteUrl: 'http://x.com' }) @@ -506,11 +506,32 @@ describe('NoteAdd Component', () => { 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'); + expect((getByTestId('note-title-input') as HTMLInputElement).value).toBe('Old Title'); + expect((getByTestId('note-content-input-area') as HTMLTextAreaElement).value).toBe('Old body'); + expect((getByTestId('note-url-input') as HTMLInputElement).value).toBe('http://x.com'); }); const stored = JSON.parse(localStorage.getItem('draft:note:new')!); - expect(stored).toEqual({ content: 'Old Title\nurl: http://x.com\n\nOld body' }); + expect(stored).toEqual({ + title: 'Old Title', + content: 'Old body', + noteUrl: 'http://x.com', + tags: [] + }); + }); + + it('should restore a new-shaped draft as-is', async () => { + localStorage.setItem( + 'draft:note:new', + JSON.stringify({ title: 'Draft', content: 'Body', noteUrl: '', tags: ['dev'] }) + ); + + const { getByTestId } = renderNoteAdd(); + + await waitFor(() => { + expect((getByTestId('note-title-input') as HTMLInputElement).value).toBe('Draft'); + expect((getByTestId('note-content-input-area') as HTMLTextAreaElement).value).toBe('Body'); + expect(getByTestId('note-tags-preview').textContent).toContain('#dev'); + }); }); }); diff --git a/client/src/components/NoteForm/index.tsx b/client/src/components/NoteForm/index.tsx new file mode 100644 index 00000000..fed69789 --- /dev/null +++ b/client/src/components/NoteForm/index.tsx @@ -0,0 +1,215 @@ +import React from 'react'; +import { Badge, Form, ListGroup } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +interface NoteFormProps { + validated: boolean; + title: string; + body: string; + url: string; + selectedTags: string[]; + currentTag: string; + tagSuggestions: string[]; + highlightedIndex: number; + submitLabel: string; + bodyInputRef: React.RefObject; + tagContainerRef: React.RefObject; + onTitleChange: (value: string) => void; + onBodyChange: (value: string) => void; + onUrlChange: (value: string) => void; + onCurrentTagChange: (value: string) => void; + onTagKeyDown: (e: React.KeyboardEvent) => void; + onTagFocus: () => void; + onAddTag: (tag: string) => void; + onRemoveTag: (tag: string) => void; + onPreviewMarkdown: (e: React.MouseEvent) => void; + onSubmit: (e: React.SubmitEvent) => void; + onCancel: () => void; +} + +/** + * Hybrid note form: a heading-styled title input on top, the note body in the + * middle, and URL/tags inputs below. Label-less with subtle placeholders so + * the form reads as a single document. + * + * @param {NoteFormProps} props - The form state and callbacks. + * @returns {React.ReactNode} The rendered note form. + */ +function NoteForm({ + validated, + title, + body, + url, + selectedTags, + currentTag, + tagSuggestions, + highlightedIndex, + submitLabel, + bodyInputRef, + tagContainerRef, + onTitleChange, + onBodyChange, + onUrlChange, + onCurrentTagChange, + onTagKeyDown, + onTagFocus, + onAddTag, + onRemoveTag, + onPreviewMarkdown, + onSubmit, + onCancel +}: NoteFormProps): React.ReactNode { + const { t } = useTranslation(); + + return ( +
+ + ) => onTitleChange(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + bodyInputRef.current?.focus(); + } + }} + data-testid="note-title-input" + /> + + + + + ) => onBodyChange(e.target.value)} + data-testid="note-content-input-area" + /> + + + + ) => onUrlChange(e.target.value)} + data-testid="note-url-input" + /> + + + + ) => onCurrentTagChange(e.target.value)} + onKeyDown={onTagKeyDown} + onFocus={onTagFocus} + data-testid="note-tags-input" + /> + {selectedTags.length > 0 && ( +
+ {selectedTags.map(tag => ( + onRemoveTag(tag)} + > + # + {tag} + {' '} + × + + ))} +
+ )} + {tagSuggestions.length > 0 && ( + + {tagSuggestions.map((suggestion, index) => ( + { + e.preventDefault(); + onAddTag(suggestion); + }} + > + + # + {suggestion} + + ))} + + )} +
+ +
+ + + +
+
+ ); +} + +export default NoteForm; diff --git a/client/src/constants/english.ts b/client/src/constants/english.ts index 9cb3ad7d..9c2553ce 100644 --- a/client/src/constants/english.ts +++ b/client/src/constants/english.ts @@ -121,7 +121,9 @@ const enTranslations = { note_form_title_label: 'Title', note_form_title_placeholder: 'Enter the note title', note_form_content_label: 'Note content', - note_form_content_placeholder: 'Enter the note content', + note_form_content_placeholder: 'Write something...', + note_form_url_placeholder: 'Add a URL', + note_form_tags_placeholder: 'Add tags', note_form_submit: 'Save note', note_table_btn_edit: 'Edit', note_table_btn_delete: 'Delete', diff --git a/client/src/constants/portuguese.ts b/client/src/constants/portuguese.ts index 3f61a345..7b94ce85 100644 --- a/client/src/constants/portuguese.ts +++ b/client/src/constants/portuguese.ts @@ -121,7 +121,9 @@ const ptBrTranslations = { note_form_title_label: 'Título', note_form_title_placeholder: 'Digite o título da nota', note_form_content_label: 'Conteúdo da nota', - note_form_content_placeholder: 'Digite o conteúdo da nota', + note_form_content_placeholder: 'Escreva algo...', + note_form_url_placeholder: 'Adicionar uma URL', + note_form_tags_placeholder: 'Adicionar etiquetas', note_form_submit: 'Salvar nota', note_table_btn_edit: 'Alterar', note_table_btn_delete: 'Excluir', diff --git a/client/src/constants/russian.ts b/client/src/constants/russian.ts index 915f2702..db3fb44d 100644 --- a/client/src/constants/russian.ts +++ b/client/src/constants/russian.ts @@ -121,7 +121,9 @@ const ruTranslations = { note_form_title_label: 'Заголовок', note_form_title_placeholder: 'Введите название заметки', note_form_content_label: 'Примечание к содержанию', - note_form_content_placeholder: 'Введите содержание заметки', + note_form_content_placeholder: 'Напишите что-нибудь...', + note_form_url_placeholder: 'Добавить URL', + note_form_tags_placeholder: 'Добавить теги', note_form_submit: 'Сохранить заметку', note_table_btn_edit: 'Редактировать', note_table_btn_delete: 'Удалить', diff --git a/client/src/constants/spanish.ts b/client/src/constants/spanish.ts index ef94782f..43f31726 100644 --- a/client/src/constants/spanish.ts +++ b/client/src/constants/spanish.ts @@ -121,7 +121,9 @@ const esTranslations = { 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', - note_form_content_placeholder: 'Introduce el contenido de la nota', + note_form_content_placeholder: 'Escribe algo...', + note_form_url_placeholder: 'Añadir una URL', + note_form_tags_placeholder: 'Añadir etiquetas', note_form_submit: 'Guardar nota', note_table_btn_edit: 'Editar', note_table_btn_delete: 'Eliminar', diff --git a/client/src/styles/custom.scss b/client/src/styles/custom.scss index 68f7ee96..d36855b0 100644 --- a/client/src/styles/custom.scss +++ b/client/src/styles/custom.scss @@ -217,6 +217,32 @@ a:hover, .btn-link:hover { } } +.note-form-input { + border: none; + border-radius: 0; + background-color: transparent; + box-shadow: none; + padding-left: 0; + padding-right: 0; + color: inherit; +} + +.note-form-input:focus { + background-color: transparent; + box-shadow: none; + color: inherit; +} + +.note-form-title-input { + font-size: 1.75rem; + font-weight: 700; +} + +.note-form-meta-input { + font-size: 14px; + color: var(--bs-secondary-color); +} + .task-note-external-link { margin-left: 0.5rem; } diff --git a/client/src/utils/noteDocumentParser.ts b/client/src/utils/noteDocumentParser.ts new file mode 100644 index 00000000..21ded849 --- /dev/null +++ b/client/src/utils/noteDocumentParser.ts @@ -0,0 +1,60 @@ +const TAGS_FOOTER_PATTERN = /^(tags:\s*)(.*)$/i; +const URL_LINE_PATTERN = /^url:\s*(.*)$/i; + +export interface ParsedNoteDocument { + title: string; + url: string; + body: string; + tags: string[]; +} + +const normalizeTitleLine = (line: string): string => line.replace(/^#+\s+/, '').trim(); + +/** + * Parses a legacy note document (title line, `url:` line, `tags:` footer) into + * separate fields. Only used to migrate localStorage drafts created before the + * hybrid note form existed. + * + * @param {string} content - The legacy note document. + * @returns {ParsedNoteDocument} The parsed title, url, body and tags. + */ +export const parseNoteDocument = (content: string): ParsedNoteDocument => { + const lines = content.split('\n'); + const title = lines.length > 0 ? normalizeTitleLine(lines[0].replace(/\r$/, '')) : ''; + + let footerLineIndex = lines.length; + let tags: string[] = []; + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i].replace(/\r$/, ''); + if (line.trim() === '') continue; + const match = line.match(TAGS_FOOTER_PATTERN); + if (match) { + footerLineIndex = i; + tags = match[2] + .split(',') + .map(token => token.trim().toLowerCase()) + .filter((token, index, all) => token.length > 0 && all.indexOf(token) === index); + } + break; + } + + 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, tags }; +}; diff --git a/client/src/views/NoteAdd/index.tsx b/client/src/views/NoteAdd/index.tsx index f1bcaf25..5917e31c 100644 --- a/client/src/views/NoteAdd/index.tsx +++ b/client/src/views/NoteAdd/index.tsx @@ -1,12 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { Alert, - Badge, Card, Col, Container, - Form, - ListGroup, Row } from 'react-bootstrap'; import { useNavigate, useParams } from 'react-router'; @@ -15,122 +12,21 @@ import { NoteResponse } from '../../types/NoteResponse'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import { translateServerResponse } from '../../utils/TranslatorUtils'; +import { parseNoteDocument } from '../../utils/noteDocumentParser'; import ModalMarkdown from '../../components/ModalMarkdown'; import AlertError from '../../components/AlertError'; import ContentHeader from '../../components/ContentHeader'; +import NoteForm from '../../components/NoteForm'; type NoteAction = 'add' | 'edit'; interface NoteDraft { - content: string; -} - -interface LegacyNoteDraft { - title?: string; - content: string; - noteUrl?: string; -} - -interface ParsedNoteDocument { title: string; - url: string; - body: string; -} - -interface ParsedTagsFooter { + content: string; + noteUrl: string; tags: string[]; - bodyWithoutFooter: string; - footerLineIndex: number; -} - -interface FooterCaretContext { - tokenStart: number; - query: string; } -const TAGS_FOOTER_PATTERN = /^(tags:\s*)(.*)$/i; - -const parseTagsFooter = (content: string): ParsedTagsFooter | null => { - const lines = content.split('\n'); - for (let i = lines.length - 1; i >= 0; i -= 1) { - const line = lines[i].replace(/\r$/, ''); - if (line.trim() === '') continue; - const match = line.match(TAGS_FOOTER_PATTERN); - if (!match) return null; - const tags = match[2] - .split(',') - .map(token => token.trim().toLowerCase()) - .filter((token, index, all) => token.length > 0 && all.indexOf(token) === index); - const bodyWithoutFooter = lines.slice(0, i).join('\n').replace(/\s+$/, ''); - return { tags, bodyWithoutFooter, footerLineIndex: i }; - } - return null; -}; - -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; - const lines = content.split('\n'); - let lineStart = 0; - for (let i = 0; i < parsed.footerLineIndex; i += 1) { - lineStart += lines[i].length + 1; - } - const line = lines[parsed.footerLineIndex].replace(/\r$/, ''); - if (caret < lineStart || caret > lineStart + line.length) return null; - const match = line.match(TAGS_FOOTER_PATTERN); - if (!match) return null; - const caretInLine = caret - lineStart; - if (caretInLine < match[1].length) return null; - const beforeCaret = line.substring(match[1].length, caretInLine); - const tokenOffset = beforeCaret.lastIndexOf(',') + 1; - return { - tokenStart: lineStart + match[1].length + tokenOffset, - query: beforeCaret.substring(tokenOffset).trim().toLowerCase() - }; -}; - /** * NoteAdd component for adding and editing notes. * @@ -140,10 +36,13 @@ function NoteAdd(): React.ReactNode { const [validated, setValidated] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [noteId, setNoteId] = useState(0); - const [noteContent, setNoteContent] = useState(''); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [noteUrl, setNoteUrl] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); + const [currentTag, setCurrentTag] = useState(''); const [tags, setTags] = useState([]); - const [footerCtx, setFooterCtx] = useState(null); - const [showFooterDropdown, setShowFooterDropdown] = useState(false); + const [showTagDropdown, setShowTagDropdown] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(0); const [action, setAction] = useState('add'); const [showPreviewMd, setShowPreviewMd] = useState(false); @@ -151,20 +50,17 @@ function NoteAdd(): React.ReactNode { const { i18n, t } = useTranslation(); const params = useParams(); const navigate = useNavigate(); - const contentAreaRef = useRef(null); - const contentInputRef = useRef(null); + const bodyInputRef = useRef(null); + const tagContainerRef = useRef(null); const debounceRef = useRef | null>(null); const hasUserEdited = useRef(false); 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 + const tagSuggestions = showTagDropdown ? tags - .filter(tag => tag.toLowerCase().includes(footerCtx.query)) - .filter(tag => !footerTags.includes(tag.toLowerCase())) + .filter(tag => tag.toLowerCase().includes(currentTag.trim().toLowerCase())) + .filter(tag => !selectedTags.includes(tag)) .slice(0, 8) : []; @@ -231,18 +127,31 @@ function NoteAdd(): React.ReactNode { */ const resetInputs = () => { setNoteId(0); - setNoteContent(''); - setFooterCtx(null); - setShowFooterDropdown(false); + setTitle(''); + setBody(''); + setNoteUrl(''); + setSelectedTags([]); + setCurrentTag(''); + setShowTagDropdown(false); setAction('add'); setValidated(false); }; - const saveDraft = (content: string): void => { + const saveDraft = ( + draftTitle: string, + draftBody: string, + draftUrl: string, + draftTags: string[] + ): void => { if (!hasUserEdited.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - const draft: NoteDraft = { content }; + const draft: NoteDraft = { + title: draftTitle, + content: draftBody, + noteUrl: draftUrl, + tags: draftTags + }; localStorage.setItem(draftKey, JSON.stringify(draft)); }, 1500); }; @@ -256,18 +165,38 @@ function NoteAdd(): React.ReactNode { const raw = localStorage.getItem(draftKey); if (!raw) return; try { - 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}`); + const draft = JSON.parse(raw); + let migrated: NoteDraft; + if (Array.isArray(draft.tags)) { + migrated = { + title: typeof draft.title === 'string' ? draft.title : '', + content: typeof draft.content === 'string' ? draft.content : '', + noteUrl: typeof draft.noteUrl === 'string' ? draft.noteUrl : '', + tags: draft.tags.filter((tag: unknown) => typeof tag === 'string') + }; + } + else { + let content = typeof draft.content === 'string' ? draft.content : ''; + if (typeof draft.title === 'string' || typeof draft.noteUrl === 'string') { + const headerLines = [typeof draft.title === 'string' ? draft.title : '']; + if (typeof draft.noteUrl === 'string' && draft.noteUrl) { + headerLines.push(`url: ${draft.noteUrl}`); + } + content = `${headerLines.join('\n')}\n\n${content}`; } - content = `${headerLines.join('\n')}\n\n${content}`; - const migrated: NoteDraft = { content }; - localStorage.setItem(draftKey, JSON.stringify(migrated)); + const parsed = parseNoteDocument(content); + migrated = { + title: parsed.title, + content: parsed.body, + noteUrl: parsed.url, + tags: parsed.tags + }; } - setNoteContent(content); + localStorage.setItem(draftKey, JSON.stringify(migrated)); + setTitle(migrated.title); + setBody(migrated.content); + setNoteUrl(migrated.noteUrl); + setSelectedTags(migrated.tags); setDraftBanner(true); } catch { @@ -295,60 +224,78 @@ function NoteAdd(): React.ReactNode { } }; - const refreshFooterAutocomplete = (content: string, caret: number): void => { - const ctx = getFooterCaretContext(content, caret); - setFooterCtx(ctx); - setShowFooterDropdown(ctx !== null); + const handleTitleChange = (value: string): void => { + setTitle(value); + hasUserEdited.current = true; + saveDraft(value, body, noteUrl, selectedTags); + }; + + const handleBodyChange = (value: string): void => { + setBody(value); + hasUserEdited.current = true; + saveDraft(title, value, noteUrl, selectedTags); + }; + + const handleUrlChange = (value: string): void => { + setNoteUrl(value); + hasUserEdited.current = true; + saveDraft(title, body, value, selectedTags); + }; + + const handleCurrentTagChange = (value: string): void => { + setCurrentTag(value); + setShowTagDropdown(true); setHighlightedIndex(0); }; - const acceptSuggestion = (suggestion: string): void => { - if (!footerCtx) return; - const rest = noteContent.substring(footerCtx.tokenStart); - const boundaries = [rest.indexOf(','), rest.indexOf('\n')].filter(index => index >= 0); - const tokenEnd = boundaries.length > 0 - ? footerCtx.tokenStart + Math.min(...boundaries) - : noteContent.length; - const leadingWhitespace = noteContent - .substring(footerCtx.tokenStart, tokenEnd) - .match(/^\s*/)?.[0] ?? ''; - const replacement = `${leadingWhitespace}${suggestion}`; - const newContent = noteContent.substring(0, footerCtx.tokenStart) - + replacement - + noteContent.substring(tokenEnd); - const newCaret = footerCtx.tokenStart + replacement.length; + const addTag = (tagName: string): void => { + const normalized = tagName.trim().toLowerCase(); + let newTags = [...selectedTags]; + if (normalized && !selectedTags.includes(normalized)) { + newTags = [...selectedTags, normalized]; + setSelectedTags(newTags); + } hasUserEdited.current = true; - setNoteContent(newContent); - saveDraft(newContent); - setTimeout(() => { - if (contentInputRef.current) { - contentInputRef.current.focus(); - contentInputRef.current.setSelectionRange(newCaret, newCaret); - refreshFooterAutocomplete(newContent, newCaret); - } - }, 0); + saveDraft(title, body, noteUrl, newTags); + setCurrentTag(''); + setShowTagDropdown(false); + setHighlightedIndex(0); }; - const handleContentKeyDown = (e: React.KeyboardEvent): void => { + const removeTag = (tagToRemove: string): void => { + const newTags = selectedTags.filter(tag => tag !== tagToRemove); + setSelectedTags(newTags); + hasUserEdited.current = true; + saveDraft(title, body, noteUrl, newTags); + }; + + const handleTagKeyDown = (e: React.KeyboardEvent): void => { if (e.key === 'Escape') { - if (showFooterDropdown) { + if (showTagDropdown) { + e.preventDefault(); + setShowTagDropdown(false); + } + return; + } + if (e.key === 'Enter') { + if (tagSuggestions.length > 0) { + e.preventDefault(); + addTag(tagSuggestions[highlightedIndex]); + } + else if (currentTag.trim()) { e.preventDefault(); - setShowFooterDropdown(false); + addTag(currentTag); } return; } - if (!showFooterDropdown || footerSuggestions.length === 0) return; + if (tagSuggestions.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); - setHighlightedIndex(prev => (prev + 1) % footerSuggestions.length); + setHighlightedIndex(prev => (prev + 1) % tagSuggestions.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setHighlightedIndex(prev => (prev - 1 + footerSuggestions.length) % footerSuggestions.length); - } - else if (e.key === 'Enter') { - e.preventDefault(); - acceptSuggestion(footerSuggestions[highlightedIndex]); + setHighlightedIndex(prev => (prev - 1 + tagSuggestions.length) % tagSuggestions.length); } }; @@ -360,17 +307,23 @@ function NoteAdd(): React.ReactNode { const saveNote = async (): Promise => { setValidated(true); - if (!parsedDocument.title || !noteContent.trim()) { + if (!title.trim()) { setErrorMessage(translateServerResponse('Please fill in all the fields', i18n.language)); return false; } + const finalTags = [...selectedTags]; + const pendingTag = currentTag.trim().toLowerCase(); + if (pendingTag && !finalTags.includes(pendingTag)) { + finalTags.push(pendingTag); + } + const payload: NoteResponse = { id: action === 'edit' ? noteId : 0, - title: parsedDocument.title, - description: parsedDocument.body, - url: parsedDocument.url, - tags: parsedFooter?.tags ?? [], + title: title.trim(), + description: body, + url: noteUrl.trim(), + tags: finalTags, lastUpdate: '', shared: false, shareToken: null, @@ -452,7 +405,10 @@ function NoteAdd(): React.ReactNode { const setNoteFromServer = (noteData: NoteResponse) => { setNoteId(noteData.id); - setNoteContent(synthesizeNoteDocument(noteData)); + setTitle(noteData.title); + setBody(noteData.description); + setNoteUrl(noteData.url ?? ''); + setSelectedTags(noteData.tags ?? []); }; /** @@ -463,7 +419,7 @@ function NoteAdd(): React.ReactNode { const previewMarkdown = (e: React.MouseEvent): void => { e.preventDefault(); e.stopPropagation(); - setShowPreviewMd(noteContent.length > 0); + setShowPreviewMd(title.length + body.length > 0); }; /** @@ -481,8 +437,8 @@ function NoteAdd(): React.ReactNode { } const handleClickOutside = (event: MouseEvent): void => { - if (contentAreaRef.current && !contentAreaRef.current.contains(event.target as Node)) { - setShowFooterDropdown(false); + if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) { + setShowTagDropdown(false); } }; @@ -507,7 +463,7 @@ function NoteAdd(): React.ReactNode { - {parsedDocument.title || t('note_form_untitled')} + {title.trim() || t('note_form_untitled')} )} -
setShowTagDropdown(true)} + onAddTag={addTag} + onRemoveTag={removeTag} + onPreviewMarkdown={previewMarkdown} onSubmit={handleSubmit} - autoComplete="off" - > - - - {t('note_form_content_label')} - - - {' '} - Preview Markdown - - - -
- ) => { - setNoteContent(e.target.value); - hasUserEdited.current = true; - saveDraft(e.target.value); - refreshFooterAutocomplete( - e.target.value, - e.target.selectionStart ?? e.target.value.length - ); - }} - onKeyDown={handleContentKeyDown} - onClick={(e: React.MouseEvent) => { - refreshFooterAutocomplete(noteContent, e.currentTarget.selectionStart ?? 0); - }} - onKeyUp={(e: React.KeyboardEvent) => { - const caretKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; - if (caretKeys.includes(e.key)) { - refreshFooterAutocomplete(noteContent, e.currentTarget.selectionStart ?? 0); - } - }} - data-testid="note-content-input-area" - /> - {footerSuggestions.length > 0 && ( - - {footerSuggestions.map((suggestion, index) => ( - { - e.preventDefault(); - acceptSuggestion(suggestion); - }} - > - - # - {suggestion} - - ))} - - )} -
- - {'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 && ( -
- {footerTags.map(tag => ( - - # - {tag} - - ))} -
- )} -
- -
- - - -
-
+ onCancel={() => { + clearDraft(); + navigate('/home'); + }} + />
@@ -661,9 +522,9 @@ function NoteAdd(): React.ReactNode {