From f1a47f381209d467fc1f84ecb6f40535bdae2ea5 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:29:41 +0100 Subject: [PATCH] refactor(notes): decompose Notes into feature-owned modules (#6713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(notes): decompose Notes into feature-owned modules * fix(notes): avoid HTML interpolation in reminder picker * docs(notes): drop checkpoint archives, session logs and publication drafts The N0-N6 tarballs and raw pytest/node logs are not read by any script or test, and the logs carry absolute home-directory paths. Once squashed into dev they would stay in history for good. notes-pr.md and notes-issue.md were the drafts used to open #6712 and #6713, not documentation. notes-validation.md keeps the counts, the checkpoint hashes and implementation.sha256 as the record; local interpreter paths and the local Docker image name are replaced with portable placeholders. * fix(notes): keep the state store raw and stop masonry re-observing after close - state: writes built from views (filter, spread, splice) stored nested read-only views in the store, so structuredClone on a stored note threw DataCloneError. Store writes now unwrap nested views. - layout: a save that resolves after closePanel re-renders the leaving pane and re-created the masonry ResizeObserver that teardown had disposed. Skip creating it once Notes is closed. - index.js: drop 30 imported or declared names that were never used and group the imports at the top. Import order is unchanged. - Revert unrelated punctuation edits in .gitattributes and keep the header comment and docstring first in compare/vote.js and test_notes_dom_xss_helpers.py. Both fixes have a node test that fails on the previous head. * test(notes): make script rejection assertion case-insensitive Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum. --------- Co-authored-by: Léo --- .gitattributes | 4 + .../evidence/notes/implementation.sha256 | 112 + .../evidence/notes/native-desktop.png | Bin 0 -> 61946 bytes .../evidence/notes/native-mobile.png | Bin 0 -> 28151 bytes docs/frontend-architecture/handbook.md | 16 +- .../frontend-architecture/notes-case-study.md | 260 + .../notes-changed-files.md | 165 + .../notes-module-inventory.md | 56 + docs/frontend-architecture/notes-roadmap.md | 13 +- .../frontend-architecture/notes-validation.md | 261 + frontend/modules.json | 206 + .../notes/application/agentActions.js | 67 + .../notes/application/cardOperations.js | 79 + .../notes/application/checklistOperations.js | 68 + .../features/notes/application/deletion.js | 51 + .../src/features/notes/application/drafts.js | 55 + .../src/features/notes/application/queries.js | 66 + .../features/notes/application/reminders.js | 182 + .../features/notes/application/selection.js | 39 + .../src/features/notes/application/state.js | 83 + .../features/notes/application/submission.js | 69 + .../features/notes/application/workflows.js | 78 + .../src/features/notes/domain/noteRules.js | 87 + .../src/features/notes/domain/recurrence.js | 269 + frontend/src/features/notes/index.js | 503 ++ .../src/features/notes/infrastructure/http.js | 29 + .../notes/infrastructure/notifications.js | 20 + .../features/notes/infrastructure/storage.js | 63 + .../src/features/notes/ui/cardChecklist.js | 148 + .../src/features/notes/ui/cardInteractions.js | 337 + .../src/features/notes/ui/cardRendering.js | 244 + frontend/src/features/notes/ui/cardReorder.js | 181 + frontend/src/features/notes/ui/checklist.js | 190 + .../src/features/notes/ui/checklistReorder.js | 111 + frontend/src/features/notes/ui/clipboard.js | 82 + frontend/src/features/notes/ui/colors.js | 111 + frontend/src/features/notes/ui/draftEditor.js | 67 + frontend/src/features/notes/ui/drawing.js | 442 ++ frontend/src/features/notes/ui/editor.js | 518 ++ frontend/src/features/notes/ui/filters.js | 139 + frontend/src/features/notes/ui/layout.js | 123 + frontend/src/features/notes/ui/lifecycle.js | 36 + .../src/features/notes/ui/mobileEditor.js | 257 + .../src/features/notes/ui/mobileReorder.js | 214 + frontend/src/features/notes/ui/panel.js | 506 ++ frontend/src/features/notes/ui/quickAdd.js | 170 + .../src/features/notes/ui/reminderDisplay.js | 129 + .../src/features/notes/ui/reminderPicker.js | 335 + frontend/src/features/notes/ui/selection.js | 61 + frontend/src/features/notes/ui/text.js | 39 + frontend/src/features/notes/ui/toolbar.js | 78 + .../src/features/notes/ui/windowLayout.js | 114 + frontend/src/shared/ui/confetti.js | 28 + scripts/verify_frontend_runtime.py | 14 +- scripts/verify_notes_browser_runtime.py | 76 + static/js/compare/vote.js | 30 +- .../notes/application/agentActions.js | 67 + .../notes/application/cardOperations.js | 79 + .../notes/application/checklistOperations.js | 68 + .../js/features/notes/application/deletion.js | 51 + .../js/features/notes/application/drafts.js | 55 + .../js/features/notes/application/queries.js | 66 + .../features/notes/application/reminders.js | 182 + .../features/notes/application/selection.js | 39 + static/js/features/notes/application/state.js | 83 + .../features/notes/application/submission.js | 69 + .../features/notes/application/workflows.js | 78 + static/js/features/notes/domain/noteRules.js | 87 + static/js/features/notes/domain/recurrence.js | 269 + static/js/features/notes/index.js | 503 ++ .../js/features/notes/infrastructure/http.js | 29 + .../notes/infrastructure/notifications.js | 20 + .../features/notes/infrastructure/storage.js | 63 + static/js/features/notes/ui/cardChecklist.js | 148 + .../js/features/notes/ui/cardInteractions.js | 337 + static/js/features/notes/ui/cardRendering.js | 244 + static/js/features/notes/ui/cardReorder.js | 181 + static/js/features/notes/ui/checklist.js | 190 + .../js/features/notes/ui/checklistReorder.js | 111 + static/js/features/notes/ui/clipboard.js | 82 + static/js/features/notes/ui/colors.js | 111 + static/js/features/notes/ui/draftEditor.js | 67 + static/js/features/notes/ui/drawing.js | 442 ++ static/js/features/notes/ui/editor.js | 518 ++ static/js/features/notes/ui/filters.js | 139 + static/js/features/notes/ui/layout.js | 123 + static/js/features/notes/ui/lifecycle.js | 36 + static/js/features/notes/ui/mobileEditor.js | 257 + static/js/features/notes/ui/mobileReorder.js | 214 + static/js/features/notes/ui/panel.js | 506 ++ static/js/features/notes/ui/quickAdd.js | 170 + .../js/features/notes/ui/reminderDisplay.js | 129 + static/js/features/notes/ui/reminderPicker.js | 335 + static/js/features/notes/ui/selection.js | 61 + static/js/features/notes/ui/text.js | 39 + static/js/features/notes/ui/toolbar.js | 78 + static/js/features/notes/ui/windowLayout.js | 114 + static/js/notes.js | 5473 +---------------- static/js/shared/ui/confetti.js | 28 + static/sw.js | 53 +- tests/browser/notesNative.mjs | 66 + tests/browser/notesOffline.mjs | 39 + tests/browser/notesReference.mjs | 148 + tests/fixtures/notesRecurrenceBaseline.mjs | 269 + tests/helpers/notes_source.py | 10 + tests/notesApplication.test.mjs | 93 + tests/notesCharacterization.test.mjs | 105 + tests/notesDraftAutosave.test.mjs | 19 +- tests/notesInfrastructure.test.mjs | 47 + tests/notesRecurrence.test.mjs | 41 + tests/notesReminders.test.mjs | 90 + tests/notesWorkflows.test.mjs | 147 + tests/selectedNotesContext.test.mjs | 2 +- tests/test_notes_document_mutual_minimize.py | 3 +- tests/test_notes_dom_xss_helpers.py | 8 +- ...st_notes_mobile_checklist_toggle_static.py | 15 +- tests/test_notes_reference_browser.py | 23 + tests/test_notes_reference_contracts_js.py | 20 + tests/test_notes_search_reset_on_reopen_js.py | 22 +- tests/test_notes_select_esc_listener_js.py | 4 +- tests/test_notes_sidebar_new_note.py | 16 +- 121 files changed, 15318 insertions(+), 5544 deletions(-) create mode 100644 docs/frontend-architecture/evidence/notes/implementation.sha256 create mode 100644 docs/frontend-architecture/evidence/notes/native-desktop.png create mode 100644 docs/frontend-architecture/evidence/notes/native-mobile.png create mode 100644 docs/frontend-architecture/notes-case-study.md create mode 100644 docs/frontend-architecture/notes-changed-files.md create mode 100644 docs/frontend-architecture/notes-module-inventory.md create mode 100644 docs/frontend-architecture/notes-validation.md create mode 100644 frontend/src/features/notes/application/agentActions.js create mode 100644 frontend/src/features/notes/application/cardOperations.js create mode 100644 frontend/src/features/notes/application/checklistOperations.js create mode 100644 frontend/src/features/notes/application/deletion.js create mode 100644 frontend/src/features/notes/application/drafts.js create mode 100644 frontend/src/features/notes/application/queries.js create mode 100644 frontend/src/features/notes/application/reminders.js create mode 100644 frontend/src/features/notes/application/selection.js create mode 100644 frontend/src/features/notes/application/state.js create mode 100644 frontend/src/features/notes/application/submission.js create mode 100644 frontend/src/features/notes/application/workflows.js create mode 100644 frontend/src/features/notes/domain/noteRules.js create mode 100644 frontend/src/features/notes/domain/recurrence.js create mode 100644 frontend/src/features/notes/index.js create mode 100644 frontend/src/features/notes/infrastructure/http.js create mode 100644 frontend/src/features/notes/infrastructure/notifications.js create mode 100644 frontend/src/features/notes/infrastructure/storage.js create mode 100644 frontend/src/features/notes/ui/cardChecklist.js create mode 100644 frontend/src/features/notes/ui/cardInteractions.js create mode 100644 frontend/src/features/notes/ui/cardRendering.js create mode 100644 frontend/src/features/notes/ui/cardReorder.js create mode 100644 frontend/src/features/notes/ui/checklist.js create mode 100644 frontend/src/features/notes/ui/checklistReorder.js create mode 100644 frontend/src/features/notes/ui/clipboard.js create mode 100644 frontend/src/features/notes/ui/colors.js create mode 100644 frontend/src/features/notes/ui/draftEditor.js create mode 100644 frontend/src/features/notes/ui/drawing.js create mode 100644 frontend/src/features/notes/ui/editor.js create mode 100644 frontend/src/features/notes/ui/filters.js create mode 100644 frontend/src/features/notes/ui/layout.js create mode 100644 frontend/src/features/notes/ui/lifecycle.js create mode 100644 frontend/src/features/notes/ui/mobileEditor.js create mode 100644 frontend/src/features/notes/ui/mobileReorder.js create mode 100644 frontend/src/features/notes/ui/panel.js create mode 100644 frontend/src/features/notes/ui/quickAdd.js create mode 100644 frontend/src/features/notes/ui/reminderDisplay.js create mode 100644 frontend/src/features/notes/ui/reminderPicker.js create mode 100644 frontend/src/features/notes/ui/selection.js create mode 100644 frontend/src/features/notes/ui/text.js create mode 100644 frontend/src/features/notes/ui/toolbar.js create mode 100644 frontend/src/features/notes/ui/windowLayout.js create mode 100644 frontend/src/shared/ui/confetti.js create mode 100644 scripts/verify_notes_browser_runtime.py create mode 100644 static/js/features/notes/application/agentActions.js create mode 100644 static/js/features/notes/application/cardOperations.js create mode 100644 static/js/features/notes/application/checklistOperations.js create mode 100644 static/js/features/notes/application/deletion.js create mode 100644 static/js/features/notes/application/drafts.js create mode 100644 static/js/features/notes/application/queries.js create mode 100644 static/js/features/notes/application/reminders.js create mode 100644 static/js/features/notes/application/selection.js create mode 100644 static/js/features/notes/application/state.js create mode 100644 static/js/features/notes/application/submission.js create mode 100644 static/js/features/notes/application/workflows.js create mode 100644 static/js/features/notes/domain/noteRules.js create mode 100644 static/js/features/notes/domain/recurrence.js create mode 100644 static/js/features/notes/index.js create mode 100644 static/js/features/notes/infrastructure/http.js create mode 100644 static/js/features/notes/infrastructure/notifications.js create mode 100644 static/js/features/notes/infrastructure/storage.js create mode 100644 static/js/features/notes/ui/cardChecklist.js create mode 100644 static/js/features/notes/ui/cardInteractions.js create mode 100644 static/js/features/notes/ui/cardRendering.js create mode 100644 static/js/features/notes/ui/cardReorder.js create mode 100644 static/js/features/notes/ui/checklist.js create mode 100644 static/js/features/notes/ui/checklistReorder.js create mode 100644 static/js/features/notes/ui/clipboard.js create mode 100644 static/js/features/notes/ui/colors.js create mode 100644 static/js/features/notes/ui/draftEditor.js create mode 100644 static/js/features/notes/ui/drawing.js create mode 100644 static/js/features/notes/ui/editor.js create mode 100644 static/js/features/notes/ui/filters.js create mode 100644 static/js/features/notes/ui/layout.js create mode 100644 static/js/features/notes/ui/lifecycle.js create mode 100644 static/js/features/notes/ui/mobileEditor.js create mode 100644 static/js/features/notes/ui/mobileReorder.js create mode 100644 static/js/features/notes/ui/panel.js create mode 100644 static/js/features/notes/ui/quickAdd.js create mode 100644 static/js/features/notes/ui/reminderDisplay.js create mode 100644 static/js/features/notes/ui/reminderPicker.js create mode 100644 static/js/features/notes/ui/selection.js create mode 100644 static/js/features/notes/ui/text.js create mode 100644 static/js/features/notes/ui/toolbar.js create mode 100644 static/js/features/notes/ui/windowLayout.js create mode 100644 static/js/shared/ui/confetti.js create mode 100644 tests/browser/notesNative.mjs create mode 100644 tests/browser/notesOffline.mjs create mode 100644 tests/browser/notesReference.mjs create mode 100644 tests/fixtures/notesRecurrenceBaseline.mjs create mode 100644 tests/helpers/notes_source.py create mode 100644 tests/notesApplication.test.mjs create mode 100644 tests/notesCharacterization.test.mjs create mode 100644 tests/notesInfrastructure.test.mjs create mode 100644 tests/notesRecurrence.test.mjs create mode 100644 tests/notesReminders.test.mjs create mode 100644 tests/notesWorkflows.test.mjs create mode 100644 tests/test_notes_reference_browser.py create mode 100644 tests/test_notes_reference_contracts_js.py diff --git a/.gitattributes b/.gitattributes index dd49c4a03..e2ae3a6e6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -36,3 +36,7 @@ static/lib/** -whitespace linguist-vendored *.ico binary *.woff binary *.woff2 binary + +# Notes reference deployment modules. +static/js/features/notes/** text eol=lf +static/js/shared/ui/confetti.js text eol=lf diff --git a/docs/frontend-architecture/evidence/notes/implementation.sha256 b/docs/frontend-architecture/evidence/notes/implementation.sha256 new file mode 100644 index 000000000..023b5ca30 --- /dev/null +++ b/docs/frontend-architecture/evidence/notes/implementation.sha256 @@ -0,0 +1,112 @@ +53dfddd71f02a9a60df42e3fb045bd1a80919d50870c0b77afe18a853f485bea .gitattributes +867c6a4b025fefecb6ac4a91a38563a36107d9653599f64685e16a5be4fe7727 frontend/modules.json +76de35ac93d72edb5dcd295e85c704af9b3f08c2fe9fdc56e0bcbebd4b322fd8 frontend/src/features/notes/application/agentActions.js +37021f9643f6a3299d36b6021783a559e60ad84848ac2b12f0980b5034b6b9c4 frontend/src/features/notes/application/cardOperations.js +fe3033db6ef316c6b15c6b01c0f202ee76a61ea2c5b6952b20144660ff17eb54 frontend/src/features/notes/application/checklistOperations.js +85fc592724524bb5ff37d7e5e58a68fda9d7e645810b6c87a2d4dd3cb41f9dd5 frontend/src/features/notes/application/deletion.js +ce38e7976ee315b198baebffe45f9a1f4e63d87affd9898ed5cf4bde37b903e7 frontend/src/features/notes/application/drafts.js +6210915184c30f73911f144fe0d12a9935d6f2ce42cb2aaf6aeaa75614ca51a2 frontend/src/features/notes/application/queries.js +0da923790f4036d5fe354df4e529feb4b96e178495cb951f269093c0a8347e4e frontend/src/features/notes/application/reminders.js +8f95d086fc2e60beae32b9e9debba41b35448e1c5b4ac78e4989652ef2d44e35 frontend/src/features/notes/application/selection.js +4ceb424b3b6e30a5f507804f42051be02390d4ce1f03ef284094e48825cde5c3 frontend/src/features/notes/application/state.js +1c5d5af833aab91f71a34c11535885eb7bf9f751de5aa89f79defe0f696f20fb frontend/src/features/notes/application/submission.js +1d58ea2d495c8d301c550eb9ba7f627c515a8784bcd105558d941522965e77b0 frontend/src/features/notes/application/workflows.js +9ddbfb20e0a5564dd83365ad498286d68ef03fd60d632619ad4d9656e2f73c95 frontend/src/features/notes/domain/noteRules.js +946ef7a98b0a2dbe329e8259250af11a11702c74fd997e17fb3aff007fbe40c9 frontend/src/features/notes/domain/recurrence.js +25cf3a1cef5dbac0840aef513ffa010a9b08d206de96ce2527e6159a5454b555 frontend/src/features/notes/index.js +8b186bbfbb003b8d738c80713e1cbb5c6924ee14119929c5ea65707b46076b0c frontend/src/features/notes/infrastructure/http.js +c7552d1f4600ae721437892fc0a1e5a0332abe30082bd943cd13a379c9514fec frontend/src/features/notes/infrastructure/notifications.js +ff91a55c55fbdd876c69d9ec10e4e992104c95b6d12d752d9745193fccbc646a frontend/src/features/notes/infrastructure/storage.js +4ff6d69d04547a0325041f774c3bf0707d8b67982d59a221127e07dc9b01383c frontend/src/features/notes/ui/cardChecklist.js +dd87f1929ef8a5d57fdf1ddb12a0a6be53d95be07946be54f946c6fe31ef80bc frontend/src/features/notes/ui/cardInteractions.js +4ff661a65d88e810f346260a354070dba325d6beaa57d1c4e82e8bf09b894538 frontend/src/features/notes/ui/cardRendering.js +113d4457f25291d15afb7fb6775ea063f24c9dac51bb0d262787c9e40705235b frontend/src/features/notes/ui/cardReorder.js +eecafbd92076209fa41826820125fbe9ffa757d6d1bff018e9acc2ede2a18e72 frontend/src/features/notes/ui/checklist.js +155ea3d70e653f38a62ff50fc5be6aee019d42e86d28753da9edb258ac7c93e1 frontend/src/features/notes/ui/checklistReorder.js +5462fea63a2aa9d1fc07dca91b462f6ff24f352b05d99ea6feb437469c0e1f3e frontend/src/features/notes/ui/clipboard.js +2619e5f8b15eb3057330ef220abe6bc354a496effc0df3d89385cdfef9242c86 frontend/src/features/notes/ui/colors.js +11cf24e7e8e8954d764cb58d786fa6f6d71f84f1c6c5ba644d761e28cfdbaedf frontend/src/features/notes/ui/draftEditor.js +f55d4eff7f550e183ecf240a45002936063dbb4eae153e11834da4cb6bd13d35 frontend/src/features/notes/ui/drawing.js +f85c86e70b3a380449fe3c7ecc7a4e68946de320523f5edc8e33f8eab3f0a02a frontend/src/features/notes/ui/editor.js +3946b8324ad8f0212621e526479668308e48b799bf0dbb608e5cd4626f6c1f03 frontend/src/features/notes/ui/filters.js +a893d09e96fb98d13db8ae5c3fb8c5e7a040c62031dd961396a18b7ae2a82a38 frontend/src/features/notes/ui/layout.js +8d8c62a87d4df134d3b9dd97eaf59d02c52f1a38f6bc2e109e8426081d453a8d frontend/src/features/notes/ui/lifecycle.js +62ad9408cb139143168f2c5834ec56b83264c4daed3e84fa9b4b0a281712144c frontend/src/features/notes/ui/mobileEditor.js +2a3ccbad103dcc880e4baf2b3aa3ece4df7b73752bd30078185f03227585148f frontend/src/features/notes/ui/mobileReorder.js +856fe945cf697278f795b8753704119839fa12bc5e0e69e9dab57c4b4dd6d24e frontend/src/features/notes/ui/panel.js +8aa081cb7341d9a67ef46c74da49aebd729e9b53a73d618b81c89566c874b507 frontend/src/features/notes/ui/quickAdd.js +8a4f23a013f06876894e6544b49540b288007d0d7e4cddca955978588f47a64a frontend/src/features/notes/ui/reminderDisplay.js +cb043be801ab40cf8141af6ff1ad788e69fef57a87960ca992071ad9ad29ad90 frontend/src/features/notes/ui/reminderPicker.js +38f12ba7771589aa427f054f48919569dcee9abe0246e02b9767202adb854cdc frontend/src/features/notes/ui/selection.js +2634f252b2899e2e70fc9fec37144b0cc9b0ed00724da81bfef74b1acf7c05b6 frontend/src/features/notes/ui/text.js +d2655ef163825ce6c40d8ef46bc9dfc63a2aa19fb1a7e10b28002f79cb0db409 frontend/src/features/notes/ui/toolbar.js +a0aa203e87814d0eebebb6b049776c3efd576f4a5c6e17e486c8a2893c9c21df frontend/src/features/notes/ui/windowLayout.js +68f0cbe07c7b61fcaf92920cd47042c576ab27fd9553c971cff45c595c25d80a frontend/src/shared/ui/confetti.js +c16fcde607dce2f10a1afd1f8845403a7d0b97a9d5a67ec7aa329c367ba585e8 scripts/verify_frontend_runtime.py +c2e5de2b8ce168220bad4fcefed710e60eeb0dd3e992e76cd980f2c0ca52815a scripts/verify_notes_browser_runtime.py +fa31d673c40f973490e9fac52130b4a915b944a7abdd217a9bd8d57e66d57ca6 static/js/compare/vote.js +76de35ac93d72edb5dcd295e85c704af9b3f08c2fe9fdc56e0bcbebd4b322fd8 static/js/features/notes/application/agentActions.js +37021f9643f6a3299d36b6021783a559e60ad84848ac2b12f0980b5034b6b9c4 static/js/features/notes/application/cardOperations.js +fe3033db6ef316c6b15c6b01c0f202ee76a61ea2c5b6952b20144660ff17eb54 static/js/features/notes/application/checklistOperations.js +85fc592724524bb5ff37d7e5e58a68fda9d7e645810b6c87a2d4dd3cb41f9dd5 static/js/features/notes/application/deletion.js +ce38e7976ee315b198baebffe45f9a1f4e63d87affd9898ed5cf4bde37b903e7 static/js/features/notes/application/drafts.js +6210915184c30f73911f144fe0d12a9935d6f2ce42cb2aaf6aeaa75614ca51a2 static/js/features/notes/application/queries.js +0da923790f4036d5fe354df4e529feb4b96e178495cb951f269093c0a8347e4e static/js/features/notes/application/reminders.js +8f95d086fc2e60beae32b9e9debba41b35448e1c5b4ac78e4989652ef2d44e35 static/js/features/notes/application/selection.js +4ceb424b3b6e30a5f507804f42051be02390d4ce1f03ef284094e48825cde5c3 static/js/features/notes/application/state.js +1c5d5af833aab91f71a34c11535885eb7bf9f751de5aa89f79defe0f696f20fb static/js/features/notes/application/submission.js +1d58ea2d495c8d301c550eb9ba7f627c515a8784bcd105558d941522965e77b0 static/js/features/notes/application/workflows.js +9ddbfb20e0a5564dd83365ad498286d68ef03fd60d632619ad4d9656e2f73c95 static/js/features/notes/domain/noteRules.js +946ef7a98b0a2dbe329e8259250af11a11702c74fd997e17fb3aff007fbe40c9 static/js/features/notes/domain/recurrence.js +25cf3a1cef5dbac0840aef513ffa010a9b08d206de96ce2527e6159a5454b555 static/js/features/notes/index.js +8b186bbfbb003b8d738c80713e1cbb5c6924ee14119929c5ea65707b46076b0c static/js/features/notes/infrastructure/http.js +c7552d1f4600ae721437892fc0a1e5a0332abe30082bd943cd13a379c9514fec static/js/features/notes/infrastructure/notifications.js +ff91a55c55fbdd876c69d9ec10e4e992104c95b6d12d752d9745193fccbc646a static/js/features/notes/infrastructure/storage.js +4ff6d69d04547a0325041f774c3bf0707d8b67982d59a221127e07dc9b01383c static/js/features/notes/ui/cardChecklist.js +dd87f1929ef8a5d57fdf1ddb12a0a6be53d95be07946be54f946c6fe31ef80bc static/js/features/notes/ui/cardInteractions.js +4ff661a65d88e810f346260a354070dba325d6beaa57d1c4e82e8bf09b894538 static/js/features/notes/ui/cardRendering.js +113d4457f25291d15afb7fb6775ea063f24c9dac51bb0d262787c9e40705235b static/js/features/notes/ui/cardReorder.js +eecafbd92076209fa41826820125fbe9ffa757d6d1bff018e9acc2ede2a18e72 static/js/features/notes/ui/checklist.js +155ea3d70e653f38a62ff50fc5be6aee019d42e86d28753da9edb258ac7c93e1 static/js/features/notes/ui/checklistReorder.js +5462fea63a2aa9d1fc07dca91b462f6ff24f352b05d99ea6feb437469c0e1f3e static/js/features/notes/ui/clipboard.js +2619e5f8b15eb3057330ef220abe6bc354a496effc0df3d89385cdfef9242c86 static/js/features/notes/ui/colors.js +11cf24e7e8e8954d764cb58d786fa6f6d71f84f1c6c5ba644d761e28cfdbaedf static/js/features/notes/ui/draftEditor.js +f55d4eff7f550e183ecf240a45002936063dbb4eae153e11834da4cb6bd13d35 static/js/features/notes/ui/drawing.js +f85c86e70b3a380449fe3c7ecc7a4e68946de320523f5edc8e33f8eab3f0a02a static/js/features/notes/ui/editor.js +3946b8324ad8f0212621e526479668308e48b799bf0dbb608e5cd4626f6c1f03 static/js/features/notes/ui/filters.js +a893d09e96fb98d13db8ae5c3fb8c5e7a040c62031dd961396a18b7ae2a82a38 static/js/features/notes/ui/layout.js +8d8c62a87d4df134d3b9dd97eaf59d02c52f1a38f6bc2e109e8426081d453a8d static/js/features/notes/ui/lifecycle.js +62ad9408cb139143168f2c5834ec56b83264c4daed3e84fa9b4b0a281712144c static/js/features/notes/ui/mobileEditor.js +2a3ccbad103dcc880e4baf2b3aa3ece4df7b73752bd30078185f03227585148f static/js/features/notes/ui/mobileReorder.js +856fe945cf697278f795b8753704119839fa12bc5e0e69e9dab57c4b4dd6d24e static/js/features/notes/ui/panel.js +8aa081cb7341d9a67ef46c74da49aebd729e9b53a73d618b81c89566c874b507 static/js/features/notes/ui/quickAdd.js +8a4f23a013f06876894e6544b49540b288007d0d7e4cddca955978588f47a64a static/js/features/notes/ui/reminderDisplay.js +cb043be801ab40cf8141af6ff1ad788e69fef57a87960ca992071ad9ad29ad90 static/js/features/notes/ui/reminderPicker.js +38f12ba7771589aa427f054f48919569dcee9abe0246e02b9767202adb854cdc static/js/features/notes/ui/selection.js +2634f252b2899e2e70fc9fec37144b0cc9b0ed00724da81bfef74b1acf7c05b6 static/js/features/notes/ui/text.js +d2655ef163825ce6c40d8ef46bc9dfc63a2aa19fb1a7e10b28002f79cb0db409 static/js/features/notes/ui/toolbar.js +a0aa203e87814d0eebebb6b049776c3efd576f4a5c6e17e486c8a2893c9c21df static/js/features/notes/ui/windowLayout.js +7f1a7961d586767298672fad71a0d627389e185f0fb28792a04d0b987e1d5c5c static/js/notes.js +68f0cbe07c7b61fcaf92920cd47042c576ab27fd9553c971cff45c595c25d80a static/js/shared/ui/confetti.js +97fd43c17f3cb1928947e24159a3b59dc81ef7dca95862e8a47154e9cb732f88 static/sw.js +910cd5777564e9f6338b8a864a772beffea197dc476464171c180e7be96e572d tests/browser/notesNative.mjs +ef4bc40a710919c81c582d3b9fe80376d0d5ae66fb9be3c293871a83ee360f97 tests/browser/notesOffline.mjs +437ec2e9865b3e066bbec8d7595a0d947f1e503b96e7b4d819ec1bed86c9bb1e tests/browser/notesReference.mjs +9af973a5d2fb08a5eed550be0198bef501af56802c3844b5d0bb8e718cc685cf tests/fixtures/notesRecurrenceBaseline.mjs +859a5f442dc717f59993891c338cd6ae9d6e5e009392a2ee69ab0e3b063bb572 tests/helpers/notes_source.py +739ffc3f20de8e2bdf1d513b17bf485f85821e9906109cdf8ffea04b81b0f1dd tests/notesApplication.test.mjs +1946369be43177b19a44cd9ae4f90d64da6558d1a470f8e347b490a2188782be tests/notesCharacterization.test.mjs +99f0cd92a30cf01c0ee6814f6684edeb80ebdb9a5ab96583244092865bfb6212 tests/notesDraftAutosave.test.mjs +0417205809de16c7c7338ea80ba19ca844603b3e06655987226f5f83d962d18b tests/notesInfrastructure.test.mjs +dcf7c2d33de9bb457f96af070354ecc3ab69669002bdc9c7c943bef1b3d2e9bc tests/notesRecurrence.test.mjs +dd9a703465dde8f92625a3226ab52d20a02aa5d3415fbcc70609f69a372ccc12 tests/notesReminders.test.mjs +5a9112569f226525faaf6603a9867620b47902534f9cd882c08f71f2f96614b2 tests/notesWorkflows.test.mjs +35fb2bd1f02757b73188342ec00f1e97253b8ba8dfb577861675872a517bd949 tests/selectedNotesContext.test.mjs +c6c64c2b695b84d00f8a80a980be00766175c482548c86484f991ef671ecfbe9 tests/test_notes_document_mutual_minimize.py +02c2aeb856798d982e5debcb9c605172933b0216a039cf0d96cdd415b1e2ef83 tests/test_notes_dom_xss_helpers.py +5a511c7a2ef5d28463df34f3462db9ce59575d624cf8181577bab2cc371b4f9f tests/test_notes_mobile_checklist_toggle_static.py +1db10a0d344818459d64279da807ddefe9b866dfe560ac4b154b486dd1a5efc0 tests/test_notes_reference_browser.py +49f466b1a717098aa25439b6ff9ef3ae3d027d9dd976212b165b0b99a582b952 tests/test_notes_reference_contracts_js.py +c2d877a11eafcb99b24a1c89111e5df4453a62bc8cf7d26ae1118b25c4a5ace4 tests/test_notes_search_reset_on_reopen_js.py +6d95f073421427b5e149ef1e85c8b5749095d00337f05960134552985f73e372 tests/test_notes_select_esc_listener_js.py +b3118a4b40fcfcbb26ad9ccbcb332fc6ced873d8cad1160be38a286ad1e664a4 tests/test_notes_sidebar_new_note.py diff --git a/docs/frontend-architecture/evidence/notes/native-desktop.png b/docs/frontend-architecture/evidence/notes/native-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..fd30ced2a811f8c02a3373dff38644b7e6be9c1e GIT binary patch literal 61946 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 literal 0 HcmV?d00001 diff --git a/docs/frontend-architecture/evidence/notes/native-mobile.png b/docs/frontend-architecture/evidence/notes/native-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..fdede2e843dd34d8fb0040b5aa4ce264600cd08a GIT binary patch literal 28151 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 KDV8g|c=KQNfk=`7 literal 0 HcmV?d00001 diff --git a/docs/frontend-architecture/handbook.md b/docs/frontend-architecture/handbook.md index 103e02ee9..ade3bec14 100644 --- a/docs/frontend-architecture/handbook.md +++ b/docs/frontend-architecture/handbook.md @@ -1,9 +1,10 @@ # Odysseus frontend architecture handbook -Status: F1 foundation and continuation guide, pending review. The final Notes -reference case study belongs to the comprehensive Notes draft PR; the recipient -handoff follows its integration. This document must evolve with reviewed internal -checkpoints and distinguish executed work from proposals. +Status: F1 merged in PR #6701. The complete Notes reference is implemented as an +unpublished review diff on `refactor/notes-reference-decomposition`. Read its +[case study](notes-case-study.md), [module inventory](notes-module-inventory.md) +and [validation receipts](notes-validation.md). Publication and the recipient's +post-integration reproduction remain pending. Read the [ADR](ADR-001-source-and-deployment.md), [assessment](assessment.md), [validation report](validation.md) and [Notes roadmap](notes-roadmap.md). @@ -90,6 +91,13 @@ existing objects and initialization timing when extracting a stateful module. Do not instantiate a new store in both the legacy facade and the feature API. Compatibility facades only forward the same live implementation. +The Notes reference uses internal read-only live views to preserve captured note +identity across existing asynchronous completions. Its state owner supplies all +mutations; the public index exposes operations without the store. Notes deliberately +retains last-completion fetch ordering. Do not infer request-generation guards from +its decomposition. Its page-owned reminders survive panel closure, and panel/editor +listeners and observers have separate disposal scopes. + Document lifecycle scopes: page, authenticated user, session, mounted panel and editor/draft. Name owners of subscriptions, DOM listeners, timers, observers, pending requests and caches. Mounting must be idempotent; disposal releases the diff --git a/docs/frontend-architecture/notes-case-study.md b/docs/frontend-architecture/notes-case-study.md new file mode 100644 index 000000000..cc1a28d7e --- /dev/null +++ b/docs/frontend-architecture/notes-case-study.md @@ -0,0 +1,260 @@ +# Notes reference decomposition + +Implementation started: 2026-10-13. Final validation: 2026-10-14. +Foundation: [PR #6701](https://github.com/odysseus-dev/odysseus/pull/6701), +`1cc56cb9d78f3e2399d1d61155b71d771a17b85b`. Branch was safely updated via +non-destructive fast-forward to latest official `upstream/dev` at +`86b886c611338282af6d4a717246ea4e256f58df`, incorporating PRs #6704 and #6693. +Neither changed a file in the Notes diff. The independent AGY audit returned +CONDITIONAL PASS; all pre-publication corrective actions (issue template conformance, +PR template conformance, visual evidence paths, and em-dash removal) have been completed. + +The entire implementation belongs to one Notes PR. N0 through N6 are internal +checkpoints, not separate branches, commits or PRs. The [validation receipts](notes-validation.md), +[module inventory](notes-module-inventory.md) and [changed-file inventory](notes-changed-files.md) +accompany this case study. + +## Responsibility map and size + +The original `static/js/notes.js` contained 5,471 lines. It mixed mutable Notes +data, view preferences, selection, undo, CRUD, date calculations, reminders, +browser persistence, editor autosave, rendering, canvas tools and window management. +Its import evaluated storage preferences and scheduled reminder initialization. + +| Original responsibility | Maintained owner | +| --- | --- | +| Completion, tags, recurrence, date labels and serialization | `domain/recurrence.js`, `domain/noteRules.js` | +| Notes data and selection/filter/editing state | `application/state.js`, instantiated once in `index.js` | +| CRUD, undo and fetch completion ordering | `application/workflows.js`, `cardOperations.js`, `deletion.js` | +| Checklist mutations and rollback | `application/checklistOperations.js` | +| Editor optimistic submission and recovery | `application/submission.js` | +| Serialized autosave and synchronous backup | `application/drafts.js`; form collection in `ui/draftEditor.js` | +| Filtering, searching and ordering | `application/queries.js`; controls in `ui/filters.js` and `toolbar.js` | +| Selected-note attachment and bulk requests | `application/selection.js`; controls in `ui/selection.js` | +| Existing agent-solve workflow | `application/agentActions.js`, through supplied HTTP/session ports | +| HTTP, persistence and browser notifications | `infrastructure/http.js`, `storage.js`, `notifications.js` | +| Background scheduler and delivery decisions | `application/reminders.js` | +| Panel mounting, closing, keyboard handling and navigation | `ui/panel.js` | +| Docking, fullscreen bounds and stacking | `ui/windowLayout.js`, using existing shared capabilities | +| Cards, card gestures, quick add and masonry | `ui/cardRendering.js`, `cardInteractions.js`, `cardChecklist.js`, `cardReorder.js`, `quickAdd.js`, `layout.js` | +| Editor types, colors, reminders and checklists | `ui/editor.js`, `colors.js`, `reminderPicker.js`, `checklist.js` | +| Drawing, local drawing undo and PNG upload | `ui/drawing.js` | +| Mobile reader/editor and reorder gestures | `ui/mobileEditor.js`, `mobileReorder.js`, `checklistReorder.js` | +| Clipboard, safe rendering and reminder display | `ui/clipboard.js`, `text.js`, `reminderDisplay.js` | +| Owned document/window listener disposal | `ui/lifecycle.js`, with panel and observer disposal callbacks | +| Composition and supported exports | `index.js`; five-line legacy facade at `static/js/notes.js` | + +The feature has 41 maintained modules: two domain, eleven application, three +infrastructure, twenty-four UI and one index. Source is counted once, excluding +byte-identical deployment copies. Domain has 356 lines, application 826, +infrastructure 112, UI 4,628, and composition 518: 6,440 maintained feature lines. +The module inventory records final per-file counts. The legacy entry shrank +from 5,471 to five lines. Interfaces and explicit composition increase the total +source size; reducing total lines was not the architectural objective. + +One 28-line stateless confetti renderer also moved to `shared/ui/confetti.js`. +Notes and Compare both use it. Compare retains its existing `spawnConfetti` export, +which is the same function as the shared export. This removes Notes' import of +Compare's private voting implementation without migrating Compare itself. + +## Dependency graph and composition + +```mermaid +flowchart TD + Legacy[Legacy Notes URL and window bridge] --> Index[Notes index: one composition] + Index --> UI[Notes UI factories] + Index --> App[Application workflows and state owner] + Index --> Ports[HTTP, persistence and notification adapters] + UI --> Rules[Pure domain rules] + App --> Rules + Index --> Shared[Existing shared UI capabilities and maintained confetti] +``` + +Application modules receive adapters and feedback operations as arguments. They +do not import browser infrastructure or DOM views. Pure domain modules receive +the clock at the original read sites. UI consumes a read-only state view and +application operations. Composition supplies callbacks between views and workflows; +modules do not import the coordinator back. F1 rejects maintained-source cycles. + +There are nine exact legacy-import exceptions, all on the Notes index. They bridge +the existing UI, modal, color-picker, drag, tile, dock, stacking, Escape and tour-hint +capabilities at their existing URLs. Their removal condition is migration of the +corresponding shared responsibility to maintained source with its identity and +lifecycle contract verified. No new feature imports another feature's private internals. + +## State and asynchronous ownership + +`createNotesState` is called once. It owns the Notes collection, selection IDs, +editing ID, search, filters, archive/view flags, loading and dismissed-reminder time. +Neither the facade nor reminders create a second collection. The public feature API +does not expose this store. + +Internal views are read-only live views rather than detached snapshots. This choice +preserves captured note identity: an existing asynchronous rollback still acts on +the same captured note. Array/object writes and selection mutations must use the +state owner's operations. Workflow-local undo queues and serialized draft promises, +and UI-local canvas undo/gesture state, are not competing Notes stores. + +Fetches retain the original last-completion behavior. An earlier request that +finishes later can overwrite a later request's result. Tests pin this behavior; +generation guards would be a separate behavioral change. Submission also retains +the original first-temporary-note replacement rule. Multiple simultaneous creations +need a separately reviewed correlation fix, not a silent change during extraction. + +Archive, unarchive, pin, item edit/remove/add, delete and undo preserve their original +optimistic ordering and rollback. Animation callbacks remain view-owned. Drawing +save keeps the existing button guard around its asynchronous PNG upload. Draft writes +are serialized per editor; a completed older save cannot clear a newer local draft. + +Reminder scans still use the originally loaded Notes collection, not a second +all-active cache. An archived-only collection or a failure-cleared list can defer +active reminders until an active refresh/reopen. The timer remains alive when the +panel closes, but extraction does not redesign this inherited data-availability +limitation. Review such a change separately from scheduler/state ownership. + +## Public compatibility + +The import URL remains `/static/js/notes.js?v=20260911notesselectioncancel1`. +App, Chat stream updates, Chat note links and Documents continue using their existing +imports. Calendar and Documents retain their window bridge calls. HTML and app +bootstrap code are unchanged. The service worker retains the exact legacy entry. + +The legacy namespace still exports `openPanel`, `closePanel`, `togglePanel`, +`isPanelOpen`, `openNote`, `newNote`, `refreshDueBadge`, `dismissFiredReminderDot`, +`openNotes`, `closeNotes`, `isNotesOpen`, and `default`. +The default object's ten methods are unchanged; `dismissFiredReminderDot` remains +named-only. Aliases refer to the same functions, including those on the default +object. `openNote` and `refreshDueBadge` remain async; function arities are pinned. + +`window.notesModule` is exactly the default controller. The canonical unversioned +feature index and both tested legacy entry URLs share the same controller and +operations. The default object retains its existing mutability. A different legacy +URL has its own ESM namespace, as native ESM requires, but creates no second store +or scheduler because it forwards to the same canonical index. + +The canonical index additionally exports `startNotesBackground` for explicit +activation. The compatibility facade invokes it once through an idempotent start +operation. Existing consumers need no change. Remove the facade only after every +consumer, global bridge, bootstrap and offline reference has migrated together. + +## Protected selected Notes/Todos integration + +The sender dispatches `odysseus:notes-context-selected` on `window` with +`{ noteIds, accepted: false }`. Dispatch and acknowledgement remain synchronous. +Chat owns acceptance, its session/pending selection, the context indicator and +the `selected_note_ids` send payload. Notes exits selection only after acceptance. +Rejected selections stay selected and display the existing feedback. + +No Chat internals were imported or changed. Existing session lifecycle events still +transfer a pending selection on materialization, restore selections on chat switches, +and reset them for a new chat. Backend ID validation and owner filtering remain +authoritative. Unit/backend tests cover these contracts; the real authenticated +browser probe verifies rejection, pending-chat acceptance, the actual indicator and +new-chat reset. Live model execution is not part of that probe. + +## Persistence and HTTP contracts + +| Key | Preserved format | +| --- | --- | +| `odysseus-notes-view` | `list` or `grid` | +| `odysseus-notes-labels-collapsed` | `1` or `0` | +| `odysseus-notes-first-open-hint-v1` | Existing first-open marker | +| `odysseus-notes-reminder-fired` | JSON array of IDs | +| `odysseus-notes-reminder-glowed` | JSON array of IDs | +| `odysseus-notes-reminder-pending-highlight` | JSON array of IDs | +| `odysseus-notes-reminder-active-highlight` | JSON array of IDs | +| `odysseus-notes-reminder-dismissed-at` | Decimal millisecond timestamp | +| `odysseus-note-draft-` and `odysseus-note-draft-__new__` | Existing JSON draft fields and `_ts` | + +CRUD, reorder, upload, reminder firing, default-chat lookup, session creation and +chat-stream requests keep their URLs, methods, credentials, payloads and error +interpretation. Uploads retain `files` and optional `gallery_id` multipart fields. +The agent-solve stream still disables `manage_notes` and drains without switching +the user's chat. No backend implementation changed. + +Storage backup failures still warn once; failed network saves keep recoverable +drafts, including intentionally empty text. Draft JSON does not serialize a canvas +bitmap. A drawing remains ephemeral until its explicit PNG upload, as before. + +## Lifecycle scopes and explicit corrections + +| Scope | Owner and disposal | +| --- | --- | +| Browser page | One reminder startup timeout and interval; page termination ends them. The reminder factory also has explicit disposal. | +| Open panel | Capture/bubble keyboard handlers, reorder listeners and masonry observer; both close paths dispose them. | +| Editor/canvas | Local DOM handlers and draft workflow; drawing's window listener is released when its canvas detaches or the panel closes. | +| Mobile editor/gesture | Overlay and document touch handlers; owner replacement and panel disposal remove handlers and reset registration flags. | +| Menus/hints | Notes portals close with the panel; shared dismiss functions unregister their Escape/tour lifetimes. | +| File picker | Existing completion or 30-second cleanup owns the temporary input. | + +Two implementation corrections follow the user's explicit lifecycle requirements. +The original close paths canceled the supposedly background reminder interval. +The new page-owned interval survives closing/minimizing Notes, while retaining the +3-second startup delay, 30-second period and immediate first check. Closing a panel +also releases owned observers/listeners, pending hold timers, active Notes gestures +and portals; late panel fetch callbacks do +not recreate UI on a detached panel. Network completion/state ordering is preserved. + +Shared drag/window utilities continue owning their own active-gesture handlers. +They were not moved into Notes. Idle open/close and editor replacement are verified; +every possible interleaving with an active shared window drag was not tested. + +## Source and deployment + +Every maintained source is registered in `frontend/modules.json` and copied without +rewriting imports. Notes sources map to `static/js/features/notes/` at the same +relative paths. Shared confetti maps to `static/js/shared/ui/confetti.js`. +Repository-owned LF attributes and byte parity cover all 43 maintained modules, +including the unchanged F1 proof. The legacy facade is an authored compatibility +entry, not a second implementation. + +All new outputs join exact service-worker precache arrays. A real offline test also +found eight previously missing exact legacy URLs in the Notes graph; those entries +are now included. The eager Notes graph has 57 exact runtime URLs. Cache naming +follows the repository's `odysseus-v659-notes-reference` convention. Root offline +reload remains outside certification because the existing worker scope is `/static/`. + +Python startup and Docker serve the committed static graph without maintained +source or frontend tooling. The publisher is a development check/copy operation, +not a runtime compiler or an atomic deployment system. + +## Checkpoints, rollback and lessons + +[Checkpoint receipts](notes-validation.md#internal-checkpoints) record the actual +checks and local snapshot hashes. These intermediate snapshots are engineering +evidence, not standalone release artifacts. N5 includes bounded editor, cards, +checklist, drawing, mobile, toolbar and window reviews on the same branch. + +The N3 mechanical state rewrite initially confused canvas-local and feature undo +bindings. The N5 drawing test found it; the canvas binding was restored and the +same drawing/undo interaction was replayed against the original implementation. +Use scope-aware transformations and test each stateful UI responsibility before +accepting a broad rewrite. Source parsing alone does not prove semantic equivalence. + +For rollback, reconstruct the prior complete artifact from its revision/snapshot +in a fresh temporary directory, run parity/graph/behavior checks there, then publish +the matching static graph and service worker as a unit through the authorized +release process. Restore the whole facade/source/output/manifest set, not just +`notes.js`. No schema or storage-key migration needs reversal. Do not reset a dirty +worktree or delete unrelated outputs. The validation report gives a non-destructive +snapshot reproduction recipe. + +For the next maintainer: + +1. Start with the module inventory and the state/workflow contracts, then reproduce + the focused tests, offline test and isolated native browser probe. +2. Review the explicit timer/lifecycle corrections separately from unchanged + business rules. Inspect both maintained source and byte-identical deployment. +3. Resolve or track the reproduced upstream CI failures and the open PR overlaps + before authorizing publication. Create/link the dedicated issue, then publish + exactly one Notes PR targeting official `dev`. +4. Record the integrated revision and independent recipient reproduction to complete + H. The designated maintainer can then apply this method to future features. + +For Calendar, Documents, Email and later monoliths, characterize actual exports, +URL identities, state writers, clock reads, I/O and lifecycle first. Extract pure +rules, then adapters, then a single state owner/workflows. Use explicit callback +ports to break coordinator cycles. Split UI by interactions and resource lifetime, +not by arbitrary line ranges. Transfer source/output ownership and offline URLs +with each boundary, and preserve source-sensitive assertions at their new owner. +Those feature migrations are not part of this implementation. diff --git a/docs/frontend-architecture/notes-changed-files.md b/docs/frontend-architecture/notes-changed-files.md new file mode 100644 index 000000000..02f916e46 --- /dev/null +++ b/docs/frontend-architecture/notes-changed-files.md @@ -0,0 +1,165 @@ +# Notes complete changed-file inventory + +Changed files in PR #6713 (`refactor/notes-reference-decomposition`) against +`dev`. 121 files: 16 modified and 105 added. No files are deleted. Deployment copies +are byte-identical to their registered maintained sources. Image files are review +evidence, not production dependencies. + +The unchanged foundation worktree, backend, app bootstrap, HTML, CSS, dependency +lockfiles, Dockerfile and CI workflow are outside this diff. The only Compare +change delegates its existing stateless confetti export to maintained shared UI. + +## Maintained source (42) + +| Status | File | +| --- | --- | +| A | [frontend/src/features/notes/application/agentActions.js](../../frontend/src/features/notes/application/agentActions.js) | +| A | [frontend/src/features/notes/application/cardOperations.js](../../frontend/src/features/notes/application/cardOperations.js) | +| A | [frontend/src/features/notes/application/checklistOperations.js](../../frontend/src/features/notes/application/checklistOperations.js) | +| A | [frontend/src/features/notes/application/deletion.js](../../frontend/src/features/notes/application/deletion.js) | +| A | [frontend/src/features/notes/application/drafts.js](../../frontend/src/features/notes/application/drafts.js) | +| A | [frontend/src/features/notes/application/queries.js](../../frontend/src/features/notes/application/queries.js) | +| A | [frontend/src/features/notes/application/reminders.js](../../frontend/src/features/notes/application/reminders.js) | +| A | [frontend/src/features/notes/application/selection.js](../../frontend/src/features/notes/application/selection.js) | +| A | [frontend/src/features/notes/application/state.js](../../frontend/src/features/notes/application/state.js) | +| A | [frontend/src/features/notes/application/submission.js](../../frontend/src/features/notes/application/submission.js) | +| A | [frontend/src/features/notes/application/workflows.js](../../frontend/src/features/notes/application/workflows.js) | +| A | [frontend/src/features/notes/domain/noteRules.js](../../frontend/src/features/notes/domain/noteRules.js) | +| A | [frontend/src/features/notes/domain/recurrence.js](../../frontend/src/features/notes/domain/recurrence.js) | +| A | [frontend/src/features/notes/index.js](../../frontend/src/features/notes/index.js) | +| A | [frontend/src/features/notes/infrastructure/http.js](../../frontend/src/features/notes/infrastructure/http.js) | +| A | [frontend/src/features/notes/infrastructure/notifications.js](../../frontend/src/features/notes/infrastructure/notifications.js) | +| A | [frontend/src/features/notes/infrastructure/storage.js](../../frontend/src/features/notes/infrastructure/storage.js) | +| A | [frontend/src/features/notes/ui/cardChecklist.js](../../frontend/src/features/notes/ui/cardChecklist.js) | +| A | [frontend/src/features/notes/ui/cardInteractions.js](../../frontend/src/features/notes/ui/cardInteractions.js) | +| A | [frontend/src/features/notes/ui/cardRendering.js](../../frontend/src/features/notes/ui/cardRendering.js) | +| A | [frontend/src/features/notes/ui/cardReorder.js](../../frontend/src/features/notes/ui/cardReorder.js) | +| A | [frontend/src/features/notes/ui/checklist.js](../../frontend/src/features/notes/ui/checklist.js) | +| A | [frontend/src/features/notes/ui/checklistReorder.js](../../frontend/src/features/notes/ui/checklistReorder.js) | +| A | [frontend/src/features/notes/ui/clipboard.js](../../frontend/src/features/notes/ui/clipboard.js) | +| A | [frontend/src/features/notes/ui/colors.js](../../frontend/src/features/notes/ui/colors.js) | +| A | [frontend/src/features/notes/ui/draftEditor.js](../../frontend/src/features/notes/ui/draftEditor.js) | +| A | [frontend/src/features/notes/ui/drawing.js](../../frontend/src/features/notes/ui/drawing.js) | +| A | [frontend/src/features/notes/ui/editor.js](../../frontend/src/features/notes/ui/editor.js) | +| A | [frontend/src/features/notes/ui/filters.js](../../frontend/src/features/notes/ui/filters.js) | +| A | [frontend/src/features/notes/ui/layout.js](../../frontend/src/features/notes/ui/layout.js) | +| A | [frontend/src/features/notes/ui/lifecycle.js](../../frontend/src/features/notes/ui/lifecycle.js) | +| A | [frontend/src/features/notes/ui/mobileEditor.js](../../frontend/src/features/notes/ui/mobileEditor.js) | +| A | [frontend/src/features/notes/ui/mobileReorder.js](../../frontend/src/features/notes/ui/mobileReorder.js) | +| A | [frontend/src/features/notes/ui/panel.js](../../frontend/src/features/notes/ui/panel.js) | +| A | [frontend/src/features/notes/ui/quickAdd.js](../../frontend/src/features/notes/ui/quickAdd.js) | +| A | [frontend/src/features/notes/ui/reminderDisplay.js](../../frontend/src/features/notes/ui/reminderDisplay.js) | +| A | [frontend/src/features/notes/ui/reminderPicker.js](../../frontend/src/features/notes/ui/reminderPicker.js) | +| A | [frontend/src/features/notes/ui/selection.js](../../frontend/src/features/notes/ui/selection.js) | +| A | [frontend/src/features/notes/ui/text.js](../../frontend/src/features/notes/ui/text.js) | +| A | [frontend/src/features/notes/ui/toolbar.js](../../frontend/src/features/notes/ui/toolbar.js) | +| A | [frontend/src/features/notes/ui/windowLayout.js](../../frontend/src/features/notes/ui/windowLayout.js) | +| A | [frontend/src/shared/ui/confetti.js](../../frontend/src/shared/ui/confetti.js) | + +## Deployment copies (42) + +| Status | File | +| --- | --- | +| A | [static/js/features/notes/application/agentActions.js](../../static/js/features/notes/application/agentActions.js) | +| A | [static/js/features/notes/application/cardOperations.js](../../static/js/features/notes/application/cardOperations.js) | +| A | [static/js/features/notes/application/checklistOperations.js](../../static/js/features/notes/application/checklistOperations.js) | +| A | [static/js/features/notes/application/deletion.js](../../static/js/features/notes/application/deletion.js) | +| A | [static/js/features/notes/application/drafts.js](../../static/js/features/notes/application/drafts.js) | +| A | [static/js/features/notes/application/queries.js](../../static/js/features/notes/application/queries.js) | +| A | [static/js/features/notes/application/reminders.js](../../static/js/features/notes/application/reminders.js) | +| A | [static/js/features/notes/application/selection.js](../../static/js/features/notes/application/selection.js) | +| A | [static/js/features/notes/application/state.js](../../static/js/features/notes/application/state.js) | +| A | [static/js/features/notes/application/submission.js](../../static/js/features/notes/application/submission.js) | +| A | [static/js/features/notes/application/workflows.js](../../static/js/features/notes/application/workflows.js) | +| A | [static/js/features/notes/domain/noteRules.js](../../static/js/features/notes/domain/noteRules.js) | +| A | [static/js/features/notes/domain/recurrence.js](../../static/js/features/notes/domain/recurrence.js) | +| A | [static/js/features/notes/index.js](../../static/js/features/notes/index.js) | +| A | [static/js/features/notes/infrastructure/http.js](../../static/js/features/notes/infrastructure/http.js) | +| A | [static/js/features/notes/infrastructure/notifications.js](../../static/js/features/notes/infrastructure/notifications.js) | +| A | [static/js/features/notes/infrastructure/storage.js](../../static/js/features/notes/infrastructure/storage.js) | +| A | [static/js/features/notes/ui/cardChecklist.js](../../static/js/features/notes/ui/cardChecklist.js) | +| A | [static/js/features/notes/ui/cardInteractions.js](../../static/js/features/notes/ui/cardInteractions.js) | +| A | [static/js/features/notes/ui/cardRendering.js](../../static/js/features/notes/ui/cardRendering.js) | +| A | [static/js/features/notes/ui/cardReorder.js](../../static/js/features/notes/ui/cardReorder.js) | +| A | [static/js/features/notes/ui/checklist.js](../../static/js/features/notes/ui/checklist.js) | +| A | [static/js/features/notes/ui/checklistReorder.js](../../static/js/features/notes/ui/checklistReorder.js) | +| A | [static/js/features/notes/ui/clipboard.js](../../static/js/features/notes/ui/clipboard.js) | +| A | [static/js/features/notes/ui/colors.js](../../static/js/features/notes/ui/colors.js) | +| A | [static/js/features/notes/ui/draftEditor.js](../../static/js/features/notes/ui/draftEditor.js) | +| A | [static/js/features/notes/ui/drawing.js](../../static/js/features/notes/ui/drawing.js) | +| A | [static/js/features/notes/ui/editor.js](../../static/js/features/notes/ui/editor.js) | +| A | [static/js/features/notes/ui/filters.js](../../static/js/features/notes/ui/filters.js) | +| A | [static/js/features/notes/ui/layout.js](../../static/js/features/notes/ui/layout.js) | +| A | [static/js/features/notes/ui/lifecycle.js](../../static/js/features/notes/ui/lifecycle.js) | +| A | [static/js/features/notes/ui/mobileEditor.js](../../static/js/features/notes/ui/mobileEditor.js) | +| A | [static/js/features/notes/ui/mobileReorder.js](../../static/js/features/notes/ui/mobileReorder.js) | +| A | [static/js/features/notes/ui/panel.js](../../static/js/features/notes/ui/panel.js) | +| A | [static/js/features/notes/ui/quickAdd.js](../../static/js/features/notes/ui/quickAdd.js) | +| A | [static/js/features/notes/ui/reminderDisplay.js](../../static/js/features/notes/ui/reminderDisplay.js) | +| A | [static/js/features/notes/ui/reminderPicker.js](../../static/js/features/notes/ui/reminderPicker.js) | +| A | [static/js/features/notes/ui/selection.js](../../static/js/features/notes/ui/selection.js) | +| A | [static/js/features/notes/ui/text.js](../../static/js/features/notes/ui/text.js) | +| A | [static/js/features/notes/ui/toolbar.js](../../static/js/features/notes/ui/toolbar.js) | +| A | [static/js/features/notes/ui/windowLayout.js](../../static/js/features/notes/ui/windowLayout.js) | +| A | [static/js/shared/ui/confetti.js](../../static/js/shared/ui/confetti.js) | + +## Compatibility and deployment configuration (5) + +| Status | File | +| --- | --- | +| M | [.gitattributes](../../.gitattributes) | +| M | [frontend/modules.json](../../frontend/modules.json) | +| M | [static/js/compare/vote.js](../../static/js/compare/vote.js) | +| M | [static/js/notes.js](../../static/js/notes.js) | +| M | [static/sw.js](../../static/sw.js) | + +## Tests (21) + +| Status | File | +| --- | --- | +| A | [tests/browser/notesNative.mjs](../../tests/browser/notesNative.mjs) | +| A | [tests/browser/notesOffline.mjs](../../tests/browser/notesOffline.mjs) | +| A | [tests/browser/notesReference.mjs](../../tests/browser/notesReference.mjs) | +| A | [tests/fixtures/notesRecurrenceBaseline.mjs](../../tests/fixtures/notesRecurrenceBaseline.mjs) | +| A | [tests/helpers/notes_source.py](../../tests/helpers/notes_source.py) | +| A | [tests/notesApplication.test.mjs](../../tests/notesApplication.test.mjs) | +| A | [tests/notesCharacterization.test.mjs](../../tests/notesCharacterization.test.mjs) | +| M | [tests/notesDraftAutosave.test.mjs](../../tests/notesDraftAutosave.test.mjs) | +| A | [tests/notesInfrastructure.test.mjs](../../tests/notesInfrastructure.test.mjs) | +| A | [tests/notesRecurrence.test.mjs](../../tests/notesRecurrence.test.mjs) | +| A | [tests/notesReminders.test.mjs](../../tests/notesReminders.test.mjs) | +| A | [tests/notesWorkflows.test.mjs](../../tests/notesWorkflows.test.mjs) | +| M | [tests/selectedNotesContext.test.mjs](../../tests/selectedNotesContext.test.mjs) | +| M | [tests/test_notes_document_mutual_minimize.py](../../tests/test_notes_document_mutual_minimize.py) | +| M | [tests/test_notes_dom_xss_helpers.py](../../tests/test_notes_dom_xss_helpers.py) | +| M | [tests/test_notes_mobile_checklist_toggle_static.py](../../tests/test_notes_mobile_checklist_toggle_static.py) | +| A | [tests/test_notes_reference_browser.py](../../tests/test_notes_reference_browser.py) | +| A | [tests/test_notes_reference_contracts_js.py](../../tests/test_notes_reference_contracts_js.py) | +| M | [tests/test_notes_search_reset_on_reopen_js.py](../../tests/test_notes_search_reset_on_reopen_js.py) | +| M | [tests/test_notes_select_esc_listener_js.py](../../tests/test_notes_select_esc_listener_js.py) | +| M | [tests/test_notes_sidebar_new_note.py](../../tests/test_notes_sidebar_new_note.py) | + +## Runtime validation (2) + +| Status | File | +| --- | --- | +| M | [scripts/verify_frontend_runtime.py](../../scripts/verify_frontend_runtime.py) | +| A | [scripts/verify_notes_browser_runtime.py](../../scripts/verify_notes_browser_runtime.py) | + +## Documentation and execution receipts (9) + +| Status | File | +| --- | --- | +| A | [docs/frontend-architecture/evidence/notes/implementation.sha256](../../docs/frontend-architecture/evidence/notes/implementation.sha256) | +| A | [docs/frontend-architecture/evidence/notes/native-desktop.png](../../docs/frontend-architecture/evidence/notes/native-desktop.png) | +| A | [docs/frontend-architecture/evidence/notes/native-mobile.png](../../docs/frontend-architecture/evidence/notes/native-mobile.png) | +| M | [docs/frontend-architecture/handbook.md](../../docs/frontend-architecture/handbook.md) | +| A | [docs/frontend-architecture/notes-case-study.md](../../docs/frontend-architecture/notes-case-study.md) | +| A | [docs/frontend-architecture/notes-changed-files.md](../../docs/frontend-architecture/notes-changed-files.md) | +| A | [docs/frontend-architecture/notes-module-inventory.md](../../docs/frontend-architecture/notes-module-inventory.md) | +| M | [docs/frontend-architecture/notes-roadmap.md](../../docs/frontend-architecture/notes-roadmap.md) | +| A | [docs/frontend-architecture/notes-validation.md](../../docs/frontend-architecture/notes-validation.md) | + +See [the case study](notes-case-study.md) for ownership and handoff, +[the module inventory](notes-module-inventory.md) for line counts, and +[the validation report](notes-validation.md) for exact executed checks. diff --git a/docs/frontend-architecture/notes-module-inventory.md b/docs/frontend-architecture/notes-module-inventory.md new file mode 100644 index 000000000..5d8184858 --- /dev/null +++ b/docs/frontend-architecture/notes-module-inventory.md @@ -0,0 +1,56 @@ +# Notes source and deployment inventory + +The feature has 41 maintained modules. The unchanged F1 action-menu proof and +shared confetti bring the full manifest to 43. Outputs are copied byte-for-byte; +count maintained source once. The legacy entry is a five-line compatibility facade. + +| Source under frontend/src | Deployment under static | Lines | Layer | +| --- | --- | ---: | --- | +| [features/notes/application/agentActions.js](../../frontend/src/features/notes/application/agentActions.js) | [js/features/notes/application/agentActions.js](../../static/js/features/notes/application/agentActions.js) | 67 | application | +| [features/notes/application/cardOperations.js](../../frontend/src/features/notes/application/cardOperations.js) | [js/features/notes/application/cardOperations.js](../../static/js/features/notes/application/cardOperations.js) | 79 | application | +| [features/notes/application/checklistOperations.js](../../frontend/src/features/notes/application/checklistOperations.js) | [js/features/notes/application/checklistOperations.js](../../static/js/features/notes/application/checklistOperations.js) | 68 | application | +| [features/notes/application/deletion.js](../../frontend/src/features/notes/application/deletion.js) | [js/features/notes/application/deletion.js](../../static/js/features/notes/application/deletion.js) | 51 | application | +| [features/notes/application/drafts.js](../../frontend/src/features/notes/application/drafts.js) | [js/features/notes/application/drafts.js](../../static/js/features/notes/application/drafts.js) | 55 | application | +| [features/notes/application/queries.js](../../frontend/src/features/notes/application/queries.js) | [js/features/notes/application/queries.js](../../static/js/features/notes/application/queries.js) | 66 | application | +| [features/notes/application/reminders.js](../../frontend/src/features/notes/application/reminders.js) | [js/features/notes/application/reminders.js](../../static/js/features/notes/application/reminders.js) | 182 | application | +| [features/notes/application/selection.js](../../frontend/src/features/notes/application/selection.js) | [js/features/notes/application/selection.js](../../static/js/features/notes/application/selection.js) | 39 | application | +| [features/notes/application/state.js](../../frontend/src/features/notes/application/state.js) | [js/features/notes/application/state.js](../../static/js/features/notes/application/state.js) | 72 | application | +| [features/notes/application/submission.js](../../frontend/src/features/notes/application/submission.js) | [js/features/notes/application/submission.js](../../static/js/features/notes/application/submission.js) | 69 | application | +| [features/notes/application/workflows.js](../../frontend/src/features/notes/application/workflows.js) | [js/features/notes/application/workflows.js](../../static/js/features/notes/application/workflows.js) | 78 | application | +| [features/notes/domain/noteRules.js](../../frontend/src/features/notes/domain/noteRules.js) | [js/features/notes/domain/noteRules.js](../../static/js/features/notes/domain/noteRules.js) | 87 | domain | +| [features/notes/domain/recurrence.js](../../frontend/src/features/notes/domain/recurrence.js) | [js/features/notes/domain/recurrence.js](../../static/js/features/notes/domain/recurrence.js) | 269 | domain | +| [features/notes/index.js](../../frontend/src/features/notes/index.js) | [js/features/notes/index.js](../../static/js/features/notes/index.js) | 518 | composition | +| [features/notes/infrastructure/http.js](../../frontend/src/features/notes/infrastructure/http.js) | [js/features/notes/infrastructure/http.js](../../static/js/features/notes/infrastructure/http.js) | 29 | infrastructure | +| [features/notes/infrastructure/notifications.js](../../frontend/src/features/notes/infrastructure/notifications.js) | [js/features/notes/infrastructure/notifications.js](../../static/js/features/notes/infrastructure/notifications.js) | 20 | infrastructure | +| [features/notes/infrastructure/storage.js](../../frontend/src/features/notes/infrastructure/storage.js) | [js/features/notes/infrastructure/storage.js](../../static/js/features/notes/infrastructure/storage.js) | 63 | infrastructure | +| [features/notes/ui/cardChecklist.js](../../frontend/src/features/notes/ui/cardChecklist.js) | [js/features/notes/ui/cardChecklist.js](../../static/js/features/notes/ui/cardChecklist.js) | 148 | ui | +| [features/notes/ui/cardInteractions.js](../../frontend/src/features/notes/ui/cardInteractions.js) | [js/features/notes/ui/cardInteractions.js](../../static/js/features/notes/ui/cardInteractions.js) | 337 | ui | +| [features/notes/ui/cardRendering.js](../../frontend/src/features/notes/ui/cardRendering.js) | [js/features/notes/ui/cardRendering.js](../../static/js/features/notes/ui/cardRendering.js) | 244 | ui | +| [features/notes/ui/cardReorder.js](../../frontend/src/features/notes/ui/cardReorder.js) | [js/features/notes/ui/cardReorder.js](../../static/js/features/notes/ui/cardReorder.js) | 181 | ui | +| [features/notes/ui/checklist.js](../../frontend/src/features/notes/ui/checklist.js) | [js/features/notes/ui/checklist.js](../../static/js/features/notes/ui/checklist.js) | 190 | ui | +| [features/notes/ui/checklistReorder.js](../../frontend/src/features/notes/ui/checklistReorder.js) | [js/features/notes/ui/checklistReorder.js](../../static/js/features/notes/ui/checklistReorder.js) | 111 | ui | +| [features/notes/ui/clipboard.js](../../frontend/src/features/notes/ui/clipboard.js) | [js/features/notes/ui/clipboard.js](../../static/js/features/notes/ui/clipboard.js) | 82 | ui | +| [features/notes/ui/colors.js](../../frontend/src/features/notes/ui/colors.js) | [js/features/notes/ui/colors.js](../../static/js/features/notes/ui/colors.js) | 111 | ui | +| [features/notes/ui/draftEditor.js](../../frontend/src/features/notes/ui/draftEditor.js) | [js/features/notes/ui/draftEditor.js](../../static/js/features/notes/ui/draftEditor.js) | 67 | ui | +| [features/notes/ui/drawing.js](../../frontend/src/features/notes/ui/drawing.js) | [js/features/notes/ui/drawing.js](../../static/js/features/notes/ui/drawing.js) | 442 | ui | +| [features/notes/ui/editor.js](../../frontend/src/features/notes/ui/editor.js) | [js/features/notes/ui/editor.js](../../static/js/features/notes/ui/editor.js) | 518 | ui | +| [features/notes/ui/filters.js](../../frontend/src/features/notes/ui/filters.js) | [js/features/notes/ui/filters.js](../../static/js/features/notes/ui/filters.js) | 139 | ui | +| [features/notes/ui/layout.js](../../frontend/src/features/notes/ui/layout.js) | [js/features/notes/ui/layout.js](../../static/js/features/notes/ui/layout.js) | 120 | ui | +| [features/notes/ui/lifecycle.js](../../frontend/src/features/notes/ui/lifecycle.js) | [js/features/notes/ui/lifecycle.js](../../static/js/features/notes/ui/lifecycle.js) | 36 | ui | +| [features/notes/ui/mobileEditor.js](../../frontend/src/features/notes/ui/mobileEditor.js) | [js/features/notes/ui/mobileEditor.js](../../static/js/features/notes/ui/mobileEditor.js) | 257 | ui | +| [features/notes/ui/mobileReorder.js](../../frontend/src/features/notes/ui/mobileReorder.js) | [js/features/notes/ui/mobileReorder.js](../../static/js/features/notes/ui/mobileReorder.js) | 214 | ui | +| [features/notes/ui/panel.js](../../frontend/src/features/notes/ui/panel.js) | [js/features/notes/ui/panel.js](../../static/js/features/notes/ui/panel.js) | 506 | ui | +| [features/notes/ui/quickAdd.js](../../frontend/src/features/notes/ui/quickAdd.js) | [js/features/notes/ui/quickAdd.js](../../static/js/features/notes/ui/quickAdd.js) | 170 | ui | +| [features/notes/ui/reminderDisplay.js](../../frontend/src/features/notes/ui/reminderDisplay.js) | [js/features/notes/ui/reminderDisplay.js](../../static/js/features/notes/ui/reminderDisplay.js) | 129 | ui | +| [features/notes/ui/reminderPicker.js](../../frontend/src/features/notes/ui/reminderPicker.js) | [js/features/notes/ui/reminderPicker.js](../../static/js/features/notes/ui/reminderPicker.js) | 334 | ui | +| [features/notes/ui/selection.js](../../frontend/src/features/notes/ui/selection.js) | [js/features/notes/ui/selection.js](../../static/js/features/notes/ui/selection.js) | 61 | ui | +| [features/notes/ui/text.js](../../frontend/src/features/notes/ui/text.js) | [js/features/notes/ui/text.js](../../static/js/features/notes/ui/text.js) | 39 | ui | +| [features/notes/ui/toolbar.js](../../frontend/src/features/notes/ui/toolbar.js) | [js/features/notes/ui/toolbar.js](../../static/js/features/notes/ui/toolbar.js) | 78 | ui | +| [features/notes/ui/windowLayout.js](../../frontend/src/features/notes/ui/windowLayout.js) | [js/features/notes/ui/windowLayout.js](../../static/js/features/notes/ui/windowLayout.js) | 114 | ui | +| [shared/ui/confetti.js](../../frontend/src/shared/ui/confetti.js) | [js/shared/ui/confetti.js](../../static/js/shared/ui/confetti.js) | 28 | shared UI | + +Maintained Notes feature total: 6440 lines. Original entry: 5,471 lines. +Legacy facade after decomposition: five lines. Shared confetti: 28 lines. + +See [the case study](notes-case-study.md) for responsibilities and public/state/lifecycle +contracts, and [the validation report](notes-validation.md) for executed evidence. diff --git a/docs/frontend-architecture/notes-roadmap.md b/docs/frontend-architecture/notes-roadmap.md index 6371e9aca..522923dc2 100644 --- a/docs/frontend-architecture/notes-roadmap.md +++ b/docs/frontend-architecture/notes-roadmap.md @@ -1,15 +1,20 @@ # Notes reference decomposition roadmap -Status: planning only. F1 changes no Notes implementation. Begin after the -foundation has been reviewed and integrated into official `upstream/dev`. -The reference program ends after Notes integration and the completed H handoff. +Status: N0 through N6 implemented as an unpublished Notes review diff after F1 +merged in PR #6701. Actual decisions, interfaces, module ownership, checkpoint +receipts and execution limits are in the [case study](notes-case-study.md) and +[validation report](notes-validation.md). The proposal below retains the original +responsibility map for comparison. Notes integration and the recipient's independent +H reproduction remain pending. F1 remains an entirely separate foundation-only PR. The complete Notes decomposition, regression tests, compatibility validation and final reference case study belong to one comprehensive draft PR targeting official `dev`. ## Verified current responsibility and compatibility map -Baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +Historical assessment baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`. +The implementation starts from merged F1 at +`1cc56cb9d78f3e2399d1d61155b71d771a17b85b`. The module is `static/js/notes.js`, about 5,470 lines, with browser identity `/static/js/notes.js?v=20260911notesselectioncancel1`. Preserve that identity across the app import, HTML, SW and lazy consumers until a separately reviewed migration diff --git a/docs/frontend-architecture/notes-validation.md b/docs/frontend-architecture/notes-validation.md new file mode 100644 index 000000000..e4e701c6c --- /dev/null +++ b/docs/frontend-architecture/notes-validation.md @@ -0,0 +1,261 @@ +# Notes implementation validation and checkpoint receipts + +Implementation baseline: `1cc56cb9d78f3e2399d1d61155b71d771a17b85b`, updated via +non-destructive fast-forward to latest official `upstream/dev` at +`86b886c611338282af6d4a717246ea4e256f58df` (incorporating PRs #6704 and #6693). +Independent AGY audit returned CONDITIONAL PASS. Pre-publication corrective +pass verified issue template, PR template, visual evidence paths, and em-dash removal. +The official integration branch is `dev`. Tracking issue #6712; PR #6713. + +## Final executed evidence + +| Check | Result | +| --- | --- | +| Notes Python tests, discovered by filename, plus F1 publication/graph/offline/loader tests | 172 passed, one release-smoke skip without `APP_PORT` | +| Node architecture, Notes oracle, domain, infrastructure, application, reminders, drafts, selection and workflow tests | 46 passed | +| Native Python lifespan/static distribution probe | Passed for every mapped output, with source/Node absent | +| Existing Docker image distribution probe | Passed with networking disabled and candidate files mounted read-only | +| Real authenticated native app, desktop and touch-mobile | Passed CRUD persistence, owner identity, actual Chat selected-context indicator/reset and the original release smoke scenario | +| Real Chromium first evaluation of Notes offline | Passed; every maintained output cached, facade/canonical API identity shared | +| Source/output parity and F1 boundaries | Passed, 43 maintained modules and nine composition-only legacy exceptions | +| Foundation-based full pytest execution | 13 failed, 13,909 passed, 60 skipped, six subtests passed, 192 warnings, 517.04 seconds | +| Targeted unchanged F1 baseline reproduction | Same 13 failures, 77 passed, six warnings | +| Targeted untouched latest `dev` reproduction | Three inherited failures, 87 passed, six warnings | +| Final indexed latest `dev` plus Notes overlay, full pytest | Three same-environment baseline failures, 13,923 passed, 60 skipped, six subtests passed, 192 warnings, 512.09 seconds | +| Untouched latest `dev` readiness checks, without the prewarm override | Five passed | + +The foundation-based full repository result is not green. Its failures reproduce against +the unchanged foundation in a disposable archive, with the same interpreter and +closed Chroma port. None was disabled, relabeled or fixed in this Notes change. +The focused Notes suite and required source/deployment gates are green. The final +code/test overlay is fingerprinted in [implementation.sha256](evidence/notes/implementation.sha256). +The only post-full-suite test-source edit removes a terminal +blank line from the browser wrapper; its content was compared ignoring terminal +whitespace and all three focused contract/browser checks passed again. Raw command +outputs are kept out of the repository; the counts above are the record. + +The first full run reported 19 failures. Six were introduced validation issues: +the SW cache name did not follow the existing numeric convention; a temporary +probe environment input was not registered by the configuration-reference checker; +and a Notes source assertion still expected the original default object spelling. +The cache name now follows the convention, the probe passes its output list as a +command argument, and the assertion verifies the new public binding. All 58 tests +in those affected files passed after correction. The foundation-based full run has only the +13 reproduced baseline failures below. + +### Reproduced baseline failures + +- `tests/test_aux_llm_owner_scope.py::test_auto_compaction_utility_endpoint_keeps_chat_owner` +- `tests/test_chatgpt_reasoning_and_grouping.py::test_history_and_chat_routes_support_reasoning_effort` +- `tests/test_containment_enforcement.py::test_namespace_handshake_closes_model_stdin` +- `tests/test_containment_enforcement.py::test_execution_environment_cannot_replace_probed_bwrap[False]` +- `tests/test_context_compactor.py::TestMaybeCompactFourthMessage::test_does_not_crash_on_none_content_turn` +- `tests/test_context_compactor.py::TestMaybeCompactFourthMessage::test_handles_multimodal_list_content` +- `tests/test_css_computed_style_snapshot.py::test_computed_styles_match_the_committed_baseline` +- `tests/test_history_compact_tool_calls.py::test_manual_compact_tolerates_chatmessage_with_none_content` +- `tests/test_history_compact_tool_calls.py::test_manual_compact_tolerates_dict_message_with_none_content` +- `tests/test_history_compact_tool_calls.py::test_registered_manual_compact_route_tolerates_none_content` +- `tests/test_history_compact_tool_calls.py::test_registered_manual_compact_route_uses_session_owner` +- `tests/test_model_helper_owner_scope.py::test_history_compact_resolves_with_owner_scope` +- `tests/test_readiness.py::test_tool_index_is_reported_but_not_critical_by_default` + +These cover existing compaction fixtures/source assertions, namespace containment, +settings-row CSS baseline drift and readiness expectations. The baseline reproduction used +`git archive` into `/tmp/odysseus-notes-baseline-OitxsU`, not another branch/worktree. + +## Reproducible commands + +### Latest integration target + +Final upstream inspection found `86b886c611338282af6d4a717246ea4e256f58df`. +PR #6704 repairs ten of the foundation's reproduced failures; PR #6693 adds +remote-only skill gating. Neither changes a file owned by the Notes diff. +The branch remains on the requested foundation, with no merge or rebase. +An archive of that latest target plus the exact final Notes code/test overlay is +validated in `/tmp/odysseus-notes-latest-VmZrgC` using the same full-suite command. +The fixture has a local Git index, initialized from the latest target and updated +with the review files, but no commit. This enables the tracked-publication checks. +The first archive-only attempt had one Git-metadata failure and seven checkout-only +skips. Both affected files subsequently passed all 19 tests with metadata present; +the complete suite was then rerun in that indexed fixture. +An untouched archive at `/tmp/odysseus-notes-latest-baseline-mfE4ss` reproduced +the remaining two containment failures and the readiness failure: three failed, +87 passed. + +These are reproduced local-environment failures, not a claim that official CI is +currently failing. The readiness assertion expects prewarming enabled, whereas +the full-run safety command explicitly sets `ODYSSEUS_TOOL_INDEX_PREWARM=0`. +With that override absent, all five readiness tests pass on untouched latest `dev`. +The two namespace tests receive extra `Failed to create stream fd` stderr in this +host's contained process output. That same output occurs without the Notes overlay; +no backend containment code was changed or a failing assertion weakened. +Reproduce those two checks on the supported CI runner before claiming a green +full-suite certification. + +In the final indexed full-suite run the only failures are `test_namespace_handshake_closes_model_stdin`, +`test_execution_environment_cannot_replace_probed_bwrap[False]`, and +`test_tool_index_is_reported_but_not_critical_by_default`. The missing-Git-metadata +publication failure and the seven checkout-only skips are absent from this run. +No Notes regression was reported. Feature code matched N6 at publication. Later +commits on the PR changed `ui/reminderPicker.js` (date input value assigned through +the DOM property) and, after review, `index.js`, `ui/layout.js` and +`application/state.js`; `implementation.sha256` covers the current files. + +To reproduce the integration rehearsal, extract the latest target with `git archive` +into a fresh fixture and copy only files in the changed-file inventory on top. +Do not overlay foundation files that are unchanged in this PR. Install the locked +development dependencies in the fixture, then run the commands below there. + +While this review diff is uncommitted, this prepares an equivalent Linux fixture +without changing either task worktree: + +```sh +notes_fixture=$(mktemp -d /tmp/ody-notes-integration-XXXXXX) +git archive 86b886c611338282af6d4a717246ea4e256f58df | tar -x -C "$notes_fixture" +{ git diff --name-only -z; git ls-files --others --exclude-standard -z; } | + tar --null -T - -cf - | tar -xf - -C "$notes_fixture" +git -C "$notes_fixture" init -q +git -C "$notes_fixture" fetch --no-write-fetch-head "$PWD" 86b886c611338282af6d4a717246ea4e256f58df +git -C "$notes_fixture" read-tree 86b886c611338282af6d4a717246ea4e256f58df +(cd "$notes_fixture" && npm ci --ignore-scripts --no-audit --no-fund && git add --all) +# Run the validation commands below from notes_fixture, using an isolated Python environment. +``` + +Use a real dependency directory for this recipe; a symlink named `node_modules` +can bypass the repository's directory-only ignore pattern. No checkpoint or +fixture commit is needed. After integration, validate the actual integrated checkout +directly, rather than relying on the uncommitted-diff recipe. + +### Commands + +Use the actual tested interpreter, or install the repository requirements in your +own isolated virtualenv. Node development dependencies were installed with the +existing lockfile and `npm ci --ignore-scripts --no-audit --no-fund`. + +```sh +python3 scripts/build_frontend.py +python3 scripts/build_frontend.py --check +node scripts/check_frontend.mjs +node scripts/check_frontend.mjs --inventory + +ENDPOINT_URL=http://127.0.0.1:1 node --test \ + tests/frontendArchitecture.test.mjs tests/noteTestOracle.test.mjs \ + tests/notesDraftAutosave.test.mjs tests/selectedNotesContext.test.mjs \ + tests/notesCharacterization.test.mjs tests/notesRecurrence.test.mjs \ + tests/notesInfrastructure.test.mjs tests/notesApplication.test.mjs \ + tests/notesReminders.test.mjs tests/notesWorkflows.test.mjs + +CHROMADB_HOST=127.0.0.1 CHROMADB_PORT=1 \ + python -m pytest -q -rs \ + $(rg --files tests -g 'test_*note*.py') \ + tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \ + tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \ + tests/test_panel_loader_js.py + +python scripts/verify_frontend_runtime.py +python scripts/verify_notes_browser_runtime.py + +docker run --rm --network none \ + --mount type=bind,src="$PWD",dst=/candidate,readonly \ + --workdir /candidate --entrypoint python \ + scripts/verify_frontend_runtime.py + +CHROMADB_HOST=127.0.0.1 CHROMADB_PORT=1 \ + ODYSSEUS_STARTUP_WARMUPS=0 ODYSSEUS_TOOL_INDEX_PREWARM=0 \ + python -m pytest -q -rfs + +git diff --check +``` + +`ENDPOINT_URL` satisfies the existing oracle's import configuration; its endpoint- +driving main does not run in these unit tests. Port 1 is the closed loopback target +used for pytest; the two runtime scripts reserve their own non-listening Chroma port. +The native browser script creates a private distribution/database and a temporary +authenticated user, then terminates the server and removes that fixture. It uses +the real application and Notes API rather than mocked browser routes. + +The deterministic browser harness uses stub API responses and the real Notes ESM, +DOM and stylesheet graph. It verifies namespace/default methods and arities, +aliases, remount/search reset, rejected/accepted attachments, draft persistence, +drawing/undo and listener/portal cleanup. It also checks larger-touch reorder after +re-render, active-drag close cleanup and cancellation of a mobile selection hold. +It includes the mobile viewport meta tag, +tap-to-read/edit behavior and the existing selection jiggle. The original monolith +can be replayed with `NOTES_BASELINE=1`; it is read from the frozen F1 revision. + +## Browser and packaging evidence + +Actual app screenshots: [desktop](evidence/notes/native-desktop.png) and +[mobile](evidence/notes/native-mobile.png). These are Chromium screenshots of the +disposable authenticated app at 1440 x 1000 and 390 x 844, not rendered mockups. +The deterministic before/after fixture had an identical mobile PNG and only 14 +changed desktop pixels in the header region; no layout/style change was observed. +CSS, app bootstrap and HTML have no implementation diff. + +The native and Docker distribution probe serves all manifest outputs without +`frontend/`, `node_modules/` or Node on the server path. It checks response bytes, +JS MIME, revalidation, CSP, shell startup, static confinement and source-tree 404. +Docker used existing image +`sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`. +This is not a fresh Dockerfile build or deployment. + +Tested locally: Linux, Python 3.14.4, Node 22.23.1 and Playwright/Chromium 1.62.1. +CI's exact Python 3.11/Node 20 pair, Firefox/WebKit, macOS/Windows executable builds, +live model/email notification delivery and full root offline reload were not run. +The Docker runtime uses its own existing dependency versions. Shared active-window +gesture interleavings are not exhaustively covered. No broader platform certification +is claimed. + +The full-run safety overrides differ from official CI's plain pytest command. +For the readiness default-contract check, use +`env -u ODYSSEUS_TOOL_INDEX_PREWARM python -m pytest -q tests/test_readiness.py`. +Keep explicit warmup/storage isolation on the disposable live-app probes. + +## Internal checkpoints + +Checkpoint snapshots are kept with the maintainer handoff, not in the repository. +The hashes below identify them. They contain scoped source/output/facade/manifest/test +overlays, not secrets or user data. Each starts from the same frozen baseline; they +are not separate PRs or release candidates. + +| Stage | Boundary and executed exit evidence | Snapshot SHA-256 | +| --- | --- | --- | +| N0 | Public/consumer/state/lifecycle map; 95 baseline Python and 11 Node passes; four new characterization tests; desktop/touch harness; 33 F1 publication/graph checks | `db0a2e6e68fec3442ce350ffb0acf47bcdf2a5266f7c64c2bf910a1c41c4fb62` | +| N1 | Pure recurrence/date rules; eight Node and nine focused Python passes; differential oracle in four time zones; parity/boundaries | `fd0638470744706fbbd5787cb47bd2078a93c9b854139f89f8fdc169c1f353c6` | +| N2 | HTTP/storage adapters; fourteen Node and seven focused Python passes; exact requests, keys and fallback contracts | `30826fb7972d1b9e3a790f97ee81f66dcb3dc58eb7d23e24d4f46e6d841b7c73` | +| N3 | Single state owner and CRUD/undo boundary; ten Node and seven focused Python passes; read-only state and archive rollback | `4bc5293519b8438d0126a61887bea64195dcdb7d59bcfaaba8ed4ae8c38be439` | +| N4 | Page-owned reminder workflow; four reminder contract tests and seven focused Python passes; singleton interval, catch-up, markers and fallback | `febb835c4958c8758e4b7c23ce89f3ec8d103c2cc28f89817d258b3791a9fcdf` | +| N5 | Complete UI/application integration; 44 Node passes, targeted source guards and actual browser checks; drawing binding correction, read-only state and disposal | `e147f645bdb2161a4b1dc6cf10c36ba6d0c214d8e12fccb478835043eaaf3e94` | +| N6 | Final facade, identity, exact offline graph, native/Docker checks, final editor-deletion/reorder ownership review, touch cleanup regression checks and maintainer review package | `c450e91f9fbfe5f0cd805e596b92a76383f22a74e9515ca3e3f6154aebf5f61f` | + +The N6 overlay contained the final code, tests, manifest and attributes at +publication, not the evidence directory itself. From the repository root, run +`sha256sum -c docs/frontend-architecture/evidence/notes/implementation.sha256` +to verify the current implementation files. + +No checkpoint commits were made. To reproduce an overlay, create a fresh temporary +directory, extract `git archive 1cc56cb9...` there, then extract the desired +snapshot from the handoff on top. Install the locked test dependencies +in that fixture and run its relevant commands. Begin from the baseline rather than +overlaying snapshots onto a later dirty tree. For live rollback, release a complete +matching artifact through the authorized release workflow. + +Temporary mechanical extraction scripts are not part of the supported deliverable. +They were archived locally, then removed. The first generated patch accidentally targeted +the separate foundation worktree; only those edits were reversed, absolute paths +were adopted, and that worktree's clean state was verified afterward. + +## Open PR overlaps and handoff limits + +The following remained open at the read-only GitHub refresh: + +- [#6585](https://github.com/odysseus-dev/odysseus/pull/6585): optional Notes field removal in the backend. Existing null-field behavior is retained here. +- [#6647](https://github.com/odysseus-dev/odysseus/pull/6647): read-only Notes intent routing. +- [#6652](https://github.com/odysseus-dev/odysseus/pull/6652): `manage_notes` registry dispatch. +- [#6470](https://github.com/odysseus-dev/odysseus/pull/6470): shared theme, typography, sidebar and modal changes. +- [#6695](https://github.com/odysseus-dev/odysseus/pull/6695): extension system/platform integration. + +Their changes were not merged or reimplemented. Shared UI exceptions, root-SW scope, +the original last-completion fetch race, temporary-note correlation and canvas-draft +limits are documented in the case study. Backend owner checks and PR #6495 contracts +remain authoritative. diff --git a/frontend/modules.json b/frontend/modules.json index 0363222b1..0f882adbd 100644 --- a/frontend/modules.json +++ b/frontend/modules.json @@ -4,6 +4,212 @@ { "source": "shared/actionMenuOrder.js", "deployment": "js/actionMenuOrder.js" + }, + { + "source": "features/notes/domain/recurrence.js", + "deployment": "js/features/notes/domain/recurrence.js" + }, + { + "source": "features/notes/infrastructure/http.js", + "deployment": "js/features/notes/infrastructure/http.js" + }, + { + "source": "features/notes/infrastructure/storage.js", + "deployment": "js/features/notes/infrastructure/storage.js" + }, + { + "source": "features/notes/application/state.js", + "deployment": "js/features/notes/application/state.js" + }, + { + "source": "features/notes/application/workflows.js", + "deployment": "js/features/notes/application/workflows.js" + }, + { + "source": "features/notes/application/reminders.js", + "deployment": "js/features/notes/application/reminders.js" + }, + { + "source": "features/notes/infrastructure/notifications.js", + "deployment": "js/features/notes/infrastructure/notifications.js" + }, + { + "source": "features/notes/ui/text.js", + "deployment": "js/features/notes/ui/text.js" + }, + { + "source": "features/notes/ui/colors.js", + "deployment": "js/features/notes/ui/colors.js" + }, + { + "source": "features/notes/ui/panel.js", + "deployment": "js/features/notes/ui/panel.js" + }, + { + "source": "features/notes/ui/reminderDisplay.js", + "deployment": "js/features/notes/ui/reminderDisplay.js" + }, + { + "source": "features/notes/ui/selection.js", + "deployment": "js/features/notes/ui/selection.js" + }, + { + "source": "features/notes/ui/filters.js", + "deployment": "js/features/notes/ui/filters.js" + }, + { + "source": "features/notes/ui/cardRendering.js", + "deployment": "js/features/notes/ui/cardRendering.js" + }, + { + "source": "features/notes/ui/layout.js", + "deployment": "js/features/notes/ui/layout.js" + }, + { + "source": "features/notes/ui/quickAdd.js", + "deployment": "js/features/notes/ui/quickAdd.js" + }, + { + "source": "features/notes/ui/cardInteractions.js", + "deployment": "js/features/notes/ui/cardInteractions.js" + }, + { + "source": "features/notes/ui/cardChecklist.js", + "deployment": "js/features/notes/ui/cardChecklist.js" + }, + { + "source": "features/notes/ui/cardReorder.js", + "deployment": "js/features/notes/ui/cardReorder.js" + }, + { + "source": "features/notes/ui/draftEditor.js", + "deployment": "js/features/notes/ui/draftEditor.js" + }, + { + "source": "features/notes/ui/editor.js", + "deployment": "js/features/notes/ui/editor.js" + }, + { + "source": "features/notes/ui/reminderPicker.js", + "deployment": "js/features/notes/ui/reminderPicker.js" + }, + { + "source": "features/notes/ui/checklist.js", + "deployment": "js/features/notes/ui/checklist.js" + }, + { + "source": "features/notes/ui/drawing.js", + "deployment": "js/features/notes/ui/drawing.js" + }, + { + "source": "features/notes/ui/clipboard.js", + "deployment": "js/features/notes/ui/clipboard.js" + }, + { + "source": "features/notes/ui/mobileEditor.js", + "deployment": "js/features/notes/ui/mobileEditor.js" + }, + { + "source": "features/notes/ui/mobileReorder.js", + "deployment": "js/features/notes/ui/mobileReorder.js" + }, + { + "source": "features/notes/ui/checklistReorder.js", + "deployment": "js/features/notes/ui/checklistReorder.js" + }, + { + "source": "features/notes/application/queries.js", + "deployment": "js/features/notes/application/queries.js" + }, + { + "source": "features/notes/application/agentActions.js", + "deployment": "js/features/notes/application/agentActions.js" + }, + { + "source": "features/notes/application/deletion.js", + "deployment": "js/features/notes/application/deletion.js" + }, + { + "source": "features/notes/domain/noteRules.js", + "deployment": "js/features/notes/domain/noteRules.js" + }, + { + "source": "features/notes/index.js", + "deployment": "js/features/notes/index.js", + "legacyImports": [ + { + "specifier": "/static/js/ui.js?v=20260916largetoolscroll1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/modalManager.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/colorPicker.js?v=20260910eyedropper1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/windowDrag.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/tileManager.js?v=20260910responsivebounds1", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/modalSnap.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/toolWindowZOrder.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/escMenuStack.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + }, + { + "specifier": "/static/js/tourHintDismiss.js", + "reason": "Existing shared UI/platform identity, injected at Notes composition. Remove when that shared responsibility migrates to maintained source." + } + ] + }, + { + "source": "features/notes/application/submission.js", + "deployment": "js/features/notes/application/submission.js" + }, + { + "source": "features/notes/application/drafts.js", + "deployment": "js/features/notes/application/drafts.js" + }, + { + "source": "features/notes/ui/lifecycle.js", + "deployment": "js/features/notes/ui/lifecycle.js" + }, + { + "source": "features/notes/application/cardOperations.js", + "deployment": "js/features/notes/application/cardOperations.js" + }, + { + "source": "features/notes/application/checklistOperations.js", + "deployment": "js/features/notes/application/checklistOperations.js" + }, + { + "source": "features/notes/application/selection.js", + "deployment": "js/features/notes/application/selection.js" + }, + { + "source": "features/notes/ui/toolbar.js", + "deployment": "js/features/notes/ui/toolbar.js" + }, + { + "source": "features/notes/ui/windowLayout.js", + "deployment": "js/features/notes/ui/windowLayout.js" + }, + { + "source": "shared/ui/confetti.js", + "deployment": "js/shared/ui/confetti.js" } ] } diff --git a/frontend/src/features/notes/application/agentActions.js b/frontend/src/features/notes/application/agentActions.js new file mode 100644 index 000000000..d96828211 --- /dev/null +++ b/frontend/src/features/notes/application/agentActions.js @@ -0,0 +1,67 @@ +/** Create an agent session, link it to a note and drain its background stream. */ +import { _noteToAgentPrompt } from '../domain/noteRules.js'; +export function createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData, + state, + streamComplete, + uiModule +}) { + async function _agentSolveNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prompt = _noteToAgentPrompt(note); + if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; } + try { + const dc = await (await api.defaultChat()).json(); + if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } + + // 1. Create the session server-side (no UI switch). skip_validation + // avoids re-probing : the default-chat endpoint is already known good. + const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40); + const csFd = createFormData(); + csFd.append('name', 'Agent: ' + label); + csFd.append('endpoint_url', dc.endpoint_url); + csFd.append('model', dc.model); + if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); + csFd.append('skip_validation', 'true'); + const csRes = await api.createSession(csFd); + if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } + const sess = await csRes.json(); + const sid = sess.id; + + // 2. Link the session to the note right away so the tag appears. + const n = state.notes.find(x => x.id === id); + if (n) app.mergeNote(n, { agent_session_id: sid }); + _renderNotes(); + _patchNote(id, { agent_session_id: sid }).catch(() => {}); + + // 3. Kick off the agent run in the background. POST to chat_stream in + // agent mode and drain the SSE so the server runs the loop to + // completion + saves : without rendering anything in the chat UI. + const fd = createFormData(); + fd.append('message', prompt); + fd.append('session', sid); + fd.append('mode', 'agent'); + fd.append('disabled_tools', JSON.stringify(['manage_notes'])); + api.startChat(fd) + .then(async (res) => { + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + // Drain to completion (server finishes + persists the run). + while (true) { const { done } = await reader.read(); if (done) break; } + streamComplete(sid); + }) + .catch(() => {}); + + uiModule.showToast('Agent working in background — tap the Agent tag when ready'); + } catch (e) { + uiModule.showError('Agent failed: ' + (e.message || e)); + } + } + + return { _agentSolveNote }; +} diff --git a/frontend/src/features/notes/application/cardOperations.js b/frontend/src/features/notes/application/cardOperations.js new file mode 100644 index 000000000..1131d2ec0 --- /dev/null +++ b/frontend/src/features/notes/application/cardOperations.js @@ -0,0 +1,79 @@ +/** Optimistic card mutations and rollback. Animation completion is a UI-supplied boundary. */ +export function createCardOperations({ + app, + api, + effects, + pushUndo, + undoArchive +}) { + const state = app.state; + function togglePin(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prevPinned = note.pinned, prevSortOrder = note.sort_order; + app.mergeNote(note, { pinned: !prevPinned }); + const patch = { pinned: note.pinned }; + if (note.pinned) { + const minPinned = state.notes.filter(n => n.pinned && n.id !== id).reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + app.mergeNote(note, { sort_order: minPinned - 1 }); + patch.sort_order = note.sort_order; + } + effects.render(); + api.patchNote(id, patch).catch(() => { + app.mergeNote(note, { pinned: prevPinned, sort_order: prevSortOrder }); + effects.render(); effects.showError('Failed to pin'); + }); + } + async function updateColor(id, color) { + try { + await api.patchNote(id, { color: color || null }); + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, { color }); + } catch { effects.showError('Failed to update color'); } + } + function stageArchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return null; + const removed = app.removeNoteAt(index); + const undo = () => undoArchive(removed, index); + pushUndo({ label: 'archive', run: undo }); + return () => { + effects.render(); + api.patchNote(id, { archived: true }).then(() => effects.archived(undo)).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to archive'); + }); + }; + } + function unarchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + api.patchNote(id, { archived: false }).then(() => effects.showToast('Unarchived')).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to unarchive'); + }); + } + function deleteOptimistically(id, toast = false) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + const deletion = api.deleteNote(id); + const completion = toast ? deletion.then(() => effects.showToast('Deleted')) : deletion; + completion.catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to delete'); + }); + } + // Desktop deliberately persists without updating local sort_order, as before. + function persistOrder(ids) { return api.reorder(ids); } + async function commitMobileOrder(ids) { + try { + await api.reorder(ids); + ids.forEach((id, index) => { + const note = state.notes.find(note => note.id === id); + if (note) app.mergeNote(note, { sort_order: index }); + }); + } catch (error) { console.warn('reorder failed', error); } + } + return { togglePin, updateColor, stageArchive, unarchive, deleteOptimistically, persistOrder, commitMobileOrder }; +} diff --git a/frontend/src/features/notes/application/checklistOperations.js b/frontend/src/features/notes/application/checklistOperations.js new file mode 100644 index 000000000..b4a596b04 --- /dev/null +++ b/frontend/src/features/notes/application/checklistOperations.js @@ -0,0 +1,68 @@ +/** Checklist item workflows. Supplied feedback callbacks update the invoking view. */ +export function createChecklistOperations({ + app, + api, + effects, + uid +}) { + const state = app.state; + function itemNote(id, index) { + const note = state.notes.find(n => n.id === id); + return note && Array.isArray(note.items) && note.items[index] ? note : null; + } + function removeItem(id, index) { + const note = itemNote(id, index); + if (!note) return; + const removed = note.items[index]; + app.mergeNote(note, { items: note.items.filter((_, i) => i !== index) }); + effects.render(); + api.patchNote(id, { items: note.items }).catch(() => { + app.spliceItems(note, index, 0, removed); effects.render(); effects.showError('Failed to remove item'); + }); + } + function editItem(note, index, text) { + const oldText = (note.items[index].text || '').trim(); + if (text === oldText) { effects.render(); return; } + const oldItem = note.items[index]; + if (!text) app.spliceItems(note, index, 1); + else app.updateItem(note, index, { text }); + api.patchNote(note.id, { items: note.items }).catch(() => { + if (!text) app.spliceItems(note, index, 0, oldItem); + else app.updateItem(note, index, { text: oldText }); + effects.render(); effects.showError('Failed to update item'); + }); + effects.render(); + } + function addItem(id, text, added) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const items = Array.isArray(note.items) ? [...note.items] : []; + items.push({ id: uid(), text, done: false }); + app.mergeNote(note, { items }); + added(); effects.render(); + return () => api.patchNote(id, { items }).catch(() => { + app.mergeNote(note, { items: items.slice(0, -1) }); effects.render(); effects.showError('Failed to add item'); + }); + } + function toggleItem(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + const wasDone = note.items.length > 0 && note.items.every(it => it.done); + app.updateItem(note, index, { done: !note.items[index].done }); + changed(note.items[index].done); + if (!wasDone && note.items.length > 0 && note.items.every(it => it.done)) completed(); + api.patchNote(id, { items: note.items }).catch(() => { + app.updateItem(note, index, { done: !note.items[index].done }); changed(note.items[index].done); + }); + } + async function toggleTodayStep(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + app.updateItem(note, index, { done: !note.items[index].done }); changed(); + try { + await api.patchNote(id, { items: note.items }); effects.render(); + if (note.items.every(it => it.done)) completed(); + } catch { app.updateItem(note, index, { done: !note.items[index].done }); } + } + return { removeItem, editItem, addItem, toggleItem, toggleTodayStep }; +} diff --git a/frontend/src/features/notes/application/deletion.js b/frontend/src/features/notes/application/deletion.js new file mode 100644 index 000000000..e31e9b422 --- /dev/null +++ b/frontend/src/features/notes/application/deletion.js @@ -0,0 +1,51 @@ +/** Confirmed deletion and past-reminder clearing workflows. */ +export function createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects, + state +}) { + async function _deleteNote(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); effects.showToast('Deleted'); } + catch (err) { effects.showError(err.message); } + } + + async function _clearPastReminders() { + const targets = state.notes.filter(n => !n.archived && _isPastReminder(n)); + if (!targets.length) { + effects.showToast?.('No past reminders to clear'); + return; + } + const ok = effects.styledConfirm + ? await effects.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }) + : effects.confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`); + if (!ok) return; + await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {}))); + await _fetchNotes(); + _renderNotes(); + effects.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`); + } + + async function deleteFromEditor(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + const index = state.notes.findIndex(note => note.id === id); + if (index >= 0) app.removeNoteAt(index); + app.setEditingId(null); + _renderNotes(); + _deleteNoteApi(id).then(() => effects.showToast('Deleted')).catch(() => { + effects.showError('Failed to delete'); + _fetchNotes().then(() => _renderNotes()); + }); + } + return { _deleteNote, _clearPastReminders, deleteFromEditor }; +} diff --git a/frontend/src/features/notes/application/drafts.js b/frontend/src/features/notes/application/drafts.js new file mode 100644 index 000000000..4f7a4715d --- /dev/null +++ b/frontend/src/features/notes/application/drafts.js @@ -0,0 +1,55 @@ +/** Serialized autosave and synchronous local draft backup. DOM collection is a supplied port. */ +export function createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects +}) { + const state = app.state; + const { _draftKey, _clearDraft } = persistence; + function bindDraft({ collect, id, view }) { + let t = null; + let pending = Promise.resolve(); + let stopped = false; + let warned = false; + const save = () => { + const d = collect(); + // An empty edit can be intentional: don't restore stale server text over it. + try { persistence.setItem(_draftKey(id), JSON.stringify(d)); } + catch { + if (!warned) effects.showError('Draft backup unavailable. Keep this note open until saved.'); + warned = true; + } + return d; + }; + const persist = (d) => { + if (!id || id === '__new__' || d.note_type === 'draw') return pending; + const { _ts, ...payload } = d; + payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null; + pending = pending.then(async () => { + try { + await api.patchNote(id, payload); + view.failed = false; + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, payload); + if (persistence.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id); + } catch { + if (!view.failed) effects.showError('Note not synced. Your edits are kept on this device; retry Save.'); + view.failed = true; + } + }); + return pending; + }; + const flush = () => { scheduler.clearTimeout(t); return save(); }; + const stop = () => { stopped = true; scheduler.clearTimeout(t); return pending; }; + const sched = () => { + const d = save(); + if (stopped) return; + scheduler.clearTimeout(t); + t = scheduler.setTimeout(() => persist(d), 500); + }; + return { flush, stop, schedule: sched }; + } + return { bindDraft }; +} diff --git a/frontend/src/features/notes/application/queries.js b/frontend/src/features/notes/application/queries.js new file mode 100644 index 000000000..6a92250c9 --- /dev/null +++ b/frontend/src/features/notes/application/queries.js @@ -0,0 +1,66 @@ +/** Application-owned filtering, searching and note ordering. */ +import { _noteTags, _visibleNoteTags, _nextGoalStep, _isCalendarReminderNote } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createQueries({ + _hasActiveReminder, + state +}) { + function _sortedVisibleNotes() { + const displayNotes = _userVisibleNotes(); + let filtered = state.activeLabel ? displayNotes.filter(n => _noteTags(n).includes(state.activeLabel)) : displayNotes; + if (state.activeFilter === 'reminders') { + filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date)); + } else if (state.activeFilter === 'no-reminders') { + filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date))); + } else if (state.activeFilter === 'default') { + filtered = filtered.filter(n => _visibleNoteTags(n).length === 0); + } else if (state.activeFilter === 'goals') { + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived); + } else if (state.activeFilter === 'today') { + // Today view: only goals that still have an unchecked step. + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)); + } + if (state.searchQuery) { + filtered = filtered.filter(n => { + const q = state.searchQuery; + if ((n.title || '').toLowerCase().includes(q)) return true; + if ((n.content || '').toLowerCase().includes(q)) return true; + if ((n.label || '').toLowerCase().includes(q)) return true; + if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true; + return false; + }); + } + const sorted = [...filtered].sort((a, b) => { + // In reminders view: sort by due date ascending (soonest first) + if (state.activeFilter === 'reminders') { + const da = new Date(a.due_date || 0).getTime(); + const db = new Date(b.due_date || 0).getTime(); + return da - db; + } + // Archived view: newest archived first (ignore manual sort_order). + if (state.showingArchived) { + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + } + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + // Active reminders (due date in the past, not done/archived) rank + // immediately under the pinned block. + const aActive = _hasActiveReminder(a); + const bActive = _hasActiveReminder(b); + if (aActive && !bActive) return -1; + if (!aActive && bActive) return 1; + const so = (a.sort_order || 0) - (b.sort_order || 0); + if (so !== 0) return so; + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + }); + + + return sorted; + } + + function _userVisibleNotes() { + return state.notes.filter(n => !_isCalendarReminderNote(n)); + } + + return { _sortedVisibleNotes, _userVisibleNotes }; +} diff --git a/frontend/src/features/notes/application/reminders.js b/frontend/src/features/notes/application/reminders.js new file mode 100644 index 000000000..5e2475993 --- /dev/null +++ b/frontend/src/features/notes/application/reminders.js @@ -0,0 +1,182 @@ +/** Page-owned reminder scheduler and notification delivery workflow. */ +import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js'; +export function createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + effects, + fetchNotes, + isNoteFullyDone, + hasItems, + dismissedKey +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders } = persistence; + const _patchNote = api.patchNote; + const _fetchNotes = fetchNotes; + const _isNoteFullyDone = isNoteFullyDone; + const _hasItems = hasItems; + const REMINDER_DISMISSED_AT_KEY = dismissedKey; + const _updateRailBadge = effects.badge; + let _reminderTimer = null; + let startupTimer = null; + let started = false; + let disposed = false; + function _checkReminders() { + if (!state.notes.length) return; + const now = clock.now(); + const fired = _loadFiredReminders(); + let changed = false; + for (const note of state.notes) { + if (!note.due_date || note.archived) continue; + if (!_hasTimeComponent(note.due_date)) continue; + if (fired.has(note.id)) continue; + const due = new Date(note.due_date).getTime(); + if (isNaN(due)) continue; + if (due <= now && due > now - 60000) { + _fireReminder(note); + // Recurring? advance the due_date instead of marking as fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + // Don't add to fired : new due_date is in the future + continue; + } + } + fired.add(note.id); + changed = true; + } else if (due <= now - 60000) { + // Past, never seen : silently advance recurring or mark fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + continue; + } + } + fired.add(note.id); + changed = true; + } + } + if (changed) _saveFiredReminders(fired); + // Always refresh badge : fired state may have changed visually without note mutation + _updateRailBadge(); + } + + function _fireReminder(note) { + const title = note.title || 'Note reminder'; + // Include the verbatim note content so the email/notification actually + // shows what to do, not just a count. Cap the per-item lines (8 max) and + // total length so the body stays inbox-friendly. + let rawBody; + if (_hasItems(note)) { + const pending = (note.items || []) + .filter(i => !i.done && !i.checked) + .map(i => (i.text || '').trim()) + .filter(Boolean); + if (pending.length) { + const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n'); + const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : ''; + rawBody = `Pending (${pending.length}):\n${shown}${extra}`; + } else { + rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`; + } + } else { + rawBody = (note.content || '').slice(0, 400); + } + + // Ask the server to dispatch according to user settings. The server may + // return an LLM-written synthesis line and/or send an email. We still show + // a local browser notification so the user gets immediate feedback even if + // the server path is disabled or slow. + const showLocal = body => notifications.show(title, body, note.id); + + // Fire-and-forget server dispatch. If synthesis comes back quickly enough, + // use it as the notification body; otherwise the local notification has + // already shown with the raw body. + let shown = false; + const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500); + + api.fireReminder({ note_id: note.id, title, body: rawBody }) + .then(r => r.ok ? r.json() : null) + .then(data => { + scheduler.clearTimeout(timer); + if (shown) return; + shown = true; + const body = (data && data.synthesis) ? data.synthesis : rawBody; + showLocal(body); + }) + .catch(() => { + scheduler.clearTimeout(timer); + if (!shown) { shown = true; showLocal(rawBody); } + }); + + // Pulse the card if visible; otherwise queue it so the next time the user + // opens the notes panel the card gets a brief glow. + effects.highlight(note.id); + } + + function _startReminderLoop() { + if (disposed || _reminderTimer) return; + _reminderTimer = scheduler.setInterval(_checkReminders, 30000); + _checkReminders(); // run once immediately + } + + function _countDueReminders() { + return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length; + } + + function _countFiredReminders() { + // Reminders whose time has actually passed (not just date-today), + // and which fired after the last user dismissal. + const now = clock.now(); + return state.notes.filter(n => { + if (n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date || !_hasTimeComponent(n.due_date)) return false; + const t = new Date(n.due_date).getTime(); + if (isNaN(t) || t > now) return false; + return t > state.firedDotDismissedAt; + }).length; + } + + function dismissFiredReminderDot() { + app.setFiredDotDismissedAt(clock.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + _updateRailBadge(); + } + + async function refreshDueBadge(opts = {}) { + // Usually lightweight, but callers that just created a note reminder can + // force a refresh so the background reminder loop sees it immediately. + if (opts.force || state.notes.length === 0) { + try { + const wasArchived = state.showingArchived; + app.setShowingArchived(false); + await _fetchNotes(); + app.setShowingArchived(wasArchived); + } catch {} + } + _updateRailBadge(); + } + + async function _initReminders() { + try { + const res = await api.listNotes(false); + if (res.ok && !disposed) { + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + _startReminderLoop(); + } + } catch {} + } + + function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); } + function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; } + return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders }; +} diff --git a/frontend/src/features/notes/application/selection.js b/frontend/src/features/notes/application/selection.js new file mode 100644 index 000000000..6017025e2 --- /dev/null +++ b/frontend/src/features/notes/application/selection.js @@ -0,0 +1,39 @@ +/** Selected-note attachment retains synchronous acceptance; bulk requests retain partial-failure behavior. */ +export function createSelectionOperations({ + app, + api, + effects, + sendContext, + exitSelection, + fetchNotes +}) { + const state = app.state; + function attachSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + const detail = { noteIds: ids, accepted: false }; + sendContext(detail); + if (!detail.accepted) { + effects.showToast('Choose a chat or model before attaching notes'); + return; + } + exitSelection(); + } + async function archiveSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + await Promise.all(ids.map(id => api.patchNote(id, { archived: true }).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Archived ${ids.length}`); + } + async function deleteSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + if (effects.styledConfirm) { + const ok = await effects.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + } + await Promise.all(ids.map(id => api.deleteNote(id).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Deleted ${ids.length}`); + } + return { attachSelected, archiveSelected, deleteSelected }; +} diff --git a/frontend/src/features/notes/application/state.js b/frontend/src/features/notes/application/state.js new file mode 100644 index 000000000..b87ef11c4 --- /dev/null +++ b/frontend/src/features/notes/application/state.js @@ -0,0 +1,83 @@ +/** The sole Notes state owner. Views have live read-only access; writes use operations. */ +export function createNotesState(initial = {}) { + const values = { + open: false, notes: [], editingId: null, selectedIds: new Set(), + activeLabel: null, activeFilter: null, reminderChipNext: 'reminders', searchQuery: '', + viewMode: initial.viewMode || 'list', showingArchived: false, selectMode: false, + suppressNextNoteTapId: null, labelsCollapsed: !!initial.labelsCollapsed, + loading: false, firedDotDismissedAt: initial.firedDotDismissedAt || 0, + }; + // Preserve captured note identity across asynchronous completions without + // giving a view a writable reference to the application store. + const views = new WeakMap(), targets = new WeakMap(); + const unwrap = value => targets.get(value) || value; + // Copies built from views (spread, filter, map) still hold nested views; + // store only raw data so the owner never keeps its own read-only views. + function unwrapDeep(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return targets.get(value); + if (seen.has(value)) return value; + seen.add(value); + if (Array.isArray(value)) value.forEach((item, index) => { value[index] = unwrapDeep(item, seen); }); + else if (Object.getPrototypeOf(value) === Object.prototype) for (const key of Object.keys(value)) value[key] = unwrapDeep(value[key], seen); + return value; + } + function readonly(value) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return value; + if (views.has(value)) return views.get(value); + const view = new Proxy(value, { + get(target, key) { + if (target instanceof Set) { + if (['add', 'delete', 'clear'].includes(key)) return () => { throw new TypeError('Use Notes selection operations'); }; + if (key === 'valueOf') return () => views.get(target); + if (key === 'forEach') return (callback, thisArg) => target.forEach(value => callback.call(thisArg, value, value, views.get(target))); + const member = Reflect.get(target, key, target); + return typeof member === 'function' ? member.bind(target) : member; + } + return readonly(Reflect.get(target, key)); + }, + set() { throw new TypeError('Use Notes state operations'); }, + deleteProperty() { throw new TypeError('Use Notes state operations'); }, + defineProperty() { throw new TypeError('Use Notes state operations'); }, + setPrototypeOf() { throw new TypeError('Use Notes state operations'); }, + preventExtensions() { throw new TypeError('Use Notes state operations'); }, + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (descriptor && 'value' in descriptor) return { ...descriptor, value: readonly(descriptor.value) }; + return descriptor; + }, + }); + views.set(value, view); targets.set(view, value); + return view; + } + const state = Object.freeze(Object.fromEntries(Object.keys(values).map(key => [key, () => readonly(values[key])]))); + const view = Object.freeze(Object.defineProperties({}, Object.fromEntries(Object.entries(state).map(([key, get]) => [key, { get, enumerable: true }])))); + return { + state: view, + setOpen: value => { values.open = value; }, + setEditingId: value => { values.editingId = value; }, + setActiveLabel: value => { values.activeLabel = value; }, + setActiveFilter: value => { values.activeFilter = value; }, + setReminderChipNext: value => { values.reminderChipNext = value; }, + setSearchQuery: value => { values.searchQuery = value; }, + setViewMode: value => { values.viewMode = value; }, + setShowingArchived: value => { values.showingArchived = value; }, + setSelectMode: value => { values.selectMode = value; }, + setSuppressNextNoteTapId: value => { values.suppressNextNoteTapId = value; }, + setLabelsCollapsed: value => { values.labelsCollapsed = value; }, + setLoading: value => { values.loading = value; }, + setFiredDotDismissedAt: value => { values.firedDotDismissedAt = value; }, + replaceNotes: notes => { values.notes = notes; }, + removeNoteAt: index => readonly(values.notes.splice(index, 1)[0]), + insertNoteAt: (index, note) => { values.notes.splice(index, 0, unwrapDeep(note)); }, + prependNote: note => { values.notes.unshift(unwrapDeep(note)); }, + replaceNoteAt: (index, note) => { values.notes[index] = unwrapDeep(note); }, + mergeNote: (note, patch) => { for (const [key, value] of Object.entries(patch)) unwrap(note)[key] = unwrapDeep(value); }, + updateItem: (note, index, patch) => { Object.assign(unwrap(unwrap(note).items[index]), unwrapDeep({ ...patch })); }, + spliceItems: (note, ...args) => unwrap(unwrap(note).items).splice(...args.map(arg => unwrapDeep(arg))), + selectId: id => { values.selectedIds.add(id); }, + deselectId: id => values.selectedIds.delete(id), + clearSelection: () => values.selectedIds.clear(), + }; +} diff --git a/frontend/src/features/notes/application/submission.js b/frontend/src/features/notes/application/submission.js new file mode 100644 index 000000000..4703eb7c1 --- /dev/null +++ b/frontend/src/features/notes/application/submission.js @@ -0,0 +1,69 @@ +/** Editor submission ordering, optimistic state, reminder rearming and save recovery. */ +export function createNotesSubmission({ + app, + api, + persistence, + clock, + uid, + effects, + fetchNotes +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders, _loadGlowedReminders, _saveGlowedReminders, _loadDraft, _clearDraft } = persistence; + function submitNote(payload, note, isEdit) { + // Reset fired reminder if due_date changed (so re-arm works), and also + // clear the entry-glow seen flag so the new firing glows again on the + // next time the user opens the panel. + if (isEdit && note.due_date !== payload.due_date) { + const fired = _loadFiredReminders(); + fired.delete(note.id); + _saveFiredReminders(fired); + const glowed = _loadGlowedReminders(); + glowed.delete(note.id); + _saveGlowedReminders(glowed); + effects.clearGlow(note.id); + } + // Edited notes move to the top of their section (under pinned). Compute + // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above + // siblings; the pin block keeps its own ordering above this. + if (!payload.pinned) { + // Both edits AND newly-created notes anchor above the rest of the + // unpinned section. Without this, freshly created notes sit at the + // bottom because manually-reordered siblings already carry negative + // sort_order values. + const minUnpinned = state.notes + .filter(n => !n.pinned && (!isEdit || n.id !== note.id)) + .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + payload.sort_order = minUnpinned - 1; + } + // Optimistic update : update local state first, render, then save in background + app.setEditingId(null); + const draftId = isEdit ? note.id : '__new__'; + const submittedDraft = JSON.stringify(_loadDraft(draftId)); + if (isEdit) { + const idx = state.notes.findIndex(n => n.id === note.id); + if (idx >= 0) app.replaceNoteAt(idx, { ...state.notes[idx], ...payload }); + } else { + app.prependNote({ ...payload, id: 'tmp_' + uid(), created_at: new Date(clock.now()).toISOString(), updated_at: new Date(clock.now()).toISOString() }); + } + effects.render(); + // Background save + api.saveNote(payload).then(saved => { + if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId); + if (!isEdit && saved && saved.id) { + // Replace temp ID with real one from server. AND re-render : the + // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign + // bumps the in-memory id, so all subsequent clicks (edit, done, copy, + // archive, delete) silently fail to find the note in `_notes`. + const tmp = state.notes.find(n => n.id.startsWith('tmp_')); + if (tmp) app.mergeNote(tmp, saved); + effects.render(); + } + }).catch(err => { + effects.showError('Save failed: ' + err.message); + fetchNotes().then(() => effects.render()); + }); + + } + return { submitNote }; +} diff --git a/frontend/src/features/notes/application/workflows.js b/frontend/src/features/notes/application/workflows.js new file mode 100644 index 000000000..018cbf8aa --- /dev/null +++ b/frontend/src/features/notes/application/workflows.js @@ -0,0 +1,78 @@ +/** Notes CRUD, optimistic archive and in-memory undo workflows. */ +export function createNotesWorkflows({ + app, + api, + effects +}) { + const state = app.state; + const _undoStack = []; + const _renderNotes = effects.render; + const _patchNote = api.patchNote; + const uiModule = effects; + function _pushUndo(entry) { + _undoStack.push(entry); + if (_undoStack.length > 20) _undoStack.shift(); + } + + function _popAndRunUndo() { + const entry = _undoStack.pop(); + if (entry) entry.run(); + return !!entry; + } + + function _undoArchive(note, prevIdx) { + // Re-insert at original position and clear archived flag on the server. + const safeIdx = Math.min(Math.max(prevIdx, 0), state.notes.length); + app.insertNoteAt(safeIdx, { ...note, archived: false }); + _renderNotes(); + _patchNote(note.id, { archived: false }).catch(() => { + // Roll back local insertion if the server refuses + const i = state.notes.findIndex(n => n.id === note.id); + if (i >= 0) app.removeNoteAt(i); + _renderNotes(); + uiModule.showError('Undo failed'); + }); + } + + function _archiveNoteById(id, { card = null, celebrate = false } = {}) { + if (!id) return false; + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return false; + const note = state.notes[idx]; + if (celebrate && card && note) effects.celebrateArchive(note, card); + const removed = app.removeNoteAt(idx); + app.setEditingId(null); + _renderNotes(); + const undo = () => _undoArchive(removed, idx); + _pushUndo({ label: 'archive', run: undo }); + _patchNote(id, { archived: true }).then(() => { + effects.archived(undo); + }).catch(() => { + app.insertNoteAt(idx, removed); + _renderNotes(); + uiModule.showError('Failed to archive'); + }); + return true; + } + + async function _fetchNotes() { + app.setLoading(true); + try { + const res = await api.listNotes(state.showingArchived); + if (!res.ok) { app.replaceNotes([]); return; } + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + } catch (e) { + console.error('Failed to fetch notes:', e); + app.replaceNotes([]); + } finally { + app.setLoading(false); + } + } + + function _saveNote(note) { return api.saveNote(note); } + + function _deleteNoteApi(id) { return api.deleteNote(id); } + + return { _pushUndo, _popAndRunUndo, _undoArchive, _archiveNoteById, _fetchNotes, _saveNote, _deleteNoteApi, _patchNote, undoCount: () => _undoStack.length }; +} diff --git a/frontend/src/features/notes/domain/noteRules.js b/frontend/src/features/notes/domain/noteRules.js new file mode 100644 index 000000000..ba3f8ae2f --- /dev/null +++ b/frontend/src/features/notes/domain/noteRules.js @@ -0,0 +1,87 @@ +/** Pure Notes completion, tags, reminder and serialization rules. */ +import { _hasTimeComponent } from './recurrence.js'; +export function _isCalendarReminderNote(n) { + return n && n.source === 'calendar' && n.label === 'calendar'; +} + +export function _hasActiveReminder(n, clock) { + if (!n || n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date) return false; + const t = new Date(n.due_date).getTime(); + return !isNaN(t) && t <= clock.now(); +} + +export function _isNoteFullyDone(note) { + if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) { + return note.items.every(it => it.done); + } + return false; +} + +export function _hasItems(note) { + return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist'); +} + +export function _goalProgress(note) { + if (!Array.isArray(note?.items) || note.items.length === 0) return ''; + const done = note.items.filter(it => it.done).length; + return ` ${done}/${note.items.length}`; +} + +export function _nextGoalStep(note) { + if (!Array.isArray(note?.items)) return null; + for (let i = 0; i < note.items.length; i++) { + if (!note.items[i].done) return { idx: i, item: note.items[i] }; + } + return null; +} + +export function _noteTags(n) { + const tags = []; + if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean)); + if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder'); + return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; +} + +export function _visibleNoteTags(n) { + return _noteTags(n).filter(t => t !== 'reminder'); +} + +export function _isPastReminder(n, clock) { + if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false; + const due = new Date(n.due_date).getTime(); + return !isNaN(due) && due <= clock.now(); +} + +export function _serializeNoteForCopy(note) { + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (Array.isArray(note.items) && note.items.length) { + if (lines.length) lines.push(''); + for (const it of note.items) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + } + return lines.join('\n').trim(); +} + +export function _noteToAgentPrompt(note) { + const parts = []; + if ((note.title || '').trim()) parts.push(note.title.trim()); + if ((note.content || '').trim()) parts.push(note.content.trim()); + if (Array.isArray(note.items)) { + note.items.filter(it => !it.done && (it.text || '').trim()) + .forEach(it => parts.push('- ' + it.text.trim())); + } + const body = parts.join('\n'); + return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : ''; +} + +export function _standardVisualVariant(note) { + const source = String(note?.id || note?.title || note?.created_at || 'standard-note'); + let hash = 0; + for (let i = 0; i < source.length; i++) hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; + return Math.abs(hash) % 5; +} diff --git a/frontend/src/features/notes/domain/recurrence.js b/frontend/src/features/notes/domain/recurrence.js new file mode 100644 index 000000000..f72498fbb --- /dev/null +++ b/frontend/src/features/notes/domain/recurrence.js @@ -0,0 +1,269 @@ +// Local-time date and recurrence rules. Callers supply the clock at the original read sites. +export const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; +export const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +export function _hasTimeComponent(dateStr) { + return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); +} + +export function _formatDueDate(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const hasTime = _hasTimeComponent(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const diffDays = Math.round((due - today) / 86400000); + const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; + if (hasTime && d < now) return 'overdue'; + if (!hasTime && diffDays < 0) return 'overdue'; + if (diffDays === 0) return hasTime ? timeStr : 'today'; + if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; +} + +export function _isDueOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + if (isNaN(d)) return false; + if (_hasTimeComponent(dateStr)) return d < new Date(clock.now()); + return d < new Date(new Date(clock.now()).toDateString()); +} + +export function _isDueTodayOrOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + const now = new Date(clock.now()); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return due <= today; +} + +export function _laterTodayDate(clock) { + const now = new Date(clock.now()); + const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today + // If less than 1 hour before 6pm, push to "in 3 hours" instead + if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); + return eight; +} + +export function _tomorrowDate(clock) { + const t = new Date(clock.now()); + t.setDate(t.getDate() + 1); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _nextWeekDate(clock) { + const t = new Date(clock.now()); + const daysUntilMon = (8 - t.getDay()) % 7 || 7; + t.setDate(t.getDate() + daysUntilMon); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _toLocalDatetimeStr(d) { + // Format as YYYY-MM-DDTHH:MM (local, no TZ) + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function _formatReminderTag(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const sameDay = d.toDateString() === now.toDateString(); + const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = d.toDateString() === tomorrow.toDateString(); + const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + if (sameDay) return `Today, ${time}`; + if (isTomorrow) return `Tomorrow, ${time}`; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return `${dateLabel}, ${time}`; +} + +export function _nthWeekdayLabel(d) { + const n = Math.ceil(d.getDate() / 7); // 1..5 + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; +} + +export function _isLastWeekdayOfMonth(d) { + const test = new Date(d); + test.setDate(d.getDate() + 7); + return test.getMonth() !== d.getMonth(); +} + +export function _nthWeekdayOfMonth(year, month, weekday, n) { + const first = new Date(year, month, 1); + const offset = (weekday - first.getDay() + 7) % 7; + let day = 1 + offset + (n - 1) * 7; + // Last day of month + const lastDay = new Date(year, month + 1, 0).getDate(); + if (day > lastDay) day -= 7; + return new Date(year, month, day, 0, 0, 0); +} + +export function _lastWeekdayOfMonth(year, month, weekday) { + const lastDay = new Date(year, month + 1, 0); + const back = (lastDay.getDay() - weekday + 7) % 7; + return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); +} + +export function _snapToRepeat(currentDate, normRepeat, clock) { + const hh = currentDate.getHours(); + const mm = currentDate.getMinutes(); + const now = clock.now(); + const anchor = currentDate.getTime() > now ? currentDate : new Date(clock.now()); + const parts = normRepeat.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + const targetWd = parseInt(parts[1], 10); + if (isNaN(targetWd)) return null; + const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); + const delta = (targetWd - d.getDay() + 7) % 7; + d.setDate(d.getDate() + delta); + if (d.getTime() <= now) d.setDate(d.getDate() + 7); + return d; + } + if (kind === 'monthly') { + const sub = parts[1]; + let y = anchor.getFullYear(); + let m = anchor.getMonth(); + // Walk forward up to 14 months to find the next matching slot. + for (let tries = 0; tries < 14; tries++) { + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + if (isNaN(wantDay)) return null; + const lastDay = new Date(y, m + 1, 0).getDate(); + target = new Date(y, m, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + if (isNaN(n) || isNaN(wd)) return null; + target = _nthWeekdayOfMonth(y, m, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + if (isNaN(wd)) return null; + target = _lastWeekdayOfMonth(y, m, wd); + } else { + return null; + } + target.setHours(hh, mm, 0, 0); + if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; + m++; + if (m > 11) { m = 0; y++; } + } + return null; + } + return null; +} + +export function _formatRepeatLabel(repeat, originalDate) { + if (!repeat || repeat === 'none') return ''; + const norm = _normalizeRepeat(repeat, originalDate); + if (norm === 'daily') return 'Daily'; + if (norm === 'yearly') return 'Yearly'; + const parts = norm.split(':'); + if (parts[0] === 'weekly') { + const wd = parseInt(parts[1], 10); + if (isNaN(wd)) return 'Weekly'; + return `Weekly on ${_DAYS[wd]}s`; + } + if (parts[0] === 'monthly') { + if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Monthly on last ${_DAYS[wd]}`; + } + } + return norm; +} + +export function _normalizeRepeat(repeat, originalDate) { + if (!repeat || repeat === 'none') return 'none'; + if (repeat === 'daily' || repeat === 'yearly') return repeat; + if (/^(weekly|monthly):/.test(repeat)) return repeat; + // Legacy bare values: derive params from the original date + const wd = originalDate.getDay(); + const n = Math.ceil(originalDate.getDate() / 7); + if (repeat === 'weekly') return `weekly:${wd}`; + if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; + if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; + if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; + return repeat; +} + +export function _advanceRecurring(dateStr, repeat, clock) { + const orig = new Date(dateStr); + const hh = orig.getHours(); + const mm = orig.getMinutes(); + let d = new Date(orig); + const norm = _normalizeRepeat(repeat, orig); + if (norm === 'none') return null; + + function step() { + if (norm === 'daily') { + d.setDate(d.getDate() + 1); + return; + } + if (norm === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + return; + } + const parts = norm.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + // Snap to the requested weekday in the next 1-7 days + const targetWd = parseInt(parts[1], 10); + let delta = (targetWd - d.getDay() + 7) % 7; + if (delta === 0) delta = 7; + d.setDate(d.getDate() + delta); + d.setHours(hh, mm, 0, 0); + return; + } + if (kind === 'monthly') { + const sub = parts[1]; + const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); + const nm = (d.getMonth() + 1) % 12; + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + const lastDay = new Date(ny, nm + 1, 0).getDate(); + target = new Date(ny, nm, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + target = _nthWeekdayOfMonth(ny, nm, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + target = _lastWeekdayOfMonth(ny, nm, wd); + } else { + d = null; return; + } + target.setHours(hh, mm, 0, 0); + d = target; + return; + } + d = null; + } + + step(); + if (d === null) return null; + const now = clock.now(); + // Cap catch-up to avoid runaway on a malformed/very-old date. + let guard = 5000; + while (d.getTime() <= now) { + if (--guard <= 0) return null; + step(); + if (d === null) return null; + } + return _toLocalDatetimeStr(d); +} diff --git a/frontend/src/features/notes/index.js b/frontend/src/features/notes/index.js new file mode 100644 index 000000000..146844b8a --- /dev/null +++ b/frontend/src/features/notes/index.js @@ -0,0 +1,503 @@ +/** Notes composition and intentional public API. The legacy entry activates compatibility startup. */ +import { createSelectionOperations } from './application/selection.js'; +import { createToolbar } from './ui/toolbar.js'; +import { createWindowLayout } from './ui/windowLayout.js'; +import { createCardOperations } from './application/cardOperations.js'; +import { createChecklistOperations } from './application/checklistOperations.js'; +import { createNotesSubmission } from './application/submission.js'; +import { createNotesDrafts } from './application/drafts.js'; +import { createNotesLifecycle } from './ui/lifecycle.js'; +import * as noteRules from './domain/noteRules.js'; +import { createText } from './ui/text.js'; +import { createColors } from './ui/colors.js'; +import { createPanel } from './ui/panel.js'; +import { createReminderDisplay } from './ui/reminderDisplay.js'; +import { createSelection } from './ui/selection.js'; +import { createFilters } from './ui/filters.js'; +import { createCardRendering } from './ui/cardRendering.js'; +import { createLayout } from './ui/layout.js'; +import { createQuickAdd } from './ui/quickAdd.js'; +import { createCardInteractions } from './ui/cardInteractions.js'; +import { createCardChecklist } from './ui/cardChecklist.js'; +import { createCardReorder } from './ui/cardReorder.js'; +import { createDraftEditor } from './ui/draftEditor.js'; +import { createEditor } from './ui/editor.js'; +import { createReminderPicker } from './ui/reminderPicker.js'; +import { createChecklist } from './ui/checklist.js'; +import { createDrawing } from './ui/drawing.js'; +import { createClipboard } from './ui/clipboard.js'; +import { createMobileEditor } from './ui/mobileEditor.js'; +import { createMobileReorder } from './ui/mobileReorder.js'; +import { createChecklistReorder } from './ui/checklistReorder.js'; +import { createQueries } from './application/queries.js'; +import { createAgentActions } from './application/agentActions.js'; +import { createDeletion } from './application/deletion.js'; +import { createNotesReminders } from './application/reminders.js'; +import { createNotesNotifications } from './infrastructure/notifications.js'; +import { createNotesState } from './application/state.js'; +import { createNotesWorkflows } from './application/workflows.js'; +import { createNotesApi } from './infrastructure/http.js'; +import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY, NOTES_FIRST_OPEN_HINT_KEY, NOTES_LABELS_COLLAPSED_KEY } from './infrastructure/storage.js'; +import * as dates from './domain/recurrence.js'; +import uiModule from '/static/js/ui.js?v=20260916largetoolscroll1'; +import { spawnConfetti } from '/static/js/shared/ui/confetti.js'; +import * as Modals from '/static/js/modalManager.js'; +import { attachColorPicker } from '/static/js/colorPicker.js?v=20260910eyedropper1'; +import { makeWindowDraggable } from '/static/js/windowDrag.js'; +import { snapModalToZone } from '/static/js/tileManager.js?v=20260910responsivebounds1'; +import { applyEdgeDock, clearDockSide } from '/static/js/modalSnap.js'; +import { topToolWindowZ, topPortalZ } from '/static/js/toolWindowZOrder.js'; +import { bindMenuDismiss, dismissOrRemove } from '/static/js/escMenuStack.js'; +import { bindTourHintDismiss } from '/static/js/tourHintDismiss.js'; + +const api = createNotesApi({ origin: window.location.origin, request: (...args) => fetch(...args) }); +const persistence = createNotesPersistence({ storage: typeof localStorage === 'undefined' ? undefined : localStorage }); +const { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft } = persistence; +const clock = { now: () => Date.now() }; +const app = createNotesState({ + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list', + labelsCollapsed: typeof localStorage !== 'undefined' && localStorage.getItem(NOTES_LABELS_COLLAPSED_KEY) === '1', + firedDotDismissedAt: (() => { try { const v = parseInt(persistence.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10); return Number.isFinite(v) && v > 0 ? v : 0; } catch { return 0; } })(), +}); +const state = app.state; +const lifecycle = createNotesLifecycle(); +const scheduler = { setTimeout: (...args) => setTimeout(...args), clearTimeout: id => clearTimeout(id), setInterval: (...args) => setInterval(...args), clearInterval: id => clearInterval(id) }; +const notifications = createNotesNotifications({ browser: window, notify: window.Notification, openPanel: () => openPanel(), toast: title => uiModule?.showToast?.(title) }); +const reminders = createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + fetchNotes: () => _fetchNotes(), + isNoteFullyDone: note => _isNoteFullyDone(note), + hasItems: note => _hasItems(note), + dismissedKey: REMINDER_DISMISSED_AT_KEY, + effects: { badge: () => _updateRailBadge(), highlight: id => { + _setReminderCardGlow(id, true); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (card) { card.classList.add('note-card-reminder-fired'); setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000); } + else _queuePendingHighlight(id); +} } +}); +const workflows = createNotesWorkflows({ + app, + api, + effects: { + render: () => _renderNotes(), + showError: (...args) => uiModule.showError(...args), + archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }), + celebrateArchive: (note, card) => { if (_hasItems(note) && !(note.items || []).some(i => !i.done)) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); } }, +} +}); +const _NOTE_UNDO_ICON = ''; +function _pushUndo(entry) { return workflows._pushUndo(entry); } +function _popAndRunUndo() { return workflows._popAndRunUndo(); } +function _undoArchive(note, prevIdx) { return workflows._undoArchive(note, prevIdx); } +function _archiveNoteById(id, options) { return workflows._archiveNoteById(id, options); } +function _fetchNotes() { return workflows._fetchNotes(); } + +function _deleteNoteApi(id) { return workflows._deleteNoteApi(id); } +function _patchNote(id, patch) { return workflows._patchNote(id, patch); } +function _uid() { return Math.random().toString(36).slice(2, 10); } +function _formatDueDate(dateStr) { return dates._formatDueDate(dateStr, clock); } +function _isDueOverdue(dateStr) { return dates._isDueOverdue(dateStr, clock); } + +function _laterTodayDate() { return dates._laterTodayDate(clock); } +function _tomorrowDate() { return dates._tomorrowDate(clock); } +function _nextWeekDate() { return dates._nextWeekDate(clock); } +function _formatReminderTag(dateStr) { return dates._formatReminderTag(dateStr, clock); } +function _snapToRepeat(currentDate, normRepeat) { return dates._snapToRepeat(currentDate, normRepeat, clock); } +function _ensureNotificationPermission() { return notifications.ensurePermission(); } + +function _startReminderLoop() { return reminders._startReminderLoop(); } + +function _countFiredReminders() { return reminders._countFiredReminders(); } + +function _hasActiveReminder(...args) { return noteRules._hasActiveReminder(...args, clock); } +const _isNoteFullyDone = noteRules._isNoteFullyDone; +const _hasItems = noteRules._hasItems; +function _isPastReminder(...args) { return noteRules._isPastReminder(...args, clock); } +function _esc(...args) { return text._esc(...args); } + +function _safeImgSrc(...args) { return text._safeImgSrc(...args); } +function _linkify(...args) { return text._linkify(...args); } +function _isBgImage(...args) { return colors._isBgImage(...args); } + +function _dotBg(...args) { return colors._dotBg(...args); } +function _dotIsActive(...args) { return colors._dotIsActive(...args); } +function _customColorStyle(...args) { return colors._customColorStyle(...args); } +function _pickCustomBgImage(...args) { return colors._pickCustomBgImage(...args); } +function _wireFormColors(...args) { return colors._wireFormColors(...args); } + +function openPanel(...args) { return panel.openPanel(...args); } + +function _smoothScrollToNoteCard(...args) { return panel._smoothScrollToNoteCard(...args); } +function _clearViewedReminderGlows(...args) { return reminderDisplay._clearViewedReminderGlows(...args); } +function _setReminderCardGlow(...args) { return reminderDisplay._setReminderCardGlow(...args); } +function _queuePendingHighlight(...args) { return reminderDisplay._queuePendingHighlight(...args); } +function _flushPendingHighlights(...args) { return reminderDisplay._flushPendingHighlights(...args); } +function _updateRailBadge(...args) { return reminderDisplay._updateRailBadge(...args); } +function _enterSelectMode(...args) { return selection._enterSelectMode(...args); } +function _exitSelectMode(...args) { return selection._exitSelectMode(...args); } +function _updateBulkBar(...args) { return selection._updateBulkBar(...args); } + +function _renderLabelsInto(...args) { return filters._renderLabelsInto(...args); } +function _renderLoadingSkeleton(...args) { return cardRendering._renderLoadingSkeleton(...args); } +function _renderNotes(...args) { return cardRendering._renderNotes(...args); } +function _captureCardPositions(...args) { return layout._captureCardPositions(...args); } +function _animateReflow(...args) { return layout._animateReflow(...args); } +function _applyMasonry(...args) { return layout._applyMasonry(...args); } +function _renderQuickAdd(...args) { return quickAdd._renderQuickAdd(...args); } + +function _editNote(...args) { return quickAdd._editNote(...args); } +function _bindCardEvents(...args) { return cardInteractions._bindCardEvents(...args); } +function _wireCardChecklist(...args) { return cardChecklist._wireCardChecklist(...args); } +function _wireTodayView(...args) { return cardChecklist._wireTodayView(...args); } +function _wireCardReorder(...args) { return cardReorder._wireCardReorder(...args); } + +function _wireDraftAutosave(...args) { return draftEditor._wireDraftAutosave(...args); } +function _commitOpenInPlaceEditor(...args) { return draftEditor._commitOpenInPlaceEditor(...args); } +function _applyDraftToNote(...args) { return draftEditor._applyDraftToNote(...args); } +function _buildForm(...args) { return editor._buildForm(...args); } +function _wireReminderPicker(...args) { return reminderPicker._wireReminderPicker(...args); } +function _buildGoalHtml(...args) { return checklist._buildGoalHtml(...args); } +function _wireGoalForm(...args) { return checklist._wireGoalForm(...args); } +function _buildChecklistHtml(...args) { return checklist._buildChecklistHtml(...args); } +function _wireRow(...args) { return checklist._wireRow(...args); } +function _wireChecklist(...args) { return checklist._wireChecklist(...args); } +function _collectItems(...args) { return checklist._collectItems(...args); } +function _buildDrawHtml(...args) { return drawing._buildDrawHtml(...args); } +function _wireCanvas(...args) { return drawing._wireCanvas(...args); } +function _uploadCanvasAsPng(...args) { return drawing._uploadCanvasAsPng(...args); } +function _openNoteCornerMenu(...args) { return clipboard._openNoteCornerMenu(...args); } +function _copyNote(...args) { return clipboard._copyNote(...args); } +function _isNotesMobileMode(...args) { return mobileEditor._isNotesMobileMode(...args); } +function _openMobileFullscreenEdit(...args) { return mobileEditor._openMobileFullscreenEdit(...args); } +function _closeMobileFullscreenEdit(...args) { return mobileEditor._closeMobileFullscreenEdit(...args); } + +function _bindLongPressDrag(...args) { return mobileReorder._bindLongPressDrag(...args); } + +function _wireChecklistTouchReorder(...args) { return checklistReorder._wireChecklistTouchReorder(...args); } + +function _sortedVisibleNotes(...args) { return queries._sortedVisibleNotes(...args); } +function _userVisibleNotes(...args) { return queries._userVisibleNotes(...args); } +function _agentSolveNote(...args) { return agentActions._agentSolveNote(...args); } + +function _clearPastReminders(...args) { return deletion._clearPastReminders(...args); } +const text = createText({ + uiModule +}); +const colors = createColors({ + api +}); +const { COLORS, COLOR_HEX } = colors; +const windowLayout = createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}); +const toolbar = createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}); +const panel = createPanel({ + wireToolbar: toolbar.wireToolbar, + wireSelectionActions: () => selection.wireSelectionActions(), + _wireNotesWindow: windowLayout._wireNotesWindow, + _restoreNotesSidebarDock: windowLayout._restoreNotesSidebarDock, + _bringNotesToFront: windowLayout._bringNotesToFront, + disposeUI: () => { layout.dispose(); mobileReorder.dispose(); checklistReorder.dispose(); lifecycle.dispose(); }, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}); +const reminderDisplay = createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}); +const selectionOperations = createSelectionOperations({ + app, + api, + fetchNotes: _fetchNotes, + exitSelection: _exitSelectMode, + sendContext: detail => window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail })), + effects: { render: _renderNotes, styledConfirm: uiModule.styledConfirm, showToast: (...args) => uiModule.showToast(...args) } +}); +const selection = createSelection({ + operations: selectionOperations, + _renderNotes, + app, + state +}); +const filters = createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}); +const cardRendering = createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}); +const layout = createLayout({ + isOpen: () => state.open +}); +const quickAdd = createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}); +const cardOperations = createCardOperations({ + app, + api, + pushUndo: _pushUndo, + undoArchive: _undoArchive, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), showToast: (...args) => uiModule.showToast(...args), archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }) } +}); +const checklistOperations = createChecklistOperations({ + app, + api, + uid: _uid, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args) } +}); +const cardInteractions = createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}); +const cardChecklist = createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}); +const cardReorder = createCardReorder({ + persistOrder: cardOperations.persistOrder, + lifecycle, + _isNotesMobileMode, + state +}); +const draftEditor = createDraftEditor({ + bindDraft: (...args) => drafts.bindDraft(...args), + _collectItems, + _loadDraft +}); +const editor = createEditor({ + deleteFromEditor: id => deletion.deleteFromEditor(id), + submitNote: (...args) => submission.submitNote(...args), + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}); +const reminderPicker = createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}); +const checklist = createChecklist({ + _esc, + _uid, + spawnConfetti +}); +const drawing = createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}); +const clipboard = createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}); +const mobileEditor = createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}); +const mobileReorder = createMobileReorder({ + commitMobileOrder: cardOperations.commitMobileOrder, + lifecycle, + _applyMasonry, + _enterSelectMode, + app +}); +const checklistReorder = createChecklistReorder({ + lifecycle +}); +const queries = createQueries({ + _hasActiveReminder, + state +}); +const agentActions = createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData: () => new FormData(), + state, + streamComplete: sid => { try { window.sessionModule?.markStreamComplete?.(sid); } catch {} }, + uiModule +}); +const deletion = createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects: { styledConfirm: uiModule.styledConfirm, confirm: message => confirm(message), showToast: (...args) => uiModule.showToast(...args), showError: (...args) => uiModule.showError(...args) }, + state +}); +const notesModule = { openPanel: panel.openPanel, closePanel: panel.closePanel, togglePanel: panel.togglePanel, isPanelOpen: panel.isPanelOpen, openNote: panel.openNote, newNote: quickAdd.newNote, openNotes: panel.openPanel, closeNotes: panel.closePanel, isNotesOpen: panel.isPanelOpen, refreshDueBadge: reminders.refreshDueBadge }; +const publicOpenPanel = panel.openPanel, publicClosePanel = panel.closePanel, publicTogglePanel = panel.togglePanel, publicIsPanelOpen = panel.isPanelOpen, publicOpenNote = panel.openNote, publicNewNote = quickAdd.newNote, publicRefreshDueBadge = reminders.refreshDueBadge, publicDismissFiredReminderDot = reminders.dismissFiredReminderDot; +export { publicOpenPanel as openPanel, publicClosePanel as closePanel, publicTogglePanel as togglePanel, publicIsPanelOpen as isPanelOpen, publicOpenNote as openNote, publicNewNote as newNote, publicRefreshDueBadge as refreshDueBadge, publicDismissFiredReminderDot as dismissFiredReminderDot, publicOpenPanel as openNotes, publicClosePanel as closeNotes, publicIsPanelOpen as isNotesOpen }; +export function startNotesBackground() { reminders.start(); } +export default notesModule; +const submission = createNotesSubmission({ + app, + api, + persistence, + clock, + uid: _uid, + fetchNotes: _fetchNotes, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), clearGlow: id => _setReminderCardGlow(id, false) } +}); +const drafts = createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects: { showError: (...args) => uiModule.showError(...args) } +}); diff --git a/frontend/src/features/notes/infrastructure/http.js b/frontend/src/features/notes/infrastructure/http.js new file mode 100644 index 000000000..eeafd1b0f --- /dev/null +++ b/frontend/src/features/notes/infrastructure/http.js @@ -0,0 +1,29 @@ +/** Notes HTTP port. Response interpretation stays with the owning workflow. */ +export function createNotesApi({ origin, request }) { + const json = (method, body) => ({ method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + async function saveNote(note) { + const response = await request(note.id ? `${origin}/api/notes/${note.id}` : `${origin}/api/notes`, json(note.id ? 'PUT' : 'POST', note)); + if (!response.ok) throw new Error('Failed to save note'); + return await response.json(); + } + async function patchNote(id, patch) { + const response = await request(`${origin}/api/notes/${id}`, json('PUT', patch)); + if (!response.ok) throw new Error('Failed to update note'); + return await response.json(); + } + async function deleteNote(id) { + const response = await request(`${origin}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' }); + if (!response.ok) throw new Error('HTTP ' + response.status); + } + return { + saveNote, patchNote, deleteNote, + listNotes: archived => request(`${origin}/api/notes${archived ? '?archived=true' : ''}`, { credentials: 'same-origin' }), + reorder: ids => request(`${origin}/api/notes/reorder`, json('POST', { ids })), + upload: body => request(`${origin}/api/upload`, { method: 'POST', body, credentials: 'same-origin' }), + uploadUrl: id => `${origin}/api/upload/${id}`, + fireReminder: body => request('/api/notes/fire-reminder', json('POST', body)), + defaultChat: () => request(`${origin}/api/default-chat`, { credentials: 'same-origin' }), + createSession: body => request(`${origin}/api/session`, { method: 'POST', body, credentials: 'same-origin' }), + startChat: body => request(`${origin}/api/chat_stream`, { method: 'POST', body, credentials: 'same-origin' }), + }; +} diff --git a/frontend/src/features/notes/infrastructure/notifications.js b/frontend/src/features/notes/infrastructure/notifications.js new file mode 100644 index 000000000..cd98eb702 --- /dev/null +++ b/frontend/src/features/notes/infrastructure/notifications.js @@ -0,0 +1,20 @@ +/** Browser notification permission/display port, independent of Notes rendering. */ +export function createNotesNotifications({ browser, notify, openPanel, toast }) { + async function ensurePermission() { + if (!('Notification' in browser)) return false; + if (notify.permission === 'granted') return true; + if (notify.permission === 'denied') return false; + try { return await notify.requestPermission() === 'granted'; } + catch { return false; } + } + function show(title, body, noteId) { + if ('Notification' in browser && notify.permission === 'granted') { + try { + const notification = new notify(title, { body, tag: 'note-' + noteId, icon: '/static/favicon.ico' }); + notification.onclick = () => { browser.focus(); openPanel(); notification.close(); }; + } catch {} + } + toast(title); + } + return { ensurePermission, show }; +} diff --git a/frontend/src/features/notes/infrastructure/storage.js b/frontend/src/features/notes/infrastructure/storage.js new file mode 100644 index 000000000..06aaa01ab --- /dev/null +++ b/frontend/src/features/notes/infrastructure/storage.js @@ -0,0 +1,63 @@ +/** Browser persistence and unchanged Notes key/serialization contracts. */ +export const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired'; +export const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed'; +export const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight'; +export const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'; +export const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at'; +export const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1'; +export const NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'; +export const _DRAFT_PREFIX = 'odysseus-note-draft-'; + +export function createNotesPersistence({ storage: localStorage }) { +function _loadPendingHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _loadGlowedReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveGlowedReminders(set) { + try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {} +} + +function _loadActiveHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveActiveHighlights(set) { + try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {} +} + +function _savePendingHighlights(set) { + try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); } + catch {} +} + +function _loadFiredReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveFiredReminders(set) { + try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); } + catch {} +} + +function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); } + +function _loadDraft(id) { + try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; } +} + +function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} } + +return { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft, + getItem: key => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: key => localStorage.removeItem(key), +}; +} diff --git a/frontend/src/features/notes/ui/cardChecklist.js b/frontend/src/features/notes/ui/cardChecklist.js new file mode 100644 index 000000000..6bbf39326 --- /dev/null +++ b/frontend/src/features/notes/ui/cardChecklist.js @@ -0,0 +1,148 @@ +/** Card checklist editing and Today-step interactions. */ +export function createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}) { + function _wireCardChecklist(body) { + // Remove a single checklist item (hover X) + body.querySelectorAll('.note-checkbox-rm').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + checklistOperations.removeItem(noteId, idx); + }); + }); + + function _startChecklistItemEdit(noteId, idx, span) { + if (span.isContentEditable) return; + const note = state.notes.find(n => n.id === noteId); + if (!note || !Array.isArray(note.items) || !note.items[idx]) return; + + span.textContent = note.items[idx].text || ''; + span.contentEditable = "true"; + span.spellcheck = false; + span.focus(); + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(span); + selection.removeAllRanges(); + selection.addRange(range); + + const save = () => { + if (!span.isContentEditable) return; + span.contentEditable = "false"; + const newText = span.textContent.trim(); + checklistOperations.editItem(note, idx, newText); + }; + + const onKeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + save(); + } else if (e.key === 'Escape') { + e.preventDefault(); + span.contentEditable = "false"; + _renderNotes(); + } + }; + + span.addEventListener('blur', save, { once: true }); + span.addEventListener('keydown', onKeydown); + } + + // Edit a single checklist item (hover Edit button) + body.querySelectorAll('.note-checkbox-edit').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + const span = btn.parentElement.querySelector('.note-check-text'); + if (span) _startChecklistItemEdit(noteId, idx, span); + }); + }); + + // Prevent clicks from toggling the row while actively editing inline + body.querySelectorAll('.note-check-text').forEach(span => { + span.addEventListener('click', (e) => { + if (span.isContentEditable) { + e.stopPropagation(); + } + }); + }); + + // Quick-add new checklist item (hover input at bottom of todo cards) + body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('keydown', async (e) => { + e.stopPropagation(); + if (e.key !== 'Enter') return; + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + const noteId = input.dataset.noteId; + const persist = checklistOperations.addItem(noteId, text, () => { input.value = ''; }); + if (!persist) return; + // Refocus the input on the same card + setTimeout(() => { + const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`); + if (next) next.focus(); + }, 0); + persist(); + }); + }); + + // Checkboxes (dot toggle, optimistic) : disabled in select mode + body.querySelectorAll('.note-checkbox').forEach(el => { + el.addEventListener('click', (e) => { + if (state.selectMode) return; // let card-level handler take over + e.preventDefault(); + e.stopPropagation(); + const noteId = el.dataset.noteId; + const idx = parseInt(el.dataset.idx); + checklistOperations.toggleItem(noteId, idx, done => el.classList.toggle('done', done), () => { + const card = el.closest('.note-card'); + if (card) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); } + }); + }); + }); + + + } + + function _wireTodayView(body) { + body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => { + dot.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = dot.dataset.noteId; + const idx = parseInt(dot.dataset.idx); + const row = dot.closest('.notes-today-row'); + await checklistOperations.toggleTodayStep(id, idx, () => row?.classList.add('done'), () => { + const r = (row || dot).getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); + }); + }); + }); + body.querySelectorAll('.notes-today-title').forEach(el => { + el.addEventListener('click', () => { + const id = el.dataset.noteId; + if (!id) return; + // Drop the Today filter first so the regular card list is rendered; + // _editNote needs to find a .note-card in the DOM to replace with + // the editor form. + app.setActiveFilter(null); + _renderNotes(); + _editNote(id); + }); + }); + } + + return { _wireCardChecklist, _wireTodayView }; +} diff --git a/frontend/src/features/notes/ui/cardInteractions.js b/frontend/src/features/notes/ui/cardInteractions.js new file mode 100644 index 000000000..09e083c41 --- /dev/null +++ b/frontend/src/features/notes/ui/cardInteractions.js @@ -0,0 +1,337 @@ +/** Card taps, pin/color controls, menus and archive animation. */ +export function createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}) { + function _bindCardEvents(body) { + // Skip completed leading rows so the first unfinished checklist item is + // visible when a note preview has its own scroll area. + requestAnimationFrame(() => { + body.querySelectorAll('.note-checklist-preview').forEach(preview => { + const firstPending = preview.querySelector('.note-checkbox:not(.done)'); + if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4); + }); + }); + const tapToEditOrSelect = (cardEl) => { + const id = cardEl.dataset.noteId; + // A long-press enters select mode and re-renders the card. Ignore the + // synthetic click that mobile browsers emit when that same touch ends. + if (state.suppressNextNoteTapId === id) { + app.setSuppressNextNoteTapId(null); + return; + } + if (state.selectMode) { + const cb = cardEl.querySelector('.note-card-cb'); + if (cb) { + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + } + } else if (_isNotesMobileMode()) { + // Mobile: open the per-note fullscreen edit overlay instead of the + // in-place form. Tiles on mobile are read-only previews. + _openMobileFullscreenEdit(id, cardEl); + } else { + _editNote(id); + } + }; + // Mobile: long-press anywhere on a note card → enter Select mode and + // select that card. Cancelled by movement (so it doesn't interfere with + // vertical scrolling) or by lifting the finger before the timer fires. + if (_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card)); + } + body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => { + card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true); + }); + // Click title : edit, or toggle select in select mode + body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + _smoothScrollToNoteCard(el.closest('.note-card')); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click content : edit, or toggle select in select mode + body.querySelectorAll('.note-content-preview').forEach(el => { + el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); + }); + // A saved drawing is the note's primary content. Open its editor directly + // when the preview is clicked, matching title/content preview behavior. + body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click empty area of checklist preview (not on checkbox/X) : edit + body.querySelectorAll('.note-checklist-preview').forEach(el => { + el.addEventListener('wheel', (e) => { + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + const pane = el.closest('.notes-pane-body'); + if (pane) { + pane.scrollTop += e.deltaY; + e.preventDefault(); + } + } + }, { passive: false }); + el.addEventListener('click', (e) => { + if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Clicking todo item text now toggles its checkbox : let the click bubble + // up to the parent .note-checkbox row handler. To open the editor, the + // user clicks the pencil corner. + // (No-op block kept as a marker : removing the listener entirely means + // clicks naturally bubble to the row toggle below.) + // In select mode, clicking anywhere on the card toggles selection + if (state.selectMode) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Mobile, non-select: tapping anywhere on the card body (not on an + // interactive child : buttons, pin, checkbox, color dot, reminder pill, + // agent tag, links) opens the fullscreen editor. Previously only the + // title / content preview triggered edit, so padding + empty gutters were + // dead zones that felt broken on mobile. + if (_isNotesMobileMode() && !state.selectMode) { + const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest(_INTERACTIVE)) return; + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Multi-select checkbox (only in select mode) + body.querySelectorAll('.note-card-cb').forEach(cb => { + cb.addEventListener('click', (e) => e.stopPropagation()); + cb.addEventListener('change', () => { + const id = cb.dataset.noteId; + if (cb.checked) app.selectId(id); + else app.deselectId(id); + cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked); + _updateBulkBar(); + }); + }); + // Pin toggle (optimistic) + body.querySelectorAll('.note-card-pin').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.togglePin(id); + }); + }); + // Color picker + const _applyCardColor = async (card, id, newColor) => { + const isBg = _isBgImage(newColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); }); + if (newColor && !isBg) card.classList.add('note-color-' + newColor); + if (isBg) card.setAttribute('style', _customColorStyle(newColor)); + else card.removeAttribute('style'); + card.querySelectorAll('.note-card-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor)); + d.style.background = _dotBg(d.dataset.color, newColor); + }); + await cardOperations.updateColor(id, newColor); + }; + body.querySelectorAll('.note-card-color-dot').forEach(dot => { + dot.addEventListener('click', (e) => { + e.stopPropagation(); + const card = dot.closest('.note-card'); + const id = card.dataset.noteId; + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); }); + return; + } + _applyCardColor(card, id, dot.dataset.color); + }); + }); + // Plain pencil corner → open editor. The unarchive corner shares the + // .note-card-edit-corner class for styling, so :not() keeps the edit + // handler off it. + body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (id) _editNote(id); + }); + }); + // Copy corner : bottom-right, just left of the Done check. Shared with + // the Ctrl/Cmd+C shortcut wired further down so both code paths run the + // same serializer + feedback flash. + // ⋯ corner menu : Copy + Agent (solve-this-todo). + body.querySelectorAll('.note-card-corner-menu').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _openNoteCornerMenu(btn); + }); + }); + body.querySelectorAll('.note-card-label-chip').forEach(chip => { + chip.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const label = chip.dataset.noteLabelFilter; + if (!label) return; + if (state.activeLabel === label && state.activeFilter === null) { + app.setActiveLabel(null); + } else { + app.setActiveFilter(null); + app.setActiveLabel(label); + } + _renderNotes(); + }); + }); + // Done (✓) at bottom-right : only visible on hover for active notes. + body.querySelectorAll('.note-card-done').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + const card = btn.closest('.note-card'); + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return; + // Celebrate completion : same confetti shower the bulk-archive uses. + if (card) { + const r = card.getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); + } + const finish = cardOperations.stageArchive(id); + if (!finish) return; + if (card) { + card.classList.add('note-card-sliding-out'); + let done = false; + const once = () => { if (done) return; done = true; finish(); }; + card.addEventListener('transitionend', once, { once: true }); + setTimeout(once, 400); + } else { + finish(); + } + }); + }); + // Unarchive corner : only visible in archive view. + body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Trash corner : archive view only. Permanent delete, no confirmation. + body.querySelectorAll('.note-card-corner-trash').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id, true); + }); + }); + + body.querySelectorAll('.note-card-archive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (!id) return; + const card = btn.closest('.note-card'); + let done = false; + const finishRemove = () => { + if (done) return; + done = true; + _archiveNoteById(id, { card, celebrate: true }); + }; + if (card) { + card.classList.add('note-card-sliding-out'); + card.addEventListener('transitionend', finishRemove, { once: true }); + setTimeout(finishRemove, 400); + } else { + finishRemove(); + } + }); + }); + // Unarchive (optimistic) : only present in archive view + body.querySelectorAll('.note-card-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Delete (optimistic) + body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id); + }); + }); + // Copy entire checklist (title + items, markdown-style) + body.querySelectorAll('.note-card-copy').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (lines.length) lines.push(''); + for (const it of (note.items || [])) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + const text = lines.join('\n').trim(); + try { + await navigator.clipboard.writeText(text); + uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`); + } catch { + // Fallback for browsers blocking the async API + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); uiModule.showToast?.('Copied'); } + catch { uiModule.showError?.('Copy failed'); } + ta.remove(); + } + }); + }); + + _wireCardChecklist(body); + _wireCardReorder(body); + + } + + return { _bindCardEvents }; +} diff --git a/frontend/src/features/notes/ui/cardRendering.js b/frontend/src/features/notes/ui/cardRendering.js new file mode 100644 index 000000000..315e47218 --- /dev/null +++ b/frontend/src/features/notes/ui/cardRendering.js @@ -0,0 +1,244 @@ +/** Render note cards from application queries and read-only state. */ +import { _nextGoalStep, _goalProgress, _hasItems, _standardVisualVariant, _visibleNoteTags } from '../domain/noteRules.js'; +import { _hasTimeComponent, _formatRepeatLabel } from '../domain/recurrence.js'; +export function createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}) { + function _renderLoadingSkeleton() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + const skel = document.createElement('div'); + skel.className = 'notes-skeleton'; + skel.innerHTML = ` +

+
+
+
+ `; + body.appendChild(skel); + } + + function _renderNotes() { + _updateRailBadge(); + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + const prevPositions = _captureCardPositions(); + const activeReminderHighlights = _loadActiveHighlights(); + + const sorted = _sortedVisibleNotes(); + + let html = ''; + // Today view: render a compact card listing the next-unchecked step from + // each active goal. Tapping a step toggles it done (same idx-based wiring + // as regular checkboxes). Tapping the title opens the goal note for full + // editing. + if (state.activeFilter === 'today') { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', `
All caught up — no pending goal steps right now.
`); + } else { + let todayHtml = `
+
+ + Today · one step per goal +
+
`; + for (const note of sorted) { + const next = _nextGoalStep(note); + if (!next) continue; + const progress = _goalProgress(note).trim(); + todayHtml += `
+ +
+
${_esc(note.title || '(untitled goal)')}
+
${_linkify(next.item.text || '')}
+
+ ${_esc(progress)} +
`; + } + todayHtml += `
`; + body.insertAdjacentHTML('beforeend', todayHtml); + } + _wireTodayView(body); + return; + } + for (const note of sorted) { + if (state.editingId === note.id) continue; // skip : form is shown instead + const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)'; + const dueFmt = _formatDueDate(note.due_date); + const overdue = _isDueOverdue(note.due_date); + + let contentHtml = ''; + if (_hasItems(note) && Array.isArray(note.items)) { + // Goal notes can carry a free-form description above the step list : + // todos rarely do, but the same render works for both. + if (note.note_type === 'goal' && (note.content || '').trim()) { + const fullText = note.content || ''; + const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText; + contentHtml += `
${_esc(preview)}
`; + } + contentHtml += '
'; + // Show ALL items : the preview container is scrollable (CSS caps + // its max-height + overflow-y:auto), so there's no need to truncate. + for (let i = 0; i < note.items.length; i++) { + const item = note.items[i]; + const doneClass = item.done ? ' done' : ''; + const indent = Math.min(item.indent || 0, 3); + contentHtml += `
+ + ${_linkify(item.text)} + + +
`; + } + contentHtml += '
'; + } else { + const fullText = note.content || ''; + const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText; + // _linkify already calls _esc internally, so URLs become clickable + // anchors (used by e.g. the "remind me to reply" email deep-link). + contentHtml = preview ? `
${_linkify(preview)}
` : ''; + } + + const isBg = _isBgImage(note.color); + const cc = (note.color && !isBg) ? ' note-color-' + note.color : ''; + const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : ''; + const isStandard = !note.note_type || note.note_type === 'note'; + const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : ''; + const sel = state.selectedIds.has(note.id) ? ' note-card-selected' : ''; + const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date) + ? `
+ + ${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''} +
` + : ''; + const noteTags = _visibleNoteTags(note); + const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `${dueFmt}` : ''; + const colorDots = COLORS.map(c => ``).join(''); + const goalClass = note.note_type === 'goal' ? ' note-card-goal' : ''; + const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : ''; + const goalPill = note.note_type === 'goal' + ? ` + + Goal${_goalProgress(note)} + ` + : ''; + const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : ''; + html += `
+ ${state.selectMode ? `` : ''} + ${goalPill} + + ${state.showingArchived + ? ` + ` + : ` + ${_hasItems(note) ? `` : ''}`} +
+
${_esc(note.title || '')}
+ ${dueBadge} +
+ ${_safeImgSrc(note.image_url) ? `` : ''} + ${contentHtml} + ${_hasItems(note) ? `
` : ''} + ${reminderTagHtml} + ${noteTags.length ? `
${noteTags.map(t => ``).join(' ')}
` : ''} +
+
${colorDots}
+ + ${state.showingArchived ? ` + + ` : ` + ${_hasItems(note) ? ` + ` : ''} + + + `} +
+
`; + } + + // Always render quick-add at top (collapsed unless user is typing) + const existingForm = body.querySelector('.note-form'); + if (existingForm && state.editingId === '__new__') { + // Keep the expanded form, replace cards after it + const next = [...body.children].filter(c => c !== existingForm); + next.forEach(c => c.remove()); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + existingForm.insertAdjacentHTML('afterend', html); + } + } else { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes yet ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + body.insertAdjacentHTML('beforeend', html); + } + } + + _bindCardEvents(body); + _animateReflow(prevPositions); + _applyMasonry(body); + } + + return { _renderLoadingSkeleton, _renderNotes }; +} diff --git a/frontend/src/features/notes/ui/cardReorder.js b/frontend/src/features/notes/ui/cardReorder.js new file mode 100644 index 000000000..dd32d517c --- /dev/null +++ b/frontend/src/features/notes/ui/cardReorder.js @@ -0,0 +1,181 @@ +/** Desktop and large-touch-device card reorder gestures. */ +export function createCardReorder({ + lifecycle, + _isNotesMobileMode, + persistOrder, + state +}) { + function _wireCardReorder(body) { + lifecycle.release(body); + // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom + // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is + // unreliable on touch browsers and can compete with the long-press flow. + if (!_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('dragstart', (e) => { + if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) { + e.preventDefault(); + return; + } + card.classList.add('dragging'); + body.classList.add('drag-active'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {} + }); + card.addEventListener('dragend', async () => { + card.classList.remove('dragging'); + body.classList.remove('drag-active'); + body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + try { await persistOrder(ids); } + catch {} + }); + }); + } + // Track which card we last swapped with so a single hover-over triggers + // one swap, not a jitter as the pointer keeps moving inside the same card. + let _lastSwapId = null; + function _maybeSwap(dragging, clientX, clientY) { + const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card'); + if (!target || target === dragging || !body.contains(target)) return; + const id = target.dataset.noteId; + if (id === _lastSwapId) return; + // FLIP across ALL siblings. In list view only `target` moves visually, but + // in grid view (2-col) and when the pinned-section grid-column-start rule + // shifts, several cards reflow at once. Capture every card's pre-swap rect, + // do the DOM swap, then animate any that actually moved. The dragging card + // is excluded : it's already finger-tracked via translate3d. + const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging); + const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()])); + const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling; + body.insertBefore(dragging, target); + body.insertBefore(target, draggingNext); + for (const c of cards) { + const prev = prevRects.get(c); + const next = c.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue; + c.style.transition = 'none'; + c.style.transform = `translate(${dx}px, ${dy}px)`; + requestAnimationFrame(() => { + c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)'; + c.style.transform = ''; + c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true }); + }); + } + _lastSwapId = id; + } + lifecycle.listen(body, body, 'dragover', (e) => { + e.preventDefault(); + const dragging = body.querySelector('.note-card.dragging'); + if (!dragging) return; + _maybeSwap(dragging, e.clientX, e.clientY); + }); + lifecycle.listen(body, body, 'dragend', () => { _lastSwapId = null; }); + + // Legacy touch drag for larger touch devices only. Phone-sized Notes uses + // the placeholder sorter wired by `_bindLongPressDrag`; running both flows + // makes one press start two independent drag sessions. + if (!_isNotesMobileMode() && 'ontouchstart' in window) { + let dragCard = null; + let isDragging = false; + let longPressTimer = null; + let startX = 0, startY = 0; + const LONG_PRESS_MS = 350; + const MOVE_THRESHOLD_PX = 8; + const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a'; + + // Anchor for the finger-follow transform. Recomputed after every swap so + // the card stays under the finger across reorderings. + let anchorX = 0, anchorY = 0; + const _follow = (clientX, clientY) => { + if (!dragCard) return; + const dx = clientX - anchorX; + const dy = clientY - anchorY; + // Compose with the CSS .dragging transform (scale + rotate). + dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`; + }; + const _reanchor = (clientX, clientY) => { + if (!dragCard) return; + // Clear any prior translate so the card resettles in its natural slot, + // then anchor at the finger's current position. Subsequent _follow calls + // translate by (finger - anchor), keeping the finger at the same relative + // spot on the card it had when we re-anchored. + dragCard.style.transform = ''; + anchorX = clientX; + anchorY = clientY; + }; + + const _endDrag = (committed) => { + clearTimeout(longPressTimer); + longPressTimer = null; + if (dragCard) { + dragCard.classList.remove('dragging'); + dragCard.style.transform = ''; + } + body.classList.remove('drag-active'); + _lastSwapId = null; + if (isDragging) { + document.documentElement.style.touchAction = ''; + if (committed) { + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + persistOrder(ids).catch(() => {}); + } + } + dragCard = null; + isDragging = false; + }; + lifecycle.onDispose(body, () => _endDrag(false)); + + lifecycle.listen(body, body, 'touchstart', (e) => { + if (state.selectMode) return; + const card = e.target.closest('.note-card'); + if (!card) return; + if (e.target.closest(_selectorSkip)) return; + const t = e.touches[0]; + startX = t.clientX; startY = t.clientY; + dragCard = card; + longPressTimer = setTimeout(() => { + if (!dragCard) return; + isDragging = true; + dragCard.classList.add('dragging'); + body.classList.add('drag-active'); + document.documentElement.style.touchAction = 'none'; + _reanchor(startX, startY); + try { if (navigator.vibrate) navigator.vibrate(15); } catch {} + }, LONG_PRESS_MS); + }, { passive: true }); + + lifecycle.listen(body, body, 'touchmove', (e) => { + if (!dragCard) return; + const t = e.touches[0]; + if (!isDragging) { + // Movement before long-press fires = user is scrolling; cancel pickup. + if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) { + clearTimeout(longPressTimer); + longPressTimer = null; + dragCard = null; + } + return; + } + e.preventDefault(); + // Live-follow the finger first, then check for a swap. After a swap the + // card's natural slot moves, so we re-anchor and re-apply the offset. + _follow(t.clientX, t.clientY); + const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + _maybeSwap(dragCard, t.clientX, t.clientY); + const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + if (before !== after) { + _reanchor(t.clientX, t.clientY); + _follow(t.clientX, t.clientY); + } + }, { passive: false }); + + lifecycle.listen(body, body, 'touchend', () => _endDrag(true)); + lifecycle.listen(body, body, 'touchcancel', () => _endDrag(false)); + } + } + + return { _wireCardReorder }; +} diff --git a/frontend/src/features/notes/ui/checklist.js b/frontend/src/features/notes/ui/checklist.js new file mode 100644 index 000000000..59a01aaa4 --- /dev/null +++ b/frontend/src/features/notes/ui/checklist.js @@ -0,0 +1,190 @@ +/** Checklist rows, goal forms, keyboard input and item collection. */ +export function createChecklist({ + _esc, + _uid, + spawnConfetti +}) { + function _buildGoalHtml(note, items) { + const desc = (note?.content || '').toString(); + return ` +
+ + ${_buildChecklistHtml(items)} +
+ `; + } + + function _wireGoalForm(form, container) { + if (!container) return; + // _wireHashtag is a closure inside _buildForm : out of scope here. Inline + // the same behavior (type "#foo " in the description → tag added to the + // form's label input) so editing a goal note doesn't ReferenceError. + const desc = container.querySelector('.note-form-goal-desc'); + const labelInput = form?.querySelector('.note-form-label'); + if (desc && labelInput) { + const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + desc.addEventListener('input', () => { + const m = tagRe.exec(desc.value); + if (!m) return; + const tag = m[2]; + // Same dedup-after-stripping fix as the plain note hashtag handler. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = desc.value.length - m[0].length + m[1].length; + desc.value = desc.value.slice(0, cut); + }); + } + // Always wire the checklist. The previous gate on a `note-form-goal-fresh` + // class was dead code : nothing ever set that class, so the editor never + // hooked up add/drag/Tab. + _wireChecklist(container); + } + + function _buildChecklistHtml(items) { + let html = '
'; + for (const item of items) { + const indent = Math.min(item.indent || 0, 3); + html += `
+ ⋮⋮ + + + +
`; + } + // `type="button"` matters on mobile : without it some browsers treat + // bare
`; + return html; + } + + function _wireRow(row, container) { + row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove()); + row.querySelector('.note-cl-dot')?.addEventListener('click', () => { + const wasDone = row.classList.contains('done'); + row.classList.toggle('done'); + // The custom dot has no native input/change event. Notify the form so + // mobile edit mode switches its action from Archive back to Update. + container.dispatchEvent(new Event('input', { bubbles: true })); + const becameDone = !wasDone; // we just flipped it + const dot = row.querySelector('.note-cl-dot'); + const dRect = (dot || row).getBoundingClientRect(); + // Small confetti burst on each fresh check so the user gets a + // "well done" beat per item, not just the grand-finale on all-done. + if (becameDone) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16); + } + // Bigger burst when the whole list is now done. + const rows = [...container.querySelectorAll('.note-cl-row')]; + const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0); + if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60); + } + }); + const txt = row.querySelector('.note-cl-text'); + txt?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); } + else if (e.key === 'Tab') { + e.preventDefault(); + const cur = parseInt(row.dataset.indent || '0'); + const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1); + row.dataset.indent = String(next); + row.style.paddingLeft = (next * 16) + 'px'; + } else if (e.key === 'Backspace' && txt.value === '') { + e.preventDefault(); + const prev = row.previousElementSibling; + row.remove(); + if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus(); + } + }); + // Drag handlers + row.addEventListener('dragstart', (e) => { + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {} + }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + }); + } + + function _wireChecklist(container) { + if (!container) return; + // Delegate the + Add click off the container so re-renders + mobile + // touch quirks don't leave the button dead. The previous direct + // `addEventListener` on the button silently broke on mobile when + // _wireChecklist ran more than once (or before the button was in DOM). + if (!container._addDelegated) { + container._addDelegated = true; + container.addEventListener('click', (ev) => { + const addBtn = ev.target.closest('.note-cl-add'); + if (!addBtn || !container.contains(addBtn)) return; + ev.preventDefault(); + ev.stopPropagation(); + const inputs = container.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const row = document.createElement('div'); + row.className = 'note-cl-row'; + row.draggable = true; + row.dataset.itemId = _uid(); + row.dataset.indent = '0'; + row.innerHTML = `⋮⋮`; + inputs.insertBefore(row, addBtn); + row.querySelector('.note-cl-text').focus(); + _wireRow(row, container); + }); + } + container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container)); + + // Drag-over handler on the inputs container + const inputs = container.querySelector('.note-checklist-inputs'); + if (inputs) { + inputs.addEventListener('dragover', (e) => { + e.preventDefault(); + const dragging = inputs.querySelector('.note-cl-row.dragging'); + if (!dragging) return; + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')]; + const after = rows.find(r => { + const box = r.getBoundingClientRect(); + return e.clientY < box.top + box.height / 2; + }); + if (after) { + after.classList.add('drop-before'); + inputs.insertBefore(dragging, after); + } else if (rows.length) { + rows[rows.length - 1].classList.add('drop-after'); + inputs.insertBefore(dragging, container.querySelector('.note-cl-add')); + } + }); + inputs.addEventListener('dragleave', (e) => { + if (!inputs.contains(e.relatedTarget)) { + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + } + }); + } + } + + function _collectItems(form) { + const items = []; + form.querySelectorAll('.note-cl-row').forEach(row => { + const text = row.querySelector('.note-cl-text')?.value?.trim(); + if (text) items.push({ + id: row.dataset.itemId || _uid(), + text, + done: row.classList.contains('done'), + indent: parseInt(row.dataset.indent || '0'), + }); + }); + return items; + } + + return { _buildGoalHtml, _wireGoalForm, _buildChecklistHtml, _wireRow, _wireChecklist, _collectItems }; +} diff --git a/frontend/src/features/notes/ui/checklistReorder.js b/frontend/src/features/notes/ui/checklistReorder.js new file mode 100644 index 000000000..c5c7e1101 --- /dev/null +++ b/frontend/src/features/notes/ui/checklistReorder.js @@ -0,0 +1,111 @@ +/** Mobile checklist grip dragging and document listener ownership. */ +export function createChecklistReorder({ + lifecycle +}) { + let _clDrag = null; + + let _clDocBound = false; + let _clOwner = null; + + function _wireChecklistTouchReorder(form) { + const container = form.querySelector('.note-checklist-inputs'); + if (!container) return; + container.querySelectorAll('.note-cl-grip').forEach(grip => { + if (grip.dataset.touchBound === '1') return; + grip.dataset.touchBound = '1'; + grip.addEventListener('touchstart', (e) => { + if (e.touches.length !== 1) return; + e.preventDefault(); + const row = grip.closest('.note-cl-row'); + if (!row) return; + _beginChecklistGrab(row, container, e.touches[0]); + }, { passive: false }); + }); + if (!_clDocBound || _clOwner !== form) { + if (_clOwner) lifecycle.release(_clOwner); + _clOwner = form; + lifecycle.listen(form, document, 'touchmove', _onClTouchMove, { passive: false }); + lifecycle.listen(form, document, 'touchend', _onClTouchEnd, { passive: true }); + lifecycle.listen(form, document, 'touchcancel', _onClTouchEnd, { passive: true }); + _clDocBound = true; + } + } + + function _beginChecklistGrab(row, container, touch) { + if (_clDrag) return; + const rect = row.getBoundingClientRect(); + const placeholder = document.createElement('div'); + placeholder.className = 'note-cl-row-placeholder'; + placeholder.style.height = rect.height + 'px'; + container.insertBefore(placeholder, row); + + row.classList.add('note-cl-row-dragging'); + row.style.position = 'fixed'; + row.style.left = rect.left + 'px'; + row.style.top = rect.top + 'px'; + row.style.width = rect.width + 'px'; + row.style.zIndex = '10002'; + row.style.pointerEvents = 'none'; + + _clDrag = { + row, placeholder, container, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onClTouchMove(e) { + if (!_clDrag) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const t = e.touches[0]; + const { row, placeholder, container } = _clDrag; + row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px'; + row.style.top = (t.clientY - _clDrag.grabOffsetY) + 'px'; + + const under = document.elementFromPoint(t.clientX, t.clientY); + const target = under && under.closest + ? under.closest('.note-cl-row:not(.note-cl-row-dragging)') + : null; + if (!target || target === row) return; + if (target.parentNode !== container) return; + + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (t.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + container.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + container.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onClTouchEnd() { + if (!_clDrag) return; + const { row, placeholder } = _clDrag; + _clDrag = null; + const phRect = placeholder.getBoundingClientRect(); + row.style.transition = 'left 0.18s ease, top 0.18s ease'; + row.style.left = phRect.left + 'px'; + row.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(row, placeholder); + placeholder.remove(); + row.classList.remove('note-cl-row-dragging'); + row.style.cssText = ''; + // Order is persisted as part of the form's normal save (rows are + // re-serialized in DOM order via _collectItems). + }, 200); + } + + function dispose() { + if (_clDrag) _onClTouchEnd(); + _clDocBound = false; + _clOwner = null; + } + return { dispose, _wireChecklistTouchReorder, _beginChecklistGrab, _onClTouchMove, _onClTouchEnd }; +} diff --git a/frontend/src/features/notes/ui/clipboard.js b/frontend/src/features/notes/ui/clipboard.js new file mode 100644 index 000000000..1dab84258 --- /dev/null +++ b/frontend/src/features/notes/ui/clipboard.js @@ -0,0 +1,82 @@ +/** Note clipboard feedback and the Copy/Agent corner menu. */ +import { _serializeNoteForCopy } from '../domain/noteRules.js'; +export function createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}) { + function _openNoteCornerMenu(btn) { + document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const menu = document.createElement('div'); + menu.className = 'note-corner-menu-dropdown'; + menu.innerHTML = ` + + `; + document.body.appendChild(menu); + const r = btn.getBoundingClientRect(); + // Right-align to the ⋯ button, clamped to the viewport. + const mw = 168; + let left = Math.min(r.right - mw, window.innerWidth - mw - 8); + left = Math.max(8, left); + // Drop down by default; flip up if there isn't room below (the button + // sits at the card's bottom edge now). + const mh = menu.offsetHeight || 96; + const below = window.innerHeight - r.bottom; + const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); + menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`; + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); + menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); + } + + async function _copyNote(noteId, btnEl) { + const note = state.notes.find(n => n.id === noteId); + if (!note) return false; + const text = _serializeNoteForCopy(note); + if (!text) return false; + let ok = false; + try { + await navigator.clipboard.writeText(text); + ok = true; + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { ok = document.execCommand('copy'); } catch { ok = false; } + ta.remove(); + } + if (ok) { + if (btnEl && !btnEl._copyFlashing) { + const original = btnEl.innerHTML; + btnEl._copyFlashing = true; + btnEl.innerHTML = ''; + btnEl.classList.add('copied'); + setTimeout(() => { + btnEl.innerHTML = original; + btnEl.classList.remove('copied'); + btnEl._copyFlashing = false; + }, 1200); + } + uiModule.showToast?.('Copied'); + } else { + uiModule.showError?.('Copy failed'); + } + return ok; + } + + return { _openNoteCornerMenu, _copyNote }; +} diff --git a/frontend/src/features/notes/ui/colors.js b/frontend/src/features/notes/ui/colors.js new file mode 100644 index 000000000..0a9503cfb --- /dev/null +++ b/frontend/src/features/notes/ui/colors.js @@ -0,0 +1,111 @@ +/** Note colors, custom background selection and form color controls. */ +export function createColors({ + api +}) { + const COLORS = [ + { name: 'none', value: '' }, + { name: 'red', value: 'red' }, + { name: 'orange', value: 'orange' }, + { name: 'yellow', value: 'yellow' }, + { name: 'green', value: 'green' }, + { name: 'blue', value: 'blue' }, + { name: 'purple', value: 'purple' }, + { name: 'custom', value: 'custom' }, // sentinel : clicking opens native color picker + ]; + + const COLOR_HEX = { + '': 'var(--border)', + // Pale/pastel palette : matches the calendar event color picker. + red: '#f0b5ba', + orange: '#e8ccb2', + yellow: '#f2dfbd', + green: '#cce0bc', + blue: '#b0d7f7', + purple: '#e2bcee', + }; + + const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; + + function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); } + + function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; } + + function _dotBg(value, noteColor) { + if (value === 'custom') { + const url = _bgImageUrl(noteColor); + return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT; + } + return COLOR_HEX[value]; + } + + function _dotIsActive(value, noteColor) { + if (value === 'custom') return _isBgImage(noteColor); + return value === (noteColor || ''); + } + + function _customColorStyle(c) { + if (!_isBgImage(c)) return ''; + const url = _bgImageUrl(c); + return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`; + } + + function _pickCustomBgImage() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; + document.body.appendChild(input); + let done = false; + const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + finish(api.uploadUrl(fileId)); + } catch { finish(null); } + }); + // Best-effort cleanup if user dismisses the dialog. + setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); + input.click(); + }); + } + + function _wireFormColors(form, setColor) { + // Color dots : apply to entire form immediately + const _applyFormColor = (newColor) => { + const currentColor = newColor || ''; + setColor(currentColor); + form.dataset.noteColor = currentColor; + const isBg = _isBgImage(currentColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); }); + if (currentColor && !isBg) form.classList.add('note-color-' + currentColor); + if (isBg) form.setAttribute('style', _customColorStyle(currentColor)); + else form.removeAttribute('style'); + form.querySelectorAll('.note-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor)); + d.style.background = _dotBg(d.dataset.color, currentColor); + }); + form.dispatchEvent(new Event('change', { bubbles: true })); + }; + form.querySelectorAll('.note-color-dot').forEach(dot => { + dot.addEventListener('click', () => { + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); }); + return; + } + _applyFormColor(dot.dataset.color); + }); + }); + + + } + + return { COLORS, COLOR_HEX, _isBgImage, _bgImageUrl, _dotBg, _dotIsActive, _customColorStyle, _pickCustomBgImage, _wireFormColors }; +} diff --git a/frontend/src/features/notes/ui/draftEditor.js b/frontend/src/features/notes/ui/draftEditor.js new file mode 100644 index 000000000..09df33b79 --- /dev/null +++ b/frontend/src/features/notes/ui/draftEditor.js @@ -0,0 +1,67 @@ +/** Collect editor drafts, restore them and wire the autosave workflow. */ +export function createDraftEditor({ + bindDraft, + _collectItems, + _loadDraft +}) { + function _collectFormDraft(form) { + if (!form) return null; + const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || form.dataset.noteType || 'note'; + const d = { + _ts: Date.now(), + note_type: type, + color: form.dataset.noteColor || '', + title: form.querySelector('.note-form-title')?.value || '', + label: form.querySelector('.note-form-label')?.value || '', + due_date: form.querySelector('.note-form-due')?.value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + }; + if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || ''; + else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); } + else d.items = _collectItems(form); + return d; + } + + function _isDraftEmpty(d) { + if (!d) return true; + if ((d.title || '').trim()) return false; + if ((d.content || '').trim()) return false; + if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false; + return true; + } + + function _wireDraftAutosave(form, id) { + const view = { + get failed() { return form._autosaveFailed; }, + set failed(value) { form._autosaveFailed = value; }, + }; + const draft = bindDraft({ collect: () => _collectFormDraft(form), id, view }); + form._flushDraft = draft.flush; + form._stopDraftAutosave = draft.stop; + form.addEventListener('input', draft.schedule); + form.addEventListener('change', draft.schedule); + form.addEventListener('click', event => { + if (event.target.closest('.note-cl-dot, .note-cl-rm')) draft.schedule(); + }); + } + + function _commitOpenInPlaceEditor() { + const form = document.querySelector('#notes-pane .note-form'); + if (!form) return; + const d = _collectFormDraft(form); + if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; } + form.querySelector('.note-form-save')?.click(); + } + + function _applyDraftToNote(note, id) { + const d = _loadDraft(id); + if (!d) return { note, restored: false }; + const merged = { ...(note || {}) }; + ['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => { + if (d[k] !== undefined) merged[k] = d[k]; + }); + return { note: merged, restored: true }; + } + + return { _collectFormDraft, _isDraftEmpty, _wireDraftAutosave, _commitOpenInPlaceEditor, _applyDraftToNote }; +} diff --git a/frontend/src/features/notes/ui/drawing.js b/frontend/src/features/notes/ui/drawing.js new file mode 100644 index 000000000..da33c9236 --- /dev/null +++ b/frontend/src/features/notes/ui/drawing.js @@ -0,0 +1,442 @@ +/** Canvas drawing tools, local drawing undo and PNG uploads. */ +export function createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}) { + function _buildDrawHtml() { + return ` +
+ +
+ + +
+ + +
+ + + + +
+
+ `; + } + + function _wireCanvas(container, initialImageUrl) { + const canvas = container.querySelector('.note-form-canvas'); + if (!canvas) return; + const resolveThemeColor = (expression, fallback) => { + const probe = document.createElement('span'); + probe.style.color = expression; + probe.style.position = 'fixed'; + probe.style.visibility = 'hidden'; + document.body.appendChild(probe); + const resolved = getComputedStyle(probe).color || fallback; + probe.remove(); + return resolved; + }; + const colorToHex = (color, fallback) => { + try { + const probe = document.createElement('canvas'); + probe.width = probe.height = 1; + const probeCtx = probe.getContext('2d'); + probeCtx.fillStyle = color; + probeCtx.fillRect(0, 0, 1, 1); + const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`; + } catch { return fallback; } + }; + const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34'); + const surfaceColorHex = colorToHex(surfaceColor, '#282c34'); + const highlightColor = colorToHex( + resolveThemeColor('var(--accent, var(--red))', '#e06c75'), + '#e06c75', + ); + const colorInput = container.querySelector('.note-form-draw-color'); + if (colorInput) colorInput.value = highlightColor; + // Bump the backing-store resolution for retina displays so strokes stay + // crisp. Set only style.width : leaving style.height to auto so the canvas + // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions + // are pinned, max-width:100% shrinks the width only, leaving rasterized + // glyphs visibly stretched relative to their on-screen input. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const cssW = canvas.width; + const cssH = canvas.height; + // Fill the container up to the logical width (don't pin a hard 600px, + // which on a narrow phone forces the card wider than the viewport and + // pushes the drawing outside the note). _pos() scales pointer coords by + // the actual displayed width, so accuracy is preserved at any size. + canvas.style.width = '100%'; + canvas.style.maxWidth = cssW + 'px'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = cssW + ' / ' + cssH; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Load prior drawing as starting point so consecutive edits compose. + const safeInitialImageUrl = _safeImgSrc(initialImageUrl); + if (safeInitialImageUrl) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} }; + img.src = safeInitialImageUrl; + // Float an X over the canvas so the user can blank it out and go back to + // a clean draw surface. Removes itself once clicked. + const wrap = container.querySelector('.note-form-draw-wrap'); + if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) { + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'note-form-draw-bg-rm'; + rm.title = 'Clear photo (regular draw)'; + rm.innerHTML = '×'; + rm.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + rm.remove(); + }); + wrap.appendChild(rm); + } + } + + // Swap the native browser color dialog for the in-house HSV picker + // (same one used by Themes + the gallery editor). Existing `input` event + // listeners + .value reads keep working : see colorPicker.js. + if (colorInput) attachColorPicker(colorInput); + const sizeInput = container.querySelector('.note-form-draw-size'); + const beSeg = container.querySelector('.note-form-draw-be'); + const brushBtn = container.querySelector('.note-form-draw-brush'); + const eraserBtn = container.querySelector('.note-form-draw-eraser'); + const textBtn = container.querySelector('.note-form-draw-text'); + const lineBtn = container.querySelector('.note-form-draw-line'); + const circleBtn = container.querySelector('.note-form-draw-circle'); + const undoBtn = container.querySelector('.note-form-draw-undo'); + // Single source of truth for what clicks/drags do. Other booleans are + // derived from this so we never end up with conflicting "eraser AND text" + // states (the bug that made T appear broken after using the eraser). + // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle' + let mode = 'pen'; + let drawing = false; + let last = null; + // The text tool has three preset sizes (CSS px font-size). + const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 }; + // Line / circle stroke widths in logical pixels : three crisp options. + const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 }; + // Snapshot taken at the start of a shape drag so the preview can repaint + // cleanly on each move without accumulating intermediate strokes. + let _shapeSnapshot = null; + + // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each + // operation : stroke, text commit, or future operations : and pop+restore + // on Undo. Cap to 30 to keep memory bounded. + const _undoStack = []; + const UNDO_LIMIT = 30; + const _syncUndoButton = () => { + const available = _undoStack.length > 0; + undoBtn?.classList.toggle('has-undo', available); + undoBtn?.toggleAttribute('disabled', !available); + undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true'); + }; + const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true })); + const _snapshot = () => { + try { + const w = canvas.width, h = canvas.height; + _undoStack.push(ctx.getImageData(0, 0, w, h)); + if (_undoStack.length > UNDO_LIMIT) _undoStack.shift(); + _syncUndoButton(); + } catch {} + }; + const _undo = () => { + const prev = _undoStack.pop(); + if (!prev) { _syncUndoButton(); return; } + // Restore against the raw backing store: temporarily reset the active + // ctx scale, paint the snapshot 1:1, then reapply our standard transform. + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(prev, 0, 0); + ctx.restore(); + _markCanvasDirty(); + _syncUndoButton(); + }; + + const _pos = (e) => { + // CSS can shrink the canvas (max-width:100%) without changing its logical + // size, so compute the displayed-to-logical scale per axis. Pointer coords + // are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH). + const r = canvas.getBoundingClientRect(); + const sx = cssW / r.width; + const sy = cssH / r.height; + const t = e.touches ? e.touches[0] : e; + return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; + }; + const _begin = (e) => { + if (mode.startsWith('text-')) { + // Stop the event so the browser doesn't synthesize a follow-up click + // that would blur the input we're about to create. + e.preventDefault?.(); + e.stopPropagation?.(); + _openTextInput(e); + return; + } + _snapshot(); + last = _pos(e); + drawing = true; + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Capture the backing pixels so the preview can replay each move from + // the same starting state (otherwise live shapes accumulate). + try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {} + return; + } + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + }; + + // Drop an HTML input at the click position so the user can type, then + // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form + // annotations work in our doc editor. + let _activeTextInput = null; + const _openTextInput = (e) => { + // Commit any prior pending input before starting a new one : otherwise + // the first click leaves an orphaned input the user thinks "didn't work". + if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} } + const r = canvas.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + // Position is anchored to the wrap, not the canvas; since the canvas is + // the first child of the wrap and the wrap has no padding, they share an + // origin, so we offset from the canvas rect directly. + const px = t.clientX - r.left; + const py = t.clientY - r.top; + const logical = _pos(e); + // Size is decided by which T variant is active (S/M/L), not the stroke + // slider : those are independent dials. + const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm'; + const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m; + const wrap = container.querySelector('.note-form-draw-wrap'); + if (!wrap) return; + if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'note-form-draw-textinput'; + input.placeholder = 'type then Enter'; + const color = colorInput?.value || '#222'; + const maxW = Math.max(120, Math.floor(r.width - px - 4)); + input.style.cssText = [ + 'position:absolute', + `left:${px}px`, + `top:${Math.max(0, py - sizeCss * 0.7)}px`, + `font:${sizeCss}px Arial, sans-serif`, + `color:${color}`, + 'background:#ffffff', + 'border:2px solid var(--accent)', + 'border-radius:4px', + 'outline:none', + 'padding:2px 6px', + 'min-width:120px', + `max-width:${maxW}px`, + 'z-index:1000', + 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', + 'pointer-events:auto', + ].join(';'); + wrap.appendChild(input); + _activeTextInput = input; + // Focus synchronously so the call still counts as inside the user gesture + // on iOS / Android, then re-focus on the next frame in case a competing + // synthetic event (touch → click) moved focus away. + input.focus(); + requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); }); + let committed = false; + const commit = () => { + if (committed) return; + committed = true; + const text = input.value; + if (_activeTextInput === input) _activeTextInput = null; + input.remove(); + if (!text) return; + // Snapshot BEFORE rasterizing so Undo removes the text in one step. + _snapshot(); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = color; + // Canvas now scales uniformly (style.height: auto), so a single ratio + // suffices for picking the logical font size that matches what the + // user just saw in the HTML input. + const sx = cssW / r.width; + const logicalSize = sizeCss * sx; + ctx.font = `${logicalSize}px sans-serif`; + ctx.textBaseline = 'top'; + ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7); + ctx.restore(); + _markCanvasDirty(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } + else if (ev.key === 'Escape') { input.value = ''; input.blur(); } + }); + }; + const _move = (e) => { + if (!drawing) return; + e.preventDefault?.(); + const p = _pos(e); + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Restore the pre-shape bitmap, then redraw the preview shape from + // anchor → current pointer. + if (_shapeSnapshot) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(_shapeSnapshot, 0, 0); + ctx.restore(); + } + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = colorInput?.value || '#222'; + const sizeKey = mode.slice(-1); + ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m; + ctx.beginPath(); + if (mode.startsWith('line-')) { + ctx.moveTo(last.x, last.y); + ctx.lineTo(p.x, p.y); + } else { + const dx = p.x - last.x; + const dy = p.y - last.y; + const radius = Math.hypot(dx, dy); + ctx.arc(last.x, last.y, radius, 0, Math.PI * 2); + } + ctx.stroke(); + return; + } + const erasing = mode === 'eraser'; + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor); + ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + last = p; + }; + const _end = () => { + if (drawing) _markCanvasDirty(); + drawing = false; last = null; _shapeSnapshot = null; + }; + + canvas.addEventListener('mousedown', _begin); + canvas.addEventListener('mousemove', _move); + lifecycle.listen(canvas, window, 'mouseup', _end); + // Non-passive so text mode can preventDefault : otherwise the synthetic + // mousedown/click that follows a touch can blur the freshly-created text + // input on iOS Safari, making T look like a no-op. + canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false }); + canvas.addEventListener('touchmove', _move, { passive: false }); + canvas.addEventListener('touchend', _end); + canvas.addEventListener('touchcancel', _end); + + // Single mode setter : keeps the toolbar, swatch, and cursor in sync, and + // makes sure exiting the eraser restores the user's chosen color whether + // they exit by clicking another tool or by toggling eraser off. + let _preEraseColor = null; + const _setMode = (next) => { + const wasEraser = mode === 'eraser'; + mode = next; + const isEraser = next === 'eraser'; + const isPen = next === 'pen'; + const isText = next.startsWith('text-'); + // Swatch: theme surface while erasing, restored when leaving that mode. + if (isEraser && !wasEraser && colorInput) { + _preEraseColor = colorInput.value; + colorInput.value = surfaceColorHex; + } else if (!isEraser && wasEraser && colorInput && _preEraseColor) { + colorInput.value = _preEraseColor; + _preEraseColor = null; + } + // Brush/Eraser segmented pill : slides to the active side. When a non- + // pen/eraser tool (T / line / circle) is active, neither half is + // highlighted, but the pill still indicates which side the user was on + // last so they can return with one click. + const isLine = next.startsWith('line-'); + const isCircle = next.startsWith('circle-'); + beSeg?.classList.toggle('is-eraser', isEraser); + brushBtn?.classList.toggle('active', isPen); + eraserBtn?.classList.toggle('active', isEraser); + textBtn?.classList.toggle('active', isText); + lineBtn?.classList.toggle('active', isLine); + circleBtn?.classList.toggle('active', isCircle); + // Per-button size badges (S/M/L), driven off the mode suffix. + const tBadge = textBtn?.querySelector('.note-form-draw-text-badge'); + if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : ''; + const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge'); + if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : ''; + const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge'); + if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : ''; + // Reflect the chosen size in the icon itself : thicker line/circle stroke + // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`. + const _sz = next.slice(-1); + [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l')); + if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz); + if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz); + if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz); + canvas.style.cursor = isText ? 'text' : 'crosshair'; + }; + brushBtn?.addEventListener('click', () => _setMode('pen')); + eraserBtn?.addEventListener('click', () => _setMode('eraser')); + // T / Line / Circle: each cycles its own three sizes, then back to pen. + const _cycle = (prefix) => { + const seq = ['s', 'm', 'l']; + if (!mode.startsWith(prefix)) return prefix + 's'; + const cur = mode.slice(-1); + const next = seq[seq.indexOf(cur) + 1]; + return next ? prefix + next : 'pen'; + }; + textBtn?.addEventListener('click', () => _setMode(_cycle('text-'))); + lineBtn?.addEventListener('click', () => _setMode(_cycle('line-'))); + circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-'))); + undoBtn?.addEventListener('click', () => _undo()); + _syncUndoButton(); + + // Stash so the save handler can read it later without re-resolving DOM. + canvas._cssW = cssW; + canvas._cssH = cssH; + return canvas; + } + + async function _uploadCanvasAsPng(canvas, galleryId = null) { + if (!canvas) return null; + const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); + if (!blob) return null; + const fd = new FormData(); + fd.append('files', blob, 'drawing.png'); + if (galleryId) fd.append('gallery_id', galleryId); + try { + const res = await api.upload(fd); + const data = await res.json(); + const id = data.files?.[0]?.id; + return id ? { + url: api.uploadUrl(id), + galleryId: data.files?.[0]?.gallery_id || null, + } : null; + } catch { return null; } + } + + return { _buildDrawHtml, _wireCanvas, _uploadCanvasAsPng }; +} diff --git a/frontend/src/features/notes/ui/editor.js b/frontend/src/features/notes/ui/editor.js new file mode 100644 index 000000000..f5bdc7c9f --- /dev/null +++ b/frontend/src/features/notes/ui/editor.js @@ -0,0 +1,518 @@ +/** Build and wire note, checklist, goal and drawing editor forms. */ +export function createEditor({ + deleteFromEditor, + submitNote, + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}) { + function _buildForm(note = null) { + const isEdit = note && note.id; + const type = note?.note_type === 'checklist' ? 'todo' : (note?.note_type || 'note'); + const color = note?.color || ''; + const items = note?.items || [{ id: _uid(), text: '', done: false }]; + + const form = document.createElement('div'); + form.className = 'note-form'; + form.dataset.noteType = type; + form.dataset.noteColor = color || ''; + if (color && !_isBgImage(color)) form.classList.add('note-color-' + color); + if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color)); + let currentImageUrl = _safeImgSrc(note?.image_url || ''); + form.innerHTML = ` +
+ + + + +
+ ${currentImageUrl && type !== 'draw' ? `
` : ''} +
+ ${type === 'note' + ? `` + : type === 'draw' + ? _buildDrawHtml() + : type === 'goal' + ? _buildGoalHtml(note, items) + : _buildChecklistHtml(items)} +
+
+
+
+ + + +
+ + +
+ ${COLORS.map(c => ``).join('')} +
+ +
+ ${isEdit ? ` + + + ` : ''} + + + +
+
+ `; + + let currentType = type; + let currentColor = color; + // Stash original-form values so round-trips (Note→Todo→Note) restore the + // user's hand-formatted text instead of a join of generated items. Same the + // other way: if you started in todo, switch to note, switch back, items + // come back unchanged. + let _stashedNoteText = (type === 'note') ? (note?.content || '') : null; + let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null; + // Goal mode kept its own pair of stashes (description + steps) so a + // Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in + // the type picker was later removed, so the only entry point now is + // *editing* an existing goal-typed note : but the switch handler still + // accepts Goal→Todo/Note transitions (downgrading legacy goals), so + // these stashes still earn their keep. + let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null; + let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null; + + // Drawing also stashes the saved image URL so it survives Note↔Draw flips. + let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null; + const _refreshFormLayout = () => { + const body = form.closest('.notes-pane-body'); + if (!body) return; + _applyMasonry(body); + requestAnimationFrame(() => { + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + }); + }; + + // Type segmented control : Note | Todo | Draw + form.querySelectorAll('.note-form-type-pill').forEach(pill => { + pill.addEventListener('click', () => { + const newType = pill.dataset.type; + if (newType === currentType) return; + const bodyEl = form.querySelector('.note-form-body'); + // Stash whatever the user has in the current mode before swapping it + // out, so a subsequent flip back restores their work. + if (currentType === 'note') { + _stashedNoteText = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'todo') { + _stashedTodoItems = _collectItems(form); + } else if (currentType === 'goal') { + _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || ''; + _stashedGoalItems = _collectItems(form); + } else if (currentType === 'draw') { + const c = form.querySelector('.note-form-canvas'); + if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} } + } + // Render the new mode's body and re-wire its inputs. + if (newType === 'todo') { + let nextItems; + if (_stashedTodoItems && _stashedTodoItems.length) { + nextItems = _stashedTodoItems; + } else if (_stashedGoalItems && _stashedGoalItems.length) { + // Going Goal→Todo keeps the AI-generated steps as a plain checklist. + nextItems = _stashedGoalItems; + } else if (_stashedNoteText) { + const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean); + nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }]; + } else { + nextItems = [{ id: _uid(), text: '', done: false }]; + } + bodyEl.innerHTML = _buildChecklistHtml(nextItems); + _wireChecklist(bodyEl); + } else if (newType === 'draw') { + bodyEl.innerHTML = _buildDrawHtml(); + // If the user just attached a photo (via the photo button) and then + // toggled to Draw, paint that photo onto the canvas so they can draw + // on top of it. _stashedDrawUrl wins if they were drawing earlier in + // the same edit session. + _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null); + } else { + const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '') + ? _stashedNoteText + : (_stashedGoalDesc && _stashedGoalDesc) + || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n'); + bodyEl.innerHTML = ``; + _wireHashtag(bodyEl.querySelector('.note-form-content')); + } + const focusEl = newType === 'note' + ? bodyEl.querySelector('.note-form-content') + : newType === 'todo' + ? bodyEl.querySelector('.note-cl-text') + : null; + if (focusEl) { + requestAnimationFrame(() => { + focusEl.focus({ preventScroll: true }); + try { + const end = focusEl.value.length; + focusEl.setSelectionRange(end, end); + } catch {} + }); + } + currentType = newType; + const seg = form.querySelector('.note-form-type-seg'); + seg?.classList.toggle('is-todo', newType === 'todo'); + seg?.classList.toggle('is-draw', newType === 'draw'); + form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType)); + // The standalone image preview (form-image-wrap) and the canvas would + // otherwise both show the same image_url when editing a drawn note. + // Hide it in draw mode, restore it when leaving draw mode. + const imgWrap = form.querySelector('.note-form-image-wrap'); + if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : ''; + // The background-color dots set the note card's bg : they make no sense + // for a drawn note (the canvas image IS the card content), so hide them. + const bgPicker = form.querySelector('.note-color-picker'); + if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : ''; + if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64'; + } + _refreshFormLayout(); + }); + }); + + // Slide a finger across the Note/Todo/Draw control to switch modes (mobile). + // On touchmove we find the pill under the finger and click it : reusing the + // pill click handler above, so the body re-renders + content stashing all + // work. Only fires when crossing into a *different* pill. + const _typeSeg = form.querySelector('.note-form-type-seg'); + if (_typeSeg) { + let _sliding = false; + const _activateAt = (x, y) => { + const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill'); + if (pill && !pill.classList.contains('active')) pill.click(); + }; + _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true }); + _typeSeg.addEventListener('touchmove', (e) => { + if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: true }); + _typeSeg.addEventListener('touchend', () => { _sliding = false; }); + _typeSeg.addEventListener('touchcancel', () => { _sliding = false; }); + } + + _wireFormColors(form, value => { currentColor = value; }); + + if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body')); + if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body')); + if (currentType === 'draw') { + _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null); + // Same hides we apply on type-switch : keep them consistent on initial open. + const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none'; + const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none'; + } + + // Auto-grow the plain-note textarea so editing longer notes is + // comfortable : it expands with the content (up to a cap) instead of + // staying a cramped 4-row box. The user can still drag-resize too. + const _contentTa = form.querySelector('.note-form-content'); + if (_contentTa) { + const _grow = () => { + _contentTa.style.height = 'auto'; + // Inline form: cap at ~50vh so a huge note doesn't push the action + // buttons off-screen. Fullscreen mobile overlay: the body scrolls and + // there are no inline buttons crowding, so allow nearly the full height + // : capping at 50vh there clipped longer notes ("part disappears"). + const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay'); + const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5)); + _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px'; + }; + _contentTa.addEventListener('input', _grow); + // Grow on open so existing content is fully visible. Run again after the + // fullscreen overlay's open animation settles : measuring mid-animation + // (the overlay starts scaled/transitioning) can under-size the box. + setTimeout(_grow, 0); + setTimeout(_grow, 360); + } + + _wireReminderPicker(form); + + // Photo upload + const photoBtn = form.querySelector('.note-form-photo-btn'); + const photoInput = form.querySelector('.note-form-photo-input'); + if (photoBtn && photoInput) { + photoBtn.addEventListener('click', () => photoInput.click()); + photoInput.addEventListener('change', async () => { + const file = photoInput.files?.[0]; + if (!file) return; + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + currentImageUrl = api.uploadUrl(fileId); + // Only ever keep the latest attached photo : drop any existing wrap + // before inserting a fresh one. Picking a second photo replaces the + // first instead of stacking. + form.querySelector('.note-form-image-wrap')?.remove(); + const wrap = document.createElement('div'); + wrap.className = 'note-form-image-wrap'; + wrap.innerHTML = ``; + // Insert AFTER the whole header (a flex-row), not after the + // title input itself : otherwise the image lands as a sibling + // of the title inside the header and flex puts them side-by-side. + form.querySelector('.note-form-header').after(wrap); + wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; }); + wrap.querySelector('img').src = currentImageUrl; + } catch (err) { uiModule.showError('Image upload failed'); } + photoInput.value = ''; + }); + } + // Existing image remove + form.querySelector('.note-form-image-rm')?.addEventListener('click', () => { + form.querySelector('.note-form-image-wrap')?.remove(); + currentImageUrl = ''; + }); + + // Title Enter -> focus body (textarea or first checklist item) + form.querySelector('.note-form-title').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); return; } + const firstItem = form.querySelector('.note-cl-text'); + if (firstItem) firstItem.focus(); + } + }); + + // Hashtag → label: typing "#foo " in title/content appends "foo" to the + // space-separated tag list. Repeats are deduplicated, so #foo #foo only + // keeps one. Tags already present in the label field are left alone. + const labelInput = form.querySelector('.note-form-label'); + const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + function _wireHashtag(el) { + if (!el || !labelInput) return; + el.addEventListener('input', () => { + const m = _hashtagRe.exec(el.value); + if (!m) return; + const tag = m[2]; + // Dedup against the stripped form : labelInput may already hold `#tag` + // (after Enter normalised), so includes(tag) on the raw split would + // miss the duplicate and append a bare `tag` next to `#tag`. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = el.value.length - m[0].length + m[1].length; + el.value = el.value.slice(0, cut); + }); + } + _wireHashtag(form.querySelector('.note-form-title')); + _wireHashtag(form.querySelector('.note-form-content')); + // Pressing Enter in the tag field commits the current word as its own tag + // and parks the cursor after a trailing space, so the next word becomes a + // separate tag rather than overwriting the previous one. + labelInput?.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); + e.stopPropagation(); + // Strip any leading #s the user typed, dedupe, then re-prepend exactly + // one #. So typing "foo" or "#foo" both end up as "#foo " in the input; + // the save handler keeps stripping #s before storing so DB stays clean. + const tags = [...new Set(labelInput.value.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (!tags.length) return; + labelInput.value = tags.map(t => '#' + t).join(' ') + ' '; + labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + }); + + // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save + // Escape -> cancel edit + form.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + form.querySelector('.note-form-save')?.click(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + form.querySelector('.note-form-cancel')?.click(); + } + }); + + // Save. Prevent the button from stealing focus on press: on mobile, the + // first tap would otherwise just blur the focused textarea/input (closing + // the keyboard and shifting layout), so the tap never reached the button and + // you had to tap "Done" twice. mousedown preventDefault keeps focus put while + // still letting the click fire. + const _saveBtnEl0 = form.querySelector('.note-form-save'); + _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault()); + _saveBtnEl0.addEventListener('click', async () => { + // Guard against spam-clicks: the drawing save AWAITS a canvas upload before + // the optimistic re-render removes the form, so without this a slow upload + // let repeated clicks create duplicate notes. + const _saveBtn = form.querySelector('.note-form-save'); + if (_saveBtn._saving) return; + // Mobile: when an existing note is opened and closed without edits, the + // Update (✓) button morphs into Archive (set up below). Route the click + // directly through the archive flow. Using a DOM .click() proxy here was + // fragile on mobile because the real archive button can move from the + // footer to the fullscreen header. + if (_saveBtn.classList.contains('archive-mode')) { + _archiveNoteById(note?.id); + return; + } + _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; + try { + form._flushDraft?.(); + await form._stopDraftAutosave?.(); + form._flushDraft?.(); + const title = form.querySelector('.note-form-title').value.trim(); + // Normalize tag input: split on whitespace, strip leading #s, dedupe, + // re-join with single spaces. Empty → null. + const _rawLabel = form.querySelector('.note-form-label')?.value || ''; + const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder'); + const labelVal = _tags.length ? _tags.join(' ') : null; + const payload = { + title, + note_type: currentType, + color: currentColor, + label: labelVal, + due_date: form.querySelector('.note-form-due').value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + image_url: currentImageUrl || null, + }; + if (currentType === 'note') { + payload.content = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'draw') { + // Upload the canvas PNG before saving so image_url points to a + // persistent file. We block the save until upload completes : drawings + // can't be re-rendered later without the URL. + const canvas = form.querySelector('.note-form-canvas'); + const upload = await _uploadCanvasAsPng(canvas, note?.gallery_id || null); + if (!upload?.url) { uiModule.showError('Failed to save drawing'); return; } + payload.image_url = upload.url; + if (upload.galleryId) payload.gallery_id = upload.galleryId; + } else if (currentType === 'goal') { + // Legacy: existing goal-type notes still edit through this branch. + // No AI involvement : save as a normal note with description + items. + payload.content = form.querySelector('.note-form-goal-desc')?.value || ''; + payload.items = _collectItems(form); + } else { + payload.items = _collectItems(form); + } + if (isEdit) payload.id = note.id; + submitNote(payload, note, isEdit); + } finally { + // Re-enable on early returns / errors. On success the form is removed by + // the optimistic re-render, so re-enabling the detached button is a no-op. + _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = ''; + } + }); + + // Mobile-only: when editing an existing note, the Update (✓) button starts in + // archive-mode (visually + behaviorally) and flips to Update on the first + // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever + // touching a separate Archive button. + if (isEdit && window.innerWidth <= 768) { + const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); + const _isFinishedTodo = () => { + if (currentType !== 'todo') return false; + const rows = [...form.querySelectorAll('.note-cl-row')]; + const withText = rows.filter(row => (row.querySelector('.note-cl-text')?.value || '').trim()); + return withText.length > 0 && withText.every(row => row.classList.contains('done')); + }; + const _enterArchive = () => { + _saveBtnEl0.classList.add('archive-mode'); + const label = _isFinishedTodo() ? 'Finish' : 'Archive'; + if (_saveLabelEl) _saveLabelEl.textContent = label; + _saveBtnEl0.title = label; + }; + const _enterUpdate = () => { + if (!_saveBtnEl0.classList.contains('archive-mode')) return; + _saveBtnEl0.classList.remove('archive-mode'); + if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; + _saveBtnEl0.title = 'Update'; + }; + _enterArchive(); + form.addEventListener('input', _enterUpdate, true); + form.addEventListener('change', _enterUpdate, true); + // Checklist dots use a custom control and dispatch their update before + // this click bubbles. Re-evaluate afterward so a fully completed todo + // offers Finish (which archives/removes it from the active list). + form.addEventListener('click', (event) => { + if (!event.target.closest('.note-cl-dot')) return; + setTimeout(() => { + if (_isFinishedTodo()) _enterArchive(); + else _enterUpdate(); + }, 0); + }, true); + } + + // Cancel + form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); app.setEditingId(null); _renderNotes(); }); + + // Archive / Delete : edit-mode-only buttons, mirror the (now-hidden) card actions. + form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => { + if (!isEdit) return; + _archiveNoteById(note.id); + }); + form.querySelector('.note-form-delete-btn')?.addEventListener('click', () => { + if (!isEdit) return; + return deleteFromEditor(note.id); + }); + + // Autosave a draft to persistence on every change so unsaved edits + // survive connection loss / reload / accidental close. + _wireDraftAutosave(form, isEdit ? note.id : '__new__'); + + return form; + } + + return { _buildForm }; +} diff --git a/frontend/src/features/notes/ui/filters.js b/frontend/src/features/notes/ui/filters.js new file mode 100644 index 000000000..49142a260 --- /dev/null +++ b/frontend/src/features/notes/ui/filters.js @@ -0,0 +1,139 @@ +/** Label chips, reminder/goal filters and collapsed tag-strip rendering. */ +import { _visibleNoteTags, _nextGoalStep } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}) { + function _renderLabels(root = document) { + const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar'); + if (!bar) return; + if (state.labelsCollapsed) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + const visibleNotes = _userVisibleNotes(); + const labels = new Set(); + for (const n of visibleNotes) for (const t of _visibleNoteTags(n)) labels.add(t); + const sortedLabels = [...labels].sort(); + // Count active reminders (not archived, has datetime due_date) + const reminderCount = visibleNotes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length; + const pastReminderCount = visibleNotes.filter(n => !n.archived && _isPastReminder(n)).length; + const defaultCount = visibleNotes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length; + // Active goals = non-archived goal notes. Today view lists pending steps + // from each, so we surface the count next to the chip. + const goalCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived).length; + const todayCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length; + const hasUsefulFilters = sortedLabels.length > 0 || reminderCount > 0 || goalCount > 0; + const hasActiveFilter = state.activeLabel !== null || state.activeFilter !== null; + if (!hasUsefulFilters && !hasActiveFilter) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + bar.style.display = ''; + const allActive = state.activeLabel === null && state.activeFilter === null; + let html = ``; + html += ``; + if (todayCount > 0) { + const isOn = state.activeFilter === 'today'; + const icon = ''; + html += ``; + } + if (goalCount > 0) { + const isOn = state.activeFilter === 'goals'; + const icon = ''; + html += ``; + } + const isReminderOn = state.activeFilter === 'reminders'; + const isReminderOff = state.activeFilter === 'no-reminders'; + const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`; + const reminderIcon = isReminderOff + // bell-off icon + ? '' + : ''; + html += ``; + const showingReminders = state.activeFilter === 'reminders'; + if (showingReminders && pastReminderCount > 0) { + html += ``; + } + for (const lbl of sortedLabels) { + html += ``; + } + bar.innerHTML = html; + bar.querySelectorAll('.notes-label-chip').forEach(chip => { + chip.addEventListener('click', () => { + if (chip.dataset.action === 'all') { + app.setActiveLabel(null); + app.setActiveFilter(null); + } else if (chip.dataset.action === 'today') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'today') ? null : 'today'); + } else if (chip.dataset.action === 'goals') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'goals') ? null : 'goals'); + } else if (chip.dataset.action === 'default') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'default') ? null : 'default'); + } else if (chip.dataset.action === 'reminders') { + app.setActiveLabel(null); + // Cycle: null → reminders → null → no-reminders → null → reminders → ... + if (state.activeFilter === null) { + app.setActiveFilter(state.reminderChipNext); + app.setReminderChipNext((state.reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders'); + } else { + app.setActiveFilter(null); + } + } else if (chip.dataset.action === 'clear-past-reminders') { + _clearPastReminders(); + return; + } else { + app.setActiveFilter(null); + app.setActiveLabel(chip.dataset.label || null); + } + _renderNotes(); + }); + }); + } + + function _renderLabelsInto(_body) { + if (!_body) return; + const pane = _body.closest('.notes-pane'); + const toggleHost = pane?.querySelector('.notes-search-bar') || _body; + let toggle = toggleHost.querySelector(':scope > .notes-label-toggle'); + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'notes-label-toggle cal-filter-toggle'; + toggle.addEventListener('click', () => { + app.setLabelsCollapsed(!state.labelsCollapsed); + try { persistence.setItem(NOTES_LABELS_COLLAPSED_KEY, state.labelsCollapsed ? '1' : '0'); } catch {} + _renderNotes(); + }); + const selectButton = toggleHost.querySelector('#notes-select-btn'); + toggleHost.insertBefore(toggle, selectButton || null); + } + toggle.textContent = state.labelsCollapsed ? '+ Tags' : '− Tags'; + toggle.title = state.labelsCollapsed ? 'Show tags' : 'Hide tags'; + toggle.setAttribute('aria-expanded', state.labelsCollapsed ? 'false' : 'true'); + + // The shared chip scroller wraps this row once it is mounted. + let bar = _body.querySelector('.notes-labels-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'notes-labels-bar'; + _body.appendChild(bar); + } + _renderLabels(_body); + } + + return { _renderLabels, _renderLabelsInto }; +} diff --git a/frontend/src/features/notes/ui/layout.js b/frontend/src/features/notes/ui/layout.js new file mode 100644 index 000000000..8935feff5 --- /dev/null +++ b/frontend/src/features/notes/ui/layout.js @@ -0,0 +1,123 @@ +/** Card reflow animation and the owned masonry resize observer. */ +export function createLayout({ isOpen = () => true } = {}) { + function _captureCardPositions() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return null; + const positions = new Map(); + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + if (id) positions.set(id, card.getBoundingClientRect()); + }); + return positions; + } + + function _animateReflow(prevPositions) { + if (!prevPositions || !prevPositions.size) return; + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || !body.isConnected) return; + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const prev = prevPositions.get(id); + if (!prev) return; + const next = card.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; + // Invert: jump back to old position + card.style.transition = 'none'; + card.style.transform = `translate(${dx}px, ${dy}px)`; + // Play: animate to 0 + requestAnimationFrame(() => { + card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)'; + card.style.transform = ''; + card.addEventListener('transitionend', () => { + card.style.transition = ''; + }, { once: true }); + }); + }); + } + + function _applyMasonry(body) { + if (!body || !body.isConnected) return; + const pane = body.closest('.notes-pane'); + const isGrid = pane?.classList.contains('notes-view-grid'); + const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches; + // Tear down any prior observer (defensive : _renderNotes wipes body.innerHTML). + if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; } + // A save that resolves after close re-renders the leaving pane; don't + // re-create the observer that panel teardown already disposed. + if (!isOpen()) return; + if (!isGrid) { + // Clear any leftover inline spans so list view lays out normally. + body.querySelectorAll('.note-card, .notes-label-toggle, .notes-labels-bar, .notes-quick-add, .note-form, .doclib-chip-scroll-frame:has(> .notes-labels-bar)').forEach(c => { c.style.gridRowEnd = ''; }); + return; + } + const ROW_PX = 4; + const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX)); + const recomputeFullRows = () => { + const quickAdd = body.querySelector('.notes-quick-add'); + const labelsToggle = body.querySelector('.notes-label-toggle'); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsToggle && getComputedStyle(labelsToggle).display !== 'none') { + labelsToggle.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsToggle.scrollHeight))}`; + } + if (labelsLayout && getComputedStyle(labelsLayout).display !== 'none') { + const shave = isMobileGrid ? 4 : 0; + labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`; + } + if (quickAdd) { + const shave = isMobileGrid ? 4 : 0; + quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`; + } + body.querySelectorAll('.note-form').forEach(form => { + form.style.gridColumn = '1 / -1'; + const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw'); + const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1; + const renderedHeight = form.getBoundingClientRect?.().height || 0; + const drawReserve = isDrawForm && isMobileGrid ? 12 : 12; + const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve; + form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`; + }); + }; + const recompute = (card) => { + // scrollHeight returns the natural content height : card.getBoundingClientRect() + // would return the grid cell height (collapsed to 4px until the span is set, + // which is the value we're trying to compute). + const h = card.scrollHeight + (isMobileGrid ? 6 : 8); + if (h <= 0) return; + card.style.gridRowEnd = `span ${spanForHeight(h)}`; + }; + recomputeFullRows(); + body.querySelectorAll('.note-card').forEach(recompute); + // Watch masonry participants : content can grow (image load, todo edits, + // quick-add/form expansion), and stale spans are what cause visual merging. + if ('ResizeObserver' in window) { + _masonryObserver = new ResizeObserver(entries => { + let fullRowsChanged = false; + for (const e of entries) { + if (e.target.classList.contains('note-card')) recompute(e.target); + else fullRowsChanged = true; + } + if (fullRowsChanged) recomputeFullRows(); + }); + body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c)); + body.querySelectorAll('.notes-label-toggle, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c)); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsLayout) _masonryObserver.observe(labelsLayout); + } + } + + let _masonryObserver = null; + + function dispose() { + _masonryObserver?.disconnect(); + _masonryObserver = null; + } + return { dispose, _captureCardPositions, _animateReflow, _applyMasonry }; +} diff --git a/frontend/src/features/notes/ui/lifecycle.js b/frontend/src/features/notes/ui/lifecycle.js new file mode 100644 index 000000000..67a8da199 --- /dev/null +++ b/frontend/src/features/notes/ui/lifecycle.js @@ -0,0 +1,36 @@ +/** Notes-owned document/window listeners. Detached editor/menu owners release their resources. */ +export function createNotesLifecycle() { + const owners = new Map(); + let observer = null; + function release(owner) { + const entry = owners.get(owner); + if (!entry) return; + owners.delete(owner); + for (const remove of entry.removers) remove(); + if (!owners.size) { observer?.disconnect(); observer = null; } + } + function onDispose(owner, remove) { + if (!owner) throw new Error('Notes listener needs a lifecycle owner'); + let entry = owners.get(owner); + if (!entry) { + entry = { connected: owner.isConnected, removers: [] }; + owners.set(owner, entry); + } + entry.removers.push(remove); + if (!observer) { + observer = new MutationObserver(() => { + for (const [node, scope] of owners) { + if (node.isConnected) scope.connected = true; + else if (scope.connected) release(node); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + } + function listen(owner, target, event, listener, options) { + onDispose(owner, () => target.removeEventListener(event, listener, options)); + target.addEventListener(event, listener, options); + } + function dispose() { for (const owner of [...owners.keys()]) release(owner); } + return { listen, onDispose, release, dispose }; +} diff --git a/frontend/src/features/notes/ui/mobileEditor.js b/frontend/src/features/notes/ui/mobileEditor.js new file mode 100644 index 000000000..f751ba7aa --- /dev/null +++ b/frontend/src/features/notes/ui/mobileEditor.js @@ -0,0 +1,257 @@ +/** Mobile fullscreen editing, link reader mode and return to the panel. */ +export function createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}) { + function _isNotesMobileMode() { + return ('ontouchstart' in window) && window.innerWidth <= 768; + } + + let _mobileFsOverlay = null; + + let _mobileFsNoteId = null; + + function _openMobileFullscreenEdit(id, fromCard) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + // Tear down any previous overlay (defensive). + _closeMobileFullscreenEdit({ save: false }); + _mobileFsNoteId = id; + app.setEditingId(id); + + const overlay = document.createElement('div'); + overlay.className = 'note-fullscreen-overlay'; + overlay.innerHTML = ` +
+ +
+
+
+ `; + const body = overlay.querySelector('.note-fullscreen-body'); + // Reuse the same edit form the in-place flow builds. Save buttons, + // checklist toggles, etc. all work as-is. Restore any unsaved draft. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + body.appendChild(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + document.body.appendChild(overlay); + _mobileFsOverlay = overlay; + + // Animate up from the tapped tile's position so the transition reads + // as a zoom rather than a hard cut. + if (fromCard) { + const r = fromCard.getBoundingClientRect(); + const vw = window.innerWidth, vh = window.innerHeight; + overlay.style.transformOrigin = + `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`; + } + overlay.classList.add('opening'); + requestAnimationFrame(() => overlay.classList.add('open')); + + // Wire the back button : saves whatever the form has and closes. + // mousedown preventDefault so it doesn't blur the input on first tap (which + // would eat the tap and require a second one). + const _backBtn = overlay.querySelector('.note-fullscreen-back'); + _backBtn.addEventListener('mousedown', (e) => e.preventDefault()); + _backBtn.addEventListener('click', () => { + _closeMobileFullscreenEdit({ save: true }); + }); + + // The form's built-in Cancel only resets the in-place edit state; in + // the overlay context it does nothing visible. Replace its handler so + // Cancel actually dismisses the overlay without saving. + const cancelBtn = form.querySelector('.note-form-cancel'); + if (cancelBtn) { + const fresh = cancelBtn.cloneNode(true); + cancelBtn.parentNode.replaceChild(fresh, cancelBtn); + fresh.addEventListener('mousedown', (e) => e.preventDefault()); + fresh.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _closeMobileFullscreenEdit({ save: false }); + }); + } + // The built-in Save handler does the API call + refresh, but doesn't + // dismiss our overlay. Augment it (do NOT replace : the original is + // async and we'd lose the API call) to schedule a close once the + // save+render has had time to complete. + const saveBtn = form.querySelector('.note-form-save'); + if (saveBtn) { + saveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350); + }); + } + // Make the checklist row drag handle (⋮⋮) actually work on touch. + // The form's default uses HTML5 native draggable which never fires + // on iOS/Android. Wire touch-based reorder for any row inside the + // overlay's checklist. + _wireChecklistTouchReorder(form); + + // For each todo row whose text contains a URL, swap the bare + // for a linkified so URLs are tappable. Tapping anywhere that + // ISN'T a link flips back to the input for editing. + form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode); + + // Move only Delete from the form's footer actions row into the header. + // Archive intentionally stays out of the header: the footer Update button + // already morphs into the mobile Archive action for an untouched note. + const headerActions = overlay.querySelector('.note-fullscreen-actions'); + const archiveBtn = form.querySelector('.note-form-archive-btn'); + const deleteBtn = form.querySelector('.note-form-delete-btn'); + if (headerActions && deleteBtn) { + deleteBtn.classList.remove('note-form-collapsible'); + headerActions.appendChild(deleteBtn); + } + // The built-in archive/delete handlers re-render the notes grid but + // leave THIS overlay sitting in front of it : looks like nothing + // happened. Add follow-up listeners that close the overlay so the + // user sees the action take effect. + if (archiveBtn) { + archiveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200); + }); + } + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + // Delete shows a styled confirm : give it room to resolve before + // we try to dismiss the overlay. + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500); + }); + } + + // Tuck the tags input into the bottom actions row (Cancel / Update), + // pinned to the LEFT. Frees the meta row of an extra wrapping line + // and groups all the "exit" controls together. + const actionsGroup = form.querySelector('.note-form-actions-group'); + const tagsInput = form.querySelector('.note-form-label'); + if (actionsGroup && tagsInput) { + actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild); + } + + // For checklist-type notes, move the photo (attach image) button into + // the same row as the + Add button (right side) : keeps the meta row + // tidy and puts the camera within thumb-reach of the active edit. + const addBtn = form.querySelector('.note-cl-add'); + const photoBtn = form.querySelector('.note-form-photo-btn'); + if (addBtn && photoBtn) { + const addRow = document.createElement('div'); + addRow.className = 'note-cl-add-row'; + addBtn.parentNode.insertBefore(addRow, addBtn); + addRow.appendChild(addBtn); + addRow.appendChild(photoBtn); + // Tapping anywhere on the row (the empty gap, the dashed border, + // the "+ Add" label) triggers add. The photo button keeps its own + // click target so attach-image isn't ambushed. + addRow.addEventListener('click', (e) => { + if (e.target.closest('.note-form-photo-btn')) return; + if (e.target === addBtn || addBtn.contains(e.target)) return; + addBtn.click(); + }); + // The form's delegated "+ Add" handler does + // inputs.insertBefore(newRow, addBtn) + // : but addBtn is no longer a direct child of `.note-checklist-inputs` + // now that we wrapped it. Bind a fresh handler that does the same + // thing but inserts before the WRAPPING row, and stop propagation + // so the broken delegate never runs. + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const inputs = form.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const newRow = document.createElement('div'); + newRow.className = 'note-cl-row'; + newRow.draggable = true; + newRow.dataset.itemId = _uid(); + newRow.dataset.indent = '0'; + newRow.innerHTML = '⋮⋮'; + inputs.insertBefore(newRow, addRow); + _wireRow(newRow, inputs); + // Touch reorder on the freshly-added row's grip. + _wireChecklistTouchReorder(form); + newRow.querySelector('.note-cl-text')?.focus(); + }, { capture: true }); + } + + // Read-mode overlay for plain notes: render the content as a div with + // clickable hyperlinks, layered above the textarea. Tapping anywhere + // in the overlay that ISN'T a link hides the overlay and focuses the + // textarea so the user can start editing. Tapping a link opens it. + const ta = form.querySelector('.note-form-content'); + if (ta && (note.content || '').trim()) { + const reader = document.createElement('div'); + reader.className = 'note-form-content-reader'; + reader.innerHTML = _linkify(note.content || ''); + ta.style.display = 'none'; + ta.insertAdjacentElement('beforebegin', reader); + reader.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let links open normally + reader.remove(); + ta.style.display = ''; + // Let the browser place the cursor naturally : forcing + // setSelectionRange right after focus() raced with the underlying + // tap event and produced inconsistent cursor positions on mobile. + ta.focus({ preventScroll: true }); + }); + } + + // Opening an EXISTING note → read mode, no keyboard pop. Only a + // brand-new note (created via the + button) should auto-focus an + // input field. The user can tap the content to switch to edit. + // (New-note creation flows through _createNote, not this function.) + } + + function _closeMobileFullscreenEdit(opts = {}) { + if (!_mobileFsOverlay) return; + const overlay = _mobileFsOverlay; + overlay.querySelector('.note-form')?._flushDraft?.(); + _mobileFsOverlay = null; + // If the form has a Save button, click it on close so edits aren't lost + // when the user uses the back arrow instead of an explicit Save. + if (opts.save) { + const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]'); + if (saveBtn) try { saveBtn.click(); } catch {} + } + overlay.classList.remove('open'); + overlay.classList.add('closing'); + setTimeout(() => { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + _mobileFsNoteId = null; + app.setEditingId(null); + // Refresh the grid so any save the user made is reflected. + if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); } + }, 220); + } + + function _addRowReadMode(row) { + const txt = row.querySelector('.note-cl-text'); + if (!txt) return; + const val = txt.value || ''; + if (!/(https?:\/\/|www\.)/i.test(val)) return; + if (row.querySelector('.note-cl-text-reader')) return; // already wired + const span = document.createElement('span'); + span.className = 'note-cl-text-reader'; + span.innerHTML = _linkify(val); + txt.style.display = 'none'; + txt.insertAdjacentElement('beforebegin', span); + span.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let the link open + span.remove(); + txt.style.display = ''; + txt.focus({ preventScroll: true }); + }); + } + + return { _isNotesMobileMode, _openMobileFullscreenEdit, _closeMobileFullscreenEdit, _addRowReadMode }; +} diff --git a/frontend/src/features/notes/ui/mobileReorder.js b/frontend/src/features/notes/ui/mobileReorder.js new file mode 100644 index 000000000..b5f6404d3 --- /dev/null +++ b/frontend/src/features/notes/ui/mobileReorder.js @@ -0,0 +1,214 @@ +/** Mobile card long press, placeholder drag and reorder persistence. */ +export function createMobileReorder({ + lifecycle, + _applyMasonry, + _enterSelectMode, + commitMobileOrder, + app, +}) { + function _bindLongPressDrag(card) { + let pressTimer = null; + let startX = 0, startY = 0; + let armed = false; + const CANCEL_PX = 8; + const HOLD_MS = 450; + + card.addEventListener('touchstart', (e) => { + // Don't fight scroll on touchpoints over real interactive children + // (in mobile-mode they're CSS-hidden anyway, but be defensive). + if (e.target.closest('button, input, a, .note-form')) return; + if (e.touches.length !== 1) return; + armed = true; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + pressTimer = setTimeout(() => { + if (!armed) return; + try { navigator.vibrate?.(15); } catch {} + app.setSuppressNextNoteTapId(card.dataset.noteId); + _enterSelectMode(card.dataset.noteId); + }, HOLD_MS); + }, { passive: true }); + card.addEventListener('touchmove', (e) => { + if (!armed) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + } + }, { passive: true }); + const cancel = () => { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + }; + lifecycle.onDispose(card, cancel); + card.addEventListener('touchend', cancel, { passive: true }); + card.addEventListener('touchcancel', cancel, { passive: true }); + } + + let _dragState = null; + + let _docDragHandlersBound = false; + + function _enterDragMode(initialCard, initialTouch) { + document.body.classList.add('notes-drag-mode'); + document.querySelectorAll('.note-card').forEach(_setupDragForCard); + if (!_docDragHandlersBound) { + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchmove', _onDocTouchMove, { passive: false }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchend', _onDocTouchEnd, { passive: true }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchcancel', _onDocTouchEnd, { passive: true }); + _docDragHandlersBound = true; + } + // Auto-grab the card the user long-pressed : they're already holding + // their finger on it, so begin the drag straight away. Releasing + // (touchend) commits the reorder AND exits drag mode in one motion. + if (initialCard && initialTouch) { + _beginGrab(initialCard, initialTouch); + } + } + + function _exitDragMode() { + document.body.classList.remove('notes-drag-mode'); + if (_dragState) { + // Defensive: if exit fires while a drag is in flight, snap the card back. + _onDocTouchEnd(); + } + // Leave _docDragHandlersBound true so re-entering drag mode reuses them. + } + + function _setupDragForCard(card) { + if (card.dataset.dragBound === '1') return; + card.dataset.dragBound = '1'; + card.addEventListener('touchstart', (e) => { + if (!document.body.classList.contains('notes-drag-mode')) return; + if (e.touches.length !== 1) return; + if (_dragState) return; + e.preventDefault(); + e.stopPropagation(); + _beginGrab(card, e.touches[0]); + }, { passive: false }); + } + + function _beginGrab(card, touch) { + const rect = card.getBoundingClientRect(); + const prevStyle = card.getAttribute('style') || ''; + // Placeholder fills the card's old slot so the grid layout doesn't reflow. + const placeholder = document.createElement('div'); + placeholder.className = 'note-card-placeholder'; + placeholder.style.width = rect.width + 'px'; + placeholder.style.height = rect.height + 'px'; + placeholder.style.margin = getComputedStyle(card).margin; + if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd; + const grid = card.parentNode; + grid.insertBefore(placeholder, card); + + // Detach the card visually : fixed-position, anchored to the finger. + card.classList.add('note-card-dragging'); + card.style.position = 'fixed'; + card.style.left = rect.left + 'px'; + card.style.top = rect.top + 'px'; + card.style.width = rect.width + 'px'; + card.style.height = rect.height + 'px'; + card.style.margin = '0'; + card.style.zIndex = '10001'; + // pointer-events:none so elementFromPoint sees the card BENEATH the finger + card.style.pointerEvents = 'none'; + + _dragState = { + card, placeholder, grid, prevStyle, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onDocTouchMove(e) { + if (!_dragState) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const { card, placeholder, grid } = _dragState; + card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px'; + const quickAdd = grid.querySelector('.notes-quick-add'); + const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top; + const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8); + const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY)); + card.style.top = nextTop + 'px'; + + const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY)); + const under = document.elementFromPoint(touch.clientX, hitY); + const target = under && under.closest + ? under.closest('.note-card:not(.note-card-dragging)') + : null; + if (!target || target === card) return; + if (target.parentNode !== grid) return; + + // Move the PLACEHOLDER (not the card) above or below the target depending + // on which half of the target the finger is in. This is the hysteresis + // that stops the oscillation : once the placeholder moves past a card, + // the cursor has to cross THAT card's midpoint in the other direction + // to swap back. + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (touch.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + grid.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + grid.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onDocTouchEnd() { + if (!_dragState) return; + const { card, placeholder, grid, prevStyle } = _dragState; + _dragState = null; + // Animate the card from its current fixed position to where the + // placeholder sits, then re-parent and clear inline styles. Drag + // mode auto-exits once the snap finishes : release = done. + const phRect = placeholder.getBoundingClientRect(); + card.style.transition = 'left 0.2s ease, top 0.2s ease'; + card.style.left = phRect.left + 'px'; + card.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + // Restore the card's pre-drag inline styles. Mobile masonry stores + // grid-row-end inline, and custom backgrounds use inline style too; wiping + // cssText made dropped cards collapse into neighboring notes in grid view. + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + _applyMasonry(grid); + _commitNoteReorder(); + // One drag, one exit : release ends the rearrange session entirely. + if (document.body.classList.contains('notes-drag-mode')) { + document.body.classList.remove('notes-drag-mode'); + } + }, 210); + } + + async function _commitNoteReorder() { + const grid = document.querySelector('#notes-pane .notes-pane-body'); + if (!grid) return; + const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean); + if (!ids.length) return; + await commitMobileOrder(ids); + } + + function dispose() { + if (_dragState) { + const { card, placeholder, prevStyle } = _dragState; + _dragState = null; + placeholder.parentNode?.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + } + document.body.classList.remove('notes-drag-mode'); + _docDragHandlersBound = false; + } + return { dispose, _bindLongPressDrag, _enterDragMode, _exitDragMode, _setupDragForCard, _beginGrab, _onDocTouchMove, _onDocTouchEnd, _commitNoteReorder }; +} diff --git a/frontend/src/features/notes/ui/panel.js b/frontend/src/features/notes/ui/panel.js new file mode 100644 index 000000000..0a6031233 --- /dev/null +++ b/frontend/src/features/notes/ui/panel.js @@ -0,0 +1,506 @@ +/** Panel mounting, keyboard handlers, minimization and note navigation. */ +export function createPanel({ + wireToolbar, + wireSelectionActions, + _wireNotesWindow, + _restoreNotesSidebarDock, + _bringNotesToFront, + disposeUI, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}) { + function _forceCloseNotesPanel() { + app.setOpen(false); + app.setEditingId(null); + try { _commitOpenInPlaceEditor(); } catch {} + try { _closeMobileFullscreenEdit({ save: true }); } catch {} + try { _clearViewedReminderGlows(); } catch {} + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); + document.getElementById('tool-notes-btn')?.classList.remove('active'); + try { Modals.unregister('notes-panel'); } catch {} + try { document.getElementById('notes-pane')?.remove(); } catch {} + try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} + try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} + } + + function _showNotesFirstOpenHint(pane) { + if (!pane || typeof persistence === 'undefined') return; + try { + if (persistence.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; + persistence.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); + } catch { + return; + } + + dismissOrRemove(document.getElementById('notes-first-open-hint')); + const hint = document.createElement('div'); + hint.id = 'notes-first-open-hint'; + hint.className = 'tour-hint'; + hint.innerHTML = ` +
Notes is your basic todo list, and also where reminders are managed.
+ + `; + document.body.appendChild(hint); + + const place = () => { + const r = pane.getBoundingClientRect(); + const hw = hint.offsetWidth || 260; + hint.style.top = Math.max(12, r.top + 58) + 'px'; + hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; + }; + requestAnimationFrame(() => { + place(); + hint.classList.add('tour-hint-in'); + }); + lifecycle.listen(hint, window, 'resize', place); + bindTourHintDismiss(hint, { + timeout: 6500, + fade: 180, + onDismiss: () => window.removeEventListener('resize', place), + }); + } + + function _ensureNotesChipRegistered() { + if (Modals.isRegistered('notes-panel')) return; + Modals.register('notes-panel', { + railBtnId: 'rail-notes', + sidebarBtnId: 'tool-notes-btn', + restoreFn: () => { openPanel(); }, + closeFn: () => { _forceCloseNotesPanel(); }, + }); + } + + function openPanel() { + if (state.open) { + _bringNotesToFront(); + return; + } + // Notes and the document editor occupy the same primary workspace. Preserve + // an open document as a dock chip instead of allowing the two panes to stack + // into a broken layout (or closing the user's document outright). + try { + if (window.documentModule?.isPanelOpen?.()) { + window.documentModule.closePanel('down'); + } + } catch (_) {} + app.setOpen(true); + app.setEditingId(null); + app.setSearchQuery(''); + _clearViewedReminderGlows(); + app.setFiredDotDismissedAt(Date.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + + const container = document.getElementById('chat-container'); + if (!container) return; + + document.body.classList.add('notes-view'); + + // On mobile the notes panel takes the whole screen : auto-close the + // sidebar so the panel isn't cropped underneath it. + if (window.innerWidth <= 768) { + const sb = document.getElementById('sidebar'); + if (sb) sb.classList.add('hidden'); + document.body.classList.add('sidebar-collapsed'); + } + // Mobile mode: tiles become read-only previews (no inline checkbox / + // edit / archive / etc.), tap opens a fullscreen edit overlay, + // long-press enters drag-to-reorder mode. See _bindCardEvents + + // .notes-mobile-mode CSS rules. + if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); + + // Toggle button state + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.add('active'); + + // Create panel + const pane = document.createElement('div'); + pane.id = 'notes-pane'; + pane.className = 'notes-pane'; + pane.innerHTML = ` + +
+

Notes

+ + + + +
+ + +
+ `; + + // On mobile open as a full-screen bottom sheet (slide up), not the desktop + // side panel. Set inline so it wins over the base .notes-pane rule regardless + // of cascade specifics (the CSS @media override wasn't reliably applying, + // which left it as a side panel squeezing the chat). + if (window.innerWidth <= 768) { + pane.style.position = 'fixed'; + pane.style.inset = '0'; + pane.style.width = '100%'; + pane.style.maxWidth = '100%'; + pane.style.zIndex = '170'; + pane.style.borderRadius = '14px 14px 0 0'; + pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; + pane.style.transformOrigin = 'bottom center'; + } + + // Mount on body so Notes can behave like the other draggable windows. On + // desktop it is immediately docked to the right by _restoreNotesSidebarDock. + const backdrop = document.createElement('div'); + backdrop.className = 'notes-pane-backdrop'; + backdrop.id = 'notes-pane-backdrop'; + backdrop.addEventListener('click', (ev) => { + if (ev.target === backdrop) closePanel('down'); + }); + backdrop.appendChild(pane); + document.body.appendChild(backdrop); + _wireNotesWindow(pane); + _restoreNotesSidebarDock(pane); + _bringNotesToFront(pane); + + // Events + // (Close chevron removed : swipe down on mobile, tool-rail toggle on desktop.) + + // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). + // Mirrors the document sheet gesture : finger-following, velocity-based + // dismiss, rubber-band on up-drag, spring snap-back. + _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); + _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); + + pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); + pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); + + const minBtn = document.getElementById('notes-minimize-btn'); + if (minBtn) minBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closePanel('down'); + }); + wireToolbar(pane); + // Select mode + document.getElementById('notes-select-btn').addEventListener('click', () => { + if (state.selectMode) _exitSelectMode(); else _enterSelectMode(); + }); + // Esc cancels select mode. Notes uses a toggle "Select" button rather + // than a *-bulk-cancel button, so the global Esc-cancel handler in + // keyboard-shortcuts.js can't reach it : handle it here. Capture phase + // + stopPropagation so Esc cancels select instead of closing the panel. + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + } + _notesSelectEscHandler = (e) => { + if (e.key === 'Escape' && state.selectMode) { + e.preventDefault(); + e.stopPropagation(); + _exitSelectMode(); + } + }; + document.addEventListener('keydown', _notesSelectEscHandler, true); + wireSelectionActions(); + // Escape: exit select mode first (if active), otherwise close the panel. + // Skip when the user is editing a form field : those have their own + // ESC-to-cancel handlers and we don't want to nuke the whole panel + // mid-edit. + // Idempotent: remove any previous handler from a prior openPanel so + // re-opening doesn't stack multiple handlers. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + _notesKeydownHandler = (e) => { + if (!state.open) return; + const t = e.target; + const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); + // Ctrl/Cmd+Z anywhere in the panel : undo the last note action. Skip when + // typing in a field so the browser's normal text-undo still works. + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { + if (inField) return; + if (workflows.undoCount() === 0) return; + e.preventDefault(); + _popAndRunUndo(); + return; + } + // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the + // user is editing or has an active text selection (let the browser handle + // a real text copy in that case). + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { + if (inField) return; + const sel = window.getSelection?.(); + if (sel && sel.toString && sel.toString().length > 0) return; + const hovered = document.querySelector('.note-card:hover'); + if (!hovered) return; + const id = hovered.dataset.noteId; + if (!id) return; + e.preventDefault(); + // Flash the ⋯ menu button (copy now lives in that menu). + const btn = hovered.querySelector('.note-card-corner-menu'); + _copyNote(id, btn); + return; + } + if (e.key !== 'Escape') return; + if (inField) return; + if (state.selectMode) { _exitSelectMode(); return; } + if (state.showingArchived) { + // Mirror the archive toggle button: flip back to active notes. + document.getElementById('notes-archive-toggle')?.click(); + return; + } + _forceCloseNotesPanel(); + }; + document.addEventListener('keydown', _notesKeydownHandler); + + // Load : show skeleton immediately, then fetch + _renderLoadingSkeleton(); + // Defer the highlight flush to the next frame so it runs *after* the cards + // are committed to the DOM (and any FLIP animations have settled), giving + // the querySelector lookups inside something to find. + _fetchNotes().then(() => { + if (!pane.isConnected || !state.open) { _startReminderLoop(); return; } + _renderNotes(); + requestAnimationFrame(() => _flushPendingHighlights()); + _startReminderLoop(); + _showNotesFirstOpenHint(pane); + }); + } + + function closePanel(direction) { + if (!state.open) return; + app.setOpen(false); + app.setEditingId(null); + _clearViewedReminderGlows(); + const _minimize = direction === 'down'; + if (_minimize) { + _ensureNotesChipRegistered(); + } else if (Modals.isRegistered('notes-panel')) { + Modals.unregister('notes-panel'); + } + + // Drop panel-owned keydown listeners. The reminder interval is page-owned. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view'); + document.body.classList.remove('notes-mobile-mode'); + document.body.classList.remove('notes-drag-mode'); + // Closing the panel should PRESERVE in-progress edits, not drop them. + // Commit any open in-place editor, and close the mobile fullscreen + // overlay with save=true so the note is persisted. + try { _commitOpenInPlaceEditor(); } catch {} + _closeMobileFullscreenEdit({ save: true }); + // /notes route may have collapsed the wide sidebar to a rail; restore. + try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} + + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.remove('active'); + + const pane = document.getElementById('notes-pane'); + const backdrop = document.getElementById('notes-pane-backdrop'); + if (pane) { + // Scale-out + fade. Match the enter animation duration so close feels + // like the same gesture played backwards. + pane.classList.add('notes-pane-leaving'); + const _cleanup = () => { + try { pane.remove(); } catch {} + try { backdrop?.remove(); } catch {} + }; + pane.addEventListener('animationend', _cleanup, { once: true }); + // Belt-and-braces: if animation is skipped (reduced motion / detached + // tab) the listener won't fire; remove after the expected duration. + setTimeout(_cleanup, 220); + } else if (backdrop) { + backdrop.remove(); + } + // Show the dock chip for a swipe-down minimize (tap it to reopen). + if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } + } + + function togglePanel() { + if (state.open) closePanel(); + else openPanel(); + } + + function isPanelOpen() { return state.open; } + + async function openNote(noteId) { + const wasOpen = !!(isPanelOpen && isPanelOpen()); + app.setShowingArchived(false); + app.setActiveLabel(null); + app.setActiveFilter(null); + app.setSearchQuery(''); + if (!wasOpen) { + openPanel(); + } else { + _bringNotesToFront(); + const searchEl = document.getElementById('notes-search'); + if (searchEl) searchEl.value = ''; + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.remove('notes-pane-archive'); + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) archiveBtn.classList.remove('active'); + await _fetchNotes(); + _renderNotes(); + } + // openPanel() kicks off _fetchNotes() asynchronously, so the cards for + // newly-created notes may not be in the DOM yet. Poll until the card exists. + if (!noteId) return; + let tries = 0; + const findAndFlash = () => { + const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) + || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); + if (card) { + _smoothScrollToNoteCard(card); + card.classList.add('note-card-flash'); + setTimeout(() => card.classList.remove('note-card-flash'), 1600); + return true; + } + return false; + }; + const tryNext = () => { + if (findAndFlash()) return; + if (++tries < 20) setTimeout(tryNext, 200); + }; + setTimeout(tryNext, 120); + } + + let _notesKeydownHandler = null; + + let _notesSelectEscHandler = null; + + function _wireNotesSwipeDismiss(el, pane) { + if (!el || !pane) return; + const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; + let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; + let dragging = false, cancelled = false; + + el.addEventListener('touchstart', (e) => { + if (window.innerWidth > 768 || e.touches.length !== 1) return; + if (e.target.closest('button, input, select, label, textarea')) return; + const t = e.touches[0]; + startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; + velocity = 0; dragging = false; cancelled = false; + }, { passive: true }); + + el.addEventListener('touchmove', (e) => { + if (cancelled || window.innerWidth > 768) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - startX); + const dy = t.clientY - startY; + if (!dragging) { + if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } + if (Math.abs(dy) > 8) { + dragging = true; + pane.style.animation = 'none'; + pane.style.transition = 'none'; + pane.style.willChange = 'transform'; + } else return; + } + const dt = e.timeStamp - lastT; + if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; + lastY = t.clientY; lastT = e.timeStamp; + e.preventDefault(); + pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; + }, { passive: false }); + + const endSwipe = () => { + if (!dragging) return; + dragging = false; + pane.style.willChange = ''; + const dy = lastY - startY; + if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { + // Slide fully off-screen, then minimise. Keep it translated down (don't + // reset) so it doesn't flash back before closePanel removes it. + pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; + pane.style.transform = 'translateY(100%)'; + setTimeout(() => closePanel('down'), 200); + } else { + pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; + pane.style.transform = ''; + setTimeout(() => { pane.style.transition = ''; }, 260); + } + }; + el.addEventListener('touchend', endSwipe, { passive: true }); + el.addEventListener('touchcancel', endSwipe, { passive: true }); + } + + function _smoothScrollToNoteCard(card) { + if (!card) return; + const scroller = card.closest('.notes-pane-body'); + if (!scroller) { + try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2; + scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' }); + } + + function disposeOwnedPortals() { + document.querySelectorAll('.note-reminder-menu, .note-corner-menu-dropdown, #notes-first-open-hint').forEach(dismissOrRemove); + } + + return { _forceCloseNotesPanel, openPanel, closePanel, togglePanel, isPanelOpen, openNote, _smoothScrollToNoteCard }; +} diff --git a/frontend/src/features/notes/ui/quickAdd.js b/frontend/src/features/notes/ui/quickAdd.js new file mode 100644 index 000000000..3a723fcdc --- /dev/null +++ b/frontend/src/features/notes/ui/quickAdd.js @@ -0,0 +1,170 @@ +/** Quick-add expansion, draft restoration and opening inline editors. */ +export function createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}) { + function _renderQuickAdd(body) { + const wrap = document.createElement('div'); + wrap.className = 'notes-quick-add'; + // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw : + // drawing happens in the expanded form). The pill that's active steers + // both the placeholder and the type the form opens in. + wrap.innerHTML = ` +
+ + +
+ + + `; + body.appendChild(wrap); + + const input = wrap.querySelector('.notes-quick-input'); + const seg = wrap.querySelector('.notes-quick-type-seg'); + let currentType = 'todo'; + const setType = (t) => { + if (t !== 'note' && t !== 'todo') return; + currentType = t; + seg.classList.toggle('is-todo', t === 'todo'); + seg.classList.toggle('is-note', t === 'note'); + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + const on = p.dataset.type === t; + p.classList.toggle('active', on); + p.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…'; + }; + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + p.addEventListener('click', (e) => { + e.stopPropagation(); + setType(p.dataset.type); + }); + }); + // Click input or type → expand to full form + const expandToForm = (initialType = 'note', initialText = '') => { + app.setEditingId('__new__'); + const form = _buildForm({ note_type: initialType }); + form.classList.add('note-form-new'); + if (initialText) { + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.value = initialText; + } + const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid') + && window.matchMedia('(max-width: 768px)').matches; + if (mobileGrid) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = 'span 64'; + } + wrap.replaceWith(form); + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) { + titleEl.focus(); + // Move caret to end + titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length); + } + }; + // Expand only on real intent: a click directly on the input, or actual + // typing. Focus alone : including focus stolen from a missed nearby + // click : no longer creates an empty form. + input.addEventListener('click', () => expandToForm(currentType, input.value)); + input.addEventListener('input', () => expandToForm(currentType, input.value)); + wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => { + e.stopPropagation(); + expandToForm(currentType); + // Trigger photo input on the new form + setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50); + }); + } + + function _createNote(type = 'todo') { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || state.editingId === '__new__') return; + app.setEditingId('__new__'); + // Restore an unsaved new-note draft if one survived a prior close/loss. + const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__'); + const form = _buildForm(_n); + form.classList.add('note-form-new'); + body.prepend(form); + const initialField = type === 'note' + ? form.querySelector('.note-form-content') + : form.querySelector('.note-cl-text, .note-form-title'); + initialField?.focus(); + if (restored) uiModule.showToast('Restored unsaved note'); + } + + function newNote() { + if (!state.open) openPanel(); + requestAnimationFrame(() => _createNote('note')); + } + + function _editNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + app.setEditingId(id); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) return; + // Restore an unsaved draft (from a prior connection loss / close) over + // the saved note so the user picks up where they left off. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + card.replaceWith(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + // Pinned notes live in the first masonry column : the edit form has + // column-span:all, which can leave the form rendered above the fold or + // visually buried under neighboring pinned cards. Bring it into view + // (and onto a higher stacking context) so editing a pinned note always + // pops to the top. + form.style.position = 'relative'; + form.style.zIndex = '5'; + // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px) + // after replaceWith, which is much shorter than the actual form. Recompute + // masonry so the form gets the correct row span and cards below stop + // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as + // the user types / adds checklist items. + const _body = form.closest('.notes-pane-body'); + if (_body) { + _applyMasonry(_body); + requestAnimationFrame(() => _applyMasonry(_body)); + } + requestAnimationFrame(() => { + try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); } + catch { form.scrollIntoView(); } + }); + // Pick the most useful field to focus. On phones especially, the user + // taps Edit to type : landing in the title when there's already a title + // (and likely a body to extend) loses momentum. Prefer the body textarea + // for plain notes, the first checklist item for todos, fall back to title. + const _focusBest = () => { + if (note.note_type === 'note' || !note.note_type) { + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; } + } + if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') { + // Last non-empty checklist row, or the first row if all empty. + const rows = form.querySelectorAll('.note-cl-row .note-cl-text'); + let target = null; + for (const inp of rows) { if ((inp.value || '').trim()) target = inp; } + target = target || rows[0]; + if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; } + } + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.focus(); + }; + _focusBest(); + } + + return { _renderQuickAdd, _createNote, newNote, _editNote }; +} diff --git a/frontend/src/features/notes/ui/reminderDisplay.js b/frontend/src/features/notes/ui/reminderDisplay.js new file mode 100644 index 000000000..5f6507d46 --- /dev/null +++ b/frontend/src/features/notes/ui/reminderDisplay.js @@ -0,0 +1,129 @@ +/** Reminder badges, sticky card highlights and entry scrolling. */ +import { _isNoteFullyDone } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}) { + function _clearViewedReminderGlows() { + const active = _loadActiveHighlights(); + if (!active.size) return; + _saveActiveHighlights(new Set()); + document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => { + card.classList.remove('note-card-reminder-fired-sticky'); + }); + } + + function _setReminderCardGlow(noteId, on = true) { + if (!noteId) return; + const active = _loadActiveHighlights(); + if (on) active.add(noteId); + else active.delete(noteId); + _saveActiveHighlights(active); + document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => { + card.classList.toggle('note-card-reminder-fired-sticky', on); + }); + } + + function _queuePendingHighlight(noteId) { + const set = _loadPendingHighlights(); + set.add(noteId); + _savePendingHighlights(set); + } + + function _flushPendingHighlights() { + // Fresh firings (queued by the background loop while the panel was closed) + // glow unconditionally : a notification just told the user something + // happened, so we always point at the note even if it was glowed before. + const queued = _loadPendingHighlights(); + const glowed = _loadGlowedReminders(); + const toGlow = new Set(queued); + // For notes that are merely overdue at open time (no fresh firing event), + // only glow the ones we haven't already shown : otherwise reopening the + // panel keeps lighting up old reminders forever. + for (const n of state.notes) { + if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue; + if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id); + } + // Always consume the queue. + _savePendingHighlights(new Set()); + if (!toGlow.size) return; + let firstCard = null; + for (const id of toGlow) { + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) continue; + _setReminderCardGlow(id, true); + if (!firstCard) firstCard = card; + glowed.add(id); + } + _saveGlowedReminders(glowed); + // Bring the first one into view so it can't get buried below the fold. + if (firstCard) { + requestAnimationFrame(() => { + _smoothScrollToNoteCard(firstCard); + }); + } + } + + function _updateRailBadge() { + const fired = _countFiredReminders(); + // Rail (mini sidebar) : only show the count when reminders have ACTUALLY + // fired since the last dismissal (i.e. you haven't opened notes yet). + // Showing every overdue note forever made the badge feel permanent. + const railBtn = document.getElementById('rail-notes'); + if (railBtn) { + let badge = railBtn.querySelector('.rail-notes-badge'); + if (fired > 0) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'rail-notes-badge'; + railBtn.appendChild(badge); + } + badge.textContent = fired > 99 ? '99+' : String(fired); + badge.classList.add('fired'); + } else if (badge) { + badge.remove(); + } + } + // Main sidebar button + const sidebarBtn = document.getElementById('tool-notes-btn'); + if (sidebarBtn) { + let dot = sidebarBtn.querySelector('.tool-notes-dot'); + if (fired > 0) { + if (!dot) { + dot = document.createElement('span'); + dot.className = 'tool-notes-dot'; + const newNoteBtn = sidebarBtn.querySelector('#notes-new-note-btn'); + sidebarBtn.insertBefore(dot, newNoteBtn || null); + } + } else if (dot) { + dot.remove(); + } + } + // Individual note cards : pulse ones with fired reminders + document.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note || note.archived || _isNoteFullyDone(note)) { + card.classList.remove('note-card-reminder-due'); + return; + } + if (note.due_date && _hasTimeComponent(note.due_date)) { + const t = new Date(note.due_date).getTime(); + card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now()); + } else { + card.classList.remove('note-card-reminder-due'); + } + }); + } + + return { _clearViewedReminderGlows, _setReminderCardGlow, _queuePendingHighlight, _flushPendingHighlights, _updateRailBadge }; +} diff --git a/frontend/src/features/notes/ui/reminderPicker.js b/frontend/src/features/notes/ui/reminderPicker.js new file mode 100644 index 000000000..da9d00398 --- /dev/null +++ b/frontend/src/features/notes/ui/reminderPicker.js @@ -0,0 +1,335 @@ +/** Reminder date presets and recurrence picker interactions. */ +import { _formatRepeatLabel, _toLocalDatetimeStr, _normalizeRepeat, _DAYS, _ORDINALS } from '../domain/recurrence.js'; +export function createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}) { + function _wireReminderPicker(form) { + // Reminder bell : opens dropdown menu + const remindBtn = form.querySelector('.note-form-remind-btn'); + const dueInput = form.querySelector('.note-form-due'); + const repeatInput = form.querySelector('.note-form-repeat'); + const tagsEl = form.querySelector('.note-form-reminder-tags'); + + function _renderReminderTag() { + if (!tagsEl) return; + const v = dueInput.value; + const rep = repeatInput.value || 'none'; + if (!v) { tagsEl.innerHTML = ''; return; } + const label = _formatReminderTag(v); + const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : ''; + tagsEl.innerHTML = ``; + tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => { + if (e.target.classList.contains('note-reminder-tag-x')) { + dueInput.value = ''; + repeatInput.value = 'none'; + _renderReminderTag(); + return; + } + _openReminderMenu(remindBtn || tagsEl, true); + }); + } + + function _openReminderMenu(anchor, isEdit = false) { + // Close any existing menu + document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + document.body.appendChild(menu); + + const presetItems = [ + { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) }, + { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) }, + { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) }, + { label: 'Select date and time', sub: '', action: () => _pickCustomDate() }, + ]; + + // Sub-page state for the repeat picker. null = top page. + // 'weekly' | 'monthly' | 'monthly_nth' + let subMode = null; + // Temporary state for monthly_nth so user can click N then weekday (or vice versa) + // before committing. + let nthDraft = { n: 0, w: -1 }; + + const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + function getNorm() { + if (!dueInput.value) return 'none'; + return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value)); + } + + function commit(val) { + repeatInput.value = val; + _renderReminderTag(); + menu.remove(); + } + + // Like commit, but first snaps dueInput.value forward to the next matching + // slot for the chosen recurrence. Use for weekly/monthly variants where the + // current due date may not match the chosen pattern (e.g. user picks + // "weekly on Mondays" while the date is a Wednesday). + function snapAndCommit(val) { + if (dueInput.value) { + const cur = new Date(dueInput.value); + const norm = _normalizeRepeat(val, cur); + const snapped = _snapToRepeat(cur, norm); + if (snapped) { + dueInput.value = _toLocalDatetimeStr(snapped); + if (remindBtn) remindBtn.classList.add('has-date'); + } + } + commit(val); + } + + function reposition() { + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 220; + const mh = menu.offsetHeight || 280; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + } + + function render() { + let html = ''; + + if (subMode === null) { + html += '
Remind me later
'; + for (let i = 0; i < presetItems.length; i++) { + const it = presetItems[i]; + html += ``; + } + if (isEdit && dueInput.value) { + const norm = getNorm(); + html += '
'; + html += '
Repeat
'; + // None + html += ``; + // Daily + html += ``; + // Weekly → + { + const isW = norm.startsWith('weekly:'); + const wd = isW ? parseInt(norm.split(':')[1], 10) : null; + const sub = isW && !isNaN(wd) ? `${_DAYS[wd]}` : ''; + html += ``; + } + // Monthly → + { + const isM = norm.startsWith('monthly:'); + const sub = isM ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + // Yearly + html += ``; + } + } else if (subMode === 'weekly') { + const norm = getNorm(); + const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1; + html += ``; + html += '
Weekly on…
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + } else if (subMode === 'monthly') { + const norm = getNorm(); + const dueDate = new Date(dueInput.value); + const dayN = dueDate.getDate(); + html += ``; + html += '
Monthly on…
'; + // Day N : uses the chosen date's day. Always offered. + const dayVal = `monthly:day:${dayN}`; + html += ``; + // Nth weekday → + { + const isNth = norm.startsWith('monthly:nth:'); + const sub = isNth ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + } else if (subMode === 'monthly_nth') { + // Pick ordinal (1..4) and weekday (0..6); commit when both chosen. + html += ``; + html += '
Nth weekday of month
'; + html += '
Which one
'; + html += '
'; + for (let i = 1; i <= 4; i++) { + html += ``; + } + html += '
'; + html += '
Weekday
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + html += '
'; + const ready = nthDraft.n > 0 && nthDraft.w >= 0; + const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday'; + html += ``; + } + + menu.innerHTML = html; + reposition(); + wire(); + } + + function wire() { + menu.querySelectorAll('[data-action]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + const a = el.dataset.action; + if (a === 'preset') { + const it = presetItems[parseInt(el.dataset.i, 10)]; + it.action(); + menu.remove(); + } else if (a === 'set') { + snapAndCommit(el.dataset.val); + } else if (a === 'sub') { + subMode = el.dataset.sub; + // Seed nth draft from saved value only on first entry : preserve + // in-progress picks across back-trips (Nth → back → Nth again). + if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) { + const norm = getNorm(); + const m = norm.match(/^monthly:nth:(\d):(\d)$/); + if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) }; + } + render(); + } else if (a === 'back') { + subMode = null; + render(); + } else if (a === 'back-monthly') { + subMode = 'monthly'; + render(); + } else if (a === 'weekly-pick') { + snapAndCommit(`weekly:${el.dataset.wd}`); + } else if (a === 'nth-n') { + nthDraft.n = parseInt(el.dataset.n, 10); + render(); + } else if (a === 'nth-w') { + nthDraft.w = parseInt(el.dataset.wd, 10); + render(); + } else if (a === 'nth-save') { + if (nthDraft.n > 0 && nthDraft.w >= 0) { + snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`); + } + } + }); + }); + } + + render(); + // Click outside to close (single global handler attached after first paint) + setTimeout(() => { + const close = (e) => { + if (!menu.isConnected) { document.removeEventListener('click', close); return; } + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } + }; + if (menu.isConnected) lifecycle.listen(menu, document, 'click', close); + }, 0); + } + + function _monthlyShortDescriptor(norm) { + const parts = norm.split(':'); + if (parts[1] === 'day') return `Day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Last ${_DAYS[wd].slice(0, 3)}`; + } + return ''; + } + + function _setReminder(datetimeLocalStr) { + dueInput.value = datetimeLocalStr; + if (remindBtn) { + remindBtn.classList.add('has-date'); + // Jingle the bell. CSS handles the animation; remove + reflow + re-add + // so it replays every time the user sets/changes a reminder. + const _bell = remindBtn.querySelector('svg'); + if (_bell) { + _bell.classList.remove('jingling'); + void _bell.offsetWidth; + _bell.classList.add('jingling'); + setTimeout(() => _bell.classList.remove('jingling'), 700); + } + } + _renderReminderTag(); + _ensureNotificationPermission(); + } + + function _pickCustomDate() { + // Replace the dropdown menu with a small inline picker + document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); + menu.innerHTML = ` +
Pick date and time
+
+ +
+
+ + `; + document.body.appendChild(menu); + // Position next to the bell button + const anchor = remindBtn || form.querySelector('.note-form-reminder-tags'); + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 240; + const mh = menu.offsetHeight || 200; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + const dInput = menu.querySelector('.note-reminder-date-input'); + dInput.value = initial; + dInput.focus(); + if (typeof dInput.showPicker === 'function') { + try { dInput.showPicker(); } catch {} + } + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { + if (dInput.value) _setReminder(dInput.value); + close(); + }); + } + + if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); + _renderReminderTag(); + + + } + + return { _wireReminderPicker }; +} diff --git a/frontend/src/features/notes/ui/selection.js b/frontend/src/features/notes/ui/selection.js new file mode 100644 index 000000000..e64549dff --- /dev/null +++ b/frontend/src/features/notes/ui/selection.js @@ -0,0 +1,61 @@ +/** Selection controls and view feedback for accepted attachments. */ +export function createSelection({ + operations, + _renderNotes, + app, + state +}) { + function _enterSelectMode(initialId = null) { + app.setSelectMode(true); + app.clearSelection(); + if (initialId) app.selectId(initialId); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + if (bar) bar.classList.remove('hidden'); + if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } + _renderNotes(); + _updateBulkBar(); + } + + function _exitSelectMode() { + app.setSelectMode(false); + app.clearSelection(); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + const all = document.getElementById('notes-select-all'); + if (bar) bar.classList.add('hidden'); + if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } + if (all) all.checked = false; + _renderNotes(); + } + + function _updateBulkBar() { + const count = state.selectedIds.size; + const countEl = document.getElementById('notes-selected-count'); + const sendAgentBtn = document.getElementById('notes-send-agent'); + const archiveBtn = document.getElementById('notes-bulk-archive'); + const deleteBtn = document.getElementById('notes-bulk-delete'); + const allEl = document.getElementById('notes-select-all'); + if (countEl) countEl.textContent = `${count} Selected`; + if (sendAgentBtn) sendAgentBtn.disabled = count === 0; + if (archiveBtn) archiveBtn.disabled = count === 0; + if (deleteBtn) deleteBtn.disabled = count === 0; + if (allEl) allEl.checked = state.notes.length > 0 && state.notes.every(n => state.selectedIds.has(n.id)); + // Toggle select-mode class so todo dots don't react to hover + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.toggle('notes-select-mode', count > 0); + } + + function wireSelectionActions() { + document.getElementById('notes-select-all').addEventListener('change', e => { + if (e.target.checked) state.notes.forEach(n => app.selectId(n.id)); + else app.clearSelection(); + _renderNotes(); _updateBulkBar(); + }); + document.getElementById('notes-send-agent').addEventListener('click', operations.attachSelected); + document.getElementById('notes-bulk-archive').addEventListener('click', operations.archiveSelected); + document.getElementById('notes-bulk-delete').addEventListener('click', operations.deleteSelected); + document.getElementById('notes-bulk-cancel').addEventListener('click', _exitSelectMode); + } + return { wireSelectionActions, _enterSelectMode, _exitSelectMode, _updateBulkBar }; +} diff --git a/frontend/src/features/notes/ui/text.js b/frontend/src/features/notes/ui/text.js new file mode 100644 index 000000000..75ecbaf78 --- /dev/null +++ b/frontend/src/features/notes/ui/text.js @@ -0,0 +1,39 @@ +/** Escaping, safe image sources and link rendering. */ +export function createText({ + uiModule +}) { + function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(//g, '>'); } + + function _attrEsc(s) { + return String(s || '') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + .replace(/`/g, '`'); + } + + function _safeImgSrc(s) { + const v = (s || '').trim(); + if (!v) return ''; + if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v; + if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v; + return ''; + } + + function _linkify(s) { + const escaped = _esc(s); + const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g; + return escaped.replace(urlRe, (m) => { + let url = m; + // Trim a trailing ')' that doesn't have a matching '(' inside the URL + if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { + url = url.slice(0, -1); + } + const href = url.startsWith('www.') ? `https://${url}` : url; + return `${url}` + (url !== m ? m.slice(url.length) : ''); + }); + } + + return { _esc, _attrEsc, _safeImgSrc, _linkify }; +} diff --git a/frontend/src/features/notes/ui/toolbar.js b/frontend/src/features/notes/ui/toolbar.js new file mode 100644 index 000000000..3c3c9fe58 --- /dev/null +++ b/frontend/src/features/notes/ui/toolbar.js @@ -0,0 +1,78 @@ +/** Panel search, archive and list/grid controls. */ +export function createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}) { + function wireToolbar(pane) { + // Search + const searchEl = document.getElementById('notes-search'); + if (searchEl) { + searchEl.addEventListener('input', () => { + app.setSearchQuery(searchEl.value.trim().toLowerCase()); + _renderNotes(); + }); + } + + // View toggle + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) { + const ARCHIVE_ICON = 'Archive'; + const CLOSE_ICON = 'Archive'; + const syncArchiveBtn = () => { + archiveBtn.classList.toggle('active', state.showingArchived); + archiveBtn.title = state.showingArchived ? 'Exit archive' : 'View archive'; + archiveBtn.style.opacity = state.showingArchived ? '1' : '0.8'; + // Swap to an X while in archive view so it doubles as a close-back- + // to-active-notes toggle. + archiveBtn.innerHTML = state.showingArchived ? CLOSE_ICON : ARCHIVE_ICON; + // Tint the whole pane so it's obvious you're not in the active list. + pane.classList.toggle('notes-pane-archive', state.showingArchived); + }; + syncArchiveBtn(); + archiveBtn.addEventListener('click', async () => { + app.setShowingArchived(!state.showingArchived); + app.clearSelection(); + syncArchiveBtn(); + // Brief fade so the body content swap doesn't snap : the bg-tint + // change is already eased by CSS transitions on .notes-pane*. + const _bodyEl = document.querySelector('#notes-pane .notes-pane-body'); + if (_bodyEl) { + _bodyEl.style.transition = 'opacity 0.18s ease'; + _bodyEl.style.opacity = '0.25'; + } + await _fetchNotes(); + _renderNotes(); + if (_bodyEl) { + requestAnimationFrame(() => { + _bodyEl.style.opacity = ''; + _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true }); + }); + } + }); + } + const viewBtn = document.getElementById('notes-view-toggle'); + if (viewBtn) { + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + // Label shows what you'll switch TO : "Grid" while in list, "List" while in grid. + const _setViewLabel = () => { + const lbl = viewBtn.querySelector('.notes-header-btn-label'); + if (lbl) lbl.textContent = state.viewMode === 'grid' ? 'List' : 'Grid'; + }; + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + viewBtn.addEventListener('click', () => { + app.setViewMode(state.viewMode === 'grid' ? 'list' : 'grid'); + try { persistence.setItem('odysseus-notes-view', state.viewMode); } catch {} + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + }); + } + + } + return { wireToolbar }; +} diff --git a/frontend/src/features/notes/ui/windowLayout.js b/frontend/src/features/notes/ui/windowLayout.js new file mode 100644 index 000000000..9e9173a47 --- /dev/null +++ b/frontend/src/features/notes/ui/windowLayout.js @@ -0,0 +1,114 @@ +/** Shared-window integration: safe fullscreen bounds, docking and stacking. */ +export function createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}) { + function _notesFullscreenSafeRect() { + const vw = window.innerWidth || document.documentElement.clientWidth || 0; + const vh = window.innerHeight || document.documentElement.clientHeight || 0; + let left = 0; + let right = vw; + + const sidebar = document.getElementById('sidebar'); + const rail = document.getElementById('icon-rail'); + const hamburgerRight = document.body.classList.contains('hamburger-right') + || sidebar?.classList.contains('right-side') + || rail?.classList.contains('right-side'); + + const reserve = (el) => { + if (!el || getComputedStyle(el).display === 'none') return; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + if (hamburgerRight) right = Math.min(right, rect.left); + else left = Math.max(left, rect.right); + }; + + if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar); + reserve(rail); + + // The fixed hamburger can remain visible even when the rail/sidebar is + // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it. + const hamburger = document.getElementById('hamburger-btn'); + if (hamburger && getComputedStyle(hamburger).display !== 'none') { + const rect = hamburger.getBoundingClientRect(); + const pad = 8; + if (hamburgerRight) right = Math.min(right, rect.left - pad); + else left = Math.max(left, rect.right + pad); + } + + left = Math.max(0, Math.min(left, vw - 80)); + right = Math.max(left + 80, Math.min(right, vw)); + return { left, top: 0, width: right - left, height: vh }; + } + + function _wireNotesWindow(pane) { + if (!pane || pane.dataset.windowDragWired === '1') return; + const header = pane.querySelector('.notes-pane-header'); + if (!header) return; + pane.dataset.windowDragWired = '1'; + makeWindowDraggable(pane, { + content: pane, + header, + fsClass: 'notes-window-fullscreen', + skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber', + enableDock: true, + enableLeftDock: true, + onEnterFullscreen: () => { + pane.classList.add('notes-window-fullscreen'); + snapModalToZone(pane, { + name: 'fullscreen', + rect: _notesFullscreenSafeRect(), + }); + }, + onExitFullscreen: () => { + _restoreNotesSidebarDock(pane); + }, + }); + } + + function _clearNotesSnapStyles(pane) { + if (!pane) return; + const hadLeft = pane.classList.contains('modal-left-docked'); + const hadRight = pane.classList.contains('modal-right-docked'); + pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked'); + if (hadLeft) clearDockSide('left', pane); + if (hadRight) clearDockSide('right', pane); + ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height', + 'max-height', 'margin', 'transform', 'border-radius'] + .forEach((prop) => pane.style.removeProperty(prop)); + delete pane.dataset._tilePreSnap; + delete pane.dataset._tileZone; + delete pane._preDockSnapshot; + delete pane._dockSide; + delete pane._dockSuspended; + } + + function _restoreNotesSidebarDock(pane) { + if (!pane || window.innerWidth <= 768) return; + _clearNotesSnapStyles(pane); + if (!pane.isConnected) return; + applyEdgeDock(pane, 'right'); + } + + function _topToolWindowZ(exclude = null) { + return topToolWindowZ({ exclude }); + } + + function _bringNotesToFront(pane = document.getElementById('notes-pane')) { + if (!pane) return; + const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement; + const z = _topToolWindowZ(backdrop) + 1; + if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important'); + try { + window.dispatchEvent(new CustomEvent('odysseus:modal-opened', { + detail: { id: 'notes-panel', modal: pane }, + })); + } catch (_) {} + } + + + return { _wireNotesWindow, _restoreNotesSidebarDock, _bringNotesToFront }; +} diff --git a/frontend/src/shared/ui/confetti.js b/frontend/src/shared/ui/confetti.js new file mode 100644 index 000000000..205f9acdc --- /dev/null +++ b/frontend/src/shared/ui/confetti.js @@ -0,0 +1,28 @@ +/** Stateless celebration renderer shared by Notes and Compare. */ +export function spawnConfetti(cx, cy, count) { + const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff']; + for (let i = 0; i < count; i++) { + const el = document.createElement('div'); + el.className = 'confetti-piece'; + const color = colors[Math.floor(Math.random() * colors.length)]; + const size = 5 + Math.random() * 8; + const isCircle = Math.random() > 0.5; + el.style.width = size + 'px'; + el.style.height = (isCircle ? size : size * 0.6) + 'px'; + el.style.background = color; + el.style.borderRadius = isCircle ? '50%' : '2px'; + el.style.left = cx + 'px'; + el.style.top = cy + 'px'; + const angle = Math.random() * Math.PI * 2; + const speed = 60 + Math.random() * 160; + const dx = Math.cos(angle) * speed; + const dy = Math.sin(angle) * speed - 100; + const duration = 1.0 + Math.random() * 1.0; + el.animate([ + { transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 }, + { transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 } + ], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' }); + document.body.appendChild(el); + setTimeout(() => el.remove(), duration * 1000 + 50); + } +} diff --git a/scripts/verify_frontend_runtime.py b/scripts/verify_frontend_runtime.py index 07b0bb3dd..2a960c32a 100644 --- a/scripts/verify_frontend_runtime.py +++ b/scripts/verify_frontend_runtime.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Verify the current Python runtime from an isolated distribution without frontend tooling.""" import os +import json from pathlib import Path import shutil import socket @@ -13,12 +14,20 @@ ROOT = Path(__file__).resolve().parents[1] PROBE = """ from pathlib import Path import shutil +import json +import sys from starlette.testclient import TestClient from app import app assert not Path('frontend').exists() assert not Path('node_modules').exists() assert shutil.which('node') is None with TestClient(app) as client: + for output in json.loads(sys.argv[1]): + asset = client.get('/static/' + output) + assert asset.status_code == 200, (output, asset.status_code) + assert asset.content == Path('static', output).read_bytes(), output + assert asset.headers['content-type'].startswith('text/javascript'), output + assert asset.headers['cache-control'] == 'no-cache', output response = client.get('/static/js/actionMenuOrder.js') assert response.status_code == 200, response.status_code assert response.content == Path('static/js/actionMenuOrder.js').read_bytes() @@ -58,7 +67,10 @@ def main() -> int: ) # A caller's Python search path must not import the original checkout. env.pop("PYTHONPATH", None) - result = subprocess.run([sys.executable, "-c", PROBE], cwd=target, env=env, capture_output=True, text=True, timeout=60) + outputs = [entry['deployment'] for entry in json.loads( + (ROOT / 'frontend/modules.json').read_text(encoding='utf-8') + )['modules']] + result = subprocess.run([sys.executable, "-c", PROBE, json.dumps(outputs)], cwd=target, env=env, capture_output=True, text=True, timeout=60) print(result.stdout, end="") if result.returncode: print(result.stderr[-8000:], file=sys.stderr) diff --git a/scripts/verify_notes_browser_runtime.py b/scripts/verify_notes_browser_runtime.py new file mode 100644 index 000000000..5fedbaf33 --- /dev/null +++ b/scripts/verify_notes_browser_runtime.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Run real Notes UI and API against a disposable native distribution and database.""" +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from urllib.request import urlopen + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + with tempfile.TemporaryDirectory(prefix="ody-notes-native-") as directory, socket.socket() as chroma_guard: + target = Path(directory) + for name in ("app.py", "HARNESS_VERSION"): + shutil.copy2(ROOT / name, target / name) + for name in ("core", "routes", "src", "services", "resources", "mcp_servers", "companion", "config", "static"): + shutil.copytree(ROOT / name, target / name, ignore=shutil.ignore_patterns("__pycache__", ".env", "secrets.env*", "*.db", "*.sqlite*")) + chroma_guard.bind(("127.0.0.1", 0)) + with socket.socket() as app_port: + app_port.bind(("127.0.0.1", 0)) + port = app_port.getsockname()[1] + env = dict(os.environ) + env.update( + ODYSSEUS_DATA_DIR=str(target / "data"), + DATABASE_URL="sqlite:///" + str(target / "notes.db"), AUTH_ENABLED="true", + ODYSSEUS_STARTUP_WARMUPS="0", ODYSSEUS_TOOL_INDEX_PREWARM="0", + CHROMADB_HOST="127.0.0.1", CHROMADB_PORT=str(chroma_guard.getsockname()[1]), + FASTEMBED_CACHE_PATH=str(target / "fastembed"), + ) + env.pop("PYTHONPATH", None) + server = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)], + cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + result = None + try: + origin = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if server.poll() is not None: + raise RuntimeError("Disposable Notes server exited during startup") + try: + with urlopen(origin + "/", timeout=1) as response: + if response.status == 200: + break + except OSError: + time.sleep(0.1) + else: + raise RuntimeError("Disposable Notes server did not become ready") + result = subprocess.run( + ["node", "tests/browser/notesNative.mjs"], cwd=ROOT, + env=dict(os.environ, ODYSSEUS_NOTES_RUNTIME_ORIGIN=origin, NOTES_TEST_PYTHON=sys.executable), + capture_output=True, text=True, timeout=120, + ) + print(result.stdout, end="") + if result.returncode: + print(result.stderr, file=sys.stderr) + return result.returncode + finally: + server.terminate() + try: + logs, _ = server.communicate(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + logs, _ = server.communicate() + if result is None or result.returncode: + print(logs[-8000:], file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/js/compare/vote.js b/static/js/compare/vote.js index cece556e8..dcf5ecee7 100644 --- a/static/js/compare/vote.js +++ b/static/js/compare/vote.js @@ -1,4 +1,5 @@ // compare/vote.js — voting, revealing, confetti +import { spawnConfetti } from '../shared/ui/confetti.js'; import Storage from '../storage.js'; import state from './state.js'; import { _modelDisplayNames } from './models.js'; @@ -222,33 +223,4 @@ function handleVote(winnerIdx) { } } -/** Spawn confetti particles from a point. */ -function spawnConfetti(cx, cy, count) { - const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff']; - for (let i = 0; i < count; i++) { - const el = document.createElement('div'); - el.className = 'confetti-piece'; - const color = colors[Math.floor(Math.random() * colors.length)]; - const size = 5 + Math.random() * 8; - const isCircle = Math.random() > 0.5; - el.style.width = size + 'px'; - el.style.height = (isCircle ? size : size * 0.6) + 'px'; - el.style.background = color; - el.style.borderRadius = isCircle ? '50%' : '2px'; - el.style.left = cx + 'px'; - el.style.top = cy + 'px'; - const angle = Math.random() * Math.PI * 2; - const speed = 60 + Math.random() * 160; - const dx = Math.cos(angle) * speed; - const dy = Math.sin(angle) * speed - 100; - const duration = 1.0 + Math.random() * 1.0; - el.animate([ - { transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 }, - { transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 } - ], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' }); - document.body.appendChild(el); - setTimeout(() => el.remove(), duration * 1000 + 50); - } -} - export { _saveVote, handleVote, buildVoteBar, addFinishBadge, spawnConfetti, registerCompareActions }; diff --git a/static/js/features/notes/application/agentActions.js b/static/js/features/notes/application/agentActions.js new file mode 100644 index 000000000..d96828211 --- /dev/null +++ b/static/js/features/notes/application/agentActions.js @@ -0,0 +1,67 @@ +/** Create an agent session, link it to a note and drain its background stream. */ +import { _noteToAgentPrompt } from '../domain/noteRules.js'; +export function createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData, + state, + streamComplete, + uiModule +}) { + async function _agentSolveNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prompt = _noteToAgentPrompt(note); + if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; } + try { + const dc = await (await api.defaultChat()).json(); + if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } + + // 1. Create the session server-side (no UI switch). skip_validation + // avoids re-probing : the default-chat endpoint is already known good. + const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40); + const csFd = createFormData(); + csFd.append('name', 'Agent: ' + label); + csFd.append('endpoint_url', dc.endpoint_url); + csFd.append('model', dc.model); + if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); + csFd.append('skip_validation', 'true'); + const csRes = await api.createSession(csFd); + if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } + const sess = await csRes.json(); + const sid = sess.id; + + // 2. Link the session to the note right away so the tag appears. + const n = state.notes.find(x => x.id === id); + if (n) app.mergeNote(n, { agent_session_id: sid }); + _renderNotes(); + _patchNote(id, { agent_session_id: sid }).catch(() => {}); + + // 3. Kick off the agent run in the background. POST to chat_stream in + // agent mode and drain the SSE so the server runs the loop to + // completion + saves : without rendering anything in the chat UI. + const fd = createFormData(); + fd.append('message', prompt); + fd.append('session', sid); + fd.append('mode', 'agent'); + fd.append('disabled_tools', JSON.stringify(['manage_notes'])); + api.startChat(fd) + .then(async (res) => { + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + // Drain to completion (server finishes + persists the run). + while (true) { const { done } = await reader.read(); if (done) break; } + streamComplete(sid); + }) + .catch(() => {}); + + uiModule.showToast('Agent working in background — tap the Agent tag when ready'); + } catch (e) { + uiModule.showError('Agent failed: ' + (e.message || e)); + } + } + + return { _agentSolveNote }; +} diff --git a/static/js/features/notes/application/cardOperations.js b/static/js/features/notes/application/cardOperations.js new file mode 100644 index 000000000..1131d2ec0 --- /dev/null +++ b/static/js/features/notes/application/cardOperations.js @@ -0,0 +1,79 @@ +/** Optimistic card mutations and rollback. Animation completion is a UI-supplied boundary. */ +export function createCardOperations({ + app, + api, + effects, + pushUndo, + undoArchive +}) { + const state = app.state; + function togglePin(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const prevPinned = note.pinned, prevSortOrder = note.sort_order; + app.mergeNote(note, { pinned: !prevPinned }); + const patch = { pinned: note.pinned }; + if (note.pinned) { + const minPinned = state.notes.filter(n => n.pinned && n.id !== id).reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + app.mergeNote(note, { sort_order: minPinned - 1 }); + patch.sort_order = note.sort_order; + } + effects.render(); + api.patchNote(id, patch).catch(() => { + app.mergeNote(note, { pinned: prevPinned, sort_order: prevSortOrder }); + effects.render(); effects.showError('Failed to pin'); + }); + } + async function updateColor(id, color) { + try { + await api.patchNote(id, { color: color || null }); + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, { color }); + } catch { effects.showError('Failed to update color'); } + } + function stageArchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return null; + const removed = app.removeNoteAt(index); + const undo = () => undoArchive(removed, index); + pushUndo({ label: 'archive', run: undo }); + return () => { + effects.render(); + api.patchNote(id, { archived: true }).then(() => effects.archived(undo)).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to archive'); + }); + }; + } + function unarchive(id) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + api.patchNote(id, { archived: false }).then(() => effects.showToast('Unarchived')).catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to unarchive'); + }); + } + function deleteOptimistically(id, toast = false) { + const index = state.notes.findIndex(n => n.id === id); + if (index < 0) return; + const removed = app.removeNoteAt(index); + effects.render(); + const deletion = api.deleteNote(id); + const completion = toast ? deletion.then(() => effects.showToast('Deleted')) : deletion; + completion.catch(() => { + app.insertNoteAt(index, removed); effects.render(); effects.showError('Failed to delete'); + }); + } + // Desktop deliberately persists without updating local sort_order, as before. + function persistOrder(ids) { return api.reorder(ids); } + async function commitMobileOrder(ids) { + try { + await api.reorder(ids); + ids.forEach((id, index) => { + const note = state.notes.find(note => note.id === id); + if (note) app.mergeNote(note, { sort_order: index }); + }); + } catch (error) { console.warn('reorder failed', error); } + } + return { togglePin, updateColor, stageArchive, unarchive, deleteOptimistically, persistOrder, commitMobileOrder }; +} diff --git a/static/js/features/notes/application/checklistOperations.js b/static/js/features/notes/application/checklistOperations.js new file mode 100644 index 000000000..b4a596b04 --- /dev/null +++ b/static/js/features/notes/application/checklistOperations.js @@ -0,0 +1,68 @@ +/** Checklist item workflows. Supplied feedback callbacks update the invoking view. */ +export function createChecklistOperations({ + app, + api, + effects, + uid +}) { + const state = app.state; + function itemNote(id, index) { + const note = state.notes.find(n => n.id === id); + return note && Array.isArray(note.items) && note.items[index] ? note : null; + } + function removeItem(id, index) { + const note = itemNote(id, index); + if (!note) return; + const removed = note.items[index]; + app.mergeNote(note, { items: note.items.filter((_, i) => i !== index) }); + effects.render(); + api.patchNote(id, { items: note.items }).catch(() => { + app.spliceItems(note, index, 0, removed); effects.render(); effects.showError('Failed to remove item'); + }); + } + function editItem(note, index, text) { + const oldText = (note.items[index].text || '').trim(); + if (text === oldText) { effects.render(); return; } + const oldItem = note.items[index]; + if (!text) app.spliceItems(note, index, 1); + else app.updateItem(note, index, { text }); + api.patchNote(note.id, { items: note.items }).catch(() => { + if (!text) app.spliceItems(note, index, 0, oldItem); + else app.updateItem(note, index, { text: oldText }); + effects.render(); effects.showError('Failed to update item'); + }); + effects.render(); + } + function addItem(id, text, added) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + const items = Array.isArray(note.items) ? [...note.items] : []; + items.push({ id: uid(), text, done: false }); + app.mergeNote(note, { items }); + added(); effects.render(); + return () => api.patchNote(id, { items }).catch(() => { + app.mergeNote(note, { items: items.slice(0, -1) }); effects.render(); effects.showError('Failed to add item'); + }); + } + function toggleItem(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + const wasDone = note.items.length > 0 && note.items.every(it => it.done); + app.updateItem(note, index, { done: !note.items[index].done }); + changed(note.items[index].done); + if (!wasDone && note.items.length > 0 && note.items.every(it => it.done)) completed(); + api.patchNote(id, { items: note.items }).catch(() => { + app.updateItem(note, index, { done: !note.items[index].done }); changed(note.items[index].done); + }); + } + async function toggleTodayStep(id, index, changed, completed) { + const note = itemNote(id, index); + if (!note) return; + app.updateItem(note, index, { done: !note.items[index].done }); changed(); + try { + await api.patchNote(id, { items: note.items }); effects.render(); + if (note.items.every(it => it.done)) completed(); + } catch { app.updateItem(note, index, { done: !note.items[index].done }); } + } + return { removeItem, editItem, addItem, toggleItem, toggleTodayStep }; +} diff --git a/static/js/features/notes/application/deletion.js b/static/js/features/notes/application/deletion.js new file mode 100644 index 000000000..e31e9b422 --- /dev/null +++ b/static/js/features/notes/application/deletion.js @@ -0,0 +1,51 @@ +/** Confirmed deletion and past-reminder clearing workflows. */ +export function createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects, + state +}) { + async function _deleteNote(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); effects.showToast('Deleted'); } + catch (err) { effects.showError(err.message); } + } + + async function _clearPastReminders() { + const targets = state.notes.filter(n => !n.archived && _isPastReminder(n)); + if (!targets.length) { + effects.showToast?.('No past reminders to clear'); + return; + } + const ok = effects.styledConfirm + ? await effects.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }) + : effects.confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`); + if (!ok) return; + await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {}))); + await _fetchNotes(); + _renderNotes(); + effects.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`); + } + + async function deleteFromEditor(id) { + const ok = effects.styledConfirm + ? await effects.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) + : effects.confirm('Delete this note?'); + if (!ok) return; + const index = state.notes.findIndex(note => note.id === id); + if (index >= 0) app.removeNoteAt(index); + app.setEditingId(null); + _renderNotes(); + _deleteNoteApi(id).then(() => effects.showToast('Deleted')).catch(() => { + effects.showError('Failed to delete'); + _fetchNotes().then(() => _renderNotes()); + }); + } + return { _deleteNote, _clearPastReminders, deleteFromEditor }; +} diff --git a/static/js/features/notes/application/drafts.js b/static/js/features/notes/application/drafts.js new file mode 100644 index 000000000..4f7a4715d --- /dev/null +++ b/static/js/features/notes/application/drafts.js @@ -0,0 +1,55 @@ +/** Serialized autosave and synchronous local draft backup. DOM collection is a supplied port. */ +export function createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects +}) { + const state = app.state; + const { _draftKey, _clearDraft } = persistence; + function bindDraft({ collect, id, view }) { + let t = null; + let pending = Promise.resolve(); + let stopped = false; + let warned = false; + const save = () => { + const d = collect(); + // An empty edit can be intentional: don't restore stale server text over it. + try { persistence.setItem(_draftKey(id), JSON.stringify(d)); } + catch { + if (!warned) effects.showError('Draft backup unavailable. Keep this note open until saved.'); + warned = true; + } + return d; + }; + const persist = (d) => { + if (!id || id === '__new__' || d.note_type === 'draw') return pending; + const { _ts, ...payload } = d; + payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null; + pending = pending.then(async () => { + try { + await api.patchNote(id, payload); + view.failed = false; + const note = state.notes.find(n => n.id === id); + if (note) app.mergeNote(note, payload); + if (persistence.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id); + } catch { + if (!view.failed) effects.showError('Note not synced. Your edits are kept on this device; retry Save.'); + view.failed = true; + } + }); + return pending; + }; + const flush = () => { scheduler.clearTimeout(t); return save(); }; + const stop = () => { stopped = true; scheduler.clearTimeout(t); return pending; }; + const sched = () => { + const d = save(); + if (stopped) return; + scheduler.clearTimeout(t); + t = scheduler.setTimeout(() => persist(d), 500); + }; + return { flush, stop, schedule: sched }; + } + return { bindDraft }; +} diff --git a/static/js/features/notes/application/queries.js b/static/js/features/notes/application/queries.js new file mode 100644 index 000000000..6a92250c9 --- /dev/null +++ b/static/js/features/notes/application/queries.js @@ -0,0 +1,66 @@ +/** Application-owned filtering, searching and note ordering. */ +import { _noteTags, _visibleNoteTags, _nextGoalStep, _isCalendarReminderNote } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createQueries({ + _hasActiveReminder, + state +}) { + function _sortedVisibleNotes() { + const displayNotes = _userVisibleNotes(); + let filtered = state.activeLabel ? displayNotes.filter(n => _noteTags(n).includes(state.activeLabel)) : displayNotes; + if (state.activeFilter === 'reminders') { + filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date)); + } else if (state.activeFilter === 'no-reminders') { + filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date))); + } else if (state.activeFilter === 'default') { + filtered = filtered.filter(n => _visibleNoteTags(n).length === 0); + } else if (state.activeFilter === 'goals') { + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived); + } else if (state.activeFilter === 'today') { + // Today view: only goals that still have an unchecked step. + filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)); + } + if (state.searchQuery) { + filtered = filtered.filter(n => { + const q = state.searchQuery; + if ((n.title || '').toLowerCase().includes(q)) return true; + if ((n.content || '').toLowerCase().includes(q)) return true; + if ((n.label || '').toLowerCase().includes(q)) return true; + if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true; + return false; + }); + } + const sorted = [...filtered].sort((a, b) => { + // In reminders view: sort by due date ascending (soonest first) + if (state.activeFilter === 'reminders') { + const da = new Date(a.due_date || 0).getTime(); + const db = new Date(b.due_date || 0).getTime(); + return da - db; + } + // Archived view: newest archived first (ignore manual sort_order). + if (state.showingArchived) { + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + } + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + // Active reminders (due date in the past, not done/archived) rank + // immediately under the pinned block. + const aActive = _hasActiveReminder(a); + const bActive = _hasActiveReminder(b); + if (aActive && !bActive) return -1; + if (!aActive && bActive) return 1; + const so = (a.sort_order || 0) - (b.sort_order || 0); + if (so !== 0) return so; + return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); + }); + + + return sorted; + } + + function _userVisibleNotes() { + return state.notes.filter(n => !_isCalendarReminderNote(n)); + } + + return { _sortedVisibleNotes, _userVisibleNotes }; +} diff --git a/static/js/features/notes/application/reminders.js b/static/js/features/notes/application/reminders.js new file mode 100644 index 000000000..5e2475993 --- /dev/null +++ b/static/js/features/notes/application/reminders.js @@ -0,0 +1,182 @@ +/** Page-owned reminder scheduler and notification delivery workflow. */ +import { _hasTimeComponent, _isDueTodayOrOverdue, _advanceRecurring } from '../domain/recurrence.js'; +export function createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + effects, + fetchNotes, + isNoteFullyDone, + hasItems, + dismissedKey +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders } = persistence; + const _patchNote = api.patchNote; + const _fetchNotes = fetchNotes; + const _isNoteFullyDone = isNoteFullyDone; + const _hasItems = hasItems; + const REMINDER_DISMISSED_AT_KEY = dismissedKey; + const _updateRailBadge = effects.badge; + let _reminderTimer = null; + let startupTimer = null; + let started = false; + let disposed = false; + function _checkReminders() { + if (!state.notes.length) return; + const now = clock.now(); + const fired = _loadFiredReminders(); + let changed = false; + for (const note of state.notes) { + if (!note.due_date || note.archived) continue; + if (!_hasTimeComponent(note.due_date)) continue; + if (fired.has(note.id)) continue; + const due = new Date(note.due_date).getTime(); + if (isNaN(due)) continue; + if (due <= now && due > now - 60000) { + _fireReminder(note); + // Recurring? advance the due_date instead of marking as fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + // Don't add to fired : new due_date is in the future + continue; + } + } + fired.add(note.id); + changed = true; + } else if (due <= now - 60000) { + // Past, never seen : silently advance recurring or mark fired + if (note.repeat && note.repeat !== 'none') { + const next = _advanceRecurring(note.due_date, note.repeat, clock); + if (next) { + app.mergeNote(note, { due_date: next }); + _patchNote(note.id, { due_date: next }).catch(() => {}); + continue; + } + } + fired.add(note.id); + changed = true; + } + } + if (changed) _saveFiredReminders(fired); + // Always refresh badge : fired state may have changed visually without note mutation + _updateRailBadge(); + } + + function _fireReminder(note) { + const title = note.title || 'Note reminder'; + // Include the verbatim note content so the email/notification actually + // shows what to do, not just a count. Cap the per-item lines (8 max) and + // total length so the body stays inbox-friendly. + let rawBody; + if (_hasItems(note)) { + const pending = (note.items || []) + .filter(i => !i.done && !i.checked) + .map(i => (i.text || '').trim()) + .filter(Boolean); + if (pending.length) { + const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n'); + const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : ''; + rawBody = `Pending (${pending.length}):\n${shown}${extra}`; + } else { + rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`; + } + } else { + rawBody = (note.content || '').slice(0, 400); + } + + // Ask the server to dispatch according to user settings. The server may + // return an LLM-written synthesis line and/or send an email. We still show + // a local browser notification so the user gets immediate feedback even if + // the server path is disabled or slow. + const showLocal = body => notifications.show(title, body, note.id); + + // Fire-and-forget server dispatch. If synthesis comes back quickly enough, + // use it as the notification body; otherwise the local notification has + // already shown with the raw body. + let shown = false; + const timer = scheduler.setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500); + + api.fireReminder({ note_id: note.id, title, body: rawBody }) + .then(r => r.ok ? r.json() : null) + .then(data => { + scheduler.clearTimeout(timer); + if (shown) return; + shown = true; + const body = (data && data.synthesis) ? data.synthesis : rawBody; + showLocal(body); + }) + .catch(() => { + scheduler.clearTimeout(timer); + if (!shown) { shown = true; showLocal(rawBody); } + }); + + // Pulse the card if visible; otherwise queue it so the next time the user + // opens the notes panel the card gets a brief glow. + effects.highlight(note.id); + } + + function _startReminderLoop() { + if (disposed || _reminderTimer) return; + _reminderTimer = scheduler.setInterval(_checkReminders, 30000); + _checkReminders(); // run once immediately + } + + function _countDueReminders() { + return state.notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date, clock) && !_isNoteFullyDone(n)).length; + } + + function _countFiredReminders() { + // Reminders whose time has actually passed (not just date-today), + // and which fired after the last user dismissal. + const now = clock.now(); + return state.notes.filter(n => { + if (n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date || !_hasTimeComponent(n.due_date)) return false; + const t = new Date(n.due_date).getTime(); + if (isNaN(t) || t > now) return false; + return t > state.firedDotDismissedAt; + }).length; + } + + function dismissFiredReminderDot() { + app.setFiredDotDismissedAt(clock.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + _updateRailBadge(); + } + + async function refreshDueBadge(opts = {}) { + // Usually lightweight, but callers that just created a note reminder can + // force a refresh so the background reminder loop sees it immediately. + if (opts.force || state.notes.length === 0) { + try { + const wasArchived = state.showingArchived; + app.setShowingArchived(false); + await _fetchNotes(); + app.setShowingArchived(wasArchived); + } catch {} + } + _updateRailBadge(); + } + + async function _initReminders() { + try { + const res = await api.listNotes(false); + if (res.ok && !disposed) { + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + _startReminderLoop(); + } + } catch {} + } + + function start() { if (started || disposed) return; started = true; startupTimer = scheduler.setTimeout(_initReminders, 3000); } + function dispose() { disposed = true; scheduler.clearTimeout(startupTimer); scheduler.clearInterval(_reminderTimer); _reminderTimer = null; } + return { start, dispose, _checkReminders, _fireReminder, _startReminderLoop, _countDueReminders, _countFiredReminders, dismissFiredReminderDot, refreshDueBadge, _initReminders }; +} diff --git a/static/js/features/notes/application/selection.js b/static/js/features/notes/application/selection.js new file mode 100644 index 000000000..6017025e2 --- /dev/null +++ b/static/js/features/notes/application/selection.js @@ -0,0 +1,39 @@ +/** Selected-note attachment retains synchronous acceptance; bulk requests retain partial-failure behavior. */ +export function createSelectionOperations({ + app, + api, + effects, + sendContext, + exitSelection, + fetchNotes +}) { + const state = app.state; + function attachSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + const detail = { noteIds: ids, accepted: false }; + sendContext(detail); + if (!detail.accepted) { + effects.showToast('Choose a chat or model before attaching notes'); + return; + } + exitSelection(); + } + async function archiveSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + await Promise.all(ids.map(id => api.patchNote(id, { archived: true }).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Archived ${ids.length}`); + } + async function deleteSelected() { + const ids = [...state.selectedIds]; + if (!ids.length) return; + if (effects.styledConfirm) { + const ok = await effects.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }); + if (!ok) return; + } + await Promise.all(ids.map(id => api.deleteNote(id).catch(() => {}))); + exitSelection(); await fetchNotes(); effects.render(); effects.showToast(`Deleted ${ids.length}`); + } + return { attachSelected, archiveSelected, deleteSelected }; +} diff --git a/static/js/features/notes/application/state.js b/static/js/features/notes/application/state.js new file mode 100644 index 000000000..b87ef11c4 --- /dev/null +++ b/static/js/features/notes/application/state.js @@ -0,0 +1,83 @@ +/** The sole Notes state owner. Views have live read-only access; writes use operations. */ +export function createNotesState(initial = {}) { + const values = { + open: false, notes: [], editingId: null, selectedIds: new Set(), + activeLabel: null, activeFilter: null, reminderChipNext: 'reminders', searchQuery: '', + viewMode: initial.viewMode || 'list', showingArchived: false, selectMode: false, + suppressNextNoteTapId: null, labelsCollapsed: !!initial.labelsCollapsed, + loading: false, firedDotDismissedAt: initial.firedDotDismissedAt || 0, + }; + // Preserve captured note identity across asynchronous completions without + // giving a view a writable reference to the application store. + const views = new WeakMap(), targets = new WeakMap(); + const unwrap = value => targets.get(value) || value; + // Copies built from views (spread, filter, map) still hold nested views; + // store only raw data so the owner never keeps its own read-only views. + function unwrapDeep(value, seen = new WeakSet()) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return targets.get(value); + if (seen.has(value)) return value; + seen.add(value); + if (Array.isArray(value)) value.forEach((item, index) => { value[index] = unwrapDeep(item, seen); }); + else if (Object.getPrototypeOf(value) === Object.prototype) for (const key of Object.keys(value)) value[key] = unwrapDeep(value[key], seen); + return value; + } + function readonly(value) { + if (!value || typeof value !== 'object') return value; + if (targets.has(value)) return value; + if (views.has(value)) return views.get(value); + const view = new Proxy(value, { + get(target, key) { + if (target instanceof Set) { + if (['add', 'delete', 'clear'].includes(key)) return () => { throw new TypeError('Use Notes selection operations'); }; + if (key === 'valueOf') return () => views.get(target); + if (key === 'forEach') return (callback, thisArg) => target.forEach(value => callback.call(thisArg, value, value, views.get(target))); + const member = Reflect.get(target, key, target); + return typeof member === 'function' ? member.bind(target) : member; + } + return readonly(Reflect.get(target, key)); + }, + set() { throw new TypeError('Use Notes state operations'); }, + deleteProperty() { throw new TypeError('Use Notes state operations'); }, + defineProperty() { throw new TypeError('Use Notes state operations'); }, + setPrototypeOf() { throw new TypeError('Use Notes state operations'); }, + preventExtensions() { throw new TypeError('Use Notes state operations'); }, + getOwnPropertyDescriptor(target, key) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, key); + if (descriptor && 'value' in descriptor) return { ...descriptor, value: readonly(descriptor.value) }; + return descriptor; + }, + }); + views.set(value, view); targets.set(view, value); + return view; + } + const state = Object.freeze(Object.fromEntries(Object.keys(values).map(key => [key, () => readonly(values[key])]))); + const view = Object.freeze(Object.defineProperties({}, Object.fromEntries(Object.entries(state).map(([key, get]) => [key, { get, enumerable: true }])))); + return { + state: view, + setOpen: value => { values.open = value; }, + setEditingId: value => { values.editingId = value; }, + setActiveLabel: value => { values.activeLabel = value; }, + setActiveFilter: value => { values.activeFilter = value; }, + setReminderChipNext: value => { values.reminderChipNext = value; }, + setSearchQuery: value => { values.searchQuery = value; }, + setViewMode: value => { values.viewMode = value; }, + setShowingArchived: value => { values.showingArchived = value; }, + setSelectMode: value => { values.selectMode = value; }, + setSuppressNextNoteTapId: value => { values.suppressNextNoteTapId = value; }, + setLabelsCollapsed: value => { values.labelsCollapsed = value; }, + setLoading: value => { values.loading = value; }, + setFiredDotDismissedAt: value => { values.firedDotDismissedAt = value; }, + replaceNotes: notes => { values.notes = notes; }, + removeNoteAt: index => readonly(values.notes.splice(index, 1)[0]), + insertNoteAt: (index, note) => { values.notes.splice(index, 0, unwrapDeep(note)); }, + prependNote: note => { values.notes.unshift(unwrapDeep(note)); }, + replaceNoteAt: (index, note) => { values.notes[index] = unwrapDeep(note); }, + mergeNote: (note, patch) => { for (const [key, value] of Object.entries(patch)) unwrap(note)[key] = unwrapDeep(value); }, + updateItem: (note, index, patch) => { Object.assign(unwrap(unwrap(note).items[index]), unwrapDeep({ ...patch })); }, + spliceItems: (note, ...args) => unwrap(unwrap(note).items).splice(...args.map(arg => unwrapDeep(arg))), + selectId: id => { values.selectedIds.add(id); }, + deselectId: id => values.selectedIds.delete(id), + clearSelection: () => values.selectedIds.clear(), + }; +} diff --git a/static/js/features/notes/application/submission.js b/static/js/features/notes/application/submission.js new file mode 100644 index 000000000..4703eb7c1 --- /dev/null +++ b/static/js/features/notes/application/submission.js @@ -0,0 +1,69 @@ +/** Editor submission ordering, optimistic state, reminder rearming and save recovery. */ +export function createNotesSubmission({ + app, + api, + persistence, + clock, + uid, + effects, + fetchNotes +}) { + const state = app.state; + const { _loadFiredReminders, _saveFiredReminders, _loadGlowedReminders, _saveGlowedReminders, _loadDraft, _clearDraft } = persistence; + function submitNote(payload, note, isEdit) { + // Reset fired reminder if due_date changed (so re-arm works), and also + // clear the entry-glow seen flag so the new firing glows again on the + // next time the user opens the panel. + if (isEdit && note.due_date !== payload.due_date) { + const fired = _loadFiredReminders(); + fired.delete(note.id); + _saveFiredReminders(fired); + const glowed = _loadGlowedReminders(); + glowed.delete(note.id); + _saveGlowedReminders(glowed); + effects.clearGlow(note.id); + } + // Edited notes move to the top of their section (under pinned). Compute + // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above + // siblings; the pin block keeps its own ordering above this. + if (!payload.pinned) { + // Both edits AND newly-created notes anchor above the rest of the + // unpinned section. Without this, freshly created notes sit at the + // bottom because manually-reordered siblings already carry negative + // sort_order values. + const minUnpinned = state.notes + .filter(n => !n.pinned && (!isEdit || n.id !== note.id)) + .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); + payload.sort_order = minUnpinned - 1; + } + // Optimistic update : update local state first, render, then save in background + app.setEditingId(null); + const draftId = isEdit ? note.id : '__new__'; + const submittedDraft = JSON.stringify(_loadDraft(draftId)); + if (isEdit) { + const idx = state.notes.findIndex(n => n.id === note.id); + if (idx >= 0) app.replaceNoteAt(idx, { ...state.notes[idx], ...payload }); + } else { + app.prependNote({ ...payload, id: 'tmp_' + uid(), created_at: new Date(clock.now()).toISOString(), updated_at: new Date(clock.now()).toISOString() }); + } + effects.render(); + // Background save + api.saveNote(payload).then(saved => { + if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId); + if (!isEdit && saved && saved.id) { + // Replace temp ID with real one from server. AND re-render : the + // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign + // bumps the in-memory id, so all subsequent clicks (edit, done, copy, + // archive, delete) silently fail to find the note in `_notes`. + const tmp = state.notes.find(n => n.id.startsWith('tmp_')); + if (tmp) app.mergeNote(tmp, saved); + effects.render(); + } + }).catch(err => { + effects.showError('Save failed: ' + err.message); + fetchNotes().then(() => effects.render()); + }); + + } + return { submitNote }; +} diff --git a/static/js/features/notes/application/workflows.js b/static/js/features/notes/application/workflows.js new file mode 100644 index 000000000..018cbf8aa --- /dev/null +++ b/static/js/features/notes/application/workflows.js @@ -0,0 +1,78 @@ +/** Notes CRUD, optimistic archive and in-memory undo workflows. */ +export function createNotesWorkflows({ + app, + api, + effects +}) { + const state = app.state; + const _undoStack = []; + const _renderNotes = effects.render; + const _patchNote = api.patchNote; + const uiModule = effects; + function _pushUndo(entry) { + _undoStack.push(entry); + if (_undoStack.length > 20) _undoStack.shift(); + } + + function _popAndRunUndo() { + const entry = _undoStack.pop(); + if (entry) entry.run(); + return !!entry; + } + + function _undoArchive(note, prevIdx) { + // Re-insert at original position and clear archived flag on the server. + const safeIdx = Math.min(Math.max(prevIdx, 0), state.notes.length); + app.insertNoteAt(safeIdx, { ...note, archived: false }); + _renderNotes(); + _patchNote(note.id, { archived: false }).catch(() => { + // Roll back local insertion if the server refuses + const i = state.notes.findIndex(n => n.id === note.id); + if (i >= 0) app.removeNoteAt(i); + _renderNotes(); + uiModule.showError('Undo failed'); + }); + } + + function _archiveNoteById(id, { card = null, celebrate = false } = {}) { + if (!id) return false; + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return false; + const note = state.notes[idx]; + if (celebrate && card && note) effects.celebrateArchive(note, card); + const removed = app.removeNoteAt(idx); + app.setEditingId(null); + _renderNotes(); + const undo = () => _undoArchive(removed, idx); + _pushUndo({ label: 'archive', run: undo }); + _patchNote(id, { archived: true }).then(() => { + effects.archived(undo); + }).catch(() => { + app.insertNoteAt(idx, removed); + _renderNotes(); + uiModule.showError('Failed to archive'); + }); + return true; + } + + async function _fetchNotes() { + app.setLoading(true); + try { + const res = await api.listNotes(state.showingArchived); + if (!res.ok) { app.replaceNotes([]); return; } + const data = await res.json(); + app.replaceNotes(data.notes || data || []); + } catch (e) { + console.error('Failed to fetch notes:', e); + app.replaceNotes([]); + } finally { + app.setLoading(false); + } + } + + function _saveNote(note) { return api.saveNote(note); } + + function _deleteNoteApi(id) { return api.deleteNote(id); } + + return { _pushUndo, _popAndRunUndo, _undoArchive, _archiveNoteById, _fetchNotes, _saveNote, _deleteNoteApi, _patchNote, undoCount: () => _undoStack.length }; +} diff --git a/static/js/features/notes/domain/noteRules.js b/static/js/features/notes/domain/noteRules.js new file mode 100644 index 000000000..ba3f8ae2f --- /dev/null +++ b/static/js/features/notes/domain/noteRules.js @@ -0,0 +1,87 @@ +/** Pure Notes completion, tags, reminder and serialization rules. */ +import { _hasTimeComponent } from './recurrence.js'; +export function _isCalendarReminderNote(n) { + return n && n.source === 'calendar' && n.label === 'calendar'; +} + +export function _hasActiveReminder(n, clock) { + if (!n || n.archived || _isNoteFullyDone(n)) return false; + if (!n.due_date) return false; + const t = new Date(n.due_date).getTime(); + return !isNaN(t) && t <= clock.now(); +} + +export function _isNoteFullyDone(note) { + if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) { + return note.items.every(it => it.done); + } + return false; +} + +export function _hasItems(note) { + return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist'); +} + +export function _goalProgress(note) { + if (!Array.isArray(note?.items) || note.items.length === 0) return ''; + const done = note.items.filter(it => it.done).length; + return ` ${done}/${note.items.length}`; +} + +export function _nextGoalStep(note) { + if (!Array.isArray(note?.items)) return null; + for (let i = 0; i < note.items.length; i++) { + if (!note.items[i].done) return { idx: i, item: note.items[i] }; + } + return null; +} + +export function _noteTags(n) { + const tags = []; + if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean)); + if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder'); + return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; +} + +export function _visibleNoteTags(n) { + return _noteTags(n).filter(t => t !== 'reminder'); +} + +export function _isPastReminder(n, clock) { + if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false; + const due = new Date(n.due_date).getTime(); + return !isNaN(due) && due <= clock.now(); +} + +export function _serializeNoteForCopy(note) { + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (Array.isArray(note.items) && note.items.length) { + if (lines.length) lines.push(''); + for (const it of note.items) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + } + return lines.join('\n').trim(); +} + +export function _noteToAgentPrompt(note) { + const parts = []; + if ((note.title || '').trim()) parts.push(note.title.trim()); + if ((note.content || '').trim()) parts.push(note.content.trim()); + if (Array.isArray(note.items)) { + note.items.filter(it => !it.done && (it.text || '').trim()) + .forEach(it => parts.push('- ' + it.text.trim())); + } + const body = parts.join('\n'); + return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : ''; +} + +export function _standardVisualVariant(note) { + const source = String(note?.id || note?.title || note?.created_at || 'standard-note'); + let hash = 0; + for (let i = 0; i < source.length; i++) hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; + return Math.abs(hash) % 5; +} diff --git a/static/js/features/notes/domain/recurrence.js b/static/js/features/notes/domain/recurrence.js new file mode 100644 index 000000000..f72498fbb --- /dev/null +++ b/static/js/features/notes/domain/recurrence.js @@ -0,0 +1,269 @@ +// Local-time date and recurrence rules. Callers supply the clock at the original read sites. +export const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; +export const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +export function _hasTimeComponent(dateStr) { + return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); +} + +export function _formatDueDate(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const hasTime = _hasTimeComponent(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const diffDays = Math.round((due - today) / 86400000); + const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; + if (hasTime && d < now) return 'overdue'; + if (!hasTime && diffDays < 0) return 'overdue'; + if (diffDays === 0) return hasTime ? timeStr : 'today'; + if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; +} + +export function _isDueOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + if (isNaN(d)) return false; + if (_hasTimeComponent(dateStr)) return d < new Date(clock.now()); + return d < new Date(new Date(clock.now()).toDateString()); +} + +export function _isDueTodayOrOverdue(dateStr, clock) { + if (!dateStr) return false; + const d = new Date(dateStr); + const now = new Date(clock.now()); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return due <= today; +} + +export function _laterTodayDate(clock) { + const now = new Date(clock.now()); + const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today + // If less than 1 hour before 6pm, push to "in 3 hours" instead + if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); + return eight; +} + +export function _tomorrowDate(clock) { + const t = new Date(clock.now()); + t.setDate(t.getDate() + 1); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _nextWeekDate(clock) { + const t = new Date(clock.now()); + const daysUntilMon = (8 - t.getDay()) % 7 || 7; + t.setDate(t.getDate() + daysUntilMon); + t.setHours(8, 0, 0, 0); + return t; +} + +export function _toLocalDatetimeStr(d) { + // Format as YYYY-MM-DDTHH:MM (local, no TZ) + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function _formatReminderTag(dateStr, clock) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(clock.now()); + const sameDay = d.toDateString() === now.toDateString(); + const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = d.toDateString() === tomorrow.toDateString(); + const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + if (sameDay) return `Today, ${time}`; + if (isTomorrow) return `Tomorrow, ${time}`; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return `${dateLabel}, ${time}`; +} + +export function _nthWeekdayLabel(d) { + const n = Math.ceil(d.getDate() / 7); // 1..5 + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; +} + +export function _isLastWeekdayOfMonth(d) { + const test = new Date(d); + test.setDate(d.getDate() + 7); + return test.getMonth() !== d.getMonth(); +} + +export function _nthWeekdayOfMonth(year, month, weekday, n) { + const first = new Date(year, month, 1); + const offset = (weekday - first.getDay() + 7) % 7; + let day = 1 + offset + (n - 1) * 7; + // Last day of month + const lastDay = new Date(year, month + 1, 0).getDate(); + if (day > lastDay) day -= 7; + return new Date(year, month, day, 0, 0, 0); +} + +export function _lastWeekdayOfMonth(year, month, weekday) { + const lastDay = new Date(year, month + 1, 0); + const back = (lastDay.getDay() - weekday + 7) % 7; + return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); +} + +export function _snapToRepeat(currentDate, normRepeat, clock) { + const hh = currentDate.getHours(); + const mm = currentDate.getMinutes(); + const now = clock.now(); + const anchor = currentDate.getTime() > now ? currentDate : new Date(clock.now()); + const parts = normRepeat.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + const targetWd = parseInt(parts[1], 10); + if (isNaN(targetWd)) return null; + const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); + const delta = (targetWd - d.getDay() + 7) % 7; + d.setDate(d.getDate() + delta); + if (d.getTime() <= now) d.setDate(d.getDate() + 7); + return d; + } + if (kind === 'monthly') { + const sub = parts[1]; + let y = anchor.getFullYear(); + let m = anchor.getMonth(); + // Walk forward up to 14 months to find the next matching slot. + for (let tries = 0; tries < 14; tries++) { + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + if (isNaN(wantDay)) return null; + const lastDay = new Date(y, m + 1, 0).getDate(); + target = new Date(y, m, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + if (isNaN(n) || isNaN(wd)) return null; + target = _nthWeekdayOfMonth(y, m, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + if (isNaN(wd)) return null; + target = _lastWeekdayOfMonth(y, m, wd); + } else { + return null; + } + target.setHours(hh, mm, 0, 0); + if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; + m++; + if (m > 11) { m = 0; y++; } + } + return null; + } + return null; +} + +export function _formatRepeatLabel(repeat, originalDate) { + if (!repeat || repeat === 'none') return ''; + const norm = _normalizeRepeat(repeat, originalDate); + if (norm === 'daily') return 'Daily'; + if (norm === 'yearly') return 'Yearly'; + const parts = norm.split(':'); + if (parts[0] === 'weekly') { + const wd = parseInt(parts[1], 10); + if (isNaN(wd)) return 'Weekly'; + return `Weekly on ${_DAYS[wd]}s`; + } + if (parts[0] === 'monthly') { + if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Monthly on last ${_DAYS[wd]}`; + } + } + return norm; +} + +export function _normalizeRepeat(repeat, originalDate) { + if (!repeat || repeat === 'none') return 'none'; + if (repeat === 'daily' || repeat === 'yearly') return repeat; + if (/^(weekly|monthly):/.test(repeat)) return repeat; + // Legacy bare values: derive params from the original date + const wd = originalDate.getDay(); + const n = Math.ceil(originalDate.getDate() / 7); + if (repeat === 'weekly') return `weekly:${wd}`; + if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; + if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; + if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; + return repeat; +} + +export function _advanceRecurring(dateStr, repeat, clock) { + const orig = new Date(dateStr); + const hh = orig.getHours(); + const mm = orig.getMinutes(); + let d = new Date(orig); + const norm = _normalizeRepeat(repeat, orig); + if (norm === 'none') return null; + + function step() { + if (norm === 'daily') { + d.setDate(d.getDate() + 1); + return; + } + if (norm === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + return; + } + const parts = norm.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + // Snap to the requested weekday in the next 1-7 days + const targetWd = parseInt(parts[1], 10); + let delta = (targetWd - d.getDay() + 7) % 7; + if (delta === 0) delta = 7; + d.setDate(d.getDate() + delta); + d.setHours(hh, mm, 0, 0); + return; + } + if (kind === 'monthly') { + const sub = parts[1]; + const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); + const nm = (d.getMonth() + 1) % 12; + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + const lastDay = new Date(ny, nm + 1, 0).getDate(); + target = new Date(ny, nm, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + target = _nthWeekdayOfMonth(ny, nm, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + target = _lastWeekdayOfMonth(ny, nm, wd); + } else { + d = null; return; + } + target.setHours(hh, mm, 0, 0); + d = target; + return; + } + d = null; + } + + step(); + if (d === null) return null; + const now = clock.now(); + // Cap catch-up to avoid runaway on a malformed/very-old date. + let guard = 5000; + while (d.getTime() <= now) { + if (--guard <= 0) return null; + step(); + if (d === null) return null; + } + return _toLocalDatetimeStr(d); +} diff --git a/static/js/features/notes/index.js b/static/js/features/notes/index.js new file mode 100644 index 000000000..146844b8a --- /dev/null +++ b/static/js/features/notes/index.js @@ -0,0 +1,503 @@ +/** Notes composition and intentional public API. The legacy entry activates compatibility startup. */ +import { createSelectionOperations } from './application/selection.js'; +import { createToolbar } from './ui/toolbar.js'; +import { createWindowLayout } from './ui/windowLayout.js'; +import { createCardOperations } from './application/cardOperations.js'; +import { createChecklistOperations } from './application/checklistOperations.js'; +import { createNotesSubmission } from './application/submission.js'; +import { createNotesDrafts } from './application/drafts.js'; +import { createNotesLifecycle } from './ui/lifecycle.js'; +import * as noteRules from './domain/noteRules.js'; +import { createText } from './ui/text.js'; +import { createColors } from './ui/colors.js'; +import { createPanel } from './ui/panel.js'; +import { createReminderDisplay } from './ui/reminderDisplay.js'; +import { createSelection } from './ui/selection.js'; +import { createFilters } from './ui/filters.js'; +import { createCardRendering } from './ui/cardRendering.js'; +import { createLayout } from './ui/layout.js'; +import { createQuickAdd } from './ui/quickAdd.js'; +import { createCardInteractions } from './ui/cardInteractions.js'; +import { createCardChecklist } from './ui/cardChecklist.js'; +import { createCardReorder } from './ui/cardReorder.js'; +import { createDraftEditor } from './ui/draftEditor.js'; +import { createEditor } from './ui/editor.js'; +import { createReminderPicker } from './ui/reminderPicker.js'; +import { createChecklist } from './ui/checklist.js'; +import { createDrawing } from './ui/drawing.js'; +import { createClipboard } from './ui/clipboard.js'; +import { createMobileEditor } from './ui/mobileEditor.js'; +import { createMobileReorder } from './ui/mobileReorder.js'; +import { createChecklistReorder } from './ui/checklistReorder.js'; +import { createQueries } from './application/queries.js'; +import { createAgentActions } from './application/agentActions.js'; +import { createDeletion } from './application/deletion.js'; +import { createNotesReminders } from './application/reminders.js'; +import { createNotesNotifications } from './infrastructure/notifications.js'; +import { createNotesState } from './application/state.js'; +import { createNotesWorkflows } from './application/workflows.js'; +import { createNotesApi } from './infrastructure/http.js'; +import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY, NOTES_FIRST_OPEN_HINT_KEY, NOTES_LABELS_COLLAPSED_KEY } from './infrastructure/storage.js'; +import * as dates from './domain/recurrence.js'; +import uiModule from '/static/js/ui.js?v=20260916largetoolscroll1'; +import { spawnConfetti } from '/static/js/shared/ui/confetti.js'; +import * as Modals from '/static/js/modalManager.js'; +import { attachColorPicker } from '/static/js/colorPicker.js?v=20260910eyedropper1'; +import { makeWindowDraggable } from '/static/js/windowDrag.js'; +import { snapModalToZone } from '/static/js/tileManager.js?v=20260910responsivebounds1'; +import { applyEdgeDock, clearDockSide } from '/static/js/modalSnap.js'; +import { topToolWindowZ, topPortalZ } from '/static/js/toolWindowZOrder.js'; +import { bindMenuDismiss, dismissOrRemove } from '/static/js/escMenuStack.js'; +import { bindTourHintDismiss } from '/static/js/tourHintDismiss.js'; + +const api = createNotesApi({ origin: window.location.origin, request: (...args) => fetch(...args) }); +const persistence = createNotesPersistence({ storage: typeof localStorage === 'undefined' ? undefined : localStorage }); +const { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft } = persistence; +const clock = { now: () => Date.now() }; +const app = createNotesState({ + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list', + labelsCollapsed: typeof localStorage !== 'undefined' && localStorage.getItem(NOTES_LABELS_COLLAPSED_KEY) === '1', + firedDotDismissedAt: (() => { try { const v = parseInt(persistence.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10); return Number.isFinite(v) && v > 0 ? v : 0; } catch { return 0; } })(), +}); +const state = app.state; +const lifecycle = createNotesLifecycle(); +const scheduler = { setTimeout: (...args) => setTimeout(...args), clearTimeout: id => clearTimeout(id), setInterval: (...args) => setInterval(...args), clearInterval: id => clearInterval(id) }; +const notifications = createNotesNotifications({ browser: window, notify: window.Notification, openPanel: () => openPanel(), toast: title => uiModule?.showToast?.(title) }); +const reminders = createNotesReminders({ + app, + api, + persistence, + notifications, + clock, + scheduler, + fetchNotes: () => _fetchNotes(), + isNoteFullyDone: note => _isNoteFullyDone(note), + hasItems: note => _hasItems(note), + dismissedKey: REMINDER_DISMISSED_AT_KEY, + effects: { badge: () => _updateRailBadge(), highlight: id => { + _setReminderCardGlow(id, true); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (card) { card.classList.add('note-card-reminder-fired'); setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000); } + else _queuePendingHighlight(id); +} } +}); +const workflows = createNotesWorkflows({ + app, + api, + effects: { + render: () => _renderNotes(), + showError: (...args) => uiModule.showError(...args), + archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }), + celebrateArchive: (note, card) => { if (_hasItems(note) && !(note.items || []).some(i => !i.done)) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); } }, +} +}); +const _NOTE_UNDO_ICON = ''; +function _pushUndo(entry) { return workflows._pushUndo(entry); } +function _popAndRunUndo() { return workflows._popAndRunUndo(); } +function _undoArchive(note, prevIdx) { return workflows._undoArchive(note, prevIdx); } +function _archiveNoteById(id, options) { return workflows._archiveNoteById(id, options); } +function _fetchNotes() { return workflows._fetchNotes(); } + +function _deleteNoteApi(id) { return workflows._deleteNoteApi(id); } +function _patchNote(id, patch) { return workflows._patchNote(id, patch); } +function _uid() { return Math.random().toString(36).slice(2, 10); } +function _formatDueDate(dateStr) { return dates._formatDueDate(dateStr, clock); } +function _isDueOverdue(dateStr) { return dates._isDueOverdue(dateStr, clock); } + +function _laterTodayDate() { return dates._laterTodayDate(clock); } +function _tomorrowDate() { return dates._tomorrowDate(clock); } +function _nextWeekDate() { return dates._nextWeekDate(clock); } +function _formatReminderTag(dateStr) { return dates._formatReminderTag(dateStr, clock); } +function _snapToRepeat(currentDate, normRepeat) { return dates._snapToRepeat(currentDate, normRepeat, clock); } +function _ensureNotificationPermission() { return notifications.ensurePermission(); } + +function _startReminderLoop() { return reminders._startReminderLoop(); } + +function _countFiredReminders() { return reminders._countFiredReminders(); } + +function _hasActiveReminder(...args) { return noteRules._hasActiveReminder(...args, clock); } +const _isNoteFullyDone = noteRules._isNoteFullyDone; +const _hasItems = noteRules._hasItems; +function _isPastReminder(...args) { return noteRules._isPastReminder(...args, clock); } +function _esc(...args) { return text._esc(...args); } + +function _safeImgSrc(...args) { return text._safeImgSrc(...args); } +function _linkify(...args) { return text._linkify(...args); } +function _isBgImage(...args) { return colors._isBgImage(...args); } + +function _dotBg(...args) { return colors._dotBg(...args); } +function _dotIsActive(...args) { return colors._dotIsActive(...args); } +function _customColorStyle(...args) { return colors._customColorStyle(...args); } +function _pickCustomBgImage(...args) { return colors._pickCustomBgImage(...args); } +function _wireFormColors(...args) { return colors._wireFormColors(...args); } + +function openPanel(...args) { return panel.openPanel(...args); } + +function _smoothScrollToNoteCard(...args) { return panel._smoothScrollToNoteCard(...args); } +function _clearViewedReminderGlows(...args) { return reminderDisplay._clearViewedReminderGlows(...args); } +function _setReminderCardGlow(...args) { return reminderDisplay._setReminderCardGlow(...args); } +function _queuePendingHighlight(...args) { return reminderDisplay._queuePendingHighlight(...args); } +function _flushPendingHighlights(...args) { return reminderDisplay._flushPendingHighlights(...args); } +function _updateRailBadge(...args) { return reminderDisplay._updateRailBadge(...args); } +function _enterSelectMode(...args) { return selection._enterSelectMode(...args); } +function _exitSelectMode(...args) { return selection._exitSelectMode(...args); } +function _updateBulkBar(...args) { return selection._updateBulkBar(...args); } + +function _renderLabelsInto(...args) { return filters._renderLabelsInto(...args); } +function _renderLoadingSkeleton(...args) { return cardRendering._renderLoadingSkeleton(...args); } +function _renderNotes(...args) { return cardRendering._renderNotes(...args); } +function _captureCardPositions(...args) { return layout._captureCardPositions(...args); } +function _animateReflow(...args) { return layout._animateReflow(...args); } +function _applyMasonry(...args) { return layout._applyMasonry(...args); } +function _renderQuickAdd(...args) { return quickAdd._renderQuickAdd(...args); } + +function _editNote(...args) { return quickAdd._editNote(...args); } +function _bindCardEvents(...args) { return cardInteractions._bindCardEvents(...args); } +function _wireCardChecklist(...args) { return cardChecklist._wireCardChecklist(...args); } +function _wireTodayView(...args) { return cardChecklist._wireTodayView(...args); } +function _wireCardReorder(...args) { return cardReorder._wireCardReorder(...args); } + +function _wireDraftAutosave(...args) { return draftEditor._wireDraftAutosave(...args); } +function _commitOpenInPlaceEditor(...args) { return draftEditor._commitOpenInPlaceEditor(...args); } +function _applyDraftToNote(...args) { return draftEditor._applyDraftToNote(...args); } +function _buildForm(...args) { return editor._buildForm(...args); } +function _wireReminderPicker(...args) { return reminderPicker._wireReminderPicker(...args); } +function _buildGoalHtml(...args) { return checklist._buildGoalHtml(...args); } +function _wireGoalForm(...args) { return checklist._wireGoalForm(...args); } +function _buildChecklistHtml(...args) { return checklist._buildChecklistHtml(...args); } +function _wireRow(...args) { return checklist._wireRow(...args); } +function _wireChecklist(...args) { return checklist._wireChecklist(...args); } +function _collectItems(...args) { return checklist._collectItems(...args); } +function _buildDrawHtml(...args) { return drawing._buildDrawHtml(...args); } +function _wireCanvas(...args) { return drawing._wireCanvas(...args); } +function _uploadCanvasAsPng(...args) { return drawing._uploadCanvasAsPng(...args); } +function _openNoteCornerMenu(...args) { return clipboard._openNoteCornerMenu(...args); } +function _copyNote(...args) { return clipboard._copyNote(...args); } +function _isNotesMobileMode(...args) { return mobileEditor._isNotesMobileMode(...args); } +function _openMobileFullscreenEdit(...args) { return mobileEditor._openMobileFullscreenEdit(...args); } +function _closeMobileFullscreenEdit(...args) { return mobileEditor._closeMobileFullscreenEdit(...args); } + +function _bindLongPressDrag(...args) { return mobileReorder._bindLongPressDrag(...args); } + +function _wireChecklistTouchReorder(...args) { return checklistReorder._wireChecklistTouchReorder(...args); } + +function _sortedVisibleNotes(...args) { return queries._sortedVisibleNotes(...args); } +function _userVisibleNotes(...args) { return queries._userVisibleNotes(...args); } +function _agentSolveNote(...args) { return agentActions._agentSolveNote(...args); } + +function _clearPastReminders(...args) { return deletion._clearPastReminders(...args); } +const text = createText({ + uiModule +}); +const colors = createColors({ + api +}); +const { COLORS, COLOR_HEX } = colors; +const windowLayout = createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}); +const toolbar = createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}); +const panel = createPanel({ + wireToolbar: toolbar.wireToolbar, + wireSelectionActions: () => selection.wireSelectionActions(), + _wireNotesWindow: windowLayout._wireNotesWindow, + _restoreNotesSidebarDock: windowLayout._restoreNotesSidebarDock, + _bringNotesToFront: windowLayout._bringNotesToFront, + disposeUI: () => { layout.dispose(); mobileReorder.dispose(); checklistReorder.dispose(); lifecycle.dispose(); }, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}); +const reminderDisplay = createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}); +const selectionOperations = createSelectionOperations({ + app, + api, + fetchNotes: _fetchNotes, + exitSelection: _exitSelectMode, + sendContext: detail => window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail })), + effects: { render: _renderNotes, styledConfirm: uiModule.styledConfirm, showToast: (...args) => uiModule.showToast(...args) } +}); +const selection = createSelection({ + operations: selectionOperations, + _renderNotes, + app, + state +}); +const filters = createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}); +const cardRendering = createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}); +const layout = createLayout({ + isOpen: () => state.open +}); +const quickAdd = createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}); +const cardOperations = createCardOperations({ + app, + api, + pushUndo: _pushUndo, + undoArchive: _undoArchive, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), showToast: (...args) => uiModule.showToast(...args), archived: undo => uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }) } +}); +const checklistOperations = createChecklistOperations({ + app, + api, + uid: _uid, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args) } +}); +const cardInteractions = createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}); +const cardChecklist = createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}); +const cardReorder = createCardReorder({ + persistOrder: cardOperations.persistOrder, + lifecycle, + _isNotesMobileMode, + state +}); +const draftEditor = createDraftEditor({ + bindDraft: (...args) => drafts.bindDraft(...args), + _collectItems, + _loadDraft +}); +const editor = createEditor({ + deleteFromEditor: id => deletion.deleteFromEditor(id), + submitNote: (...args) => submission.submitNote(...args), + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}); +const reminderPicker = createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}); +const checklist = createChecklist({ + _esc, + _uid, + spawnConfetti +}); +const drawing = createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}); +const clipboard = createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}); +const mobileEditor = createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}); +const mobileReorder = createMobileReorder({ + commitMobileOrder: cardOperations.commitMobileOrder, + lifecycle, + _applyMasonry, + _enterSelectMode, + app +}); +const checklistReorder = createChecklistReorder({ + lifecycle +}); +const queries = createQueries({ + _hasActiveReminder, + state +}); +const agentActions = createAgentActions({ + _patchNote, + _renderNotes, + api, + app, + createFormData: () => new FormData(), + state, + streamComplete: sid => { try { window.sessionModule?.markStreamComplete?.(sid); } catch {} }, + uiModule +}); +const deletion = createDeletion({ + app, + _deleteNoteApi, + _fetchNotes, + _isPastReminder, + _renderNotes, + effects: { styledConfirm: uiModule.styledConfirm, confirm: message => confirm(message), showToast: (...args) => uiModule.showToast(...args), showError: (...args) => uiModule.showError(...args) }, + state +}); +const notesModule = { openPanel: panel.openPanel, closePanel: panel.closePanel, togglePanel: panel.togglePanel, isPanelOpen: panel.isPanelOpen, openNote: panel.openNote, newNote: quickAdd.newNote, openNotes: panel.openPanel, closeNotes: panel.closePanel, isNotesOpen: panel.isPanelOpen, refreshDueBadge: reminders.refreshDueBadge }; +const publicOpenPanel = panel.openPanel, publicClosePanel = panel.closePanel, publicTogglePanel = panel.togglePanel, publicIsPanelOpen = panel.isPanelOpen, publicOpenNote = panel.openNote, publicNewNote = quickAdd.newNote, publicRefreshDueBadge = reminders.refreshDueBadge, publicDismissFiredReminderDot = reminders.dismissFiredReminderDot; +export { publicOpenPanel as openPanel, publicClosePanel as closePanel, publicTogglePanel as togglePanel, publicIsPanelOpen as isPanelOpen, publicOpenNote as openNote, publicNewNote as newNote, publicRefreshDueBadge as refreshDueBadge, publicDismissFiredReminderDot as dismissFiredReminderDot, publicOpenPanel as openNotes, publicClosePanel as closeNotes, publicIsPanelOpen as isNotesOpen }; +export function startNotesBackground() { reminders.start(); } +export default notesModule; +const submission = createNotesSubmission({ + app, + api, + persistence, + clock, + uid: _uid, + fetchNotes: _fetchNotes, + effects: { render: _renderNotes, showError: (...args) => uiModule.showError(...args), clearGlow: id => _setReminderCardGlow(id, false) } +}); +const drafts = createNotesDrafts({ + app, + api, + persistence, + scheduler, + effects: { showError: (...args) => uiModule.showError(...args) } +}); diff --git a/static/js/features/notes/infrastructure/http.js b/static/js/features/notes/infrastructure/http.js new file mode 100644 index 000000000..eeafd1b0f --- /dev/null +++ b/static/js/features/notes/infrastructure/http.js @@ -0,0 +1,29 @@ +/** Notes HTTP port. Response interpretation stays with the owning workflow. */ +export function createNotesApi({ origin, request }) { + const json = (method, body) => ({ method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + async function saveNote(note) { + const response = await request(note.id ? `${origin}/api/notes/${note.id}` : `${origin}/api/notes`, json(note.id ? 'PUT' : 'POST', note)); + if (!response.ok) throw new Error('Failed to save note'); + return await response.json(); + } + async function patchNote(id, patch) { + const response = await request(`${origin}/api/notes/${id}`, json('PUT', patch)); + if (!response.ok) throw new Error('Failed to update note'); + return await response.json(); + } + async function deleteNote(id) { + const response = await request(`${origin}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' }); + if (!response.ok) throw new Error('HTTP ' + response.status); + } + return { + saveNote, patchNote, deleteNote, + listNotes: archived => request(`${origin}/api/notes${archived ? '?archived=true' : ''}`, { credentials: 'same-origin' }), + reorder: ids => request(`${origin}/api/notes/reorder`, json('POST', { ids })), + upload: body => request(`${origin}/api/upload`, { method: 'POST', body, credentials: 'same-origin' }), + uploadUrl: id => `${origin}/api/upload/${id}`, + fireReminder: body => request('/api/notes/fire-reminder', json('POST', body)), + defaultChat: () => request(`${origin}/api/default-chat`, { credentials: 'same-origin' }), + createSession: body => request(`${origin}/api/session`, { method: 'POST', body, credentials: 'same-origin' }), + startChat: body => request(`${origin}/api/chat_stream`, { method: 'POST', body, credentials: 'same-origin' }), + }; +} diff --git a/static/js/features/notes/infrastructure/notifications.js b/static/js/features/notes/infrastructure/notifications.js new file mode 100644 index 000000000..cd98eb702 --- /dev/null +++ b/static/js/features/notes/infrastructure/notifications.js @@ -0,0 +1,20 @@ +/** Browser notification permission/display port, independent of Notes rendering. */ +export function createNotesNotifications({ browser, notify, openPanel, toast }) { + async function ensurePermission() { + if (!('Notification' in browser)) return false; + if (notify.permission === 'granted') return true; + if (notify.permission === 'denied') return false; + try { return await notify.requestPermission() === 'granted'; } + catch { return false; } + } + function show(title, body, noteId) { + if ('Notification' in browser && notify.permission === 'granted') { + try { + const notification = new notify(title, { body, tag: 'note-' + noteId, icon: '/static/favicon.ico' }); + notification.onclick = () => { browser.focus(); openPanel(); notification.close(); }; + } catch {} + } + toast(title); + } + return { ensurePermission, show }; +} diff --git a/static/js/features/notes/infrastructure/storage.js b/static/js/features/notes/infrastructure/storage.js new file mode 100644 index 000000000..06aaa01ab --- /dev/null +++ b/static/js/features/notes/infrastructure/storage.js @@ -0,0 +1,63 @@ +/** Browser persistence and unchanged Notes key/serialization contracts. */ +export const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired'; +export const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed'; +export const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight'; +export const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'; +export const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at'; +export const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1'; +export const NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'; +export const _DRAFT_PREFIX = 'odysseus-note-draft-'; + +export function createNotesPersistence({ storage: localStorage }) { +function _loadPendingHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _loadGlowedReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveGlowedReminders(set) { + try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {} +} + +function _loadActiveHighlights() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveActiveHighlights(set) { + try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {} +} + +function _savePendingHighlights(set) { + try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); } + catch {} +} + +function _loadFiredReminders() { + try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); } + catch { return new Set(); } +} + +function _saveFiredReminders(set) { + try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); } + catch {} +} + +function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); } + +function _loadDraft(id) { + try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; } +} + +function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} } + +return { _loadPendingHighlights, _loadGlowedReminders, _saveGlowedReminders, _loadActiveHighlights, _saveActiveHighlights, _savePendingHighlights, _loadFiredReminders, _saveFiredReminders, _draftKey, _loadDraft, _clearDraft, + getItem: key => localStorage.getItem(key), + setItem: (key, value) => localStorage.setItem(key, value), + removeItem: key => localStorage.removeItem(key), +}; +} diff --git a/static/js/features/notes/ui/cardChecklist.js b/static/js/features/notes/ui/cardChecklist.js new file mode 100644 index 000000000..6bbf39326 --- /dev/null +++ b/static/js/features/notes/ui/cardChecklist.js @@ -0,0 +1,148 @@ +/** Card checklist editing and Today-step interactions. */ +export function createCardChecklist({ + checklistOperations, + _editNote, + _renderNotes, + app, + spawnConfetti, + state +}) { + function _wireCardChecklist(body) { + // Remove a single checklist item (hover X) + body.querySelectorAll('.note-checkbox-rm').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + checklistOperations.removeItem(noteId, idx); + }); + }); + + function _startChecklistItemEdit(noteId, idx, span) { + if (span.isContentEditable) return; + const note = state.notes.find(n => n.id === noteId); + if (!note || !Array.isArray(note.items) || !note.items[idx]) return; + + span.textContent = note.items[idx].text || ''; + span.contentEditable = "true"; + span.spellcheck = false; + span.focus(); + + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(span); + selection.removeAllRanges(); + selection.addRange(range); + + const save = () => { + if (!span.isContentEditable) return; + span.contentEditable = "false"; + const newText = span.textContent.trim(); + checklistOperations.editItem(note, idx, newText); + }; + + const onKeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + save(); + } else if (e.key === 'Escape') { + e.preventDefault(); + span.contentEditable = "false"; + _renderNotes(); + } + }; + + span.addEventListener('blur', save, { once: true }); + span.addEventListener('keydown', onKeydown); + } + + // Edit a single checklist item (hover Edit button) + body.querySelectorAll('.note-checkbox-edit').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (state.selectMode) return; + const noteId = btn.dataset.noteId; + const idx = parseInt(btn.dataset.idx); + const span = btn.parentElement.querySelector('.note-check-text'); + if (span) _startChecklistItemEdit(noteId, idx, span); + }); + }); + + // Prevent clicks from toggling the row while actively editing inline + body.querySelectorAll('.note-check-text').forEach(span => { + span.addEventListener('click', (e) => { + if (span.isContentEditable) { + e.stopPropagation(); + } + }); + }); + + // Quick-add new checklist item (hover input at bottom of todo cards) + body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { + input.addEventListener('click', (e) => e.stopPropagation()); + input.addEventListener('keydown', async (e) => { + e.stopPropagation(); + if (e.key !== 'Enter') return; + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + const noteId = input.dataset.noteId; + const persist = checklistOperations.addItem(noteId, text, () => { input.value = ''; }); + if (!persist) return; + // Refocus the input on the same card + setTimeout(() => { + const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`); + if (next) next.focus(); + }, 0); + persist(); + }); + }); + + // Checkboxes (dot toggle, optimistic) : disabled in select mode + body.querySelectorAll('.note-checkbox').forEach(el => { + el.addEventListener('click', (e) => { + if (state.selectMode) return; // let card-level handler take over + e.preventDefault(); + e.stopPropagation(); + const noteId = el.dataset.noteId; + const idx = parseInt(el.dataset.idx); + checklistOperations.toggleItem(noteId, idx, done => el.classList.toggle('done', done), () => { + const card = el.closest('.note-card'); + if (card) { const r = card.getBoundingClientRect(); spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); } + }); + }); + }); + + + } + + function _wireTodayView(body) { + body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => { + dot.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = dot.dataset.noteId; + const idx = parseInt(dot.dataset.idx); + const row = dot.closest('.notes-today-row'); + await checklistOperations.toggleTodayStep(id, idx, () => row?.classList.add('done'), () => { + const r = (row || dot).getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); + }); + }); + }); + body.querySelectorAll('.notes-today-title').forEach(el => { + el.addEventListener('click', () => { + const id = el.dataset.noteId; + if (!id) return; + // Drop the Today filter first so the regular card list is rendered; + // _editNote needs to find a .note-card in the DOM to replace with + // the editor form. + app.setActiveFilter(null); + _renderNotes(); + _editNote(id); + }); + }); + } + + return { _wireCardChecklist, _wireTodayView }; +} diff --git a/static/js/features/notes/ui/cardInteractions.js b/static/js/features/notes/ui/cardInteractions.js new file mode 100644 index 000000000..09e083c41 --- /dev/null +++ b/static/js/features/notes/ui/cardInteractions.js @@ -0,0 +1,337 @@ +/** Card taps, pin/color controls, menus and archive animation. */ +export function createCardInteractions({ + cardOperations, + COLORS, + _archiveNoteById, + _bindLongPressDrag, + _customColorStyle, + _dotBg, + _dotIsActive, + _editNote, + _isBgImage, + _isNotesMobileMode, + _openMobileFullscreenEdit, + _openNoteCornerMenu, + _pickCustomBgImage, + _renderNotes, + _setReminderCardGlow, + _smoothScrollToNoteCard, + _updateBulkBar, + _wireCardChecklist, + _wireCardReorder, + app, + spawnConfetti, + state, + uiModule +}) { + function _bindCardEvents(body) { + // Skip completed leading rows so the first unfinished checklist item is + // visible when a note preview has its own scroll area. + requestAnimationFrame(() => { + body.querySelectorAll('.note-checklist-preview').forEach(preview => { + const firstPending = preview.querySelector('.note-checkbox:not(.done)'); + if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4); + }); + }); + const tapToEditOrSelect = (cardEl) => { + const id = cardEl.dataset.noteId; + // A long-press enters select mode and re-renders the card. Ignore the + // synthetic click that mobile browsers emit when that same touch ends. + if (state.suppressNextNoteTapId === id) { + app.setSuppressNextNoteTapId(null); + return; + } + if (state.selectMode) { + const cb = cardEl.querySelector('.note-card-cb'); + if (cb) { + cb.checked = !cb.checked; + cb.dispatchEvent(new Event('change')); + } + } else if (_isNotesMobileMode()) { + // Mobile: open the per-note fullscreen edit overlay instead of the + // in-place form. Tiles on mobile are read-only previews. + _openMobileFullscreenEdit(id, cardEl); + } else { + _editNote(id); + } + }; + // Mobile: long-press anywhere on a note card → enter Select mode and + // select that card. Cancelled by movement (so it doesn't interfere with + // vertical scrolling) or by lifting the finger before the timer fires. + if (_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card)); + } + body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => { + card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true); + }); + // Click title : edit, or toggle select in select mode + body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + _smoothScrollToNoteCard(el.closest('.note-card')); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click content : edit, or toggle select in select mode + body.querySelectorAll('.note-content-preview').forEach(el => { + el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); + }); + // A saved drawing is the note's primary content. Open its editor directly + // when the preview is clicked, matching title/content preview behavior. + body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Click empty area of checklist preview (not on checkbox/X) : edit + body.querySelectorAll('.note-checklist-preview').forEach(el => { + el.addEventListener('wheel', (e) => { + const atTop = el.scrollTop <= 0; + const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; + if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { + const pane = el.closest('.notes-pane-body'); + if (pane) { + pane.scrollTop += e.deltaY; + e.preventDefault(); + } + } + }, { passive: false }); + el.addEventListener('click', (e) => { + if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; + e.stopPropagation(); + tapToEditOrSelect(el.closest('.note-card')); + }); + }); + // Clicking todo item text now toggles its checkbox : let the click bubble + // up to the parent .note-checkbox row handler. To open the editor, the + // user clicks the pencil corner. + // (No-op block kept as a marker : removing the listener entirely means + // clicks naturally bubble to the row toggle below.) + // In select mode, clicking anywhere on the card toggles selection + if (state.selectMode) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.note-card-cb')) return; // checkbox handles itself + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Mobile, non-select: tapping anywhere on the card body (not on an + // interactive child : buttons, pin, checkbox, color dot, reminder pill, + // agent tag, links) opens the fullscreen editor. Previously only the + // title / content preview triggered edit, so padding + empty gutters were + // dead zones that felt broken on mobile. + if (_isNotesMobileMode() && !state.selectMode) { + const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest(_INTERACTIVE)) return; + e.stopPropagation(); + tapToEditOrSelect(card); + }); + }); + } + // Multi-select checkbox (only in select mode) + body.querySelectorAll('.note-card-cb').forEach(cb => { + cb.addEventListener('click', (e) => e.stopPropagation()); + cb.addEventListener('change', () => { + const id = cb.dataset.noteId; + if (cb.checked) app.selectId(id); + else app.deselectId(id); + cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked); + _updateBulkBar(); + }); + }); + // Pin toggle (optimistic) + body.querySelectorAll('.note-card-pin').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.togglePin(id); + }); + }); + // Color picker + const _applyCardColor = async (card, id, newColor) => { + const isBg = _isBgImage(newColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); }); + if (newColor && !isBg) card.classList.add('note-color-' + newColor); + if (isBg) card.setAttribute('style', _customColorStyle(newColor)); + else card.removeAttribute('style'); + card.querySelectorAll('.note-card-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor)); + d.style.background = _dotBg(d.dataset.color, newColor); + }); + await cardOperations.updateColor(id, newColor); + }; + body.querySelectorAll('.note-card-color-dot').forEach(dot => { + dot.addEventListener('click', (e) => { + e.stopPropagation(); + const card = dot.closest('.note-card'); + const id = card.dataset.noteId; + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); }); + return; + } + _applyCardColor(card, id, dot.dataset.color); + }); + }); + // Plain pencil corner → open editor. The unarchive corner shares the + // .note-card-edit-corner class for styling, so :not() keeps the edit + // handler off it. + body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (id) _editNote(id); + }); + }); + // Copy corner : bottom-right, just left of the Done check. Shared with + // the Ctrl/Cmd+C shortcut wired further down so both code paths run the + // same serializer + feedback flash. + // ⋯ corner menu : Copy + Agent (solve-this-todo). + body.querySelectorAll('.note-card-corner-menu').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _openNoteCornerMenu(btn); + }); + }); + body.querySelectorAll('.note-card-label-chip').forEach(chip => { + chip.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const label = chip.dataset.noteLabelFilter; + if (!label) return; + if (state.activeLabel === label && state.activeFilter === null) { + app.setActiveLabel(null); + } else { + app.setActiveFilter(null); + app.setActiveLabel(label); + } + _renderNotes(); + }); + }); + // Done (✓) at bottom-right : only visible on hover for active notes. + body.querySelectorAll('.note-card-done').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + const card = btn.closest('.note-card'); + const idx = state.notes.findIndex(n => n.id === id); + if (idx < 0) return; + // Celebrate completion : same confetti shower the bulk-archive uses. + if (card) { + const r = card.getBoundingClientRect(); + spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); + } + const finish = cardOperations.stageArchive(id); + if (!finish) return; + if (card) { + card.classList.add('note-card-sliding-out'); + let done = false; + const once = () => { if (done) return; done = true; finish(); }; + card.addEventListener('transitionend', once, { once: true }); + setTimeout(once, 400); + } else { + finish(); + } + }); + }); + // Unarchive corner : only visible in archive view. + body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Trash corner : archive view only. Permanent delete, no confirmation. + body.querySelectorAll('.note-card-corner-trash').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id, true); + }); + }); + + body.querySelectorAll('.note-card-archive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + if (!id) return; + const card = btn.closest('.note-card'); + let done = false; + const finishRemove = () => { + if (done) return; + done = true; + _archiveNoteById(id, { card, celebrate: true }); + }; + if (card) { + card.classList.add('note-card-sliding-out'); + card.addEventListener('transitionend', finishRemove, { once: true }); + setTimeout(finishRemove, 400); + } else { + finishRemove(); + } + }); + }); + // Unarchive (optimistic) : only present in archive view + body.querySelectorAll('.note-card-unarchive').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.unarchive(id); + }); + }); + // Delete (optimistic) + body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.dataset.noteId; + cardOperations.deleteOptimistically(id); + }); + }); + // Copy entire checklist (title + items, markdown-style) + body.querySelectorAll('.note-card-copy').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const lines = []; + if (note.title) lines.push(note.title); + if (note.content) lines.push(note.content); + if (lines.length) lines.push(''); + for (const it of (note.items || [])) { + if (!it || !(it.text || '').trim()) continue; + lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); + } + const text = lines.join('\n').trim(); + try { + await navigator.clipboard.writeText(text); + uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`); + } catch { + // Fallback for browsers blocking the async API + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); uiModule.showToast?.('Copied'); } + catch { uiModule.showError?.('Copy failed'); } + ta.remove(); + } + }); + }); + + _wireCardChecklist(body); + _wireCardReorder(body); + + } + + return { _bindCardEvents }; +} diff --git a/static/js/features/notes/ui/cardRendering.js b/static/js/features/notes/ui/cardRendering.js new file mode 100644 index 000000000..315e47218 --- /dev/null +++ b/static/js/features/notes/ui/cardRendering.js @@ -0,0 +1,244 @@ +/** Render note cards from application queries and read-only state. */ +import { _nextGoalStep, _goalProgress, _hasItems, _standardVisualVariant, _visibleNoteTags } from '../domain/noteRules.js'; +import { _hasTimeComponent, _formatRepeatLabel } from '../domain/recurrence.js'; +export function createCardRendering({ + COLORS, + COLOR_HEX, + _animateReflow, + _applyMasonry, + _bindCardEvents, + _captureCardPositions, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _formatDueDate, + _formatReminderTag, + _hasActiveReminder, + _isBgImage, + _isDueOverdue, + _isNotesMobileMode, + _linkify, + _loadActiveHighlights, + _renderLabelsInto, + _renderQuickAdd, + _safeImgSrc, + _sortedVisibleNotes, + _updateRailBadge, + _wireTodayView, + state, + uiModule +}) { + function _renderLoadingSkeleton() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + const skel = document.createElement('div'); + skel.className = 'notes-skeleton'; + skel.innerHTML = ` +
+
+
+
+ `; + body.appendChild(skel); + } + + function _renderNotes() { + _updateRailBadge(); + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return; + const prevPositions = _captureCardPositions(); + const activeReminderHighlights = _loadActiveHighlights(); + + const sorted = _sortedVisibleNotes(); + + let html = ''; + // Today view: render a compact card listing the next-unchecked step from + // each active goal. Tapping a step toggles it done (same idx-based wiring + // as regular checkboxes). Tapping the title opens the goal note for full + // editing. + if (state.activeFilter === 'today') { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', `
All caught up — no pending goal steps right now.
`); + } else { + let todayHtml = `
+
+ + Today · one step per goal +
+
`; + for (const note of sorted) { + const next = _nextGoalStep(note); + if (!next) continue; + const progress = _goalProgress(note).trim(); + todayHtml += `
+ +
+
${_esc(note.title || '(untitled goal)')}
+
${_linkify(next.item.text || '')}
+
+ ${_esc(progress)} +
`; + } + todayHtml += `
`; + body.insertAdjacentHTML('beforeend', todayHtml); + } + _wireTodayView(body); + return; + } + for (const note of sorted) { + if (state.editingId === note.id) continue; // skip : form is shown instead + const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)'; + const dueFmt = _formatDueDate(note.due_date); + const overdue = _isDueOverdue(note.due_date); + + let contentHtml = ''; + if (_hasItems(note) && Array.isArray(note.items)) { + // Goal notes can carry a free-form description above the step list : + // todos rarely do, but the same render works for both. + if (note.note_type === 'goal' && (note.content || '').trim()) { + const fullText = note.content || ''; + const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText; + contentHtml += `
${_esc(preview)}
`; + } + contentHtml += '
'; + // Show ALL items : the preview container is scrollable (CSS caps + // its max-height + overflow-y:auto), so there's no need to truncate. + for (let i = 0; i < note.items.length; i++) { + const item = note.items[i]; + const doneClass = item.done ? ' done' : ''; + const indent = Math.min(item.indent || 0, 3); + contentHtml += `
+ + ${_linkify(item.text)} + + +
`; + } + contentHtml += '
'; + } else { + const fullText = note.content || ''; + const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText; + // _linkify already calls _esc internally, so URLs become clickable + // anchors (used by e.g. the "remind me to reply" email deep-link). + contentHtml = preview ? `
${_linkify(preview)}
` : ''; + } + + const isBg = _isBgImage(note.color); + const cc = (note.color && !isBg) ? ' note-color-' + note.color : ''; + const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : ''; + const isStandard = !note.note_type || note.note_type === 'note'; + const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : ''; + const sel = state.selectedIds.has(note.id) ? ' note-card-selected' : ''; + const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date) + ? `
+ + ${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''} +
` + : ''; + const noteTags = _visibleNoteTags(note); + const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `${dueFmt}` : ''; + const colorDots = COLORS.map(c => ``).join(''); + const goalClass = note.note_type === 'goal' ? ' note-card-goal' : ''; + const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : ''; + const goalPill = note.note_type === 'goal' + ? ` + + Goal${_goalProgress(note)} + ` + : ''; + const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : ''; + html += `
+ ${state.selectMode ? `` : ''} + ${goalPill} + + ${state.showingArchived + ? ` + ` + : ` + ${_hasItems(note) ? `` : ''}`} +
+
${_esc(note.title || '')}
+ ${dueBadge} +
+ ${_safeImgSrc(note.image_url) ? `` : ''} + ${contentHtml} + ${_hasItems(note) ? `
` : ''} + ${reminderTagHtml} + ${noteTags.length ? `
${noteTags.map(t => ``).join(' ')}
` : ''} +
+
${colorDots}
+ + ${state.showingArchived ? ` + + ` : ` + ${_hasItems(note) ? ` + ` : ''} + + + `} +
+
`; + } + + // Always render quick-add at top (collapsed unless user is typing) + const existingForm = body.querySelector('.note-form'); + if (existingForm && state.editingId === '__new__') { + // Keep the expanded form, replace cards after it + const next = [...body.children].filter(c => c !== existingForm); + next.forEach(c => c.remove()); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + existingForm.insertAdjacentHTML('afterend', html); + } + } else { + body.innerHTML = ''; + _renderLabelsInto(body); + _renderQuickAdd(body); + if (sorted.length === 0) { + body.insertAdjacentHTML('beforeend', '
No notes yet ' + uiModule.emptyStateIcon('smiley') + '
'); + } else { + body.insertAdjacentHTML('beforeend', html); + } + } + + _bindCardEvents(body); + _animateReflow(prevPositions); + _applyMasonry(body); + } + + return { _renderLoadingSkeleton, _renderNotes }; +} diff --git a/static/js/features/notes/ui/cardReorder.js b/static/js/features/notes/ui/cardReorder.js new file mode 100644 index 000000000..dd32d517c --- /dev/null +++ b/static/js/features/notes/ui/cardReorder.js @@ -0,0 +1,181 @@ +/** Desktop and large-touch-device card reorder gestures. */ +export function createCardReorder({ + lifecycle, + _isNotesMobileMode, + persistOrder, + state +}) { + function _wireCardReorder(body) { + lifecycle.release(body); + // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom + // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is + // unreliable on touch browsers and can compete with the long-press flow. + if (!_isNotesMobileMode()) { + body.querySelectorAll('.note-card').forEach(card => { + card.addEventListener('dragstart', (e) => { + if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) { + e.preventDefault(); + return; + } + card.classList.add('dragging'); + body.classList.add('drag-active'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {} + }); + card.addEventListener('dragend', async () => { + card.classList.remove('dragging'); + body.classList.remove('drag-active'); + body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + try { await persistOrder(ids); } + catch {} + }); + }); + } + // Track which card we last swapped with so a single hover-over triggers + // one swap, not a jitter as the pointer keeps moving inside the same card. + let _lastSwapId = null; + function _maybeSwap(dragging, clientX, clientY) { + const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card'); + if (!target || target === dragging || !body.contains(target)) return; + const id = target.dataset.noteId; + if (id === _lastSwapId) return; + // FLIP across ALL siblings. In list view only `target` moves visually, but + // in grid view (2-col) and when the pinned-section grid-column-start rule + // shifts, several cards reflow at once. Capture every card's pre-swap rect, + // do the DOM swap, then animate any that actually moved. The dragging card + // is excluded : it's already finger-tracked via translate3d. + const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging); + const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()])); + const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling; + body.insertBefore(dragging, target); + body.insertBefore(target, draggingNext); + for (const c of cards) { + const prev = prevRects.get(c); + const next = c.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue; + c.style.transition = 'none'; + c.style.transform = `translate(${dx}px, ${dy}px)`; + requestAnimationFrame(() => { + c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)'; + c.style.transform = ''; + c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true }); + }); + } + _lastSwapId = id; + } + lifecycle.listen(body, body, 'dragover', (e) => { + e.preventDefault(); + const dragging = body.querySelector('.note-card.dragging'); + if (!dragging) return; + _maybeSwap(dragging, e.clientX, e.clientY); + }); + lifecycle.listen(body, body, 'dragend', () => { _lastSwapId = null; }); + + // Legacy touch drag for larger touch devices only. Phone-sized Notes uses + // the placeholder sorter wired by `_bindLongPressDrag`; running both flows + // makes one press start two independent drag sessions. + if (!_isNotesMobileMode() && 'ontouchstart' in window) { + let dragCard = null; + let isDragging = false; + let longPressTimer = null; + let startX = 0, startY = 0; + const LONG_PRESS_MS = 350; + const MOVE_THRESHOLD_PX = 8; + const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a'; + + // Anchor for the finger-follow transform. Recomputed after every swap so + // the card stays under the finger across reorderings. + let anchorX = 0, anchorY = 0; + const _follow = (clientX, clientY) => { + if (!dragCard) return; + const dx = clientX - anchorX; + const dy = clientY - anchorY; + // Compose with the CSS .dragging transform (scale + rotate). + dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`; + }; + const _reanchor = (clientX, clientY) => { + if (!dragCard) return; + // Clear any prior translate so the card resettles in its natural slot, + // then anchor at the finger's current position. Subsequent _follow calls + // translate by (finger - anchor), keeping the finger at the same relative + // spot on the card it had when we re-anchored. + dragCard.style.transform = ''; + anchorX = clientX; + anchorY = clientY; + }; + + const _endDrag = (committed) => { + clearTimeout(longPressTimer); + longPressTimer = null; + if (dragCard) { + dragCard.classList.remove('dragging'); + dragCard.style.transform = ''; + } + body.classList.remove('drag-active'); + _lastSwapId = null; + if (isDragging) { + document.documentElement.style.touchAction = ''; + if (committed) { + const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); + persistOrder(ids).catch(() => {}); + } + } + dragCard = null; + isDragging = false; + }; + lifecycle.onDispose(body, () => _endDrag(false)); + + lifecycle.listen(body, body, 'touchstart', (e) => { + if (state.selectMode) return; + const card = e.target.closest('.note-card'); + if (!card) return; + if (e.target.closest(_selectorSkip)) return; + const t = e.touches[0]; + startX = t.clientX; startY = t.clientY; + dragCard = card; + longPressTimer = setTimeout(() => { + if (!dragCard) return; + isDragging = true; + dragCard.classList.add('dragging'); + body.classList.add('drag-active'); + document.documentElement.style.touchAction = 'none'; + _reanchor(startX, startY); + try { if (navigator.vibrate) navigator.vibrate(15); } catch {} + }, LONG_PRESS_MS); + }, { passive: true }); + + lifecycle.listen(body, body, 'touchmove', (e) => { + if (!dragCard) return; + const t = e.touches[0]; + if (!isDragging) { + // Movement before long-press fires = user is scrolling; cancel pickup. + if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) { + clearTimeout(longPressTimer); + longPressTimer = null; + dragCard = null; + } + return; + } + e.preventDefault(); + // Live-follow the finger first, then check for a swap. After a swap the + // card's natural slot moves, so we re-anchor and re-apply the offset. + _follow(t.clientX, t.clientY); + const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + _maybeSwap(dragCard, t.clientX, t.clientY); + const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); + if (before !== after) { + _reanchor(t.clientX, t.clientY); + _follow(t.clientX, t.clientY); + } + }, { passive: false }); + + lifecycle.listen(body, body, 'touchend', () => _endDrag(true)); + lifecycle.listen(body, body, 'touchcancel', () => _endDrag(false)); + } + } + + return { _wireCardReorder }; +} diff --git a/static/js/features/notes/ui/checklist.js b/static/js/features/notes/ui/checklist.js new file mode 100644 index 000000000..59a01aaa4 --- /dev/null +++ b/static/js/features/notes/ui/checklist.js @@ -0,0 +1,190 @@ +/** Checklist rows, goal forms, keyboard input and item collection. */ +export function createChecklist({ + _esc, + _uid, + spawnConfetti +}) { + function _buildGoalHtml(note, items) { + const desc = (note?.content || '').toString(); + return ` +
+ + ${_buildChecklistHtml(items)} +
+ `; + } + + function _wireGoalForm(form, container) { + if (!container) return; + // _wireHashtag is a closure inside _buildForm : out of scope here. Inline + // the same behavior (type "#foo " in the description → tag added to the + // form's label input) so editing a goal note doesn't ReferenceError. + const desc = container.querySelector('.note-form-goal-desc'); + const labelInput = form?.querySelector('.note-form-label'); + if (desc && labelInput) { + const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + desc.addEventListener('input', () => { + const m = tagRe.exec(desc.value); + if (!m) return; + const tag = m[2]; + // Same dedup-after-stripping fix as the plain note hashtag handler. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = desc.value.length - m[0].length + m[1].length; + desc.value = desc.value.slice(0, cut); + }); + } + // Always wire the checklist. The previous gate on a `note-form-goal-fresh` + // class was dead code : nothing ever set that class, so the editor never + // hooked up add/drag/Tab. + _wireChecklist(container); + } + + function _buildChecklistHtml(items) { + let html = '
'; + for (const item of items) { + const indent = Math.min(item.indent || 0, 3); + html += `
+ ⋮⋮ + + + +
`; + } + // `type="button"` matters on mobile : without it some browsers treat + // bare
`; + return html; + } + + function _wireRow(row, container) { + row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove()); + row.querySelector('.note-cl-dot')?.addEventListener('click', () => { + const wasDone = row.classList.contains('done'); + row.classList.toggle('done'); + // The custom dot has no native input/change event. Notify the form so + // mobile edit mode switches its action from Archive back to Update. + container.dispatchEvent(new Event('input', { bubbles: true })); + const becameDone = !wasDone; // we just flipped it + const dot = row.querySelector('.note-cl-dot'); + const dRect = (dot || row).getBoundingClientRect(); + // Small confetti burst on each fresh check so the user gets a + // "well done" beat per item, not just the grand-finale on all-done. + if (becameDone) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16); + } + // Bigger burst when the whole list is now done. + const rows = [...container.querySelectorAll('.note-cl-row')]; + const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0); + if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) { + spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60); + } + }); + const txt = row.querySelector('.note-cl-text'); + txt?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); } + else if (e.key === 'Tab') { + e.preventDefault(); + const cur = parseInt(row.dataset.indent || '0'); + const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1); + row.dataset.indent = String(next); + row.style.paddingLeft = (next * 16) + 'px'; + } else if (e.key === 'Backspace' && txt.value === '') { + e.preventDefault(); + const prev = row.previousElementSibling; + row.remove(); + if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus(); + } + }); + // Drag handlers + row.addEventListener('dragstart', (e) => { + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {} + }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + }); + } + + function _wireChecklist(container) { + if (!container) return; + // Delegate the + Add click off the container so re-renders + mobile + // touch quirks don't leave the button dead. The previous direct + // `addEventListener` on the button silently broke on mobile when + // _wireChecklist ran more than once (or before the button was in DOM). + if (!container._addDelegated) { + container._addDelegated = true; + container.addEventListener('click', (ev) => { + const addBtn = ev.target.closest('.note-cl-add'); + if (!addBtn || !container.contains(addBtn)) return; + ev.preventDefault(); + ev.stopPropagation(); + const inputs = container.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const row = document.createElement('div'); + row.className = 'note-cl-row'; + row.draggable = true; + row.dataset.itemId = _uid(); + row.dataset.indent = '0'; + row.innerHTML = `⋮⋮`; + inputs.insertBefore(row, addBtn); + row.querySelector('.note-cl-text').focus(); + _wireRow(row, container); + }); + } + container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container)); + + // Drag-over handler on the inputs container + const inputs = container.querySelector('.note-checklist-inputs'); + if (inputs) { + inputs.addEventListener('dragover', (e) => { + e.preventDefault(); + const dragging = inputs.querySelector('.note-cl-row.dragging'); + if (!dragging) return; + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')]; + const after = rows.find(r => { + const box = r.getBoundingClientRect(); + return e.clientY < box.top + box.height / 2; + }); + if (after) { + after.classList.add('drop-before'); + inputs.insertBefore(dragging, after); + } else if (rows.length) { + rows[rows.length - 1].classList.add('drop-after'); + inputs.insertBefore(dragging, container.querySelector('.note-cl-add')); + } + }); + inputs.addEventListener('dragleave', (e) => { + if (!inputs.contains(e.relatedTarget)) { + inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); + } + }); + } + } + + function _collectItems(form) { + const items = []; + form.querySelectorAll('.note-cl-row').forEach(row => { + const text = row.querySelector('.note-cl-text')?.value?.trim(); + if (text) items.push({ + id: row.dataset.itemId || _uid(), + text, + done: row.classList.contains('done'), + indent: parseInt(row.dataset.indent || '0'), + }); + }); + return items; + } + + return { _buildGoalHtml, _wireGoalForm, _buildChecklistHtml, _wireRow, _wireChecklist, _collectItems }; +} diff --git a/static/js/features/notes/ui/checklistReorder.js b/static/js/features/notes/ui/checklistReorder.js new file mode 100644 index 000000000..c5c7e1101 --- /dev/null +++ b/static/js/features/notes/ui/checklistReorder.js @@ -0,0 +1,111 @@ +/** Mobile checklist grip dragging and document listener ownership. */ +export function createChecklistReorder({ + lifecycle +}) { + let _clDrag = null; + + let _clDocBound = false; + let _clOwner = null; + + function _wireChecklistTouchReorder(form) { + const container = form.querySelector('.note-checklist-inputs'); + if (!container) return; + container.querySelectorAll('.note-cl-grip').forEach(grip => { + if (grip.dataset.touchBound === '1') return; + grip.dataset.touchBound = '1'; + grip.addEventListener('touchstart', (e) => { + if (e.touches.length !== 1) return; + e.preventDefault(); + const row = grip.closest('.note-cl-row'); + if (!row) return; + _beginChecklistGrab(row, container, e.touches[0]); + }, { passive: false }); + }); + if (!_clDocBound || _clOwner !== form) { + if (_clOwner) lifecycle.release(_clOwner); + _clOwner = form; + lifecycle.listen(form, document, 'touchmove', _onClTouchMove, { passive: false }); + lifecycle.listen(form, document, 'touchend', _onClTouchEnd, { passive: true }); + lifecycle.listen(form, document, 'touchcancel', _onClTouchEnd, { passive: true }); + _clDocBound = true; + } + } + + function _beginChecklistGrab(row, container, touch) { + if (_clDrag) return; + const rect = row.getBoundingClientRect(); + const placeholder = document.createElement('div'); + placeholder.className = 'note-cl-row-placeholder'; + placeholder.style.height = rect.height + 'px'; + container.insertBefore(placeholder, row); + + row.classList.add('note-cl-row-dragging'); + row.style.position = 'fixed'; + row.style.left = rect.left + 'px'; + row.style.top = rect.top + 'px'; + row.style.width = rect.width + 'px'; + row.style.zIndex = '10002'; + row.style.pointerEvents = 'none'; + + _clDrag = { + row, placeholder, container, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onClTouchMove(e) { + if (!_clDrag) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const t = e.touches[0]; + const { row, placeholder, container } = _clDrag; + row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px'; + row.style.top = (t.clientY - _clDrag.grabOffsetY) + 'px'; + + const under = document.elementFromPoint(t.clientX, t.clientY); + const target = under && under.closest + ? under.closest('.note-cl-row:not(.note-cl-row-dragging)') + : null; + if (!target || target === row) return; + if (target.parentNode !== container) return; + + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (t.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + container.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + container.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onClTouchEnd() { + if (!_clDrag) return; + const { row, placeholder } = _clDrag; + _clDrag = null; + const phRect = placeholder.getBoundingClientRect(); + row.style.transition = 'left 0.18s ease, top 0.18s ease'; + row.style.left = phRect.left + 'px'; + row.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(row, placeholder); + placeholder.remove(); + row.classList.remove('note-cl-row-dragging'); + row.style.cssText = ''; + // Order is persisted as part of the form's normal save (rows are + // re-serialized in DOM order via _collectItems). + }, 200); + } + + function dispose() { + if (_clDrag) _onClTouchEnd(); + _clDocBound = false; + _clOwner = null; + } + return { dispose, _wireChecklistTouchReorder, _beginChecklistGrab, _onClTouchMove, _onClTouchEnd }; +} diff --git a/static/js/features/notes/ui/clipboard.js b/static/js/features/notes/ui/clipboard.js new file mode 100644 index 000000000..1dab84258 --- /dev/null +++ b/static/js/features/notes/ui/clipboard.js @@ -0,0 +1,82 @@ +/** Note clipboard feedback and the Copy/Agent corner menu. */ +import { _serializeNoteForCopy } from '../domain/noteRules.js'; +export function createClipboard({ + _agentSolveNote, + bindMenuDismiss, + dismissOrRemove, + state, + topPortalZ, + uiModule +}) { + function _openNoteCornerMenu(btn) { + document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove); + const id = btn.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note) return; + const menu = document.createElement('div'); + menu.className = 'note-corner-menu-dropdown'; + menu.innerHTML = ` + + `; + document.body.appendChild(menu); + const r = btn.getBoundingClientRect(); + // Right-align to the ⋯ button, clamped to the viewport. + const mw = 168; + let left = Math.min(r.right - mw, window.innerWidth - mw - 8); + left = Math.max(8, left); + // Drop down by default; flip up if there isn't room below (the button + // sits at the card's bottom edge now). + const mh = menu.offsetHeight || 96; + const below = window.innerHeight - r.bottom; + const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); + menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`; + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); + menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); + } + + async function _copyNote(noteId, btnEl) { + const note = state.notes.find(n => n.id === noteId); + if (!note) return false; + const text = _serializeNoteForCopy(note); + if (!text) return false; + let ok = false; + try { + await navigator.clipboard.writeText(text); + ok = true; + } catch { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { ok = document.execCommand('copy'); } catch { ok = false; } + ta.remove(); + } + if (ok) { + if (btnEl && !btnEl._copyFlashing) { + const original = btnEl.innerHTML; + btnEl._copyFlashing = true; + btnEl.innerHTML = ''; + btnEl.classList.add('copied'); + setTimeout(() => { + btnEl.innerHTML = original; + btnEl.classList.remove('copied'); + btnEl._copyFlashing = false; + }, 1200); + } + uiModule.showToast?.('Copied'); + } else { + uiModule.showError?.('Copy failed'); + } + return ok; + } + + return { _openNoteCornerMenu, _copyNote }; +} diff --git a/static/js/features/notes/ui/colors.js b/static/js/features/notes/ui/colors.js new file mode 100644 index 000000000..0a9503cfb --- /dev/null +++ b/static/js/features/notes/ui/colors.js @@ -0,0 +1,111 @@ +/** Note colors, custom background selection and form color controls. */ +export function createColors({ + api +}) { + const COLORS = [ + { name: 'none', value: '' }, + { name: 'red', value: 'red' }, + { name: 'orange', value: 'orange' }, + { name: 'yellow', value: 'yellow' }, + { name: 'green', value: 'green' }, + { name: 'blue', value: 'blue' }, + { name: 'purple', value: 'purple' }, + { name: 'custom', value: 'custom' }, // sentinel : clicking opens native color picker + ]; + + const COLOR_HEX = { + '': 'var(--border)', + // Pale/pastel palette : matches the calendar event color picker. + red: '#f0b5ba', + orange: '#e8ccb2', + yellow: '#f2dfbd', + green: '#cce0bc', + blue: '#b0d7f7', + purple: '#e2bcee', + }; + + const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; + + function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); } + + function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; } + + function _dotBg(value, noteColor) { + if (value === 'custom') { + const url = _bgImageUrl(noteColor); + return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT; + } + return COLOR_HEX[value]; + } + + function _dotIsActive(value, noteColor) { + if (value === 'custom') return _isBgImage(noteColor); + return value === (noteColor || ''); + } + + function _customColorStyle(c) { + if (!_isBgImage(c)) return ''; + const url = _bgImageUrl(c); + return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`; + } + + function _pickCustomBgImage() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; + document.body.appendChild(input); + let done = false; + const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return finish(null); + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + finish(api.uploadUrl(fileId)); + } catch { finish(null); } + }); + // Best-effort cleanup if user dismisses the dialog. + setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); + input.click(); + }); + } + + function _wireFormColors(form, setColor) { + // Color dots : apply to entire form immediately + const _applyFormColor = (newColor) => { + const currentColor = newColor || ''; + setColor(currentColor); + form.dataset.noteColor = currentColor; + const isBg = _isBgImage(currentColor); + COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); }); + if (currentColor && !isBg) form.classList.add('note-color-' + currentColor); + if (isBg) form.setAttribute('style', _customColorStyle(currentColor)); + else form.removeAttribute('style'); + form.querySelectorAll('.note-color-dot').forEach(d => { + d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor)); + d.style.background = _dotBg(d.dataset.color, currentColor); + }); + form.dispatchEvent(new Event('change', { bubbles: true })); + }; + form.querySelectorAll('.note-color-dot').forEach(dot => { + dot.addEventListener('click', () => { + if (dot.dataset.color === 'custom') { + _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); }); + return; + } + _applyFormColor(dot.dataset.color); + }); + }); + + + } + + return { COLORS, COLOR_HEX, _isBgImage, _bgImageUrl, _dotBg, _dotIsActive, _customColorStyle, _pickCustomBgImage, _wireFormColors }; +} diff --git a/static/js/features/notes/ui/draftEditor.js b/static/js/features/notes/ui/draftEditor.js new file mode 100644 index 000000000..09df33b79 --- /dev/null +++ b/static/js/features/notes/ui/draftEditor.js @@ -0,0 +1,67 @@ +/** Collect editor drafts, restore them and wire the autosave workflow. */ +export function createDraftEditor({ + bindDraft, + _collectItems, + _loadDraft +}) { + function _collectFormDraft(form) { + if (!form) return null; + const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || form.dataset.noteType || 'note'; + const d = { + _ts: Date.now(), + note_type: type, + color: form.dataset.noteColor || '', + title: form.querySelector('.note-form-title')?.value || '', + label: form.querySelector('.note-form-label')?.value || '', + due_date: form.querySelector('.note-form-due')?.value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + }; + if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || ''; + else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); } + else d.items = _collectItems(form); + return d; + } + + function _isDraftEmpty(d) { + if (!d) return true; + if ((d.title || '').trim()) return false; + if ((d.content || '').trim()) return false; + if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false; + return true; + } + + function _wireDraftAutosave(form, id) { + const view = { + get failed() { return form._autosaveFailed; }, + set failed(value) { form._autosaveFailed = value; }, + }; + const draft = bindDraft({ collect: () => _collectFormDraft(form), id, view }); + form._flushDraft = draft.flush; + form._stopDraftAutosave = draft.stop; + form.addEventListener('input', draft.schedule); + form.addEventListener('change', draft.schedule); + form.addEventListener('click', event => { + if (event.target.closest('.note-cl-dot, .note-cl-rm')) draft.schedule(); + }); + } + + function _commitOpenInPlaceEditor() { + const form = document.querySelector('#notes-pane .note-form'); + if (!form) return; + const d = _collectFormDraft(form); + if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; } + form.querySelector('.note-form-save')?.click(); + } + + function _applyDraftToNote(note, id) { + const d = _loadDraft(id); + if (!d) return { note, restored: false }; + const merged = { ...(note || {}) }; + ['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => { + if (d[k] !== undefined) merged[k] = d[k]; + }); + return { note: merged, restored: true }; + } + + return { _collectFormDraft, _isDraftEmpty, _wireDraftAutosave, _commitOpenInPlaceEditor, _applyDraftToNote }; +} diff --git a/static/js/features/notes/ui/drawing.js b/static/js/features/notes/ui/drawing.js new file mode 100644 index 000000000..da33c9236 --- /dev/null +++ b/static/js/features/notes/ui/drawing.js @@ -0,0 +1,442 @@ +/** Canvas drawing tools, local drawing undo and PNG uploads. */ +export function createDrawing({ + lifecycle, + _safeImgSrc, + api, + attachColorPicker +}) { + function _buildDrawHtml() { + return ` +
+ +
+ + +
+ + +
+ + + + +
+
+ `; + } + + function _wireCanvas(container, initialImageUrl) { + const canvas = container.querySelector('.note-form-canvas'); + if (!canvas) return; + const resolveThemeColor = (expression, fallback) => { + const probe = document.createElement('span'); + probe.style.color = expression; + probe.style.position = 'fixed'; + probe.style.visibility = 'hidden'; + document.body.appendChild(probe); + const resolved = getComputedStyle(probe).color || fallback; + probe.remove(); + return resolved; + }; + const colorToHex = (color, fallback) => { + try { + const probe = document.createElement('canvas'); + probe.width = probe.height = 1; + const probeCtx = probe.getContext('2d'); + probeCtx.fillStyle = color; + probeCtx.fillRect(0, 0, 1, 1); + const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`; + } catch { return fallback; } + }; + const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34'); + const surfaceColorHex = colorToHex(surfaceColor, '#282c34'); + const highlightColor = colorToHex( + resolveThemeColor('var(--accent, var(--red))', '#e06c75'), + '#e06c75', + ); + const colorInput = container.querySelector('.note-form-draw-color'); + if (colorInput) colorInput.value = highlightColor; + // Bump the backing-store resolution for retina displays so strokes stay + // crisp. Set only style.width : leaving style.height to auto so the canvas + // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions + // are pinned, max-width:100% shrinks the width only, leaving rasterized + // glyphs visibly stretched relative to their on-screen input. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const cssW = canvas.width; + const cssH = canvas.height; + // Fill the container up to the logical width (don't pin a hard 600px, + // which on a narrow phone forces the card wider than the viewport and + // pushes the drawing outside the note). _pos() scales pointer coords by + // the actual displayed width, so accuracy is preserved at any size. + canvas.style.width = '100%'; + canvas.style.maxWidth = cssW + 'px'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = cssW + ' / ' + cssH; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // Load prior drawing as starting point so consecutive edits compose. + const safeInitialImageUrl = _safeImgSrc(initialImageUrl); + if (safeInitialImageUrl) { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} }; + img.src = safeInitialImageUrl; + // Float an X over the canvas so the user can blank it out and go back to + // a clean draw surface. Removes itself once clicked. + const wrap = container.querySelector('.note-form-draw-wrap'); + if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) { + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'note-form-draw-bg-rm'; + rm.title = 'Clear photo (regular draw)'; + rm.innerHTML = '×'; + rm.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + ctx.fillStyle = surfaceColor; + ctx.fillRect(0, 0, cssW, cssH); + rm.remove(); + }); + wrap.appendChild(rm); + } + } + + // Swap the native browser color dialog for the in-house HSV picker + // (same one used by Themes + the gallery editor). Existing `input` event + // listeners + .value reads keep working : see colorPicker.js. + if (colorInput) attachColorPicker(colorInput); + const sizeInput = container.querySelector('.note-form-draw-size'); + const beSeg = container.querySelector('.note-form-draw-be'); + const brushBtn = container.querySelector('.note-form-draw-brush'); + const eraserBtn = container.querySelector('.note-form-draw-eraser'); + const textBtn = container.querySelector('.note-form-draw-text'); + const lineBtn = container.querySelector('.note-form-draw-line'); + const circleBtn = container.querySelector('.note-form-draw-circle'); + const undoBtn = container.querySelector('.note-form-draw-undo'); + // Single source of truth for what clicks/drags do. Other booleans are + // derived from this so we never end up with conflicting "eraser AND text" + // states (the bug that made T appear broken after using the eraser). + // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle' + let mode = 'pen'; + let drawing = false; + let last = null; + // The text tool has three preset sizes (CSS px font-size). + const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 }; + // Line / circle stroke widths in logical pixels : three crisp options. + const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 }; + // Snapshot taken at the start of a shape drag so the preview can repaint + // cleanly on each move without accumulating intermediate strokes. + let _shapeSnapshot = null; + + // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each + // operation : stroke, text commit, or future operations : and pop+restore + // on Undo. Cap to 30 to keep memory bounded. + const _undoStack = []; + const UNDO_LIMIT = 30; + const _syncUndoButton = () => { + const available = _undoStack.length > 0; + undoBtn?.classList.toggle('has-undo', available); + undoBtn?.toggleAttribute('disabled', !available); + undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true'); + }; + const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true })); + const _snapshot = () => { + try { + const w = canvas.width, h = canvas.height; + _undoStack.push(ctx.getImageData(0, 0, w, h)); + if (_undoStack.length > UNDO_LIMIT) _undoStack.shift(); + _syncUndoButton(); + } catch {} + }; + const _undo = () => { + const prev = _undoStack.pop(); + if (!prev) { _syncUndoButton(); return; } + // Restore against the raw backing store: temporarily reset the active + // ctx scale, paint the snapshot 1:1, then reapply our standard transform. + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(prev, 0, 0); + ctx.restore(); + _markCanvasDirty(); + _syncUndoButton(); + }; + + const _pos = (e) => { + // CSS can shrink the canvas (max-width:100%) without changing its logical + // size, so compute the displayed-to-logical scale per axis. Pointer coords + // are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH). + const r = canvas.getBoundingClientRect(); + const sx = cssW / r.width; + const sy = cssH / r.height; + const t = e.touches ? e.touches[0] : e; + return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; + }; + const _begin = (e) => { + if (mode.startsWith('text-')) { + // Stop the event so the browser doesn't synthesize a follow-up click + // that would blur the input we're about to create. + e.preventDefault?.(); + e.stopPropagation?.(); + _openTextInput(e); + return; + } + _snapshot(); + last = _pos(e); + drawing = true; + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Capture the backing pixels so the preview can replay each move from + // the same starting state (otherwise live shapes accumulate). + try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {} + return; + } + ctx.beginPath(); + ctx.moveTo(last.x, last.y); + }; + + // Drop an HTML input at the click position so the user can type, then + // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form + // annotations work in our doc editor. + let _activeTextInput = null; + const _openTextInput = (e) => { + // Commit any prior pending input before starting a new one : otherwise + // the first click leaves an orphaned input the user thinks "didn't work". + if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} } + const r = canvas.getBoundingClientRect(); + const t = e.touches ? e.touches[0] : e; + // Position is anchored to the wrap, not the canvas; since the canvas is + // the first child of the wrap and the wrap has no padding, they share an + // origin, so we offset from the canvas rect directly. + const px = t.clientX - r.left; + const py = t.clientY - r.top; + const logical = _pos(e); + // Size is decided by which T variant is active (S/M/L), not the stroke + // slider : those are independent dials. + const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm'; + const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m; + const wrap = container.querySelector('.note-form-draw-wrap'); + if (!wrap) return; + if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'note-form-draw-textinput'; + input.placeholder = 'type then Enter'; + const color = colorInput?.value || '#222'; + const maxW = Math.max(120, Math.floor(r.width - px - 4)); + input.style.cssText = [ + 'position:absolute', + `left:${px}px`, + `top:${Math.max(0, py - sizeCss * 0.7)}px`, + `font:${sizeCss}px Arial, sans-serif`, + `color:${color}`, + 'background:#ffffff', + 'border:2px solid var(--accent)', + 'border-radius:4px', + 'outline:none', + 'padding:2px 6px', + 'min-width:120px', + `max-width:${maxW}px`, + 'z-index:1000', + 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', + 'pointer-events:auto', + ].join(';'); + wrap.appendChild(input); + _activeTextInput = input; + // Focus synchronously so the call still counts as inside the user gesture + // on iOS / Android, then re-focus on the next frame in case a competing + // synthetic event (touch → click) moved focus away. + input.focus(); + requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); }); + let committed = false; + const commit = () => { + if (committed) return; + committed = true; + const text = input.value; + if (_activeTextInput === input) _activeTextInput = null; + input.remove(); + if (!text) return; + // Snapshot BEFORE rasterizing so Undo removes the text in one step. + _snapshot(); + ctx.save(); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = color; + // Canvas now scales uniformly (style.height: auto), so a single ratio + // suffices for picking the logical font size that matches what the + // user just saw in the HTML input. + const sx = cssW / r.width; + const logicalSize = sizeCss * sx; + ctx.font = `${logicalSize}px sans-serif`; + ctx.textBaseline = 'top'; + ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7); + ctx.restore(); + _markCanvasDirty(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } + else if (ev.key === 'Escape') { input.value = ''; input.blur(); } + }); + }; + const _move = (e) => { + if (!drawing) return; + e.preventDefault?.(); + const p = _pos(e); + if (mode.startsWith('line-') || mode.startsWith('circle-')) { + // Restore the pre-shape bitmap, then redraw the preview shape from + // anchor → current pointer. + if (_shapeSnapshot) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.putImageData(_shapeSnapshot, 0, 0); + ctx.restore(); + } + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = colorInput?.value || '#222'; + const sizeKey = mode.slice(-1); + ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m; + ctx.beginPath(); + if (mode.startsWith('line-')) { + ctx.moveTo(last.x, last.y); + ctx.lineTo(p.x, p.y); + } else { + const dx = p.x - last.x; + const dy = p.y - last.y; + const radius = Math.hypot(dx, dy); + ctx.arc(last.x, last.y, radius, 0, Math.PI * 2); + } + ctx.stroke(); + return; + } + const erasing = mode === 'eraser'; + ctx.globalCompositeOperation = 'source-over'; + ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor); + ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + last = p; + }; + const _end = () => { + if (drawing) _markCanvasDirty(); + drawing = false; last = null; _shapeSnapshot = null; + }; + + canvas.addEventListener('mousedown', _begin); + canvas.addEventListener('mousemove', _move); + lifecycle.listen(canvas, window, 'mouseup', _end); + // Non-passive so text mode can preventDefault : otherwise the synthetic + // mousedown/click that follows a touch can blur the freshly-created text + // input on iOS Safari, making T look like a no-op. + canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false }); + canvas.addEventListener('touchmove', _move, { passive: false }); + canvas.addEventListener('touchend', _end); + canvas.addEventListener('touchcancel', _end); + + // Single mode setter : keeps the toolbar, swatch, and cursor in sync, and + // makes sure exiting the eraser restores the user's chosen color whether + // they exit by clicking another tool or by toggling eraser off. + let _preEraseColor = null; + const _setMode = (next) => { + const wasEraser = mode === 'eraser'; + mode = next; + const isEraser = next === 'eraser'; + const isPen = next === 'pen'; + const isText = next.startsWith('text-'); + // Swatch: theme surface while erasing, restored when leaving that mode. + if (isEraser && !wasEraser && colorInput) { + _preEraseColor = colorInput.value; + colorInput.value = surfaceColorHex; + } else if (!isEraser && wasEraser && colorInput && _preEraseColor) { + colorInput.value = _preEraseColor; + _preEraseColor = null; + } + // Brush/Eraser segmented pill : slides to the active side. When a non- + // pen/eraser tool (T / line / circle) is active, neither half is + // highlighted, but the pill still indicates which side the user was on + // last so they can return with one click. + const isLine = next.startsWith('line-'); + const isCircle = next.startsWith('circle-'); + beSeg?.classList.toggle('is-eraser', isEraser); + brushBtn?.classList.toggle('active', isPen); + eraserBtn?.classList.toggle('active', isEraser); + textBtn?.classList.toggle('active', isText); + lineBtn?.classList.toggle('active', isLine); + circleBtn?.classList.toggle('active', isCircle); + // Per-button size badges (S/M/L), driven off the mode suffix. + const tBadge = textBtn?.querySelector('.note-form-draw-text-badge'); + if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : ''; + const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge'); + if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : ''; + const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge'); + if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : ''; + // Reflect the chosen size in the icon itself : thicker line/circle stroke + // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`. + const _sz = next.slice(-1); + [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l')); + if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz); + if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz); + if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz); + canvas.style.cursor = isText ? 'text' : 'crosshair'; + }; + brushBtn?.addEventListener('click', () => _setMode('pen')); + eraserBtn?.addEventListener('click', () => _setMode('eraser')); + // T / Line / Circle: each cycles its own three sizes, then back to pen. + const _cycle = (prefix) => { + const seq = ['s', 'm', 'l']; + if (!mode.startsWith(prefix)) return prefix + 's'; + const cur = mode.slice(-1); + const next = seq[seq.indexOf(cur) + 1]; + return next ? prefix + next : 'pen'; + }; + textBtn?.addEventListener('click', () => _setMode(_cycle('text-'))); + lineBtn?.addEventListener('click', () => _setMode(_cycle('line-'))); + circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-'))); + undoBtn?.addEventListener('click', () => _undo()); + _syncUndoButton(); + + // Stash so the save handler can read it later without re-resolving DOM. + canvas._cssW = cssW; + canvas._cssH = cssH; + return canvas; + } + + async function _uploadCanvasAsPng(canvas, galleryId = null) { + if (!canvas) return null; + const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); + if (!blob) return null; + const fd = new FormData(); + fd.append('files', blob, 'drawing.png'); + if (galleryId) fd.append('gallery_id', galleryId); + try { + const res = await api.upload(fd); + const data = await res.json(); + const id = data.files?.[0]?.id; + return id ? { + url: api.uploadUrl(id), + galleryId: data.files?.[0]?.gallery_id || null, + } : null; + } catch { return null; } + } + + return { _buildDrawHtml, _wireCanvas, _uploadCanvasAsPng }; +} diff --git a/static/js/features/notes/ui/editor.js b/static/js/features/notes/ui/editor.js new file mode 100644 index 000000000..f5bdc7c9f --- /dev/null +++ b/static/js/features/notes/ui/editor.js @@ -0,0 +1,518 @@ +/** Build and wire note, checklist, goal and drawing editor forms. */ +export function createEditor({ + deleteFromEditor, + submitNote, + COLORS, + _applyMasonry, + _archiveNoteById, + _buildChecklistHtml, + _buildDrawHtml, + _buildGoalHtml, + _collectItems, + _customColorStyle, + _dotBg, + _dotIsActive, + _esc, + _isBgImage, + _renderNotes, + _safeImgSrc, + _uid, + _uploadCanvasAsPng, + _wireCanvas, + _wireChecklist, + _wireDraftAutosave, + _wireFormColors, + _wireGoalForm, + _wireReminderPicker, + api, + app, + state, + uiModule +}) { + function _buildForm(note = null) { + const isEdit = note && note.id; + const type = note?.note_type === 'checklist' ? 'todo' : (note?.note_type || 'note'); + const color = note?.color || ''; + const items = note?.items || [{ id: _uid(), text: '', done: false }]; + + const form = document.createElement('div'); + form.className = 'note-form'; + form.dataset.noteType = type; + form.dataset.noteColor = color || ''; + if (color && !_isBgImage(color)) form.classList.add('note-color-' + color); + if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color)); + let currentImageUrl = _safeImgSrc(note?.image_url || ''); + form.innerHTML = ` +
+ + + + +
+ ${currentImageUrl && type !== 'draw' ? `
` : ''} +
+ ${type === 'note' + ? `` + : type === 'draw' + ? _buildDrawHtml() + : type === 'goal' + ? _buildGoalHtml(note, items) + : _buildChecklistHtml(items)} +
+
+
+
+ + + +
+ + +
+ ${COLORS.map(c => ``).join('')} +
+ +
+ ${isEdit ? ` + + + ` : ''} + + + +
+
+ `; + + let currentType = type; + let currentColor = color; + // Stash original-form values so round-trips (Note→Todo→Note) restore the + // user's hand-formatted text instead of a join of generated items. Same the + // other way: if you started in todo, switch to note, switch back, items + // come back unchanged. + let _stashedNoteText = (type === 'note') ? (note?.content || '') : null; + let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null; + // Goal mode kept its own pair of stashes (description + steps) so a + // Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in + // the type picker was later removed, so the only entry point now is + // *editing* an existing goal-typed note : but the switch handler still + // accepts Goal→Todo/Note transitions (downgrading legacy goals), so + // these stashes still earn their keep. + let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null; + let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null; + + // Drawing also stashes the saved image URL so it survives Note↔Draw flips. + let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null; + const _refreshFormLayout = () => { + const body = form.closest('.notes-pane-body'); + if (!body) return; + _applyMasonry(body); + requestAnimationFrame(() => { + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + }); + }; + + // Type segmented control : Note | Todo | Draw + form.querySelectorAll('.note-form-type-pill').forEach(pill => { + pill.addEventListener('click', () => { + const newType = pill.dataset.type; + if (newType === currentType) return; + const bodyEl = form.querySelector('.note-form-body'); + // Stash whatever the user has in the current mode before swapping it + // out, so a subsequent flip back restores their work. + if (currentType === 'note') { + _stashedNoteText = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'todo') { + _stashedTodoItems = _collectItems(form); + } else if (currentType === 'goal') { + _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || ''; + _stashedGoalItems = _collectItems(form); + } else if (currentType === 'draw') { + const c = form.querySelector('.note-form-canvas'); + if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} } + } + // Render the new mode's body and re-wire its inputs. + if (newType === 'todo') { + let nextItems; + if (_stashedTodoItems && _stashedTodoItems.length) { + nextItems = _stashedTodoItems; + } else if (_stashedGoalItems && _stashedGoalItems.length) { + // Going Goal→Todo keeps the AI-generated steps as a plain checklist. + nextItems = _stashedGoalItems; + } else if (_stashedNoteText) { + const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean); + nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }]; + } else { + nextItems = [{ id: _uid(), text: '', done: false }]; + } + bodyEl.innerHTML = _buildChecklistHtml(nextItems); + _wireChecklist(bodyEl); + } else if (newType === 'draw') { + bodyEl.innerHTML = _buildDrawHtml(); + // If the user just attached a photo (via the photo button) and then + // toggled to Draw, paint that photo onto the canvas so they can draw + // on top of it. _stashedDrawUrl wins if they were drawing earlier in + // the same edit session. + _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null); + } else { + const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '') + ? _stashedNoteText + : (_stashedGoalDesc && _stashedGoalDesc) + || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n'); + bodyEl.innerHTML = ``; + _wireHashtag(bodyEl.querySelector('.note-form-content')); + } + const focusEl = newType === 'note' + ? bodyEl.querySelector('.note-form-content') + : newType === 'todo' + ? bodyEl.querySelector('.note-cl-text') + : null; + if (focusEl) { + requestAnimationFrame(() => { + focusEl.focus({ preventScroll: true }); + try { + const end = focusEl.value.length; + focusEl.setSelectionRange(end, end); + } catch {} + }); + } + currentType = newType; + const seg = form.querySelector('.note-form-type-seg'); + seg?.classList.toggle('is-todo', newType === 'todo'); + seg?.classList.toggle('is-draw', newType === 'draw'); + form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType)); + // The standalone image preview (form-image-wrap) and the canvas would + // otherwise both show the same image_url when editing a drawn note. + // Hide it in draw mode, restore it when leaving draw mode. + const imgWrap = form.querySelector('.note-form-image-wrap'); + if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : ''; + // The background-color dots set the note card's bg : they make no sense + // for a drawn note (the canvas image IS the card content), so hide them. + const bgPicker = form.querySelector('.note-color-picker'); + if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : ''; + if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64'; + } + _refreshFormLayout(); + }); + }); + + // Slide a finger across the Note/Todo/Draw control to switch modes (mobile). + // On touchmove we find the pill under the finger and click it : reusing the + // pill click handler above, so the body re-renders + content stashing all + // work. Only fires when crossing into a *different* pill. + const _typeSeg = form.querySelector('.note-form-type-seg'); + if (_typeSeg) { + let _sliding = false; + const _activateAt = (x, y) => { + const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill'); + if (pill && !pill.classList.contains('active')) pill.click(); + }; + _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true }); + _typeSeg.addEventListener('touchmove', (e) => { + if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY); + }, { passive: true }); + _typeSeg.addEventListener('touchend', () => { _sliding = false; }); + _typeSeg.addEventListener('touchcancel', () => { _sliding = false; }); + } + + _wireFormColors(form, value => { currentColor = value; }); + + if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body')); + if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body')); + if (currentType === 'draw') { + _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null); + // Same hides we apply on type-switch : keep them consistent on initial open. + const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none'; + const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none'; + } + + // Auto-grow the plain-note textarea so editing longer notes is + // comfortable : it expands with the content (up to a cap) instead of + // staying a cramped 4-row box. The user can still drag-resize too. + const _contentTa = form.querySelector('.note-form-content'); + if (_contentTa) { + const _grow = () => { + _contentTa.style.height = 'auto'; + // Inline form: cap at ~50vh so a huge note doesn't push the action + // buttons off-screen. Fullscreen mobile overlay: the body scrolls and + // there are no inline buttons crowding, so allow nearly the full height + // : capping at 50vh there clipped longer notes ("part disappears"). + const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay'); + const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5)); + _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px'; + }; + _contentTa.addEventListener('input', _grow); + // Grow on open so existing content is fully visible. Run again after the + // fullscreen overlay's open animation settles : measuring mid-animation + // (the overlay starts scaled/transitioning) can under-size the box. + setTimeout(_grow, 0); + setTimeout(_grow, 360); + } + + _wireReminderPicker(form); + + // Photo upload + const photoBtn = form.querySelector('.note-form-photo-btn'); + const photoInput = form.querySelector('.note-form-photo-input'); + if (photoBtn && photoInput) { + photoBtn.addEventListener('click', () => photoInput.click()); + photoInput.addEventListener('change', async () => { + const file = photoInput.files?.[0]; + if (!file) return; + const fd = new FormData(); + fd.append('files', file); + try { + const res = await api.upload(fd); + const data = await res.json(); + const fileId = data.files?.[0]?.id; + if (!fileId) throw new Error('Upload failed'); + currentImageUrl = api.uploadUrl(fileId); + // Only ever keep the latest attached photo : drop any existing wrap + // before inserting a fresh one. Picking a second photo replaces the + // first instead of stacking. + form.querySelector('.note-form-image-wrap')?.remove(); + const wrap = document.createElement('div'); + wrap.className = 'note-form-image-wrap'; + wrap.innerHTML = ``; + // Insert AFTER the whole header (a flex-row), not after the + // title input itself : otherwise the image lands as a sibling + // of the title inside the header and flex puts them side-by-side. + form.querySelector('.note-form-header').after(wrap); + wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; }); + wrap.querySelector('img').src = currentImageUrl; + } catch (err) { uiModule.showError('Image upload failed'); } + photoInput.value = ''; + }); + } + // Existing image remove + form.querySelector('.note-form-image-rm')?.addEventListener('click', () => { + form.querySelector('.note-form-image-wrap')?.remove(); + currentImageUrl = ''; + }); + + // Title Enter -> focus body (textarea or first checklist item) + form.querySelector('.note-form-title').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); return; } + const firstItem = form.querySelector('.note-cl-text'); + if (firstItem) firstItem.focus(); + } + }); + + // Hashtag → label: typing "#foo " in title/content appends "foo" to the + // space-separated tag list. Repeats are deduplicated, so #foo #foo only + // keeps one. Tags already present in the label field are left alone. + const labelInput = form.querySelector('.note-form-label'); + const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; + function _wireHashtag(el) { + if (!el || !labelInput) return; + el.addEventListener('input', () => { + const m = _hashtagRe.exec(el.value); + if (!m) return; + const tag = m[2]; + // Dedup against the stripped form : labelInput may already hold `#tag` + // (after Enter normalised), so includes(tag) on the raw split would + // miss the duplicate and append a bare `tag` next to `#tag`. + const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); + const stripped = existing.map(t => t.replace(/^#+/, '')); + if (!stripped.includes(tag)) { + existing.push('#' + tag); + labelInput.value = existing.join(' '); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + } + const cut = el.value.length - m[0].length + m[1].length; + el.value = el.value.slice(0, cut); + }); + } + _wireHashtag(form.querySelector('.note-form-title')); + _wireHashtag(form.querySelector('.note-form-content')); + // Pressing Enter in the tag field commits the current word as its own tag + // and parks the cursor after a trailing space, so the next word becomes a + // separate tag rather than overwriting the previous one. + labelInput?.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return; + e.preventDefault(); + e.stopPropagation(); + // Strip any leading #s the user typed, dedupe, then re-prepend exactly + // one #. So typing "foo" or "#foo" both end up as "#foo " in the input; + // the save handler keeps stripping #s before storing so DB stays clean. + const tags = [...new Set(labelInput.value.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (!tags.length) return; + labelInput.value = tags.map(t => '#' + t).join(' ') + ' '; + labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length); + labelInput.classList.add('flash-once'); + setTimeout(() => labelInput.classList.remove('flash-once'), 600); + }); + + // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save + // Escape -> cancel edit + form.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + form.querySelector('.note-form-save')?.click(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + form.querySelector('.note-form-cancel')?.click(); + } + }); + + // Save. Prevent the button from stealing focus on press: on mobile, the + // first tap would otherwise just blur the focused textarea/input (closing + // the keyboard and shifting layout), so the tap never reached the button and + // you had to tap "Done" twice. mousedown preventDefault keeps focus put while + // still letting the click fire. + const _saveBtnEl0 = form.querySelector('.note-form-save'); + _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault()); + _saveBtnEl0.addEventListener('click', async () => { + // Guard against spam-clicks: the drawing save AWAITS a canvas upload before + // the optimistic re-render removes the form, so without this a slow upload + // let repeated clicks create duplicate notes. + const _saveBtn = form.querySelector('.note-form-save'); + if (_saveBtn._saving) return; + // Mobile: when an existing note is opened and closed without edits, the + // Update (✓) button morphs into Archive (set up below). Route the click + // directly through the archive flow. Using a DOM .click() proxy here was + // fragile on mobile because the real archive button can move from the + // footer to the fullscreen header. + if (_saveBtn.classList.contains('archive-mode')) { + _archiveNoteById(note?.id); + return; + } + _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; + try { + form._flushDraft?.(); + await form._stopDraftAutosave?.(); + form._flushDraft?.(); + const title = form.querySelector('.note-form-title').value.trim(); + // Normalize tag input: split on whitespace, strip leading #s, dedupe, + // re-join with single spaces. Empty → null. + const _rawLabel = form.querySelector('.note-form-label')?.value || ''; + const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; + if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder'); + const labelVal = _tags.length ? _tags.join(' ') : null; + const payload = { + title, + note_type: currentType, + color: currentColor, + label: labelVal, + due_date: form.querySelector('.note-form-due').value || null, + repeat: form.querySelector('.note-form-repeat')?.value || 'none', + image_url: currentImageUrl || null, + }; + if (currentType === 'note') { + payload.content = form.querySelector('.note-form-content')?.value || ''; + } else if (currentType === 'draw') { + // Upload the canvas PNG before saving so image_url points to a + // persistent file. We block the save until upload completes : drawings + // can't be re-rendered later without the URL. + const canvas = form.querySelector('.note-form-canvas'); + const upload = await _uploadCanvasAsPng(canvas, note?.gallery_id || null); + if (!upload?.url) { uiModule.showError('Failed to save drawing'); return; } + payload.image_url = upload.url; + if (upload.galleryId) payload.gallery_id = upload.galleryId; + } else if (currentType === 'goal') { + // Legacy: existing goal-type notes still edit through this branch. + // No AI involvement : save as a normal note with description + items. + payload.content = form.querySelector('.note-form-goal-desc')?.value || ''; + payload.items = _collectItems(form); + } else { + payload.items = _collectItems(form); + } + if (isEdit) payload.id = note.id; + submitNote(payload, note, isEdit); + } finally { + // Re-enable on early returns / errors. On success the form is removed by + // the optimistic re-render, so re-enabling the detached button is a no-op. + _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = ''; + } + }); + + // Mobile-only: when editing an existing note, the Update (✓) button starts in + // archive-mode (visually + behaviorally) and flips to Update on the first + // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever + // touching a separate Archive button. + if (isEdit && window.innerWidth <= 768) { + const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); + const _isFinishedTodo = () => { + if (currentType !== 'todo') return false; + const rows = [...form.querySelectorAll('.note-cl-row')]; + const withText = rows.filter(row => (row.querySelector('.note-cl-text')?.value || '').trim()); + return withText.length > 0 && withText.every(row => row.classList.contains('done')); + }; + const _enterArchive = () => { + _saveBtnEl0.classList.add('archive-mode'); + const label = _isFinishedTodo() ? 'Finish' : 'Archive'; + if (_saveLabelEl) _saveLabelEl.textContent = label; + _saveBtnEl0.title = label; + }; + const _enterUpdate = () => { + if (!_saveBtnEl0.classList.contains('archive-mode')) return; + _saveBtnEl0.classList.remove('archive-mode'); + if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; + _saveBtnEl0.title = 'Update'; + }; + _enterArchive(); + form.addEventListener('input', _enterUpdate, true); + form.addEventListener('change', _enterUpdate, true); + // Checklist dots use a custom control and dispatch their update before + // this click bubbles. Re-evaluate afterward so a fully completed todo + // offers Finish (which archives/removes it from the active list). + form.addEventListener('click', (event) => { + if (!event.target.closest('.note-cl-dot')) return; + setTimeout(() => { + if (_isFinishedTodo()) _enterArchive(); + else _enterUpdate(); + }, 0); + }, true); + } + + // Cancel + form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); app.setEditingId(null); _renderNotes(); }); + + // Archive / Delete : edit-mode-only buttons, mirror the (now-hidden) card actions. + form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => { + if (!isEdit) return; + _archiveNoteById(note.id); + }); + form.querySelector('.note-form-delete-btn')?.addEventListener('click', () => { + if (!isEdit) return; + return deleteFromEditor(note.id); + }); + + // Autosave a draft to persistence on every change so unsaved edits + // survive connection loss / reload / accidental close. + _wireDraftAutosave(form, isEdit ? note.id : '__new__'); + + return form; + } + + return { _buildForm }; +} diff --git a/static/js/features/notes/ui/filters.js b/static/js/features/notes/ui/filters.js new file mode 100644 index 000000000..49142a260 --- /dev/null +++ b/static/js/features/notes/ui/filters.js @@ -0,0 +1,139 @@ +/** Label chips, reminder/goal filters and collapsed tag-strip rendering. */ +import { _visibleNoteTags, _nextGoalStep } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createFilters({ + NOTES_LABELS_COLLAPSED_KEY, + _clearPastReminders, + _esc, + _isPastReminder, + _renderNotes, + _userVisibleNotes, + app, + persistence, + state +}) { + function _renderLabels(root = document) { + const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar'); + if (!bar) return; + if (state.labelsCollapsed) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + const visibleNotes = _userVisibleNotes(); + const labels = new Set(); + for (const n of visibleNotes) for (const t of _visibleNoteTags(n)) labels.add(t); + const sortedLabels = [...labels].sort(); + // Count active reminders (not archived, has datetime due_date) + const reminderCount = visibleNotes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length; + const pastReminderCount = visibleNotes.filter(n => !n.archived && _isPastReminder(n)).length; + const defaultCount = visibleNotes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length; + // Active goals = non-archived goal notes. Today view lists pending steps + // from each, so we surface the count next to the chip. + const goalCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived).length; + const todayCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length; + const hasUsefulFilters = sortedLabels.length > 0 || reminderCount > 0 || goalCount > 0; + const hasActiveFilter = state.activeLabel !== null || state.activeFilter !== null; + if (!hasUsefulFilters && !hasActiveFilter) { + bar.innerHTML = ''; + bar.style.display = 'none'; + return; + } + bar.style.display = ''; + const allActive = state.activeLabel === null && state.activeFilter === null; + let html = ``; + html += ``; + if (todayCount > 0) { + const isOn = state.activeFilter === 'today'; + const icon = ''; + html += ``; + } + if (goalCount > 0) { + const isOn = state.activeFilter === 'goals'; + const icon = ''; + html += ``; + } + const isReminderOn = state.activeFilter === 'reminders'; + const isReminderOff = state.activeFilter === 'no-reminders'; + const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`; + const reminderIcon = isReminderOff + // bell-off icon + ? '' + : ''; + html += ``; + const showingReminders = state.activeFilter === 'reminders'; + if (showingReminders && pastReminderCount > 0) { + html += ``; + } + for (const lbl of sortedLabels) { + html += ``; + } + bar.innerHTML = html; + bar.querySelectorAll('.notes-label-chip').forEach(chip => { + chip.addEventListener('click', () => { + if (chip.dataset.action === 'all') { + app.setActiveLabel(null); + app.setActiveFilter(null); + } else if (chip.dataset.action === 'today') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'today') ? null : 'today'); + } else if (chip.dataset.action === 'goals') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'goals') ? null : 'goals'); + } else if (chip.dataset.action === 'default') { + app.setActiveLabel(null); + app.setActiveFilter((state.activeFilter === 'default') ? null : 'default'); + } else if (chip.dataset.action === 'reminders') { + app.setActiveLabel(null); + // Cycle: null → reminders → null → no-reminders → null → reminders → ... + if (state.activeFilter === null) { + app.setActiveFilter(state.reminderChipNext); + app.setReminderChipNext((state.reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders'); + } else { + app.setActiveFilter(null); + } + } else if (chip.dataset.action === 'clear-past-reminders') { + _clearPastReminders(); + return; + } else { + app.setActiveFilter(null); + app.setActiveLabel(chip.dataset.label || null); + } + _renderNotes(); + }); + }); + } + + function _renderLabelsInto(_body) { + if (!_body) return; + const pane = _body.closest('.notes-pane'); + const toggleHost = pane?.querySelector('.notes-search-bar') || _body; + let toggle = toggleHost.querySelector(':scope > .notes-label-toggle'); + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'notes-label-toggle cal-filter-toggle'; + toggle.addEventListener('click', () => { + app.setLabelsCollapsed(!state.labelsCollapsed); + try { persistence.setItem(NOTES_LABELS_COLLAPSED_KEY, state.labelsCollapsed ? '1' : '0'); } catch {} + _renderNotes(); + }); + const selectButton = toggleHost.querySelector('#notes-select-btn'); + toggleHost.insertBefore(toggle, selectButton || null); + } + toggle.textContent = state.labelsCollapsed ? '+ Tags' : '− Tags'; + toggle.title = state.labelsCollapsed ? 'Show tags' : 'Hide tags'; + toggle.setAttribute('aria-expanded', state.labelsCollapsed ? 'false' : 'true'); + + // The shared chip scroller wraps this row once it is mounted. + let bar = _body.querySelector('.notes-labels-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'notes-labels-bar'; + _body.appendChild(bar); + } + _renderLabels(_body); + } + + return { _renderLabels, _renderLabelsInto }; +} diff --git a/static/js/features/notes/ui/layout.js b/static/js/features/notes/ui/layout.js new file mode 100644 index 000000000..8935feff5 --- /dev/null +++ b/static/js/features/notes/ui/layout.js @@ -0,0 +1,123 @@ +/** Card reflow animation and the owned masonry resize observer. */ +export function createLayout({ isOpen = () => true } = {}) { + function _captureCardPositions() { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body) return null; + const positions = new Map(); + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + if (id) positions.set(id, card.getBoundingClientRect()); + }); + return positions; + } + + function _animateReflow(prevPositions) { + if (!prevPositions || !prevPositions.size) return; + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || !body.isConnected) return; + body.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const prev = prevPositions.get(id); + if (!prev) return; + const next = card.getBoundingClientRect(); + const dx = prev.left - next.left; + const dy = prev.top - next.top; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; + // Invert: jump back to old position + card.style.transition = 'none'; + card.style.transform = `translate(${dx}px, ${dy}px)`; + // Play: animate to 0 + requestAnimationFrame(() => { + card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)'; + card.style.transform = ''; + card.addEventListener('transitionend', () => { + card.style.transition = ''; + }, { once: true }); + }); + }); + } + + function _applyMasonry(body) { + if (!body || !body.isConnected) return; + const pane = body.closest('.notes-pane'); + const isGrid = pane?.classList.contains('notes-view-grid'); + const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches; + // Tear down any prior observer (defensive : _renderNotes wipes body.innerHTML). + if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; } + // A save that resolves after close re-renders the leaving pane; don't + // re-create the observer that panel teardown already disposed. + if (!isOpen()) return; + if (!isGrid) { + // Clear any leftover inline spans so list view lays out normally. + body.querySelectorAll('.note-card, .notes-label-toggle, .notes-labels-bar, .notes-quick-add, .note-form, .doclib-chip-scroll-frame:has(> .notes-labels-bar)').forEach(c => { c.style.gridRowEnd = ''; }); + return; + } + const ROW_PX = 4; + const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX)); + const recomputeFullRows = () => { + const quickAdd = body.querySelector('.notes-quick-add'); + const labelsToggle = body.querySelector('.notes-label-toggle'); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsToggle && getComputedStyle(labelsToggle).display !== 'none') { + labelsToggle.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsToggle.scrollHeight))}`; + } + if (labelsLayout && getComputedStyle(labelsLayout).display !== 'none') { + const shave = isMobileGrid ? 4 : 0; + labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`; + } + if (quickAdd) { + const shave = isMobileGrid ? 4 : 0; + quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`; + } + body.querySelectorAll('.note-form').forEach(form => { + form.style.gridColumn = '1 / -1'; + const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw'); + const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1; + const renderedHeight = form.getBoundingClientRect?.().height || 0; + const drawReserve = isDrawForm && isMobileGrid ? 12 : 12; + const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve; + form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`; + }); + }; + const recompute = (card) => { + // scrollHeight returns the natural content height : card.getBoundingClientRect() + // would return the grid cell height (collapsed to 4px until the span is set, + // which is the value we're trying to compute). + const h = card.scrollHeight + (isMobileGrid ? 6 : 8); + if (h <= 0) return; + card.style.gridRowEnd = `span ${spanForHeight(h)}`; + }; + recomputeFullRows(); + body.querySelectorAll('.note-card').forEach(recompute); + // Watch masonry participants : content can grow (image load, todo edits, + // quick-add/form expansion), and stale spans are what cause visual merging. + if ('ResizeObserver' in window) { + _masonryObserver = new ResizeObserver(entries => { + let fullRowsChanged = false; + for (const e of entries) { + if (e.target.classList.contains('note-card')) recompute(e.target); + else fullRowsChanged = true; + } + if (fullRowsChanged) recomputeFullRows(); + }); + body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c)); + body.querySelectorAll('.notes-label-toggle, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c)); + const labelsBar = body.querySelector('.notes-labels-bar'); + const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') + ? labelsBar.parentElement + : labelsBar; + if (labelsLayout) _masonryObserver.observe(labelsLayout); + } + } + + let _masonryObserver = null; + + function dispose() { + _masonryObserver?.disconnect(); + _masonryObserver = null; + } + return { dispose, _captureCardPositions, _animateReflow, _applyMasonry }; +} diff --git a/static/js/features/notes/ui/lifecycle.js b/static/js/features/notes/ui/lifecycle.js new file mode 100644 index 000000000..67a8da199 --- /dev/null +++ b/static/js/features/notes/ui/lifecycle.js @@ -0,0 +1,36 @@ +/** Notes-owned document/window listeners. Detached editor/menu owners release their resources. */ +export function createNotesLifecycle() { + const owners = new Map(); + let observer = null; + function release(owner) { + const entry = owners.get(owner); + if (!entry) return; + owners.delete(owner); + for (const remove of entry.removers) remove(); + if (!owners.size) { observer?.disconnect(); observer = null; } + } + function onDispose(owner, remove) { + if (!owner) throw new Error('Notes listener needs a lifecycle owner'); + let entry = owners.get(owner); + if (!entry) { + entry = { connected: owner.isConnected, removers: [] }; + owners.set(owner, entry); + } + entry.removers.push(remove); + if (!observer) { + observer = new MutationObserver(() => { + for (const [node, scope] of owners) { + if (node.isConnected) scope.connected = true; + else if (scope.connected) release(node); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + } + function listen(owner, target, event, listener, options) { + onDispose(owner, () => target.removeEventListener(event, listener, options)); + target.addEventListener(event, listener, options); + } + function dispose() { for (const owner of [...owners.keys()]) release(owner); } + return { listen, onDispose, release, dispose }; +} diff --git a/static/js/features/notes/ui/mobileEditor.js b/static/js/features/notes/ui/mobileEditor.js new file mode 100644 index 000000000..f751ba7aa --- /dev/null +++ b/static/js/features/notes/ui/mobileEditor.js @@ -0,0 +1,257 @@ +/** Mobile fullscreen editing, link reader mode and return to the panel. */ +export function createMobileEditor({ + _applyDraftToNote, + _buildForm, + _fetchNotes, + _linkify, + _renderNotes, + _uid, + _wireChecklistTouchReorder, + _wireRow, + app, + state, + uiModule +}) { + function _isNotesMobileMode() { + return ('ontouchstart' in window) && window.innerWidth <= 768; + } + + let _mobileFsOverlay = null; + + let _mobileFsNoteId = null; + + function _openMobileFullscreenEdit(id, fromCard) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + // Tear down any previous overlay (defensive). + _closeMobileFullscreenEdit({ save: false }); + _mobileFsNoteId = id; + app.setEditingId(id); + + const overlay = document.createElement('div'); + overlay.className = 'note-fullscreen-overlay'; + overlay.innerHTML = ` +
+ +
+
+
+ `; + const body = overlay.querySelector('.note-fullscreen-body'); + // Reuse the same edit form the in-place flow builds. Save buttons, + // checklist toggles, etc. all work as-is. Restore any unsaved draft. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + body.appendChild(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + document.body.appendChild(overlay); + _mobileFsOverlay = overlay; + + // Animate up from the tapped tile's position so the transition reads + // as a zoom rather than a hard cut. + if (fromCard) { + const r = fromCard.getBoundingClientRect(); + const vw = window.innerWidth, vh = window.innerHeight; + overlay.style.transformOrigin = + `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`; + } + overlay.classList.add('opening'); + requestAnimationFrame(() => overlay.classList.add('open')); + + // Wire the back button : saves whatever the form has and closes. + // mousedown preventDefault so it doesn't blur the input on first tap (which + // would eat the tap and require a second one). + const _backBtn = overlay.querySelector('.note-fullscreen-back'); + _backBtn.addEventListener('mousedown', (e) => e.preventDefault()); + _backBtn.addEventListener('click', () => { + _closeMobileFullscreenEdit({ save: true }); + }); + + // The form's built-in Cancel only resets the in-place edit state; in + // the overlay context it does nothing visible. Replace its handler so + // Cancel actually dismisses the overlay without saving. + const cancelBtn = form.querySelector('.note-form-cancel'); + if (cancelBtn) { + const fresh = cancelBtn.cloneNode(true); + cancelBtn.parentNode.replaceChild(fresh, cancelBtn); + fresh.addEventListener('mousedown', (e) => e.preventDefault()); + fresh.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + _closeMobileFullscreenEdit({ save: false }); + }); + } + // The built-in Save handler does the API call + refresh, but doesn't + // dismiss our overlay. Augment it (do NOT replace : the original is + // async and we'd lose the API call) to schedule a close once the + // save+render has had time to complete. + const saveBtn = form.querySelector('.note-form-save'); + if (saveBtn) { + saveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350); + }); + } + // Make the checklist row drag handle (⋮⋮) actually work on touch. + // The form's default uses HTML5 native draggable which never fires + // on iOS/Android. Wire touch-based reorder for any row inside the + // overlay's checklist. + _wireChecklistTouchReorder(form); + + // For each todo row whose text contains a URL, swap the bare + // for a linkified so URLs are tappable. Tapping anywhere that + // ISN'T a link flips back to the input for editing. + form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode); + + // Move only Delete from the form's footer actions row into the header. + // Archive intentionally stays out of the header: the footer Update button + // already morphs into the mobile Archive action for an untouched note. + const headerActions = overlay.querySelector('.note-fullscreen-actions'); + const archiveBtn = form.querySelector('.note-form-archive-btn'); + const deleteBtn = form.querySelector('.note-form-delete-btn'); + if (headerActions && deleteBtn) { + deleteBtn.classList.remove('note-form-collapsible'); + headerActions.appendChild(deleteBtn); + } + // The built-in archive/delete handlers re-render the notes grid but + // leave THIS overlay sitting in front of it : looks like nothing + // happened. Add follow-up listeners that close the overlay so the + // user sees the action take effect. + if (archiveBtn) { + archiveBtn.addEventListener('click', () => { + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200); + }); + } + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + // Delete shows a styled confirm : give it room to resolve before + // we try to dismiss the overlay. + setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500); + }); + } + + // Tuck the tags input into the bottom actions row (Cancel / Update), + // pinned to the LEFT. Frees the meta row of an extra wrapping line + // and groups all the "exit" controls together. + const actionsGroup = form.querySelector('.note-form-actions-group'); + const tagsInput = form.querySelector('.note-form-label'); + if (actionsGroup && tagsInput) { + actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild); + } + + // For checklist-type notes, move the photo (attach image) button into + // the same row as the + Add button (right side) : keeps the meta row + // tidy and puts the camera within thumb-reach of the active edit. + const addBtn = form.querySelector('.note-cl-add'); + const photoBtn = form.querySelector('.note-form-photo-btn'); + if (addBtn && photoBtn) { + const addRow = document.createElement('div'); + addRow.className = 'note-cl-add-row'; + addBtn.parentNode.insertBefore(addRow, addBtn); + addRow.appendChild(addBtn); + addRow.appendChild(photoBtn); + // Tapping anywhere on the row (the empty gap, the dashed border, + // the "+ Add" label) triggers add. The photo button keeps its own + // click target so attach-image isn't ambushed. + addRow.addEventListener('click', (e) => { + if (e.target.closest('.note-form-photo-btn')) return; + if (e.target === addBtn || addBtn.contains(e.target)) return; + addBtn.click(); + }); + // The form's delegated "+ Add" handler does + // inputs.insertBefore(newRow, addBtn) + // : but addBtn is no longer a direct child of `.note-checklist-inputs` + // now that we wrapped it. Bind a fresh handler that does the same + // thing but inserts before the WRAPPING row, and stop propagation + // so the broken delegate never runs. + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const inputs = form.querySelector('.note-checklist-inputs'); + if (!inputs) return; + const newRow = document.createElement('div'); + newRow.className = 'note-cl-row'; + newRow.draggable = true; + newRow.dataset.itemId = _uid(); + newRow.dataset.indent = '0'; + newRow.innerHTML = '⋮⋮'; + inputs.insertBefore(newRow, addRow); + _wireRow(newRow, inputs); + // Touch reorder on the freshly-added row's grip. + _wireChecklistTouchReorder(form); + newRow.querySelector('.note-cl-text')?.focus(); + }, { capture: true }); + } + + // Read-mode overlay for plain notes: render the content as a div with + // clickable hyperlinks, layered above the textarea. Tapping anywhere + // in the overlay that ISN'T a link hides the overlay and focuses the + // textarea so the user can start editing. Tapping a link opens it. + const ta = form.querySelector('.note-form-content'); + if (ta && (note.content || '').trim()) { + const reader = document.createElement('div'); + reader.className = 'note-form-content-reader'; + reader.innerHTML = _linkify(note.content || ''); + ta.style.display = 'none'; + ta.insertAdjacentElement('beforebegin', reader); + reader.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let links open normally + reader.remove(); + ta.style.display = ''; + // Let the browser place the cursor naturally : forcing + // setSelectionRange right after focus() raced with the underlying + // tap event and produced inconsistent cursor positions on mobile. + ta.focus({ preventScroll: true }); + }); + } + + // Opening an EXISTING note → read mode, no keyboard pop. Only a + // brand-new note (created via the + button) should auto-focus an + // input field. The user can tap the content to switch to edit. + // (New-note creation flows through _createNote, not this function.) + } + + function _closeMobileFullscreenEdit(opts = {}) { + if (!_mobileFsOverlay) return; + const overlay = _mobileFsOverlay; + overlay.querySelector('.note-form')?._flushDraft?.(); + _mobileFsOverlay = null; + // If the form has a Save button, click it on close so edits aren't lost + // when the user uses the back arrow instead of an explicit Save. + if (opts.save) { + const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]'); + if (saveBtn) try { saveBtn.click(); } catch {} + } + overlay.classList.remove('open'); + overlay.classList.add('closing'); + setTimeout(() => { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + _mobileFsNoteId = null; + app.setEditingId(null); + // Refresh the grid so any save the user made is reflected. + if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); } + }, 220); + } + + function _addRowReadMode(row) { + const txt = row.querySelector('.note-cl-text'); + if (!txt) return; + const val = txt.value || ''; + if (!/(https?:\/\/|www\.)/i.test(val)) return; + if (row.querySelector('.note-cl-text-reader')) return; // already wired + const span = document.createElement('span'); + span.className = 'note-cl-text-reader'; + span.innerHTML = _linkify(val); + txt.style.display = 'none'; + txt.insertAdjacentElement('beforebegin', span); + span.addEventListener('click', (e) => { + if (e.target.closest('a')) return; // let the link open + span.remove(); + txt.style.display = ''; + txt.focus({ preventScroll: true }); + }); + } + + return { _isNotesMobileMode, _openMobileFullscreenEdit, _closeMobileFullscreenEdit, _addRowReadMode }; +} diff --git a/static/js/features/notes/ui/mobileReorder.js b/static/js/features/notes/ui/mobileReorder.js new file mode 100644 index 000000000..b5f6404d3 --- /dev/null +++ b/static/js/features/notes/ui/mobileReorder.js @@ -0,0 +1,214 @@ +/** Mobile card long press, placeholder drag and reorder persistence. */ +export function createMobileReorder({ + lifecycle, + _applyMasonry, + _enterSelectMode, + commitMobileOrder, + app, +}) { + function _bindLongPressDrag(card) { + let pressTimer = null; + let startX = 0, startY = 0; + let armed = false; + const CANCEL_PX = 8; + const HOLD_MS = 450; + + card.addEventListener('touchstart', (e) => { + // Don't fight scroll on touchpoints over real interactive children + // (in mobile-mode they're CSS-hidden anyway, but be defensive). + if (e.target.closest('button, input, a, .note-form')) return; + if (e.touches.length !== 1) return; + armed = true; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + pressTimer = setTimeout(() => { + if (!armed) return; + try { navigator.vibrate?.(15); } catch {} + app.setSuppressNextNoteTapId(card.dataset.noteId); + _enterSelectMode(card.dataset.noteId); + }, HOLD_MS); + }, { passive: true }); + card.addEventListener('touchmove', (e) => { + if (!armed) return; + const t = e.touches[0]; + if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + } + }, { passive: true }); + const cancel = () => { + armed = false; + if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } + }; + lifecycle.onDispose(card, cancel); + card.addEventListener('touchend', cancel, { passive: true }); + card.addEventListener('touchcancel', cancel, { passive: true }); + } + + let _dragState = null; + + let _docDragHandlersBound = false; + + function _enterDragMode(initialCard, initialTouch) { + document.body.classList.add('notes-drag-mode'); + document.querySelectorAll('.note-card').forEach(_setupDragForCard); + if (!_docDragHandlersBound) { + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchmove', _onDocTouchMove, { passive: false }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchend', _onDocTouchEnd, { passive: true }); + lifecycle.listen(document.getElementById('notes-pane'), document, 'touchcancel', _onDocTouchEnd, { passive: true }); + _docDragHandlersBound = true; + } + // Auto-grab the card the user long-pressed : they're already holding + // their finger on it, so begin the drag straight away. Releasing + // (touchend) commits the reorder AND exits drag mode in one motion. + if (initialCard && initialTouch) { + _beginGrab(initialCard, initialTouch); + } + } + + function _exitDragMode() { + document.body.classList.remove('notes-drag-mode'); + if (_dragState) { + // Defensive: if exit fires while a drag is in flight, snap the card back. + _onDocTouchEnd(); + } + // Leave _docDragHandlersBound true so re-entering drag mode reuses them. + } + + function _setupDragForCard(card) { + if (card.dataset.dragBound === '1') return; + card.dataset.dragBound = '1'; + card.addEventListener('touchstart', (e) => { + if (!document.body.classList.contains('notes-drag-mode')) return; + if (e.touches.length !== 1) return; + if (_dragState) return; + e.preventDefault(); + e.stopPropagation(); + _beginGrab(card, e.touches[0]); + }, { passive: false }); + } + + function _beginGrab(card, touch) { + const rect = card.getBoundingClientRect(); + const prevStyle = card.getAttribute('style') || ''; + // Placeholder fills the card's old slot so the grid layout doesn't reflow. + const placeholder = document.createElement('div'); + placeholder.className = 'note-card-placeholder'; + placeholder.style.width = rect.width + 'px'; + placeholder.style.height = rect.height + 'px'; + placeholder.style.margin = getComputedStyle(card).margin; + if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd; + const grid = card.parentNode; + grid.insertBefore(placeholder, card); + + // Detach the card visually : fixed-position, anchored to the finger. + card.classList.add('note-card-dragging'); + card.style.position = 'fixed'; + card.style.left = rect.left + 'px'; + card.style.top = rect.top + 'px'; + card.style.width = rect.width + 'px'; + card.style.height = rect.height + 'px'; + card.style.margin = '0'; + card.style.zIndex = '10001'; + // pointer-events:none so elementFromPoint sees the card BENEATH the finger + card.style.pointerEvents = 'none'; + + _dragState = { + card, placeholder, grid, prevStyle, + grabOffsetX: touch.clientX - rect.left, + grabOffsetY: touch.clientY - rect.top, + }; + try { navigator.vibrate?.(8); } catch {} + } + + function _onDocTouchMove(e) { + if (!_dragState) return; + if (e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + const { card, placeholder, grid } = _dragState; + card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px'; + const quickAdd = grid.querySelector('.notes-quick-add'); + const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top; + const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8); + const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY)); + card.style.top = nextTop + 'px'; + + const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY)); + const under = document.elementFromPoint(touch.clientX, hitY); + const target = under && under.closest + ? under.closest('.note-card:not(.note-card-dragging)') + : null; + if (!target || target === card) return; + if (target.parentNode !== grid) return; + + // Move the PLACEHOLDER (not the card) above or below the target depending + // on which half of the target the finger is in. This is the hysteresis + // that stops the oscillation : once the placeholder moves past a card, + // the cursor has to cross THAT card's midpoint in the other direction + // to swap back. + const tRect = target.getBoundingClientRect(); + const targetMidY = tRect.top + tRect.height / 2; + if (touch.clientY < targetMidY) { + if (placeholder.nextElementSibling !== target) { + grid.insertBefore(placeholder, target); + } + } else { + if (target.nextElementSibling !== placeholder) { + grid.insertBefore(placeholder, target.nextElementSibling); + } + } + } + + function _onDocTouchEnd() { + if (!_dragState) return; + const { card, placeholder, grid, prevStyle } = _dragState; + _dragState = null; + // Animate the card from its current fixed position to where the + // placeholder sits, then re-parent and clear inline styles. Drag + // mode auto-exits once the snap finishes : release = done. + const phRect = placeholder.getBoundingClientRect(); + card.style.transition = 'left 0.2s ease, top 0.2s ease'; + card.style.left = phRect.left + 'px'; + card.style.top = phRect.top + 'px'; + setTimeout(() => { + placeholder.parentNode.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + // Restore the card's pre-drag inline styles. Mobile masonry stores + // grid-row-end inline, and custom backgrounds use inline style too; wiping + // cssText made dropped cards collapse into neighboring notes in grid view. + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + _applyMasonry(grid); + _commitNoteReorder(); + // One drag, one exit : release ends the rearrange session entirely. + if (document.body.classList.contains('notes-drag-mode')) { + document.body.classList.remove('notes-drag-mode'); + } + }, 210); + } + + async function _commitNoteReorder() { + const grid = document.querySelector('#notes-pane .notes-pane-body'); + if (!grid) return; + const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean); + if (!ids.length) return; + await commitMobileOrder(ids); + } + + function dispose() { + if (_dragState) { + const { card, placeholder, prevStyle } = _dragState; + _dragState = null; + placeholder.parentNode?.insertBefore(card, placeholder); + placeholder.remove(); + card.classList.remove('note-card-dragging'); + if (prevStyle) card.setAttribute('style', prevStyle); + else card.removeAttribute('style'); + } + document.body.classList.remove('notes-drag-mode'); + _docDragHandlersBound = false; + } + return { dispose, _bindLongPressDrag, _enterDragMode, _exitDragMode, _setupDragForCard, _beginGrab, _onDocTouchMove, _onDocTouchEnd, _commitNoteReorder }; +} diff --git a/static/js/features/notes/ui/panel.js b/static/js/features/notes/ui/panel.js new file mode 100644 index 000000000..0a6031233 --- /dev/null +++ b/static/js/features/notes/ui/panel.js @@ -0,0 +1,506 @@ +/** Panel mounting, keyboard handlers, minimization and note navigation. */ +export function createPanel({ + wireToolbar, + wireSelectionActions, + _wireNotesWindow, + _restoreNotesSidebarDock, + _bringNotesToFront, + disposeUI, + lifecycle, + Modals, + NOTES_FIRST_OPEN_HINT_KEY, + REMINDER_DISMISSED_AT_KEY, + _clearViewedReminderGlows, + _closeMobileFullscreenEdit, + _commitOpenInPlaceEditor, + _copyNote, + _enterSelectMode, + _exitSelectMode, + _fetchNotes, + _flushPendingHighlights, + _isNotesMobileMode, + _popAndRunUndo, + _renderLoadingSkeleton, + _renderNotes, + _startReminderLoop, + app, + bindTourHintDismiss, + dismissOrRemove, + persistence, + state, + workflows +}) { + function _forceCloseNotesPanel() { + app.setOpen(false); + app.setEditingId(null); + try { _commitOpenInPlaceEditor(); } catch {} + try { _closeMobileFullscreenEdit({ save: true }); } catch {} + try { _clearViewedReminderGlows(); } catch {} + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); + document.getElementById('tool-notes-btn')?.classList.remove('active'); + try { Modals.unregister('notes-panel'); } catch {} + try { document.getElementById('notes-pane')?.remove(); } catch {} + try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} + try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} + } + + function _showNotesFirstOpenHint(pane) { + if (!pane || typeof persistence === 'undefined') return; + try { + if (persistence.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; + persistence.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); + } catch { + return; + } + + dismissOrRemove(document.getElementById('notes-first-open-hint')); + const hint = document.createElement('div'); + hint.id = 'notes-first-open-hint'; + hint.className = 'tour-hint'; + hint.innerHTML = ` +
Notes is your basic todo list, and also where reminders are managed.
+ + `; + document.body.appendChild(hint); + + const place = () => { + const r = pane.getBoundingClientRect(); + const hw = hint.offsetWidth || 260; + hint.style.top = Math.max(12, r.top + 58) + 'px'; + hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; + }; + requestAnimationFrame(() => { + place(); + hint.classList.add('tour-hint-in'); + }); + lifecycle.listen(hint, window, 'resize', place); + bindTourHintDismiss(hint, { + timeout: 6500, + fade: 180, + onDismiss: () => window.removeEventListener('resize', place), + }); + } + + function _ensureNotesChipRegistered() { + if (Modals.isRegistered('notes-panel')) return; + Modals.register('notes-panel', { + railBtnId: 'rail-notes', + sidebarBtnId: 'tool-notes-btn', + restoreFn: () => { openPanel(); }, + closeFn: () => { _forceCloseNotesPanel(); }, + }); + } + + function openPanel() { + if (state.open) { + _bringNotesToFront(); + return; + } + // Notes and the document editor occupy the same primary workspace. Preserve + // an open document as a dock chip instead of allowing the two panes to stack + // into a broken layout (or closing the user's document outright). + try { + if (window.documentModule?.isPanelOpen?.()) { + window.documentModule.closePanel('down'); + } + } catch (_) {} + app.setOpen(true); + app.setEditingId(null); + app.setSearchQuery(''); + _clearViewedReminderGlows(); + app.setFiredDotDismissedAt(Date.now()); + try { persistence.setItem(REMINDER_DISMISSED_AT_KEY, String(state.firedDotDismissedAt)); } catch {} + + const container = document.getElementById('chat-container'); + if (!container) return; + + document.body.classList.add('notes-view'); + + // On mobile the notes panel takes the whole screen : auto-close the + // sidebar so the panel isn't cropped underneath it. + if (window.innerWidth <= 768) { + const sb = document.getElementById('sidebar'); + if (sb) sb.classList.add('hidden'); + document.body.classList.add('sidebar-collapsed'); + } + // Mobile mode: tiles become read-only previews (no inline checkbox / + // edit / archive / etc.), tap opens a fullscreen edit overlay, + // long-press enters drag-to-reorder mode. See _bindCardEvents + + // .notes-mobile-mode CSS rules. + if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); + + // Toggle button state + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.add('active'); + + // Create panel + const pane = document.createElement('div'); + pane.id = 'notes-pane'; + pane.className = 'notes-pane'; + pane.innerHTML = ` + +
+

Notes

+ + + + +
+ + +
+ `; + + // On mobile open as a full-screen bottom sheet (slide up), not the desktop + // side panel. Set inline so it wins over the base .notes-pane rule regardless + // of cascade specifics (the CSS @media override wasn't reliably applying, + // which left it as a side panel squeezing the chat). + if (window.innerWidth <= 768) { + pane.style.position = 'fixed'; + pane.style.inset = '0'; + pane.style.width = '100%'; + pane.style.maxWidth = '100%'; + pane.style.zIndex = '170'; + pane.style.borderRadius = '14px 14px 0 0'; + pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; + pane.style.transformOrigin = 'bottom center'; + } + + // Mount on body so Notes can behave like the other draggable windows. On + // desktop it is immediately docked to the right by _restoreNotesSidebarDock. + const backdrop = document.createElement('div'); + backdrop.className = 'notes-pane-backdrop'; + backdrop.id = 'notes-pane-backdrop'; + backdrop.addEventListener('click', (ev) => { + if (ev.target === backdrop) closePanel('down'); + }); + backdrop.appendChild(pane); + document.body.appendChild(backdrop); + _wireNotesWindow(pane); + _restoreNotesSidebarDock(pane); + _bringNotesToFront(pane); + + // Events + // (Close chevron removed : swipe down on mobile, tool-rail toggle on desktop.) + + // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). + // Mirrors the document sheet gesture : finger-following, velocity-based + // dismiss, rubber-band on up-drag, spring snap-back. + _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); + _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); + + pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); + pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); + + const minBtn = document.getElementById('notes-minimize-btn'); + if (minBtn) minBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + closePanel('down'); + }); + wireToolbar(pane); + // Select mode + document.getElementById('notes-select-btn').addEventListener('click', () => { + if (state.selectMode) _exitSelectMode(); else _enterSelectMode(); + }); + // Esc cancels select mode. Notes uses a toggle "Select" button rather + // than a *-bulk-cancel button, so the global Esc-cancel handler in + // keyboard-shortcuts.js can't reach it : handle it here. Capture phase + // + stopPropagation so Esc cancels select instead of closing the panel. + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + } + _notesSelectEscHandler = (e) => { + if (e.key === 'Escape' && state.selectMode) { + e.preventDefault(); + e.stopPropagation(); + _exitSelectMode(); + } + }; + document.addEventListener('keydown', _notesSelectEscHandler, true); + wireSelectionActions(); + // Escape: exit select mode first (if active), otherwise close the panel. + // Skip when the user is editing a form field : those have their own + // ESC-to-cancel handlers and we don't want to nuke the whole panel + // mid-edit. + // Idempotent: remove any previous handler from a prior openPanel so + // re-opening doesn't stack multiple handlers. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + _notesKeydownHandler = (e) => { + if (!state.open) return; + const t = e.target; + const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); + // Ctrl/Cmd+Z anywhere in the panel : undo the last note action. Skip when + // typing in a field so the browser's normal text-undo still works. + if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { + if (inField) return; + if (workflows.undoCount() === 0) return; + e.preventDefault(); + _popAndRunUndo(); + return; + } + // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the + // user is editing or has an active text selection (let the browser handle + // a real text copy in that case). + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { + if (inField) return; + const sel = window.getSelection?.(); + if (sel && sel.toString && sel.toString().length > 0) return; + const hovered = document.querySelector('.note-card:hover'); + if (!hovered) return; + const id = hovered.dataset.noteId; + if (!id) return; + e.preventDefault(); + // Flash the ⋯ menu button (copy now lives in that menu). + const btn = hovered.querySelector('.note-card-corner-menu'); + _copyNote(id, btn); + return; + } + if (e.key !== 'Escape') return; + if (inField) return; + if (state.selectMode) { _exitSelectMode(); return; } + if (state.showingArchived) { + // Mirror the archive toggle button: flip back to active notes. + document.getElementById('notes-archive-toggle')?.click(); + return; + } + _forceCloseNotesPanel(); + }; + document.addEventListener('keydown', _notesKeydownHandler); + + // Load : show skeleton immediately, then fetch + _renderLoadingSkeleton(); + // Defer the highlight flush to the next frame so it runs *after* the cards + // are committed to the DOM (and any FLIP animations have settled), giving + // the querySelector lookups inside something to find. + _fetchNotes().then(() => { + if (!pane.isConnected || !state.open) { _startReminderLoop(); return; } + _renderNotes(); + requestAnimationFrame(() => _flushPendingHighlights()); + _startReminderLoop(); + _showNotesFirstOpenHint(pane); + }); + } + + function closePanel(direction) { + if (!state.open) return; + app.setOpen(false); + app.setEditingId(null); + _clearViewedReminderGlows(); + const _minimize = direction === 'down'; + if (_minimize) { + _ensureNotesChipRegistered(); + } else if (Modals.isRegistered('notes-panel')) { + Modals.unregister('notes-panel'); + } + + // Drop panel-owned keydown listeners. The reminder interval is page-owned. + if (_notesKeydownHandler) { + document.removeEventListener('keydown', _notesKeydownHandler); + _notesKeydownHandler = null; + } + if (_notesSelectEscHandler) { + document.removeEventListener('keydown', _notesSelectEscHandler, true); + _notesSelectEscHandler = null; + } + + disposeOwnedPortals(); + disposeUI(); + document.body.classList.remove('notes-view'); + document.body.classList.remove('notes-mobile-mode'); + document.body.classList.remove('notes-drag-mode'); + // Closing the panel should PRESERVE in-progress edits, not drop them. + // Commit any open in-place editor, and close the mobile fullscreen + // overlay with save=true so the note is persisted. + try { _commitOpenInPlaceEditor(); } catch {} + _closeMobileFullscreenEdit({ save: true }); + // /notes route may have collapsed the wide sidebar to a rail; restore. + try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} + + const btn = document.getElementById('tool-notes-btn'); + if (btn) btn.classList.remove('active'); + + const pane = document.getElementById('notes-pane'); + const backdrop = document.getElementById('notes-pane-backdrop'); + if (pane) { + // Scale-out + fade. Match the enter animation duration so close feels + // like the same gesture played backwards. + pane.classList.add('notes-pane-leaving'); + const _cleanup = () => { + try { pane.remove(); } catch {} + try { backdrop?.remove(); } catch {} + }; + pane.addEventListener('animationend', _cleanup, { once: true }); + // Belt-and-braces: if animation is skipped (reduced motion / detached + // tab) the listener won't fire; remove after the expected duration. + setTimeout(_cleanup, 220); + } else if (backdrop) { + backdrop.remove(); + } + // Show the dock chip for a swipe-down minimize (tap it to reopen). + if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } + } + + function togglePanel() { + if (state.open) closePanel(); + else openPanel(); + } + + function isPanelOpen() { return state.open; } + + async function openNote(noteId) { + const wasOpen = !!(isPanelOpen && isPanelOpen()); + app.setShowingArchived(false); + app.setActiveLabel(null); + app.setActiveFilter(null); + app.setSearchQuery(''); + if (!wasOpen) { + openPanel(); + } else { + _bringNotesToFront(); + const searchEl = document.getElementById('notes-search'); + if (searchEl) searchEl.value = ''; + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.remove('notes-pane-archive'); + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) archiveBtn.classList.remove('active'); + await _fetchNotes(); + _renderNotes(); + } + // openPanel() kicks off _fetchNotes() asynchronously, so the cards for + // newly-created notes may not be in the DOM yet. Poll until the card exists. + if (!noteId) return; + let tries = 0; + const findAndFlash = () => { + const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) + || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); + if (card) { + _smoothScrollToNoteCard(card); + card.classList.add('note-card-flash'); + setTimeout(() => card.classList.remove('note-card-flash'), 1600); + return true; + } + return false; + }; + const tryNext = () => { + if (findAndFlash()) return; + if (++tries < 20) setTimeout(tryNext, 200); + }; + setTimeout(tryNext, 120); + } + + let _notesKeydownHandler = null; + + let _notesSelectEscHandler = null; + + function _wireNotesSwipeDismiss(el, pane) { + if (!el || !pane) return; + const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; + let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; + let dragging = false, cancelled = false; + + el.addEventListener('touchstart', (e) => { + if (window.innerWidth > 768 || e.touches.length !== 1) return; + if (e.target.closest('button, input, select, label, textarea')) return; + const t = e.touches[0]; + startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; + velocity = 0; dragging = false; cancelled = false; + }, { passive: true }); + + el.addEventListener('touchmove', (e) => { + if (cancelled || window.innerWidth > 768) return; + const t = e.touches[0]; + const dx = Math.abs(t.clientX - startX); + const dy = t.clientY - startY; + if (!dragging) { + if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } + if (Math.abs(dy) > 8) { + dragging = true; + pane.style.animation = 'none'; + pane.style.transition = 'none'; + pane.style.willChange = 'transform'; + } else return; + } + const dt = e.timeStamp - lastT; + if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; + lastY = t.clientY; lastT = e.timeStamp; + e.preventDefault(); + pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; + }, { passive: false }); + + const endSwipe = () => { + if (!dragging) return; + dragging = false; + pane.style.willChange = ''; + const dy = lastY - startY; + if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { + // Slide fully off-screen, then minimise. Keep it translated down (don't + // reset) so it doesn't flash back before closePanel removes it. + pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; + pane.style.transform = 'translateY(100%)'; + setTimeout(() => closePanel('down'), 200); + } else { + pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; + pane.style.transform = ''; + setTimeout(() => { pane.style.transition = ''; }, 260); + } + }; + el.addEventListener('touchend', endSwipe, { passive: true }); + el.addEventListener('touchcancel', endSwipe, { passive: true }); + } + + function _smoothScrollToNoteCard(card) { + if (!card) return; + const scroller = card.closest('.notes-pane-body'); + if (!scroller) { + try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} + return; + } + const scrollerRect = scroller.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2; + scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' }); + } + + function disposeOwnedPortals() { + document.querySelectorAll('.note-reminder-menu, .note-corner-menu-dropdown, #notes-first-open-hint').forEach(dismissOrRemove); + } + + return { _forceCloseNotesPanel, openPanel, closePanel, togglePanel, isPanelOpen, openNote, _smoothScrollToNoteCard }; +} diff --git a/static/js/features/notes/ui/quickAdd.js b/static/js/features/notes/ui/quickAdd.js new file mode 100644 index 000000000..3a723fcdc --- /dev/null +++ b/static/js/features/notes/ui/quickAdd.js @@ -0,0 +1,170 @@ +/** Quick-add expansion, draft restoration and opening inline editors. */ +export function createQuickAdd({ + _applyDraftToNote, + _applyMasonry, + _buildForm, + app, + openPanel, + state, + uiModule +}) { + function _renderQuickAdd(body) { + const wrap = document.createElement('div'); + wrap.className = 'notes-quick-add'; + // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw : + // drawing happens in the expanded form). The pill that's active steers + // both the placeholder and the type the form opens in. + wrap.innerHTML = ` +
+ + +
+ + + `; + body.appendChild(wrap); + + const input = wrap.querySelector('.notes-quick-input'); + const seg = wrap.querySelector('.notes-quick-type-seg'); + let currentType = 'todo'; + const setType = (t) => { + if (t !== 'note' && t !== 'todo') return; + currentType = t; + seg.classList.toggle('is-todo', t === 'todo'); + seg.classList.toggle('is-note', t === 'note'); + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + const on = p.dataset.type === t; + p.classList.toggle('active', on); + p.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…'; + }; + seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { + p.addEventListener('click', (e) => { + e.stopPropagation(); + setType(p.dataset.type); + }); + }); + // Click input or type → expand to full form + const expandToForm = (initialType = 'note', initialText = '') => { + app.setEditingId('__new__'); + const form = _buildForm({ note_type: initialType }); + form.classList.add('note-form-new'); + if (initialText) { + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.value = initialText; + } + const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid') + && window.matchMedia('(max-width: 768px)').matches; + if (mobileGrid) { + form.style.gridColumn = '1 / -1'; + form.style.gridRowEnd = 'span 64'; + } + wrap.replaceWith(form); + _applyMasonry(body); + requestAnimationFrame(() => _applyMasonry(body)); + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) { + titleEl.focus(); + // Move caret to end + titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length); + } + }; + // Expand only on real intent: a click directly on the input, or actual + // typing. Focus alone : including focus stolen from a missed nearby + // click : no longer creates an empty form. + input.addEventListener('click', () => expandToForm(currentType, input.value)); + input.addEventListener('input', () => expandToForm(currentType, input.value)); + wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => { + e.stopPropagation(); + expandToForm(currentType); + // Trigger photo input on the new form + setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50); + }); + } + + function _createNote(type = 'todo') { + const body = document.querySelector('#notes-pane .notes-pane-body'); + if (!body || state.editingId === '__new__') return; + app.setEditingId('__new__'); + // Restore an unsaved new-note draft if one survived a prior close/loss. + const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__'); + const form = _buildForm(_n); + form.classList.add('note-form-new'); + body.prepend(form); + const initialField = type === 'note' + ? form.querySelector('.note-form-content') + : form.querySelector('.note-cl-text, .note-form-title'); + initialField?.focus(); + if (restored) uiModule.showToast('Restored unsaved note'); + } + + function newNote() { + if (!state.open) openPanel(); + requestAnimationFrame(() => _createNote('note')); + } + + function _editNote(id) { + const note = state.notes.find(n => n.id === id); + if (!note) return; + app.setEditingId(id); + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) return; + // Restore an unsaved draft (from a prior connection loss / close) over + // the saved note so the user picks up where they left off. + const { note: _n, restored } = _applyDraftToNote(note, id); + const form = _buildForm(_n); + card.replaceWith(form); + if (restored) uiModule.showToast('Restored unsaved changes'); + // Pinned notes live in the first masonry column : the edit form has + // column-span:all, which can leave the form rendered above the fold or + // visually buried under neighboring pinned cards. Bring it into view + // (and onto a higher stacking context) so editing a pinned note always + // pops to the top. + form.style.position = 'relative'; + form.style.zIndex = '5'; + // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px) + // after replaceWith, which is much shorter than the actual form. Recompute + // masonry so the form gets the correct row span and cards below stop + // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as + // the user types / adds checklist items. + const _body = form.closest('.notes-pane-body'); + if (_body) { + _applyMasonry(_body); + requestAnimationFrame(() => _applyMasonry(_body)); + } + requestAnimationFrame(() => { + try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); } + catch { form.scrollIntoView(); } + }); + // Pick the most useful field to focus. On phones especially, the user + // taps Edit to type : landing in the title when there's already a title + // (and likely a body to extend) loses momentum. Prefer the body textarea + // for plain notes, the first checklist item for todos, fall back to title. + const _focusBest = () => { + if (note.note_type === 'note' || !note.note_type) { + const ta = form.querySelector('.note-form-content'); + if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; } + } + if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') { + // Last non-empty checklist row, or the first row if all empty. + const rows = form.querySelectorAll('.note-cl-row .note-cl-text'); + let target = null; + for (const inp of rows) { if ((inp.value || '').trim()) target = inp; } + target = target || rows[0]; + if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; } + } + const titleEl = form.querySelector('.note-form-title'); + if (titleEl) titleEl.focus(); + }; + _focusBest(); + } + + return { _renderQuickAdd, _createNote, newNote, _editNote }; +} diff --git a/static/js/features/notes/ui/reminderDisplay.js b/static/js/features/notes/ui/reminderDisplay.js new file mode 100644 index 000000000..5f6507d46 --- /dev/null +++ b/static/js/features/notes/ui/reminderDisplay.js @@ -0,0 +1,129 @@ +/** Reminder badges, sticky card highlights and entry scrolling. */ +import { _isNoteFullyDone } from '../domain/noteRules.js'; +import { _hasTimeComponent } from '../domain/recurrence.js'; +export function createReminderDisplay({ + _countFiredReminders, + _hasActiveReminder, + _loadActiveHighlights, + _loadGlowedReminders, + _loadPendingHighlights, + _saveActiveHighlights, + _saveGlowedReminders, + _savePendingHighlights, + _smoothScrollToNoteCard, + state +}) { + function _clearViewedReminderGlows() { + const active = _loadActiveHighlights(); + if (!active.size) return; + _saveActiveHighlights(new Set()); + document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => { + card.classList.remove('note-card-reminder-fired-sticky'); + }); + } + + function _setReminderCardGlow(noteId, on = true) { + if (!noteId) return; + const active = _loadActiveHighlights(); + if (on) active.add(noteId); + else active.delete(noteId); + _saveActiveHighlights(active); + document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => { + card.classList.toggle('note-card-reminder-fired-sticky', on); + }); + } + + function _queuePendingHighlight(noteId) { + const set = _loadPendingHighlights(); + set.add(noteId); + _savePendingHighlights(set); + } + + function _flushPendingHighlights() { + // Fresh firings (queued by the background loop while the panel was closed) + // glow unconditionally : a notification just told the user something + // happened, so we always point at the note even if it was glowed before. + const queued = _loadPendingHighlights(); + const glowed = _loadGlowedReminders(); + const toGlow = new Set(queued); + // For notes that are merely overdue at open time (no fresh firing event), + // only glow the ones we haven't already shown : otherwise reopening the + // panel keeps lighting up old reminders forever. + for (const n of state.notes) { + if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue; + if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id); + } + // Always consume the queue. + _savePendingHighlights(new Set()); + if (!toGlow.size) return; + let firstCard = null; + for (const id of toGlow) { + const card = document.querySelector(`.note-card[data-note-id="${id}"]`); + if (!card) continue; + _setReminderCardGlow(id, true); + if (!firstCard) firstCard = card; + glowed.add(id); + } + _saveGlowedReminders(glowed); + // Bring the first one into view so it can't get buried below the fold. + if (firstCard) { + requestAnimationFrame(() => { + _smoothScrollToNoteCard(firstCard); + }); + } + } + + function _updateRailBadge() { + const fired = _countFiredReminders(); + // Rail (mini sidebar) : only show the count when reminders have ACTUALLY + // fired since the last dismissal (i.e. you haven't opened notes yet). + // Showing every overdue note forever made the badge feel permanent. + const railBtn = document.getElementById('rail-notes'); + if (railBtn) { + let badge = railBtn.querySelector('.rail-notes-badge'); + if (fired > 0) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'rail-notes-badge'; + railBtn.appendChild(badge); + } + badge.textContent = fired > 99 ? '99+' : String(fired); + badge.classList.add('fired'); + } else if (badge) { + badge.remove(); + } + } + // Main sidebar button + const sidebarBtn = document.getElementById('tool-notes-btn'); + if (sidebarBtn) { + let dot = sidebarBtn.querySelector('.tool-notes-dot'); + if (fired > 0) { + if (!dot) { + dot = document.createElement('span'); + dot.className = 'tool-notes-dot'; + const newNoteBtn = sidebarBtn.querySelector('#notes-new-note-btn'); + sidebarBtn.insertBefore(dot, newNoteBtn || null); + } + } else if (dot) { + dot.remove(); + } + } + // Individual note cards : pulse ones with fired reminders + document.querySelectorAll('.note-card').forEach(card => { + const id = card.dataset.noteId; + const note = state.notes.find(n => n.id === id); + if (!note || note.archived || _isNoteFullyDone(note)) { + card.classList.remove('note-card-reminder-due'); + return; + } + if (note.due_date && _hasTimeComponent(note.due_date)) { + const t = new Date(note.due_date).getTime(); + card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now()); + } else { + card.classList.remove('note-card-reminder-due'); + } + }); + } + + return { _clearViewedReminderGlows, _setReminderCardGlow, _queuePendingHighlight, _flushPendingHighlights, _updateRailBadge }; +} diff --git a/static/js/features/notes/ui/reminderPicker.js b/static/js/features/notes/ui/reminderPicker.js new file mode 100644 index 000000000..da9d00398 --- /dev/null +++ b/static/js/features/notes/ui/reminderPicker.js @@ -0,0 +1,335 @@ +/** Reminder date presets and recurrence picker interactions. */ +import { _formatRepeatLabel, _toLocalDatetimeStr, _normalizeRepeat, _DAYS, _ORDINALS } from '../domain/recurrence.js'; +export function createReminderPicker({ + lifecycle, + _ensureNotificationPermission, + _esc, + _formatReminderTag, + _laterTodayDate, + _nextWeekDate, + _snapToRepeat, + _tomorrowDate, + bindMenuDismiss, + dismissOrRemove +}) { + function _wireReminderPicker(form) { + // Reminder bell : opens dropdown menu + const remindBtn = form.querySelector('.note-form-remind-btn'); + const dueInput = form.querySelector('.note-form-due'); + const repeatInput = form.querySelector('.note-form-repeat'); + const tagsEl = form.querySelector('.note-form-reminder-tags'); + + function _renderReminderTag() { + if (!tagsEl) return; + const v = dueInput.value; + const rep = repeatInput.value || 'none'; + if (!v) { tagsEl.innerHTML = ''; return; } + const label = _formatReminderTag(v); + const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : ''; + tagsEl.innerHTML = ``; + tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => { + if (e.target.classList.contains('note-reminder-tag-x')) { + dueInput.value = ''; + repeatInput.value = 'none'; + _renderReminderTag(); + return; + } + _openReminderMenu(remindBtn || tagsEl, true); + }); + } + + function _openReminderMenu(anchor, isEdit = false) { + // Close any existing menu + document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + document.body.appendChild(menu); + + const presetItems = [ + { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) }, + { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) }, + { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) }, + { label: 'Select date and time', sub: '', action: () => _pickCustomDate() }, + ]; + + // Sub-page state for the repeat picker. null = top page. + // 'weekly' | 'monthly' | 'monthly_nth' + let subMode = null; + // Temporary state for monthly_nth so user can click N then weekday (or vice versa) + // before committing. + let nthDraft = { n: 0, w: -1 }; + + const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + function getNorm() { + if (!dueInput.value) return 'none'; + return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value)); + } + + function commit(val) { + repeatInput.value = val; + _renderReminderTag(); + menu.remove(); + } + + // Like commit, but first snaps dueInput.value forward to the next matching + // slot for the chosen recurrence. Use for weekly/monthly variants where the + // current due date may not match the chosen pattern (e.g. user picks + // "weekly on Mondays" while the date is a Wednesday). + function snapAndCommit(val) { + if (dueInput.value) { + const cur = new Date(dueInput.value); + const norm = _normalizeRepeat(val, cur); + const snapped = _snapToRepeat(cur, norm); + if (snapped) { + dueInput.value = _toLocalDatetimeStr(snapped); + if (remindBtn) remindBtn.classList.add('has-date'); + } + } + commit(val); + } + + function reposition() { + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 220; + const mh = menu.offsetHeight || 280; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + } + + function render() { + let html = ''; + + if (subMode === null) { + html += '
Remind me later
'; + for (let i = 0; i < presetItems.length; i++) { + const it = presetItems[i]; + html += ``; + } + if (isEdit && dueInput.value) { + const norm = getNorm(); + html += '
'; + html += '
Repeat
'; + // None + html += ``; + // Daily + html += ``; + // Weekly → + { + const isW = norm.startsWith('weekly:'); + const wd = isW ? parseInt(norm.split(':')[1], 10) : null; + const sub = isW && !isNaN(wd) ? `${_DAYS[wd]}` : ''; + html += ``; + } + // Monthly → + { + const isM = norm.startsWith('monthly:'); + const sub = isM ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + // Yearly + html += ``; + } + } else if (subMode === 'weekly') { + const norm = getNorm(); + const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1; + html += ``; + html += '
Weekly on…
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + } else if (subMode === 'monthly') { + const norm = getNorm(); + const dueDate = new Date(dueInput.value); + const dayN = dueDate.getDate(); + html += ``; + html += '
Monthly on…
'; + // Day N : uses the chosen date's day. Always offered. + const dayVal = `monthly:day:${dayN}`; + html += ``; + // Nth weekday → + { + const isNth = norm.startsWith('monthly:nth:'); + const sub = isNth ? `${_monthlyShortDescriptor(norm)}` : ''; + html += ``; + } + } else if (subMode === 'monthly_nth') { + // Pick ordinal (1..4) and weekday (0..6); commit when both chosen. + html += ``; + html += '
Nth weekday of month
'; + html += '
Which one
'; + html += '
'; + for (let i = 1; i <= 4; i++) { + html += ``; + } + html += '
'; + html += '
Weekday
'; + html += '
'; + for (let i = 0; i < 7; i++) { + html += ``; + } + html += '
'; + html += '
'; + const ready = nthDraft.n > 0 && nthDraft.w >= 0; + const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday'; + html += ``; + } + + menu.innerHTML = html; + reposition(); + wire(); + } + + function wire() { + menu.querySelectorAll('[data-action]').forEach(el => { + el.addEventListener('click', (e) => { + e.stopPropagation(); + const a = el.dataset.action; + if (a === 'preset') { + const it = presetItems[parseInt(el.dataset.i, 10)]; + it.action(); + menu.remove(); + } else if (a === 'set') { + snapAndCommit(el.dataset.val); + } else if (a === 'sub') { + subMode = el.dataset.sub; + // Seed nth draft from saved value only on first entry : preserve + // in-progress picks across back-trips (Nth → back → Nth again). + if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) { + const norm = getNorm(); + const m = norm.match(/^monthly:nth:(\d):(\d)$/); + if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) }; + } + render(); + } else if (a === 'back') { + subMode = null; + render(); + } else if (a === 'back-monthly') { + subMode = 'monthly'; + render(); + } else if (a === 'weekly-pick') { + snapAndCommit(`weekly:${el.dataset.wd}`); + } else if (a === 'nth-n') { + nthDraft.n = parseInt(el.dataset.n, 10); + render(); + } else if (a === 'nth-w') { + nthDraft.w = parseInt(el.dataset.wd, 10); + render(); + } else if (a === 'nth-save') { + if (nthDraft.n > 0 && nthDraft.w >= 0) { + snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`); + } + } + }); + }); + } + + render(); + // Click outside to close (single global handler attached after first paint) + setTimeout(() => { + const close = (e) => { + if (!menu.isConnected) { document.removeEventListener('click', close); return; } + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } + }; + if (menu.isConnected) lifecycle.listen(menu, document, 'click', close); + }, 0); + } + + function _monthlyShortDescriptor(norm) { + const parts = norm.split(':'); + if (parts[1] === 'day') return `Day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Last ${_DAYS[wd].slice(0, 3)}`; + } + return ''; + } + + function _setReminder(datetimeLocalStr) { + dueInput.value = datetimeLocalStr; + if (remindBtn) { + remindBtn.classList.add('has-date'); + // Jingle the bell. CSS handles the animation; remove + reflow + re-add + // so it replays every time the user sets/changes a reminder. + const _bell = remindBtn.querySelector('svg'); + if (_bell) { + _bell.classList.remove('jingling'); + void _bell.offsetWidth; + _bell.classList.add('jingling'); + setTimeout(() => _bell.classList.remove('jingling'), 700); + } + } + _renderReminderTag(); + _ensureNotificationPermission(); + } + + function _pickCustomDate() { + // Replace the dropdown menu with a small inline picker + document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove); + const menu = document.createElement('div'); + menu.className = 'note-reminder-menu'; + const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); + menu.innerHTML = ` +
Pick date and time
+
+ +
+
+ + `; + document.body.appendChild(menu); + // Position next to the bell button + const anchor = remindBtn || form.querySelector('.note-form-reminder-tags'); + const rect = anchor.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const mw = menu.offsetWidth || 240; + const mh = menu.offsetHeight || 200; + let top = rect.bottom + 4; + let left = rect.left; + if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); + if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); + if (left < 8) left = 8; + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + const dInput = menu.querySelector('.note-reminder-date-input'); + dInput.value = initial; + dInput.focus(); + if (typeof dInput.showPicker === 'function') { + try { dInput.showPicker(); } catch {} + } + const close = bindMenuDismiss(menu, () => { menu.remove(); }); + menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { + if (dInput.value) _setReminder(dInput.value); + close(); + }); + } + + if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); + _renderReminderTag(); + + + } + + return { _wireReminderPicker }; +} diff --git a/static/js/features/notes/ui/selection.js b/static/js/features/notes/ui/selection.js new file mode 100644 index 000000000..e64549dff --- /dev/null +++ b/static/js/features/notes/ui/selection.js @@ -0,0 +1,61 @@ +/** Selection controls and view feedback for accepted attachments. */ +export function createSelection({ + operations, + _renderNotes, + app, + state +}) { + function _enterSelectMode(initialId = null) { + app.setSelectMode(true); + app.clearSelection(); + if (initialId) app.selectId(initialId); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + if (bar) bar.classList.remove('hidden'); + if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } + _renderNotes(); + _updateBulkBar(); + } + + function _exitSelectMode() { + app.setSelectMode(false); + app.clearSelection(); + const bar = document.getElementById('notes-bulk-bar'); + const btn = document.getElementById('notes-select-btn'); + const all = document.getElementById('notes-select-all'); + if (bar) bar.classList.add('hidden'); + if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } + if (all) all.checked = false; + _renderNotes(); + } + + function _updateBulkBar() { + const count = state.selectedIds.size; + const countEl = document.getElementById('notes-selected-count'); + const sendAgentBtn = document.getElementById('notes-send-agent'); + const archiveBtn = document.getElementById('notes-bulk-archive'); + const deleteBtn = document.getElementById('notes-bulk-delete'); + const allEl = document.getElementById('notes-select-all'); + if (countEl) countEl.textContent = `${count} Selected`; + if (sendAgentBtn) sendAgentBtn.disabled = count === 0; + if (archiveBtn) archiveBtn.disabled = count === 0; + if (deleteBtn) deleteBtn.disabled = count === 0; + if (allEl) allEl.checked = state.notes.length > 0 && state.notes.every(n => state.selectedIds.has(n.id)); + // Toggle select-mode class so todo dots don't react to hover + const pane = document.getElementById('notes-pane'); + if (pane) pane.classList.toggle('notes-select-mode', count > 0); + } + + function wireSelectionActions() { + document.getElementById('notes-select-all').addEventListener('change', e => { + if (e.target.checked) state.notes.forEach(n => app.selectId(n.id)); + else app.clearSelection(); + _renderNotes(); _updateBulkBar(); + }); + document.getElementById('notes-send-agent').addEventListener('click', operations.attachSelected); + document.getElementById('notes-bulk-archive').addEventListener('click', operations.archiveSelected); + document.getElementById('notes-bulk-delete').addEventListener('click', operations.deleteSelected); + document.getElementById('notes-bulk-cancel').addEventListener('click', _exitSelectMode); + } + return { wireSelectionActions, _enterSelectMode, _exitSelectMode, _updateBulkBar }; +} diff --git a/static/js/features/notes/ui/text.js b/static/js/features/notes/ui/text.js new file mode 100644 index 000000000..75ecbaf78 --- /dev/null +++ b/static/js/features/notes/ui/text.js @@ -0,0 +1,39 @@ +/** Escaping, safe image sources and link rendering. */ +export function createText({ + uiModule +}) { + function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(//g, '>'); } + + function _attrEsc(s) { + return String(s || '') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') + .replace(/`/g, '`'); + } + + function _safeImgSrc(s) { + const v = (s || '').trim(); + if (!v) return ''; + if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v; + if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v; + return ''; + } + + function _linkify(s) { + const escaped = _esc(s); + const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g; + return escaped.replace(urlRe, (m) => { + let url = m; + // Trim a trailing ')' that doesn't have a matching '(' inside the URL + if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { + url = url.slice(0, -1); + } + const href = url.startsWith('www.') ? `https://${url}` : url; + return `${url}` + (url !== m ? m.slice(url.length) : ''); + }); + } + + return { _esc, _attrEsc, _safeImgSrc, _linkify }; +} diff --git a/static/js/features/notes/ui/toolbar.js b/static/js/features/notes/ui/toolbar.js new file mode 100644 index 000000000..3c3c9fe58 --- /dev/null +++ b/static/js/features/notes/ui/toolbar.js @@ -0,0 +1,78 @@ +/** Panel search, archive and list/grid controls. */ +export function createToolbar({ + app, + state, + persistence, + _renderNotes, + _fetchNotes, + _applyMasonry +}) { + function wireToolbar(pane) { + // Search + const searchEl = document.getElementById('notes-search'); + if (searchEl) { + searchEl.addEventListener('input', () => { + app.setSearchQuery(searchEl.value.trim().toLowerCase()); + _renderNotes(); + }); + } + + // View toggle + const archiveBtn = document.getElementById('notes-archive-toggle'); + if (archiveBtn) { + const ARCHIVE_ICON = 'Archive'; + const CLOSE_ICON = 'Archive'; + const syncArchiveBtn = () => { + archiveBtn.classList.toggle('active', state.showingArchived); + archiveBtn.title = state.showingArchived ? 'Exit archive' : 'View archive'; + archiveBtn.style.opacity = state.showingArchived ? '1' : '0.8'; + // Swap to an X while in archive view so it doubles as a close-back- + // to-active-notes toggle. + archiveBtn.innerHTML = state.showingArchived ? CLOSE_ICON : ARCHIVE_ICON; + // Tint the whole pane so it's obvious you're not in the active list. + pane.classList.toggle('notes-pane-archive', state.showingArchived); + }; + syncArchiveBtn(); + archiveBtn.addEventListener('click', async () => { + app.setShowingArchived(!state.showingArchived); + app.clearSelection(); + syncArchiveBtn(); + // Brief fade so the body content swap doesn't snap : the bg-tint + // change is already eased by CSS transitions on .notes-pane*. + const _bodyEl = document.querySelector('#notes-pane .notes-pane-body'); + if (_bodyEl) { + _bodyEl.style.transition = 'opacity 0.18s ease'; + _bodyEl.style.opacity = '0.25'; + } + await _fetchNotes(); + _renderNotes(); + if (_bodyEl) { + requestAnimationFrame(() => { + _bodyEl.style.opacity = ''; + _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true }); + }); + } + }); + } + const viewBtn = document.getElementById('notes-view-toggle'); + if (viewBtn) { + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + // Label shows what you'll switch TO : "Grid" while in list, "List" while in grid. + const _setViewLabel = () => { + const lbl = viewBtn.querySelector('.notes-header-btn-label'); + if (lbl) lbl.textContent = state.viewMode === 'grid' ? 'List' : 'Grid'; + }; + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + viewBtn.addEventListener('click', () => { + app.setViewMode(state.viewMode === 'grid' ? 'list' : 'grid'); + try { persistence.setItem('odysseus-notes-view', state.viewMode); } catch {} + pane.classList.toggle('notes-view-grid', state.viewMode === 'grid'); + _setViewLabel(); + requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); + }); + } + + } + return { wireToolbar }; +} diff --git a/static/js/features/notes/ui/windowLayout.js b/static/js/features/notes/ui/windowLayout.js new file mode 100644 index 000000000..9e9173a47 --- /dev/null +++ b/static/js/features/notes/ui/windowLayout.js @@ -0,0 +1,114 @@ +/** Shared-window integration: safe fullscreen bounds, docking and stacking. */ +export function createWindowLayout({ + makeWindowDraggable, + snapModalToZone, + clearDockSide, + applyEdgeDock, + topToolWindowZ +}) { + function _notesFullscreenSafeRect() { + const vw = window.innerWidth || document.documentElement.clientWidth || 0; + const vh = window.innerHeight || document.documentElement.clientHeight || 0; + let left = 0; + let right = vw; + + const sidebar = document.getElementById('sidebar'); + const rail = document.getElementById('icon-rail'); + const hamburgerRight = document.body.classList.contains('hamburger-right') + || sidebar?.classList.contains('right-side') + || rail?.classList.contains('right-side'); + + const reserve = (el) => { + if (!el || getComputedStyle(el).display === 'none') return; + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + if (hamburgerRight) right = Math.min(right, rect.left); + else left = Math.max(left, rect.right); + }; + + if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar); + reserve(rail); + + // The fixed hamburger can remain visible even when the rail/sidebar is + // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it. + const hamburger = document.getElementById('hamburger-btn'); + if (hamburger && getComputedStyle(hamburger).display !== 'none') { + const rect = hamburger.getBoundingClientRect(); + const pad = 8; + if (hamburgerRight) right = Math.min(right, rect.left - pad); + else left = Math.max(left, rect.right + pad); + } + + left = Math.max(0, Math.min(left, vw - 80)); + right = Math.max(left + 80, Math.min(right, vw)); + return { left, top: 0, width: right - left, height: vh }; + } + + function _wireNotesWindow(pane) { + if (!pane || pane.dataset.windowDragWired === '1') return; + const header = pane.querySelector('.notes-pane-header'); + if (!header) return; + pane.dataset.windowDragWired = '1'; + makeWindowDraggable(pane, { + content: pane, + header, + fsClass: 'notes-window-fullscreen', + skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber', + enableDock: true, + enableLeftDock: true, + onEnterFullscreen: () => { + pane.classList.add('notes-window-fullscreen'); + snapModalToZone(pane, { + name: 'fullscreen', + rect: _notesFullscreenSafeRect(), + }); + }, + onExitFullscreen: () => { + _restoreNotesSidebarDock(pane); + }, + }); + } + + function _clearNotesSnapStyles(pane) { + if (!pane) return; + const hadLeft = pane.classList.contains('modal-left-docked'); + const hadRight = pane.classList.contains('modal-right-docked'); + pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked'); + if (hadLeft) clearDockSide('left', pane); + if (hadRight) clearDockSide('right', pane); + ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height', + 'max-height', 'margin', 'transform', 'border-radius'] + .forEach((prop) => pane.style.removeProperty(prop)); + delete pane.dataset._tilePreSnap; + delete pane.dataset._tileZone; + delete pane._preDockSnapshot; + delete pane._dockSide; + delete pane._dockSuspended; + } + + function _restoreNotesSidebarDock(pane) { + if (!pane || window.innerWidth <= 768) return; + _clearNotesSnapStyles(pane); + if (!pane.isConnected) return; + applyEdgeDock(pane, 'right'); + } + + function _topToolWindowZ(exclude = null) { + return topToolWindowZ({ exclude }); + } + + function _bringNotesToFront(pane = document.getElementById('notes-pane')) { + if (!pane) return; + const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement; + const z = _topToolWindowZ(backdrop) + 1; + if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important'); + try { + window.dispatchEvent(new CustomEvent('odysseus:modal-opened', { + detail: { id: 'notes-panel', modal: pane }, + })); + } catch (_) {} + } + + + return { _wireNotesWindow, _restoreNotesSidebarDock, _bringNotesToFront }; +} diff --git a/static/js/notes.js b/static/js/notes.js index 8db829706..38bf3d7e6 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -1,5470 +1,5 @@ -/** - * Notes Module — Google Keep-style notes and todos. - * Renders as a sidebar panel (like document editor), not a modal. - */ - -import uiModule from './ui.js?v=20260916largetoolscroll1'; -import { spawnConfetti } from './compare/vote.js?v=20260828resendcaldrag1'; -import * as Modals from './modalManager.js'; -import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; -import { makeWindowDraggable } from './windowDrag.js'; -import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1'; -import { applyEdgeDock, clearDockSide } from './modalSnap.js'; -import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js'; -import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; -import { bindTourHintDismiss } from './tourHintDismiss.js'; - -const API_BASE = window.location.origin; -let _open = false; -let _notes = []; -let _editingId = null; -let _selectedIds = new Set(); -let _activeLabel = null; -let _activeFilter = null; // null | 'default' | 'reminders' | 'no-reminders' -// Cycle order for the Reminders chip: each click on it advances reminders → -// null → no-reminders → null → reminders → ... This var tracks which non-null -// state the next click should land on after passing through null. -let _reminderChipNext = 'reminders'; -let _searchQuery = ''; -let _viewMode = (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list'; // 'list' or 'grid' -let _showingArchived = false; -let _selectMode = false; -let _suppressNextNoteTapId = null; -let _reminderTimer = null; -// Tracks the global keydown listener so closePanel can remove it -// (previously leaked one per openPanel; on multi-open sessions this -// stacked dozens of identical handlers). -let _notesKeydownHandler = null; -// Capture-phase "Esc cancels select mode" listener on document — tracked so it -// is removed on close instead of leaking +1 per panel open/close cycle. -let _notesSelectEscHandler = null; -const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired'; -// Note IDs already shown with the entry-glow once. Re-set when the user -// reschedules the reminder so the new firing glows again on next open. -const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed'; -// IDs of notes whose reminders fired while the notes panel was closed. On the -// next open of the panel we briefly glow those cards so the user can spot them. -const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight'; -const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight'; -// Timestamp of the last time the user opened the notes panel — used to gate -// the rail "fired" badge so old reminders don't re-fire on every page reload. -const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at'; -const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1'; -const NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'; -let _labelsCollapsed = (typeof localStorage !== 'undefined' && localStorage.getItem(NOTES_LABELS_COLLAPSED_KEY) === '1'); - -function _forceCloseNotesPanel() { - _open = false; - _editingId = null; - try { _commitOpenInPlaceEditor(); } catch {} - try { _closeMobileFullscreenEdit({ save: true }); } catch {} - try { _clearViewedReminderGlows(); } catch {} - if (_notesKeydownHandler) { - document.removeEventListener('keydown', _notesKeydownHandler); - _notesKeydownHandler = null; - } - if (_notesSelectEscHandler) { - document.removeEventListener('keydown', _notesSelectEscHandler, true); - _notesSelectEscHandler = null; - } - if (_reminderTimer) { - clearInterval(_reminderTimer); - _reminderTimer = null; - } - document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode'); - document.getElementById('tool-notes-btn')?.classList.remove('active'); - try { Modals.unregister('notes-panel'); } catch {} - try { document.getElementById('notes-pane')?.remove(); } catch {} - try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {} - try { window._restoreSidebarIfRouteCollapsed?.(); } catch {} -} - -function _showNotesFirstOpenHint(pane) { - if (!pane || typeof localStorage === 'undefined') return; - try { - if (localStorage.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return; - localStorage.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1'); - } catch { - return; - } - - dismissOrRemove(document.getElementById('notes-first-open-hint')); - const hint = document.createElement('div'); - hint.id = 'notes-first-open-hint'; - hint.className = 'tour-hint'; - hint.innerHTML = ` -
Notes is your basic todo list, and also where reminders are managed.
- - `; - document.body.appendChild(hint); - - const place = () => { - const r = pane.getBoundingClientRect(); - const hw = hint.offsetWidth || 260; - hint.style.top = Math.max(12, r.top + 58) + 'px'; - hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px'; - }; - requestAnimationFrame(() => { - place(); - hint.classList.add('tour-hint-in'); - }); - window.addEventListener('resize', place); - bindTourHintDismiss(hint, { - timeout: 6500, - fade: 180, - onDismiss: () => window.removeEventListener('resize', place), - }); -} - -function _notesFullscreenSafeRect() { - const vw = window.innerWidth || document.documentElement.clientWidth || 0; - const vh = window.innerHeight || document.documentElement.clientHeight || 0; - let left = 0; - let right = vw; - - const sidebar = document.getElementById('sidebar'); - const rail = document.getElementById('icon-rail'); - const hamburgerRight = document.body.classList.contains('hamburger-right') - || sidebar?.classList.contains('right-side') - || rail?.classList.contains('right-side'); - - const reserve = (el) => { - if (!el || getComputedStyle(el).display === 'none') return; - const rect = el.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return; - if (hamburgerRight) right = Math.min(right, rect.left); - else left = Math.max(left, rect.right); - }; - - if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar); - reserve(rail); - - // The fixed hamburger can remain visible even when the rail/sidebar is - // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it. - const hamburger = document.getElementById('hamburger-btn'); - if (hamburger && getComputedStyle(hamburger).display !== 'none') { - const rect = hamburger.getBoundingClientRect(); - const pad = 8; - if (hamburgerRight) right = Math.min(right, rect.left - pad); - else left = Math.max(left, rect.right + pad); - } - - left = Math.max(0, Math.min(left, vw - 80)); - right = Math.max(left + 80, Math.min(right, vw)); - return { left, top: 0, width: right - left, height: vh }; -} - -function _wireNotesWindow(pane) { - if (!pane || pane.dataset.windowDragWired === '1') return; - const header = pane.querySelector('.notes-pane-header'); - if (!header) return; - pane.dataset.windowDragWired = '1'; - makeWindowDraggable(pane, { - content: pane, - header, - fsClass: 'notes-window-fullscreen', - skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber', - enableDock: true, - enableLeftDock: true, - onEnterFullscreen: () => { - pane.classList.add('notes-window-fullscreen'); - snapModalToZone(pane, { - name: 'fullscreen', - rect: _notesFullscreenSafeRect(), - }); - }, - onExitFullscreen: () => { - _restoreNotesSidebarDock(pane); - }, - }); -} - -function _clearNotesSnapStyles(pane) { - if (!pane) return; - const hadLeft = pane.classList.contains('modal-left-docked'); - const hadRight = pane.classList.contains('modal-right-docked'); - pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked'); - if (hadLeft) clearDockSide('left', pane); - if (hadRight) clearDockSide('right', pane); - ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height', - 'max-height', 'margin', 'transform', 'border-radius'] - .forEach((prop) => pane.style.removeProperty(prop)); - delete pane.dataset._tilePreSnap; - delete pane.dataset._tileZone; - delete pane._preDockSnapshot; - delete pane._dockSide; - delete pane._dockSuspended; -} - -function _restoreNotesSidebarDock(pane) { - if (!pane || window.innerWidth <= 768) return; - _clearNotesSnapStyles(pane); - if (!pane.isConnected) return; - applyEdgeDock(pane, 'right'); -} - -// Notes is not a `.modal`; its backdrop is the top-level stacking surface. -function _topToolWindowZ(exclude = null) { - return topToolWindowZ({ exclude }); -} - -function _bringNotesToFront(pane = document.getElementById('notes-pane')) { - if (!pane) return; - const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement; - const z = _topToolWindowZ(backdrop) + 1; - if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important'); - try { - window.dispatchEvent(new CustomEvent('odysseus:modal-opened', { - detail: { id: 'notes-panel', modal: pane }, - })); - } catch (_) {} -} - -function _loadPendingHighlights() { - try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); } - catch { return new Set(); } -} -function _loadGlowedReminders() { - try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); } - catch { return new Set(); } -} -function _saveGlowedReminders(set) { - try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {} -} -function _loadActiveHighlights() { - try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); } - catch { return new Set(); } -} -function _saveActiveHighlights(set) { - try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {} -} - -function _isCalendarReminderNote(n) { - return n && n.source === 'calendar' && n.label === 'calendar'; -} - -function _userVisibleNotes() { - return _notes.filter(n => !_isCalendarReminderNote(n)); -} - -function _clearViewedReminderGlows() { - const active = _loadActiveHighlights(); - if (!active.size) return; - _saveActiveHighlights(new Set()); - document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => { - card.classList.remove('note-card-reminder-fired-sticky'); - }); -} -function _setReminderCardGlow(noteId, on = true) { - if (!noteId) return; - const active = _loadActiveHighlights(); - if (on) active.add(noteId); - else active.delete(noteId); - _saveActiveHighlights(active); - document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => { - card.classList.toggle('note-card-reminder-fired-sticky', on); - }); -} -// A note has an active reminder when its due time has passed and the user -// hasn't archived or fully completed it. Used for both sorting (bumped above -// the rest of the unpinned section) and the entry-glow flush. -function _hasActiveReminder(n) { - if (!n || n.archived || _isNoteFullyDone(n)) return false; - if (!n.due_date) return false; - const t = new Date(n.due_date).getTime(); - return !isNaN(t) && t <= Date.now(); -} -function _savePendingHighlights(set) { - try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); } - catch {} -} -function _queuePendingHighlight(noteId) { - const set = _loadPendingHighlights(); - set.add(noteId); - _savePendingHighlights(set); -} -function _flushPendingHighlights() { - // Fresh firings (queued by the background loop while the panel was closed) - // glow unconditionally — a notification just told the user something - // happened, so we always point at the note even if it was glowed before. - const queued = _loadPendingHighlights(); - const glowed = _loadGlowedReminders(); - const toGlow = new Set(queued); - // For notes that are merely overdue at open time (no fresh firing event), - // only glow the ones we haven't already shown — otherwise reopening the - // panel keeps lighting up old reminders forever. - for (const n of _notes) { - if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue; - if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id); - } - // Always consume the queue. - _savePendingHighlights(new Set()); - if (!toGlow.size) return; - let firstCard = null; - for (const id of toGlow) { - const card = document.querySelector(`.note-card[data-note-id="${id}"]`); - if (!card) continue; - _setReminderCardGlow(id, true); - if (!firstCard) firstCard = card; - glowed.add(id); - } - _saveGlowedReminders(glowed); - // Bring the first one into view so it can't get buried below the fold. - if (firstCard) { - requestAnimationFrame(() => { - _smoothScrollToNoteCard(firstCard); - }); - } -} - -const COLORS = [ - { name: 'none', value: '' }, - { name: 'red', value: 'red' }, - { name: 'orange', value: 'orange' }, - { name: 'yellow', value: 'yellow' }, - { name: 'green', value: 'green' }, - { name: 'blue', value: 'blue' }, - { name: 'purple', value: 'purple' }, - { name: 'custom', value: 'custom' }, // sentinel — clicking opens native color picker -]; - -const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)'; - -// A note's color is one of: '' (none), a preset name (red/orange/…), or a -// sentinel "bg:" for a custom background image uploaded by the user. -function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); } -function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; } - -function _dotBg(value, noteColor) { - if (value === 'custom') { - const url = _bgImageUrl(noteColor); - return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT; - } - return COLOR_HEX[value]; -} - -function _dotIsActive(value, noteColor) { - if (value === 'custom') return _isBgImage(noteColor); - return value === (noteColor || ''); -} - -// Inline style for a note card/form when color is a custom bg image. -function _customColorStyle(c) { - if (!_isBgImage(c)) return ''; - const url = _bgImageUrl(c); - return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`; -} - -// Open a file picker, upload the chosen image, and resolve with the URL. -function _pickCustomBgImage() { - return new Promise(resolve => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/*'; - input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;'; - document.body.appendChild(input); - let done = false; - const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); }; - input.addEventListener('change', async () => { - const file = input.files?.[0]; - if (!file) return finish(null); - const fd = new FormData(); - fd.append('files', file); - try { - const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); - const data = await res.json(); - const fileId = data.files?.[0]?.id; - if (!fileId) throw new Error('Upload failed'); - finish(`${API_BASE}/api/upload/${fileId}`); - } catch { finish(null); } - }); - // Best-effort cleanup if user dismisses the dialog. - setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000); - input.click(); - }); -} - -const COLOR_HEX = { - '': 'var(--border)', - // Pale/pastel palette — matches the calendar event color picker. - red: '#f0b5ba', - orange: '#e8ccb2', - yellow: '#f2dfbd', - green: '#cce0bc', - blue: '#b0d7f7', - purple: '#e2bcee', -}; - -// Plain notes get a stable visual variant derived from their identity. This -// adds recognition without changing a color the user explicitly selected. -function _standardVisualVariant(note) { - const source = String(note?.id || note?.title || note?.created_at || 'standard-note'); - let hash = 0; - for (let i = 0; i < source.length; i++) hash = ((hash << 5) - hash + source.charCodeAt(i)) | 0; - return Math.abs(hash) % 5; -} - -// ---- API ---- - -let _loading = false; -// Undo stack — most recent action is at the end. We cap it small because the -// only entries that survive a panel reload are in-memory anyway. -const _undoStack = []; -const _NOTE_UNDO_ICON = ''; -function _pushUndo(entry) { - _undoStack.push(entry); - if (_undoStack.length > 20) _undoStack.shift(); -} -function _popAndRunUndo() { - const entry = _undoStack.pop(); - if (entry) entry.run(); - return !!entry; -} - -function _undoArchive(note, prevIdx) { - // Re-insert at original position and clear archived flag on the server. - const safeIdx = Math.min(Math.max(prevIdx, 0), _notes.length); - _notes.splice(safeIdx, 0, { ...note, archived: false }); - _renderNotes(); - _patchNote(note.id, { archived: false }).catch(() => { - // Roll back local insertion if the server refuses - const i = _notes.findIndex(n => n.id === note.id); - if (i >= 0) _notes.splice(i, 1); - _renderNotes(); - uiModule.showError('Undo failed'); - }); -} - -function _archiveNoteById(id, { card = null, celebrate = false } = {}) { - if (!id) return false; - const idx = _notes.findIndex(n => n.id === id); - if (idx < 0) return false; - const note = _notes[idx]; - if (celebrate && card && note && _hasItems(note)) { - const undone = (note.items || []).filter(i => !i.done); - if (undone.length === 0) { - const r = card.getBoundingClientRect(); - spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); - } - } - const removed = _notes.splice(idx, 1)[0]; - _editingId = null; - _renderNotes(); - const undo = () => _undoArchive(removed, idx); - _pushUndo({ label: 'archive', run: undo }); - _patchNote(id, { archived: true }).then(() => { - uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' }); - }).catch(() => { - _notes.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to archive'); - }); - return true; -} - -async function _fetchNotes() { - _loading = true; - try { - const url = `${API_BASE}/api/notes${_showingArchived ? '?archived=true' : ''}`; - const res = await fetch(url, { credentials: 'same-origin' }); - if (!res.ok) { _notes = []; return; } - const data = await res.json(); - _notes = data.notes || data || []; - } catch (e) { - console.error('Failed to fetch notes:', e); - _notes = []; - } finally { - _loading = false; - } -} - -async function _saveNote(note) { - const method = note.id ? 'PUT' : 'POST'; - const url = note.id ? `${API_BASE}/api/notes/${note.id}` : `${API_BASE}/api/notes`; - const res = await fetch(url, { - method, credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(note), - }); - if (!res.ok) throw new Error('Failed to save note'); - return await res.json(); -} - -async function _deleteNoteApi(id) { - // v2 review — used to swallow 4xx/5xx silently. Throw so callers can - // distinguish success vs failure and toast accordingly. - const r = await fetch(`${API_BASE}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' }); - if (!r.ok) throw new Error('HTTP ' + r.status); -} - -async function _patchNote(id, patch) { - const res = await fetch(`${API_BASE}/api/notes/${id}`, { - method: 'PUT', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }); - if (!res.ok) throw new Error('Failed to update note'); - return await res.json(); -} - -// ---- Helpers ---- - -function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(//g, '>'); } -function _attrEsc(s) { - return String(s || '') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>') - .replace(/`/g, '`'); -} -// Image src guard — reject anything that isn't a relative path, http(s), or -// raster data URL so an AI-saved note can't slip script-capable media into the -// rendered . -function _safeImgSrc(s) { - const v = (s || '').trim(); - if (!v) return ''; - if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v; - if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v; - return ''; -} - -// Escape then turn http(s)://... URLs into clickable anchors. XSS-safe. -// Allow balanced `(...)` inside the URL (Wikipedia, MD links) by accepting -// `(` in the body, then trim a trailing unmatched `)` afterwards. -function _linkify(s) { - const escaped = _esc(s); - const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g; - return escaped.replace(urlRe, (m) => { - let url = m; - // Trim a trailing ')' that doesn't have a matching '(' inside the URL - if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) { - url = url.slice(0, -1); - } - const href = url.startsWith('www.') ? `https://${url}` : url; - return `${url}` + (url !== m ? m.slice(url.length) : ''); - }); -} -function _uid() { return Math.random().toString(36).slice(2, 10); } - -// Mobile swipe-to-dismiss for the notes sheet. Mirrors the document panel -// gesture (finger-following, velocity-based dismiss, rubber-band, snap-back) -// so both sheets feel identical; dismisses via the notes closePanel('down'). -function _wireNotesSwipeDismiss(el, pane) { - if (!el || !pane) return; - const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35; - let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0; - let dragging = false, cancelled = false; - - el.addEventListener('touchstart', (e) => { - if (window.innerWidth > 768 || e.touches.length !== 1) return; - if (e.target.closest('button, input, select, label, textarea')) return; - const t = e.touches[0]; - startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp; - velocity = 0; dragging = false; cancelled = false; - }, { passive: true }); - - el.addEventListener('touchmove', (e) => { - if (cancelled || window.innerWidth > 768) return; - const t = e.touches[0]; - const dx = Math.abs(t.clientX - startX); - const dy = t.clientY - startY; - if (!dragging) { - if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; } - if (Math.abs(dy) > 8) { - dragging = true; - pane.style.animation = 'none'; - pane.style.transition = 'none'; - pane.style.willChange = 'transform'; - } else return; - } - const dt = e.timeStamp - lastT; - if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4; - lastY = t.clientY; lastT = e.timeStamp; - e.preventDefault(); - pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`; - }, { passive: false }); - - const endSwipe = () => { - if (!dragging) return; - dragging = false; - pane.style.willChange = ''; - const dy = lastY - startY; - if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) { - // Slide fully off-screen, then minimise. Keep it translated down (don't - // reset) so it doesn't flash back before closePanel removes it. - pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)'; - pane.style.transform = 'translateY(100%)'; - setTimeout(() => closePanel('down'), 200); - } else { - pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)'; - pane.style.transform = ''; - setTimeout(() => { pane.style.transition = ''; }, 260); - } - }; - el.addEventListener('touchend', endSwipe, { passive: true }); - el.addEventListener('touchcancel', endSwipe, { passive: true }); -} - -function _hasTimeComponent(dateStr) { - return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); -} - -function _formatDueDate(dateStr) { - if (!dateStr) return ''; - const d = new Date(dateStr); - if (isNaN(d)) return ''; - const now = new Date(); - const hasTime = _hasTimeComponent(dateStr); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); - const diffDays = Math.round((due - today) / 86400000); - const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; - if (hasTime && d < now) return 'overdue'; - if (!hasTime && diffDays < 0) return 'overdue'; - if (diffDays === 0) return hasTime ? timeStr : 'today'; - if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; - const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); - return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; -} - -function _isDueOverdue(dateStr) { - if (!dateStr) return false; - const d = new Date(dateStr); - if (isNaN(d)) return false; - if (_hasTimeComponent(dateStr)) return d < new Date(); - return d < new Date(new Date().toDateString()); -} - -function _isDueTodayOrOverdue(dateStr) { - if (!dateStr) return false; - const d = new Date(dateStr); - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); - return due <= today; -} - -function _isNoteFullyDone(note) { - if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) { - return note.items.every(it => it.done); - } - return false; -} - -// A "checklist note" — todo or goal — has structured items[] that the cards -// render as checkboxes and that "fully done" / progress logic reads from. -function _hasItems(note) { - return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist'); -} - -// Compact " N/M" progress string for a goal's checklist. Empty when the goal -// has no steps yet (e.g. AI breakdown is still in flight or was cancelled). -function _goalProgress(note) { - if (!Array.isArray(note?.items) || note.items.length === 0) return ''; - const done = note.items.filter(it => it.done).length; - return ` ${done}/${note.items.length}`; -} - -// The next unchecked step in a goal, or null if all done / no items. -function _nextGoalStep(note) { - if (!Array.isArray(note?.items)) return null; - for (let i = 0; i < note.items.length; i++) { - if (!note.items[i].done) return { idx: i, item: note.items[i] }; - } - return null; -} - -// ---- Reminder presets ---- - -function _laterTodayDate() { - const now = new Date(); - const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today - // If less than 1 hour before 6pm, push to "in 3 hours" instead - if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); - return eight; -} -function _tomorrowDate() { - const t = new Date(); - t.setDate(t.getDate() + 1); - t.setHours(8, 0, 0, 0); - return t; -} -function _nextWeekDate() { - const t = new Date(); - const daysUntilMon = (8 - t.getDay()) % 7 || 7; - t.setDate(t.getDate() + daysUntilMon); - t.setHours(8, 0, 0, 0); - return t; -} -function _toLocalDatetimeStr(d) { - // Format as YYYY-MM-DDTHH:MM (local, no TZ) - const pad = n => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} -function _formatReminderTag(dateStr) { - if (!dateStr) return ''; - const d = new Date(dateStr); - if (isNaN(d)) return ''; - const now = new Date(); - const sameDay = d.toDateString() === now.toDateString(); - const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); - const isTomorrow = d.toDateString() === tomorrow.toDateString(); - const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); - if (sameDay) return `Today, ${time}`; - if (isTomorrow) return `Tomorrow, ${time}`; - const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); - return `${dateLabel}, ${time}`; -} -// Build a human label for a date's nth-weekday-of-month, e.g. "2nd Tuesday" -const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; -const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; -function _nthWeekdayLabel(d) { - const n = Math.ceil(d.getDate() / 7); // 1..5 - return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; -} -function _isLastWeekdayOfMonth(d) { - const test = new Date(d); - test.setDate(d.getDate() + 7); - return test.getMonth() !== d.getMonth(); -} -// Find the Nth occurrence of `weekday` in a given year/month. n=1..5. -// If n=5 and there's no 5th occurrence, returns the 4th (so "5th Monday" still works). -function _nthWeekdayOfMonth(year, month, weekday, n) { - const first = new Date(year, month, 1); - const offset = (weekday - first.getDay() + 7) % 7; - let day = 1 + offset + (n - 1) * 7; - // Last day of month - const lastDay = new Date(year, month + 1, 0).getDate(); - if (day > lastDay) day -= 7; - return new Date(year, month, day, 0, 0, 0); -} -function _lastWeekdayOfMonth(year, month, weekday) { - const lastDay = new Date(year, month + 1, 0); - const back = (lastDay.getDay() - weekday + 7) % 7; - return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); -} - -// Snap a chosen datetime forward to the next slot matching a normalized -// recurrence pattern (preserving time-of-day, strictly in the future). -// Anchors to the user's chosen date when it's in the future (so picking a -// recurrence on a far-future date doesn't drag it back to today); otherwise -// anchors to "now". Returns null for daily/yearly/none. -function _snapToRepeat(currentDate, normRepeat) { - const hh = currentDate.getHours(); - const mm = currentDate.getMinutes(); - const now = Date.now(); - const anchor = currentDate.getTime() > now ? currentDate : new Date(); - const parts = normRepeat.split(':'); - const kind = parts[0]; - if (kind === 'weekly') { - const targetWd = parseInt(parts[1], 10); - if (isNaN(targetWd)) return null; - const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); - const delta = (targetWd - d.getDay() + 7) % 7; - d.setDate(d.getDate() + delta); - if (d.getTime() <= now) d.setDate(d.getDate() + 7); - return d; - } - if (kind === 'monthly') { - const sub = parts[1]; - let y = anchor.getFullYear(); - let m = anchor.getMonth(); - // Walk forward up to 14 months to find the next matching slot. - for (let tries = 0; tries < 14; tries++) { - let target; - if (sub === 'day') { - const wantDay = parseInt(parts[2], 10); - if (isNaN(wantDay)) return null; - const lastDay = new Date(y, m + 1, 0).getDate(); - target = new Date(y, m, Math.min(wantDay, lastDay)); - } else if (sub === 'nth') { - const n = parseInt(parts[2], 10); - const wd = parseInt(parts[3], 10); - if (isNaN(n) || isNaN(wd)) return null; - target = _nthWeekdayOfMonth(y, m, wd, n); - } else if (sub === 'last') { - const wd = parseInt(parts[2], 10); - if (isNaN(wd)) return null; - target = _lastWeekdayOfMonth(y, m, wd); - } else { - return null; - } - target.setHours(hh, mm, 0, 0); - if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; - m++; - if (m > 11) { m = 0; y++; } - } - return null; - } - return null; -} - -// Render a repeat value as a human-readable label. -// originalDate is required only to interpret legacy bare values ("weekly", "monthly", ...). -// All call sites pass it; missing it would silently misinterpret legacy values. -function _formatRepeatLabel(repeat, originalDate) { - if (!repeat || repeat === 'none') return ''; - const norm = _normalizeRepeat(repeat, originalDate); - if (norm === 'daily') return 'Daily'; - if (norm === 'yearly') return 'Yearly'; - const parts = norm.split(':'); - if (parts[0] === 'weekly') { - const wd = parseInt(parts[1], 10); - if (isNaN(wd)) return 'Weekly'; - return `Weekly on ${_DAYS[wd]}s`; - } - if (parts[0] === 'monthly') { - if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; - if (parts[1] === 'nth') { - const n = parseInt(parts[2], 10); - const wd = parseInt(parts[3], 10); - return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; - } - if (parts[1] === 'last') { - const wd = parseInt(parts[2], 10); - return `Monthly on last ${_DAYS[wd]}`; - } - } - return norm; -} - -// ---- Reminders ---- - -function _loadFiredReminders() { - try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); } - catch { return new Set(); } -} - -function _saveFiredReminders(set) { - try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); } - catch {} -} - -async function _ensureNotificationPermission() { - if (!('Notification' in window)) return false; - if (Notification.permission === 'granted') return true; - if (Notification.permission === 'denied') return false; - try { const p = await Notification.requestPermission(); return p === 'granted'; } - catch { return false; } -} - -// Repeat format: -// none -// daily -// weekly:W W = 0-6 (Sun..Sat) -// monthly:day:D D = 1-31 (calendar day) -// monthly:nth:N:W N = 1-4 (1st..4th), W = 0-6 (weekday) -// monthly:last:W W = 0-6 (last weekday of month) -// yearly -// Legacy "weekly", "monthly", "monthly_nth_weekday", "monthly_last_weekday" -// are normalized using the original due_date's weekday/Nth. -function _normalizeRepeat(repeat, originalDate) { - if (!repeat || repeat === 'none') return 'none'; - if (repeat === 'daily' || repeat === 'yearly') return repeat; - if (/^(weekly|monthly):/.test(repeat)) return repeat; - // Legacy bare values — derive params from the original date - const wd = originalDate.getDay(); - const n = Math.ceil(originalDate.getDate() / 7); - if (repeat === 'weekly') return `weekly:${wd}`; - if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; - if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; - if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; - return repeat; -} - -function _advanceRecurring(dateStr, repeat) { - const orig = new Date(dateStr); - const hh = orig.getHours(); - const mm = orig.getMinutes(); - let d = new Date(orig); - const norm = _normalizeRepeat(repeat, orig); - if (norm === 'none') return null; - - function step() { - if (norm === 'daily') { - d.setDate(d.getDate() + 1); - return; - } - if (norm === 'yearly') { - d.setFullYear(d.getFullYear() + 1); - return; - } - const parts = norm.split(':'); - const kind = parts[0]; - if (kind === 'weekly') { - // Snap to the requested weekday in the next 1-7 days - const targetWd = parseInt(parts[1], 10); - let delta = (targetWd - d.getDay() + 7) % 7; - if (delta === 0) delta = 7; - d.setDate(d.getDate() + delta); - d.setHours(hh, mm, 0, 0); - return; - } - if (kind === 'monthly') { - const sub = parts[1]; - const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); - const nm = (d.getMonth() + 1) % 12; - let target; - if (sub === 'day') { - const wantDay = parseInt(parts[2], 10); - const lastDay = new Date(ny, nm + 1, 0).getDate(); - target = new Date(ny, nm, Math.min(wantDay, lastDay)); - } else if (sub === 'nth') { - const n = parseInt(parts[2], 10); - const wd = parseInt(parts[3], 10); - target = _nthWeekdayOfMonth(ny, nm, wd, n); - } else if (sub === 'last') { - const wd = parseInt(parts[2], 10); - target = _lastWeekdayOfMonth(ny, nm, wd); - } else { - d = null; return; - } - target.setHours(hh, mm, 0, 0); - d = target; - return; - } - d = null; - } - - step(); - if (d === null) return null; - const now = Date.now(); - // Cap catch-up to avoid runaway on a malformed/very-old date. - let guard = 5000; - while (d.getTime() <= now) { - if (--guard <= 0) return null; - step(); - if (d === null) return null; - } - return _toLocalDatetimeStr(d); -} - -function _checkReminders() { - if (!_notes.length) return; - const now = Date.now(); - const fired = _loadFiredReminders(); - let changed = false; - for (const note of _notes) { - if (!note.due_date || note.archived) continue; - if (!_hasTimeComponent(note.due_date)) continue; - if (fired.has(note.id)) continue; - const due = new Date(note.due_date).getTime(); - if (isNaN(due)) continue; - if (due <= now && due > now - 60000) { - _fireReminder(note); - // Recurring? advance the due_date instead of marking as fired - if (note.repeat && note.repeat !== 'none') { - const next = _advanceRecurring(note.due_date, note.repeat); - if (next) { - note.due_date = next; - _patchNote(note.id, { due_date: next }).catch(() => {}); - // Don't add to fired — new due_date is in the future - continue; - } - } - fired.add(note.id); - changed = true; - } else if (due <= now - 60000) { - // Past, never seen — silently advance recurring or mark fired - if (note.repeat && note.repeat !== 'none') { - const next = _advanceRecurring(note.due_date, note.repeat); - if (next) { - note.due_date = next; - _patchNote(note.id, { due_date: next }).catch(() => {}); - continue; - } - } - fired.add(note.id); - changed = true; - } - } - if (changed) _saveFiredReminders(fired); - // Always refresh badge — fired state may have changed visually without note mutation - _updateRailBadge(); -} - -function _fireReminder(note) { - const title = note.title || 'Note reminder'; - // Include the verbatim note content so the email/notification actually - // shows what to do, not just a count. Cap the per-item lines (8 max) and - // total length so the body stays inbox-friendly. - let rawBody; - if (_hasItems(note)) { - const pending = (note.items || []) - .filter(i => !i.done && !i.checked) - .map(i => (i.text || '').trim()) - .filter(Boolean); - if (pending.length) { - const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n'); - const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : ''; - rawBody = `Pending (${pending.length}):\n${shown}${extra}`; - } else { - rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`; - } - } else { - rawBody = (note.content || '').slice(0, 400); - } - - // Ask the server to dispatch according to user settings. The server may - // return an LLM-written synthesis line and/or send an email. We still show - // a local browser notification so the user gets immediate feedback even if - // the server path is disabled or slow. - const showLocal = (body) => { - if ('Notification' in window && Notification.permission === 'granted') { - try { - const n = new Notification(title, { body, tag: 'note-' + note.id, icon: '/static/favicon.ico' }); - n.onclick = () => { window.focus(); openPanel(); n.close(); }; - } catch {} - } - if (uiModule?.showToast) uiModule.showToast(title); - }; - - // Fire-and-forget server dispatch. If synthesis comes back quickly enough, - // use it as the notification body; otherwise the local notification has - // already shown with the raw body. - let shown = false; - const timer = setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500); - - fetch('/api/notes/fire-reminder', { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ note_id: note.id, title, body: rawBody }), - }) - .then(r => r.ok ? r.json() : null) - .then(data => { - clearTimeout(timer); - if (shown) return; - shown = true; - const body = (data && data.synthesis) ? data.synthesis : rawBody; - showLocal(body); - }) - .catch(() => { - clearTimeout(timer); - if (!shown) { shown = true; showLocal(rawBody); } - }); - - // Pulse the card if visible; otherwise queue it so the next time the user - // opens the notes panel the card gets a brief glow. - _setReminderCardGlow(note.id, true); - const card = document.querySelector(`.note-card[data-note-id="${note.id}"]`); - if (card) { - card.classList.add('note-card-reminder-fired'); - setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000); - } else { - _queuePendingHighlight(note.id); - } -} - -function _startReminderLoop() { - if (_reminderTimer) return; - _reminderTimer = setInterval(_checkReminders, 30000); - _checkReminders(); // run once immediately -} - -function _countDueReminders() { - return _notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date) && !_isNoteFullyDone(n)).length; -} - -let _firedDotDismissedAt = (() => { - try { - const v = parseInt(localStorage.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10); - return Number.isFinite(v) && v > 0 ? v : 0; - } catch { return 0; } -})(); - -function _countFiredReminders() { - // Reminders whose time has actually passed (not just date-today), - // and which fired after the last user dismissal. - const now = Date.now(); - return _notes.filter(n => { - if (n.archived || _isNoteFullyDone(n)) return false; - if (!n.due_date || !_hasTimeComponent(n.due_date)) return false; - const t = new Date(n.due_date).getTime(); - if (isNaN(t) || t > now) return false; - return t > _firedDotDismissedAt; - }).length; -} - -export function dismissFiredReminderDot() { - _firedDotDismissedAt = Date.now(); - try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {} - _updateRailBadge(); -} - -function _updateRailBadge() { - const fired = _countFiredReminders(); - // Rail (mini sidebar) — only show the count when reminders have ACTUALLY - // fired since the last dismissal (i.e. you haven't opened notes yet). - // Showing every overdue note forever made the badge feel permanent. - const railBtn = document.getElementById('rail-notes'); - if (railBtn) { - let badge = railBtn.querySelector('.rail-notes-badge'); - if (fired > 0) { - if (!badge) { - badge = document.createElement('span'); - badge.className = 'rail-notes-badge'; - railBtn.appendChild(badge); - } - badge.textContent = fired > 99 ? '99+' : String(fired); - badge.classList.add('fired'); - } else if (badge) { - badge.remove(); - } - } - // Main sidebar button - const sidebarBtn = document.getElementById('tool-notes-btn'); - if (sidebarBtn) { - let dot = sidebarBtn.querySelector('.tool-notes-dot'); - if (fired > 0) { - if (!dot) { - dot = document.createElement('span'); - dot.className = 'tool-notes-dot'; - const newNoteBtn = sidebarBtn.querySelector('#notes-new-note-btn'); - sidebarBtn.insertBefore(dot, newNoteBtn || null); - } - } else if (dot) { - dot.remove(); - } - } - // Individual note cards — pulse ones with fired reminders - document.querySelectorAll('.note-card').forEach(card => { - const id = card.dataset.noteId; - const note = _notes.find(n => n.id === id); - if (!note || note.archived || _isNoteFullyDone(note)) { - card.classList.remove('note-card-reminder-due'); - return; - } - if (note.due_date && _hasTimeComponent(note.due_date)) { - const t = new Date(note.due_date).getTime(); - card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now()); - } else { - card.classList.remove('note-card-reminder-due'); - } - }); -} - -export async function refreshDueBadge(opts = {}) { - // Usually lightweight, but callers that just created a note reminder can - // force a refresh so the background reminder loop sees it immediately. - if (opts.force || _notes.length === 0) { - try { - const wasArchived = _showingArchived; - _showingArchived = false; - await _fetchNotes(); - _showingArchived = wasArchived; - } catch {} - } - _updateRailBadge(); -} - -// ---- Panel ---- - -export function openPanel() { - if (_open) { - _bringNotesToFront(); - return; - } - // Notes and the document editor occupy the same primary workspace. Preserve - // an open document as a dock chip instead of allowing the two panes to stack - // into a broken layout (or closing the user's document outright). - try { - if (window.documentModule?.isPanelOpen?.()) { - window.documentModule.closePanel('down'); - } - } catch (_) {} - _open = true; - _editingId = null; - _searchQuery = ''; - _clearViewedReminderGlows(); - _firedDotDismissedAt = Date.now(); - try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {} - - const container = document.getElementById('chat-container'); - if (!container) return; - - document.body.classList.add('notes-view'); - - // On mobile the notes panel takes the whole screen — auto-close the - // sidebar so the panel isn't cropped underneath it. - if (window.innerWidth <= 768) { - const sb = document.getElementById('sidebar'); - if (sb) sb.classList.add('hidden'); - document.body.classList.add('sidebar-collapsed'); - } - // Mobile mode: tiles become read-only previews (no inline checkbox / - // edit / archive / etc.), tap opens a fullscreen edit overlay, - // long-press enters drag-to-reorder mode. See _bindCardEvents + - // .notes-mobile-mode CSS rules. - if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode'); - - // Toggle button state - const btn = document.getElementById('tool-notes-btn'); - if (btn) btn.classList.add('active'); - - // Create panel - const pane = document.createElement('div'); - pane.id = 'notes-pane'; - pane.className = 'notes-pane'; - pane.innerHTML = ` - -
-

Notes

- - - - -
- - -
- `; - - // On mobile open as a full-screen bottom sheet (slide up), not the desktop - // side panel. Set inline so it wins over the base .notes-pane rule regardless - // of cascade specifics (the CSS @media override wasn't reliably applying, - // which left it as a side panel squeezing the chat). - if (window.innerWidth <= 768) { - pane.style.position = 'fixed'; - pane.style.inset = '0'; - pane.style.width = '100%'; - pane.style.maxWidth = '100%'; - pane.style.zIndex = '170'; - pane.style.borderRadius = '14px 14px 0 0'; - pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both'; - pane.style.transformOrigin = 'bottom center'; - } - - // Mount on body so Notes can behave like the other draggable windows. On - // desktop it is immediately docked to the right by _restoreNotesSidebarDock. - const backdrop = document.createElement('div'); - backdrop.className = 'notes-pane-backdrop'; - backdrop.id = 'notes-pane-backdrop'; - backdrop.addEventListener('click', (ev) => { - if (ev.target === backdrop) closePanel('down'); - }); - backdrop.appendChild(pane); - document.body.appendChild(backdrop); - _wireNotesWindow(pane); - _restoreNotesSidebarDock(pane); - _bringNotesToFront(pane); - - // Events - // (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.) - - // Mobile: swipe the grab handle / header down to dismiss (minimise to chip). - // Mirrors the document sheet gesture — finger-following, velocity-based - // dismiss, rubber-band on up-drag, spring snap-back. - _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane); - _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane); - - pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true); - pane.addEventListener('focusin', () => _bringNotesToFront(pane), true); - - const minBtn = document.getElementById('notes-minimize-btn'); - if (minBtn) minBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - closePanel('down'); - }); - // Search - const searchEl = document.getElementById('notes-search'); - if (searchEl) { - searchEl.addEventListener('input', () => { - _searchQuery = searchEl.value.trim().toLowerCase(); - _renderNotes(); - }); - } - - // View toggle - const archiveBtn = document.getElementById('notes-archive-toggle'); - if (archiveBtn) { - const ARCHIVE_ICON = 'Archive'; - const CLOSE_ICON = 'Archive'; - const syncArchiveBtn = () => { - archiveBtn.classList.toggle('active', _showingArchived); - archiveBtn.title = _showingArchived ? 'Exit archive' : 'View archive'; - archiveBtn.style.opacity = _showingArchived ? '1' : '0.8'; - // Swap to an X while in archive view so it doubles as a close-back- - // to-active-notes toggle. - archiveBtn.innerHTML = _showingArchived ? CLOSE_ICON : ARCHIVE_ICON; - // Tint the whole pane so it's obvious you're not in the active list. - pane.classList.toggle('notes-pane-archive', _showingArchived); - }; - syncArchiveBtn(); - archiveBtn.addEventListener('click', async () => { - _showingArchived = !_showingArchived; - _selectedIds.clear(); - syncArchiveBtn(); - // Brief fade so the body content swap doesn't snap — the bg-tint - // change is already eased by CSS transitions on .notes-pane*. - const _bodyEl = document.querySelector('#notes-pane .notes-pane-body'); - if (_bodyEl) { - _bodyEl.style.transition = 'opacity 0.18s ease'; - _bodyEl.style.opacity = '0.25'; - } - await _fetchNotes(); - _renderNotes(); - if (_bodyEl) { - requestAnimationFrame(() => { - _bodyEl.style.opacity = ''; - _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true }); - }); - } - }); - } - const viewBtn = document.getElementById('notes-view-toggle'); - if (viewBtn) { - pane.classList.toggle('notes-view-grid', _viewMode === 'grid'); - // Label shows what you'll switch TO — "Grid" while in list, "List" while in grid. - const _setViewLabel = () => { - const lbl = viewBtn.querySelector('.notes-header-btn-label'); - if (lbl) lbl.textContent = _viewMode === 'grid' ? 'List' : 'Grid'; - }; - _setViewLabel(); - requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); - viewBtn.addEventListener('click', () => { - _viewMode = _viewMode === 'grid' ? 'list' : 'grid'; - try { localStorage.setItem('odysseus-notes-view', _viewMode); } catch {} - pane.classList.toggle('notes-view-grid', _viewMode === 'grid'); - _setViewLabel(); - requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body'))); - }); - } - // Select mode - document.getElementById('notes-select-btn').addEventListener('click', () => { - if (_selectMode) _exitSelectMode(); else _enterSelectMode(); - }); - // Esc cancels select mode. Notes uses a toggle "Select" button rather - // than a *-bulk-cancel button, so the global Esc-cancel handler in - // keyboard-shortcuts.js can't reach it — handle it here. Capture phase - // + stopPropagation so Esc cancels select instead of closing the panel. - if (_notesSelectEscHandler) { - document.removeEventListener('keydown', _notesSelectEscHandler, true); - } - _notesSelectEscHandler = (e) => { - if (e.key === 'Escape' && _selectMode) { - e.preventDefault(); - e.stopPropagation(); - _exitSelectMode(); - } - }; - document.addEventListener('keydown', _notesSelectEscHandler, true); - document.getElementById('notes-select-all').addEventListener('change', (e) => { - if (e.target.checked) _notes.forEach(n => _selectedIds.add(n.id)); - else _selectedIds.clear(); - _renderNotes(); - _updateBulkBar(); - }); - document.getElementById('notes-send-agent').addEventListener('click', async () => { - const ids = [..._selectedIds]; - if (!ids.length) return; - const detail = { noteIds: ids, accepted: false }; - window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail })); - if (!detail.accepted) { - uiModule.showToast('Choose a chat or model before attaching notes'); - return; - } - _exitSelectMode(); - }) - document.getElementById('notes-bulk-archive').addEventListener('click', async () => { - const ids = [..._selectedIds]; - if (!ids.length) return; - await Promise.all(ids.map(id => _patchNote(id, { archived: true }).catch(() => {}))); - _exitSelectMode(); - await _fetchNotes(); - _renderNotes(); - uiModule.showToast(`Archived ${ids.length}`); - }); - document.getElementById('notes-bulk-delete').addEventListener('click', async () => { - const ids = [..._selectedIds]; - if (!ids.length) return; - if (uiModule && uiModule.styledConfirm) { - const ok = await uiModule.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }); - if (!ok) return; - } - await Promise.all(ids.map(id => _deleteNoteApi(id).catch(() => {}))); - _exitSelectMode(); - await _fetchNotes(); - _renderNotes(); - uiModule.showToast(`Deleted ${ids.length}`); - }); - document.getElementById('notes-bulk-cancel').addEventListener('click', _exitSelectMode); - // Escape: exit select mode first (if active), otherwise close the panel. - // Skip when the user is editing a form field — those have their own - // ESC-to-cancel handlers and we don't want to nuke the whole panel - // mid-edit. - // Idempotent: remove any previous handler from a prior openPanel so - // re-opening doesn't stack multiple handlers. - if (_notesKeydownHandler) { - document.removeEventListener('keydown', _notesKeydownHandler); - _notesKeydownHandler = null; - } - _notesKeydownHandler = (e) => { - if (!_open) return; - const t = e.target; - const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable); - // Ctrl/Cmd+Z anywhere in the panel — undo the last note action. Skip when - // typing in a field so the browser's normal text-undo still works. - if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) { - if (inField) return; - if (_undoStack.length === 0) return; - e.preventDefault(); - _popAndRunUndo(); - return; - } - // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the - // user is editing or has an active text selection (let the browser handle - // a real text copy in that case). - if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) { - if (inField) return; - const sel = window.getSelection?.(); - if (sel && sel.toString && sel.toString().length > 0) return; - const hovered = document.querySelector('.note-card:hover'); - if (!hovered) return; - const id = hovered.dataset.noteId; - if (!id) return; - e.preventDefault(); - // Flash the ⋯ menu button (copy now lives in that menu). - const btn = hovered.querySelector('.note-card-corner-menu'); - _copyNote(id, btn); - return; - } - if (e.key !== 'Escape') return; - if (inField) return; - if (_selectMode) { _exitSelectMode(); return; } - if (_showingArchived) { - // Mirror the archive toggle button: flip back to active notes. - document.getElementById('notes-archive-toggle')?.click(); - return; - } - _forceCloseNotesPanel(); - }; - document.addEventListener('keydown', _notesKeydownHandler); - - // Load — show skeleton immediately, then fetch - _renderLoadingSkeleton(); - // Defer the highlight flush to the next frame so it runs *after* the cards - // are committed to the DOM (and any FLIP animations have settled), giving - // the querySelector lookups inside something to find. - _fetchNotes().then(() => { - _renderNotes(); - requestAnimationFrame(() => _flushPendingHighlights()); - _startReminderLoop(); - _showNotesFirstOpenHint(pane); - }); -} - -function _renderLoadingSkeleton() { - const body = document.querySelector('#notes-pane .notes-pane-body'); - if (!body) return; - body.innerHTML = ''; - _renderLabelsInto(body); - _renderQuickAdd(body); - const skel = document.createElement('div'); - skel.className = 'notes-skeleton'; - skel.innerHTML = ` -
-
-
-
- `; - body.appendChild(skel); -} - -function _enterSelectMode(initialId = null) { - _selectMode = true; - _selectedIds.clear(); - if (initialId) _selectedIds.add(initialId); - const bar = document.getElementById('notes-bulk-bar'); - const btn = document.getElementById('notes-select-btn'); - if (bar) bar.classList.remove('hidden'); - if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; } - _renderNotes(); - _updateBulkBar(); -} - -function _exitSelectMode() { - _selectMode = false; - _selectedIds.clear(); - const bar = document.getElementById('notes-bulk-bar'); - const btn = document.getElementById('notes-select-btn'); - const all = document.getElementById('notes-select-all'); - if (bar) bar.classList.add('hidden'); - if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; } - if (all) all.checked = false; - _renderNotes(); -} - -function _updateBulkBar() { - const count = _selectedIds.size; - const countEl = document.getElementById('notes-selected-count'); - const sendAgentBtn = document.getElementById('notes-send-agent'); - const archiveBtn = document.getElementById('notes-bulk-archive'); - const deleteBtn = document.getElementById('notes-bulk-delete'); - const allEl = document.getElementById('notes-select-all'); - if (countEl) countEl.textContent = `${count} Selected`; - if (sendAgentBtn) sendAgentBtn.disabled = count === 0; - if (archiveBtn) archiveBtn.disabled = count === 0; - if (deleteBtn) deleteBtn.disabled = count === 0; - if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id)); - // Toggle select-mode class so todo dots don't react to hover - const pane = document.getElementById('notes-pane'); - if (pane) pane.classList.toggle('notes-select-mode', count > 0); -} - -// A note's label field may hold multiple space-separated tags. Split + dedupe. -function _noteTags(n) { - const tags = []; - if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean)); - if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder'); - return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; -} - -function _visibleNoteTags(n) { - return _noteTags(n).filter(t => t !== 'reminder'); -} - -function _isPastReminder(n) { - if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false; - const due = new Date(n.due_date).getTime(); - return !isNaN(due) && due <= Date.now(); -} - -async function _clearPastReminders() { - const targets = _notes.filter(n => !n.archived && _isPastReminder(n)); - if (!targets.length) { - uiModule.showToast?.('No past reminders to clear'); - return; - } - const ok = uiModule?.styledConfirm - ? await uiModule.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true }) - : confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`); - if (!ok) return; - await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {}))); - await _fetchNotes(); - _renderNotes(); - uiModule.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`); -} - -function _renderLabels(root = document) { - const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar'); - if (!bar) return; - if (_labelsCollapsed) { - bar.innerHTML = ''; - bar.style.display = 'none'; - return; - } - const visibleNotes = _userVisibleNotes(); - const labels = new Set(); - for (const n of visibleNotes) for (const t of _visibleNoteTags(n)) labels.add(t); - const sortedLabels = [...labels].sort(); - // Count active reminders (not archived, has datetime due_date) - const reminderCount = visibleNotes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length; - const pastReminderCount = visibleNotes.filter(n => !n.archived && _isPastReminder(n)).length; - const defaultCount = visibleNotes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length; - // Active goals = non-archived goal notes. Today view lists pending steps - // from each, so we surface the count next to the chip. - const goalCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived).length; - const todayCount = visibleNotes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length; - const hasUsefulFilters = sortedLabels.length > 0 || reminderCount > 0 || goalCount > 0; - const hasActiveFilter = _activeLabel !== null || _activeFilter !== null; - if (!hasUsefulFilters && !hasActiveFilter) { - bar.innerHTML = ''; - bar.style.display = 'none'; - return; - } - bar.style.display = ''; - const allActive = _activeLabel === null && _activeFilter === null; - let html = ``; - html += ``; - if (todayCount > 0) { - const isOn = _activeFilter === 'today'; - const icon = ''; - html += ``; - } - if (goalCount > 0) { - const isOn = _activeFilter === 'goals'; - const icon = ''; - html += ``; - } - const isReminderOn = _activeFilter === 'reminders'; - const isReminderOff = _activeFilter === 'no-reminders'; - const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`; - const reminderIcon = isReminderOff - // bell-off icon - ? '' - : ''; - html += ``; - const showingReminders = _activeFilter === 'reminders'; - if (showingReminders && pastReminderCount > 0) { - html += ``; - } - for (const lbl of sortedLabels) { - html += ``; - } - bar.innerHTML = html; - bar.querySelectorAll('.notes-label-chip').forEach(chip => { - chip.addEventListener('click', () => { - if (chip.dataset.action === 'all') { - _activeLabel = null; - _activeFilter = null; - } else if (chip.dataset.action === 'today') { - _activeLabel = null; - _activeFilter = (_activeFilter === 'today') ? null : 'today'; - } else if (chip.dataset.action === 'goals') { - _activeLabel = null; - _activeFilter = (_activeFilter === 'goals') ? null : 'goals'; - } else if (chip.dataset.action === 'default') { - _activeLabel = null; - _activeFilter = (_activeFilter === 'default') ? null : 'default'; - } else if (chip.dataset.action === 'reminders') { - _activeLabel = null; - // Cycle: null → reminders → null → no-reminders → null → reminders → ... - if (_activeFilter === null) { - _activeFilter = _reminderChipNext; - _reminderChipNext = (_reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders'; - } else { - _activeFilter = null; - } - } else if (chip.dataset.action === 'clear-past-reminders') { - _clearPastReminders(); - return; - } else { - _activeFilter = null; - _activeLabel = chip.dataset.label || null; - } - _renderNotes(); - }); - }); -} - -function _renderLabelsInto(_body) { - if (!_body) return; - const pane = _body.closest('.notes-pane'); - const toggleHost = pane?.querySelector('.notes-search-bar') || _body; - let toggle = toggleHost.querySelector(':scope > .notes-label-toggle'); - if (!toggle) { - toggle = document.createElement('button'); - toggle.type = 'button'; - toggle.className = 'notes-label-toggle cal-filter-toggle'; - toggle.addEventListener('click', () => { - _labelsCollapsed = !_labelsCollapsed; - try { localStorage.setItem(NOTES_LABELS_COLLAPSED_KEY, _labelsCollapsed ? '1' : '0'); } catch {} - _renderNotes(); - }); - const selectButton = toggleHost.querySelector('#notes-select-btn'); - toggleHost.insertBefore(toggle, selectButton || null); - } - toggle.textContent = _labelsCollapsed ? '+ Tags' : '− Tags'; - toggle.title = _labelsCollapsed ? 'Show tags' : 'Hide tags'; - toggle.setAttribute('aria-expanded', _labelsCollapsed ? 'false' : 'true'); - - // The shared chip scroller wraps this row once it is mounted. - let bar = _body.querySelector('.notes-labels-bar'); - if (!bar) { - bar = document.createElement('div'); - bar.className = 'notes-labels-bar'; - _body.appendChild(bar); - } - _renderLabels(_body); -} - -function _ensureNotesChipRegistered() { - if (Modals.isRegistered('notes-panel')) return; - Modals.register('notes-panel', { - railBtnId: 'rail-notes', - sidebarBtnId: 'tool-notes-btn', - restoreFn: () => { openPanel(); }, - closeFn: () => { _forceCloseNotesPanel(); }, - }); -} - -// `direction === 'down'` (mobile swipe-down) MINIMIZES the panel to a -// dock chip instead of fully closing — tapping the chip reopens it. -// Any other call (close button, programmatic) is a full close. -export function closePanel(direction) { - if (!_open) return; - _open = false; - _editingId = null; - _clearViewedReminderGlows(); - const _minimize = direction === 'down'; - if (_minimize) { - _ensureNotesChipRegistered(); - } else if (Modals.isRegistered('notes-panel')) { - Modals.unregister('notes-panel'); - } - - // Drop the document keydown listener and the 30s reminder interval — - // both leaked across open/close cycles in the v2 review. - if (_notesKeydownHandler) { - document.removeEventListener('keydown', _notesKeydownHandler); - _notesKeydownHandler = null; - } - if (_notesSelectEscHandler) { - document.removeEventListener('keydown', _notesSelectEscHandler, true); - _notesSelectEscHandler = null; - } - if (_reminderTimer) { - clearInterval(_reminderTimer); - _reminderTimer = null; - } - - document.body.classList.remove('notes-view'); - document.body.classList.remove('notes-mobile-mode'); - document.body.classList.remove('notes-drag-mode'); - // Closing the panel should PRESERVE in-progress edits, not drop them. - // Commit any open in-place editor, and close the mobile fullscreen - // overlay with save=true so the note is persisted. - try { _commitOpenInPlaceEditor(); } catch {} - _closeMobileFullscreenEdit({ save: true }); - // /notes route may have collapsed the wide sidebar to a rail; restore. - try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} - - const btn = document.getElementById('tool-notes-btn'); - if (btn) btn.classList.remove('active'); - - const pane = document.getElementById('notes-pane'); - const backdrop = document.getElementById('notes-pane-backdrop'); - if (pane) { - // Scale-out + fade. Match the enter animation duration so close feels - // like the same gesture played backwards. - pane.classList.add('notes-pane-leaving'); - const _cleanup = () => { - try { pane.remove(); } catch {} - try { backdrop?.remove(); } catch {} - }; - pane.addEventListener('animationend', _cleanup, { once: true }); - // Belt-and-braces: if animation is skipped (reduced motion / detached - // tab) the listener won't fire; remove after the expected duration. - setTimeout(_cleanup, 220); - } else if (backdrop) { - backdrop.remove(); - } - // Show the dock chip for a swipe-down minimize (tap it to reopen). - if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} } -} - -export function togglePanel() { - if (_open) closePanel(); - else openPanel(); -} - -export function isPanelOpen() { return _open; } - -// ---- Render ---- - -// FLIP animation — capture positions before render, animate back after -function _captureCardPositions() { - const body = document.querySelector('#notes-pane .notes-pane-body'); - if (!body) return null; - const positions = new Map(); - body.querySelectorAll('.note-card').forEach(card => { - const id = card.dataset.noteId; - if (id) positions.set(id, card.getBoundingClientRect()); - }); - return positions; -} - -function _animateReflow(prevPositions) { - if (!prevPositions || !prevPositions.size) return; - const body = document.querySelector('#notes-pane .notes-pane-body'); - if (!body) return; - body.querySelectorAll('.note-card').forEach(card => { - const id = card.dataset.noteId; - const prev = prevPositions.get(id); - if (!prev) return; - const next = card.getBoundingClientRect(); - const dx = prev.left - next.left; - const dy = prev.top - next.top; - if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return; - // Invert: jump back to old position - card.style.transition = 'none'; - card.style.transform = `translate(${dx}px, ${dy}px)`; - // Play: animate to 0 - requestAnimationFrame(() => { - card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)'; - card.style.transform = ''; - card.addEventListener('transitionend', () => { - card.style.transition = ''; - }, { once: true }); - }); - }); -} - -function _renderNotes() { - _updateRailBadge(); - const body = document.querySelector('#notes-pane .notes-pane-body'); - if (!body) return; - const prevPositions = _captureCardPositions(); - const activeReminderHighlights = _loadActiveHighlights(); - - const displayNotes = _userVisibleNotes(); - let filtered = _activeLabel ? displayNotes.filter(n => _noteTags(n).includes(_activeLabel)) : displayNotes; - if (_activeFilter === 'reminders') { - filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date)); - } else if (_activeFilter === 'no-reminders') { - filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date))); - } else if (_activeFilter === 'default') { - filtered = filtered.filter(n => _visibleNoteTags(n).length === 0); - } else if (_activeFilter === 'goals') { - filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived); - } else if (_activeFilter === 'today') { - // Today view: only goals that still have an unchecked step. - filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)); - } - if (_searchQuery) { - filtered = filtered.filter(n => { - const q = _searchQuery; - if ((n.title || '').toLowerCase().includes(q)) return true; - if ((n.content || '').toLowerCase().includes(q)) return true; - if ((n.label || '').toLowerCase().includes(q)) return true; - if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true; - return false; - }); - } - const sorted = [...filtered].sort((a, b) => { - // In reminders view: sort by due date ascending (soonest first) - if (_activeFilter === 'reminders') { - const da = new Date(a.due_date || 0).getTime(); - const db = new Date(b.due_date || 0).getTime(); - return da - db; - } - // Archived view: newest archived first (ignore manual sort_order). - if (_showingArchived) { - return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); - } - if (a.pinned && !b.pinned) return -1; - if (!a.pinned && b.pinned) return 1; - // Active reminders (due date in the past, not done/archived) rank - // immediately under the pinned block. - const aActive = _hasActiveReminder(a); - const bActive = _hasActiveReminder(b); - if (aActive && !bActive) return -1; - if (!aActive && bActive) return 1; - const so = (a.sort_order || 0) - (b.sort_order || 0); - if (so !== 0) return so; - return new Date(b.updated_at || 0) - new Date(a.updated_at || 0); - }); - - let html = ''; - // Today view: render a compact card listing the next-unchecked step from - // each active goal. Tapping a step toggles it done (same idx-based wiring - // as regular checkboxes). Tapping the title opens the goal note for full - // editing. - if (_activeFilter === 'today') { - body.innerHTML = ''; - _renderLabelsInto(body); - _renderQuickAdd(body); - if (sorted.length === 0) { - body.insertAdjacentHTML('beforeend', `
All caught up — no pending goal steps right now.
`); - } else { - let todayHtml = `
-
- - Today · one step per goal -
-
`; - for (const note of sorted) { - const next = _nextGoalStep(note); - if (!next) continue; - const progress = _goalProgress(note).trim(); - todayHtml += `
- -
-
${_esc(note.title || '(untitled goal)')}
-
${_linkify(next.item.text || '')}
-
- ${_esc(progress)} -
`; - } - todayHtml += `
`; - body.insertAdjacentHTML('beforeend', todayHtml); - } - _wireTodayView(body); - return; - } - for (const note of sorted) { - if (_editingId === note.id) continue; // skip — form is shown instead - const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)'; - const dueFmt = _formatDueDate(note.due_date); - const overdue = _isDueOverdue(note.due_date); - - let contentHtml = ''; - if (_hasItems(note) && Array.isArray(note.items)) { - // Goal notes can carry a free-form description above the step list — - // todos rarely do, but the same render works for both. - if (note.note_type === 'goal' && (note.content || '').trim()) { - const fullText = note.content || ''; - const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText; - contentHtml += `
${_esc(preview)}
`; - } - contentHtml += '
'; - // Show ALL items — the preview container is scrollable (CSS caps - // its max-height + overflow-y:auto), so there's no need to truncate. - for (let i = 0; i < note.items.length; i++) { - const item = note.items[i]; - const doneClass = item.done ? ' done' : ''; - const indent = Math.min(item.indent || 0, 3); - contentHtml += `
- - ${_linkify(item.text)} - - -
`; - } - contentHtml += '
'; - } else { - const fullText = note.content || ''; - const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText; - // _linkify already calls _esc internally, so URLs become clickable - // anchors (used by e.g. the "remind me to reply" email deep-link). - contentHtml = preview ? `
${_linkify(preview)}
` : ''; - } - - const isBg = _isBgImage(note.color); - const cc = (note.color && !isBg) ? ' note-color-' + note.color : ''; - const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : ''; - const isStandard = !note.note_type || note.note_type === 'note'; - const visualClass = isStandard && !note.color ? ` note-standard note-standard-v${_standardVisualVariant(note)}` : ''; - const sel = _selectedIds.has(note.id) ? ' note-card-selected' : ''; - const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date) - ? `
- - ${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''} -
` - : ''; - const noteTags = _visibleNoteTags(note); - const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `${dueFmt}` : ''; - const colorDots = COLORS.map(c => ``).join(''); - const goalClass = note.note_type === 'goal' ? ' note-card-goal' : ''; - const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : ''; - const goalPill = note.note_type === 'goal' - ? ` - - Goal${_goalProgress(note)} - ` - : ''; - const drawingClass = note.note_type === 'draw' ? ' note-card-drawing' : ''; - html += `
- ${_selectMode ? `` : ''} - ${goalPill} - - ${_showingArchived - ? ` - ` - : ` - ${_hasItems(note) ? `` : ''}`} -
-
${_esc(note.title || '')}
- ${dueBadge} -
- ${_safeImgSrc(note.image_url) ? `` : ''} - ${contentHtml} - ${_hasItems(note) ? `
` : ''} - ${reminderTagHtml} - ${noteTags.length ? `
${noteTags.map(t => ``).join(' ')}
` : ''} -
-
${colorDots}
- - ${_showingArchived ? ` - - ` : ` - ${_hasItems(note) ? ` - ` : ''} - - - `} -
-
`; - } - - // Always render quick-add at top (collapsed unless user is typing) - const existingForm = body.querySelector('.note-form'); - if (existingForm && _editingId === '__new__') { - // Keep the expanded form, replace cards after it - const next = [...body.children].filter(c => c !== existingForm); - next.forEach(c => c.remove()); - if (sorted.length === 0) { - body.insertAdjacentHTML('beforeend', '
No notes ' + uiModule.emptyStateIcon('smiley') + '
'); - } else { - existingForm.insertAdjacentHTML('afterend', html); - } - } else { - body.innerHTML = ''; - _renderLabelsInto(body); - _renderQuickAdd(body); - if (sorted.length === 0) { - body.insertAdjacentHTML('beforeend', '
No notes yet ' + uiModule.emptyStateIcon('smiley') + '
'); - } else { - body.insertAdjacentHTML('beforeend', html); - } - } - - _bindCardEvents(body); - _animateReflow(prevPositions); - _applyMasonry(body); -} - -// In grid view, lay out the cards as masonry by -// computing each card's `grid-row-end: span N` from its measured height -// (rows are 4px tall + 8px row gap simulated via card margin-bottom). The -// grid's `grid-auto-flow: dense` packs columns independently so left/right -// lanes no longer share row heights. -// -// Re-runs on layout-affecting changes via ResizeObserver bound per-card. -let _masonryObserver = null; -function _applyMasonry(body) { - if (!body) return; - const pane = body.closest('.notes-pane'); - const isGrid = pane?.classList.contains('notes-view-grid'); - const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches; - // Tear down any prior observer (defensive — _renderNotes wipes body.innerHTML). - if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; } - if (!isGrid) { - // Clear any leftover inline spans so list view lays out normally. - body.querySelectorAll('.note-card, .notes-label-toggle, .notes-labels-bar, .notes-quick-add, .note-form, .doclib-chip-scroll-frame:has(> .notes-labels-bar)').forEach(c => { c.style.gridRowEnd = ''; }); - return; - } - const ROW_PX = 4; - const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX)); - const recomputeFullRows = () => { - const quickAdd = body.querySelector('.notes-quick-add'); - const labelsToggle = body.querySelector('.notes-label-toggle'); - const labelsBar = body.querySelector('.notes-labels-bar'); - const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') - ? labelsBar.parentElement - : labelsBar; - if (labelsToggle && getComputedStyle(labelsToggle).display !== 'none') { - labelsToggle.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsToggle.scrollHeight))}`; - } - if (labelsLayout && getComputedStyle(labelsLayout).display !== 'none') { - const shave = isMobileGrid ? 4 : 0; - labelsLayout.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsLayout.scrollHeight) - shave)}`; - } - if (quickAdd) { - const shave = isMobileGrid ? 4 : 0; - quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`; - } - body.querySelectorAll('.note-form').forEach(form => { - form.style.gridColumn = '1 / -1'; - const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw'); - const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1; - const renderedHeight = form.getBoundingClientRect?.().height || 0; - const drawReserve = isDrawForm && isMobileGrid ? 12 : 12; - const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve; - form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`; - }); - }; - const recompute = (card) => { - // scrollHeight returns the natural content height — card.getBoundingClientRect() - // would return the grid cell height (collapsed to 4px until the span is set, - // which is the value we're trying to compute). - const h = card.scrollHeight + (isMobileGrid ? 6 : 8); - if (h <= 0) return; - card.style.gridRowEnd = `span ${spanForHeight(h)}`; - }; - recomputeFullRows(); - body.querySelectorAll('.note-card').forEach(recompute); - // Watch masonry participants — content can grow (image load, todo edits, - // quick-add/form expansion), and stale spans are what cause visual merging. - if ('ResizeObserver' in window) { - _masonryObserver = new ResizeObserver(entries => { - let fullRowsChanged = false; - for (const e of entries) { - if (e.target.classList.contains('note-card')) recompute(e.target); - else fullRowsChanged = true; - } - if (fullRowsChanged) recomputeFullRows(); - }); - body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c)); - body.querySelectorAll('.notes-label-toggle, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c)); - const labelsBar = body.querySelector('.notes-labels-bar'); - const labelsLayout = labelsBar?.parentElement?.classList.contains('doclib-chip-scroll-frame') - ? labelsBar.parentElement - : labelsBar; - if (labelsLayout) _masonryObserver.observe(labelsLayout); - } -} - -// Wire the Today aggregated view: tap a step's dot toggles it done; tap -// the goal title opens the full note for editing. Done steps fade and the -// next pending step rotates in on the next render. -function _wireTodayView(body) { - body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => { - dot.addEventListener('click', async (e) => { - e.stopPropagation(); - const id = dot.dataset.noteId; - const idx = parseInt(dot.dataset.idx); - const note = _notes.find(n => n.id === id); - if (!note || !Array.isArray(note.items) || !note.items[idx]) return; - note.items[idx].done = !note.items[idx].done; - const row = dot.closest('.notes-today-row'); - if (row) row.classList.add('done'); - try { - await _patchNote(id, { items: note.items }); - // Re-render so the next pending step bubbles up (or the row drops - // out entirely if the goal is fully done now). - _renderNotes(); - // Confetti when ALL items just turned done. - if (note.items.every(it => it.done)) { - const r = (row || dot).getBoundingClientRect(); - spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); - } - } catch { - note.items[idx].done = !note.items[idx].done; - } - }); - }); - body.querySelectorAll('.notes-today-title').forEach(el => { - el.addEventListener('click', () => { - const id = el.dataset.noteId; - if (!id) return; - // Drop the Today filter first so the regular card list is rendered; - // _editNote needs to find a .note-card in the DOM to replace with - // the editor form. - _activeFilter = null; - _renderNotes(); - _editNote(id); - }); - }); -} - -function _renderQuickAdd(body) { - const wrap = document.createElement('div'); - wrap.className = 'notes-quick-add'; - // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw — - // drawing happens in the expanded form). The pill that's active steers - // both the placeholder and the type the form opens in. - wrap.innerHTML = ` -
- - -
- - - `; - body.appendChild(wrap); - - const input = wrap.querySelector('.notes-quick-input'); - const seg = wrap.querySelector('.notes-quick-type-seg'); - let currentType = 'todo'; - const setType = (t) => { - if (t !== 'note' && t !== 'todo') return; - currentType = t; - seg.classList.toggle('is-todo', t === 'todo'); - seg.classList.toggle('is-note', t === 'note'); - seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { - const on = p.dataset.type === t; - p.classList.toggle('active', on); - p.setAttribute('aria-pressed', on ? 'true' : 'false'); - }); - input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…'; - }; - seg.querySelectorAll('.notes-quick-type-pill').forEach(p => { - p.addEventListener('click', (e) => { - e.stopPropagation(); - setType(p.dataset.type); - }); - }); - // Click input or type → expand to full form - const expandToForm = (initialType = 'note', initialText = '') => { - _editingId = '__new__'; - const form = _buildForm({ note_type: initialType }); - form.classList.add('note-form-new'); - if (initialText) { - const titleEl = form.querySelector('.note-form-title'); - if (titleEl) titleEl.value = initialText; - } - const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid') - && window.matchMedia('(max-width: 768px)').matches; - if (mobileGrid) { - form.style.gridColumn = '1 / -1'; - form.style.gridRowEnd = 'span 64'; - } - wrap.replaceWith(form); - _applyMasonry(body); - requestAnimationFrame(() => _applyMasonry(body)); - const titleEl = form.querySelector('.note-form-title'); - if (titleEl) { - titleEl.focus(); - // Move caret to end - titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length); - } - }; - // Expand only on real intent: a click directly on the input, or actual - // typing. Focus alone — including focus stolen from a missed nearby - // click — no longer creates an empty form. - input.addEventListener('click', () => expandToForm(currentType, input.value)); - input.addEventListener('input', () => expandToForm(currentType, input.value)); - wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => { - e.stopPropagation(); - expandToForm(currentType); - // Trigger photo input on the new form - setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50); - }); -} - -function _bindCardEvents(body) { - // Skip completed leading rows so the first unfinished checklist item is - // visible when a note preview has its own scroll area. - requestAnimationFrame(() => { - body.querySelectorAll('.note-checklist-preview').forEach(preview => { - const firstPending = preview.querySelector('.note-checkbox:not(.done)'); - if (firstPending) preview.scrollTop = Math.max(0, firstPending.offsetTop - 4); - }); - }); - const tapToEditOrSelect = (cardEl) => { - const id = cardEl.dataset.noteId; - // A long-press enters select mode and re-renders the card. Ignore the - // synthetic click that mobile browsers emit when that same touch ends. - if (_suppressNextNoteTapId === id) { - _suppressNextNoteTapId = null; - return; - } - if (_selectMode) { - const cb = cardEl.querySelector('.note-card-cb'); - if (cb) { - cb.checked = !cb.checked; - cb.dispatchEvent(new Event('change')); - } - } else if (_isNotesMobileMode()) { - // Mobile: open the per-note fullscreen edit overlay instead of the - // in-place form. Tiles on mobile are read-only previews. - _openMobileFullscreenEdit(id, cardEl); - } else { - _editNote(id); - } - }; - // Mobile: long-press anywhere on a note card → enter Select mode and - // select that card. Cancelled by movement (so it doesn't interfere with - // vertical scrolling) or by lifting the finger before the timer fires. - if (_isNotesMobileMode()) { - body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card)); - } - body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => { - card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true); - }); - // Click title — edit, or toggle select in select mode - body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - _smoothScrollToNoteCard(el.closest('.note-card')); - tapToEditOrSelect(el.closest('.note-card')); - }); - }); - // Click content — edit, or toggle select in select mode - body.querySelectorAll('.note-content-preview').forEach(el => { - el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); }); - }); - // A saved drawing is the note's primary content. Open its editor directly - // when the preview is clicked, matching title/content preview behavior. - body.querySelectorAll('.note-card-drawing .note-card-image').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - tapToEditOrSelect(el.closest('.note-card')); - }); - }); - // Click empty area of checklist preview (not on checkbox/X) — edit - body.querySelectorAll('.note-checklist-preview').forEach(el => { - el.addEventListener('wheel', (e) => { - const atTop = el.scrollTop <= 0; - const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; - if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) { - const pane = el.closest('.notes-pane-body'); - if (pane) { - pane.scrollTop += e.deltaY; - e.preventDefault(); - } - } - }, { passive: false }); - el.addEventListener('click', (e) => { - if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return; - e.stopPropagation(); - tapToEditOrSelect(el.closest('.note-card')); - }); - }); - // Clicking todo item text now toggles its checkbox — let the click bubble - // up to the parent .note-checkbox row handler. To open the editor, the - // user clicks the pencil corner. - // (No-op block kept as a marker — removing the listener entirely means - // clicks naturally bubble to the row toggle below.) - // In select mode, clicking anywhere on the card toggles selection - if (_selectMode) { - body.querySelectorAll('.note-card').forEach(card => { - card.addEventListener('click', (e) => { - if (e.target.closest('.note-card-cb')) return; // checkbox handles itself - e.stopPropagation(); - tapToEditOrSelect(card); - }); - }); - } - // Mobile, non-select: tapping anywhere on the card body (not on an - // interactive child — buttons, pin, checkbox, color dot, reminder pill, - // agent tag, links) opens the fullscreen editor. Previously only the - // title / content preview triggered edit, so padding + empty gutters were - // dead zones that felt broken on mobile. - if (_isNotesMobileMode() && !_selectMode) { - const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb'; - body.querySelectorAll('.note-card').forEach(card => { - card.addEventListener('click', (e) => { - if (e.target.closest(_INTERACTIVE)) return; - e.stopPropagation(); - tapToEditOrSelect(card); - }); - }); - } - // Multi-select checkbox (only in select mode) - body.querySelectorAll('.note-card-cb').forEach(cb => { - cb.addEventListener('click', (e) => e.stopPropagation()); - cb.addEventListener('change', () => { - const id = cb.dataset.noteId; - if (cb.checked) _selectedIds.add(id); - else _selectedIds.delete(id); - cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked); - _updateBulkBar(); - }); - }); - // Pin toggle (optimistic) - body.querySelectorAll('.note-card-pin').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - const note = _notes.find(n => n.id === id); - if (!note) return; - const prevPinned = note.pinned; - const prevSortOrder = note.sort_order; - note.pinned = !prevPinned; - const patch = { pinned: note.pinned }; - if (note.pinned) { - const minPinned = _notes - .filter(n => n.pinned && n.id !== id) - .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); - note.sort_order = minPinned - 1; - patch.sort_order = note.sort_order; - } - _renderNotes(); - _patchNote(id, patch).catch(() => { - note.pinned = prevPinned; - note.sort_order = prevSortOrder; - _renderNotes(); - uiModule.showError('Failed to pin'); - }); - }); - }); - // Color picker - const _applyCardColor = async (card, id, newColor) => { - const isBg = _isBgImage(newColor); - COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); }); - if (newColor && !isBg) card.classList.add('note-color-' + newColor); - if (isBg) card.setAttribute('style', _customColorStyle(newColor)); - else card.removeAttribute('style'); - card.querySelectorAll('.note-card-color-dot').forEach(d => { - d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor)); - d.style.background = _dotBg(d.dataset.color, newColor); - }); - try { await _patchNote(id, { color: newColor || null }); const note = _notes.find(n => n.id === id); if (note) note.color = newColor; } - catch { uiModule.showError('Failed to update color'); } - }; - body.querySelectorAll('.note-card-color-dot').forEach(dot => { - dot.addEventListener('click', (e) => { - e.stopPropagation(); - const card = dot.closest('.note-card'); - const id = card.dataset.noteId; - if (dot.dataset.color === 'custom') { - _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); }); - return; - } - _applyCardColor(card, id, dot.dataset.color); - }); - }); - // Plain pencil corner → open editor. The unarchive corner shares the - // .note-card-edit-corner class for styling, so :not() keeps the edit - // handler off it. - body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - if (id) _editNote(id); - }); - }); - // Copy corner — bottom-right, just left of the Done check. Shared with - // the Ctrl/Cmd+C shortcut wired further down so both code paths run the - // same serializer + feedback flash. - // ⋯ corner menu — Copy + Agent (solve-this-todo). - body.querySelectorAll('.note-card-corner-menu').forEach(btn => { - btn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - _openNoteCornerMenu(btn); - }); - }); - body.querySelectorAll('.note-card-label-chip').forEach(chip => { - chip.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - const label = chip.dataset.noteLabelFilter; - if (!label) return; - if (_activeLabel === label && _activeFilter === null) { - _activeLabel = null; - } else { - _activeFilter = null; - _activeLabel = label; - } - _renderNotes(); - }); - }); - // Done (✓) at bottom-right — only visible on hover for active notes. - body.querySelectorAll('.note-card-done').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - const card = btn.closest('.note-card'); - const idx = _notes.findIndex(n => n.id === id); - if (idx < 0) return; - // Celebrate completion — same confetti shower the bulk-archive uses. - if (card) { - const r = card.getBoundingClientRect(); - spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80); - } - const removed = _notes.splice(idx, 1)[0]; - const undo = () => _undoArchive(removed, idx); - _pushUndo({ label: 'archive', run: undo }); - const _undoIcon = ''; - const finish = () => { - _renderNotes(); - _patchNote(id, { archived: true }).then(() => { - uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' }); - }).catch(() => { - _notes.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to archive'); - }); - }; - if (card) { - card.classList.add('note-card-sliding-out'); - let done = false; - const once = () => { if (done) return; done = true; finish(); }; - card.addEventListener('transitionend', once, { once: true }); - setTimeout(once, 400); - } else { - finish(); - } - }); - }); - // Unarchive corner — only visible in archive view. - body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - const idx = _notes.findIndex(n => n.id === id); - if (idx < 0) return; - const removed = _notes.splice(idx, 1)[0]; - _renderNotes(); - _patchNote(id, { archived: false }).then(() => uiModule.showToast('Unarchived')).catch(() => { - _notes.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to unarchive'); - }); - }); - }); - // Trash corner — archive view only. Permanent delete, no confirmation. - body.querySelectorAll('.note-card-corner-trash').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - const idx = _notes.findIndex(n => n.id === id); - if (idx < 0) return; - const removed = _notes.splice(idx, 1)[0]; - _renderNotes(); - _deleteNoteApi(id).then(() => uiModule.showToast('Deleted')).catch(() => { - _notes.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to delete'); - }); - }); - }); - - body.querySelectorAll('.note-card-archive').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - if (!id) return; - const card = btn.closest('.note-card'); - let done = false; - const finishRemove = () => { - if (done) return; - done = true; - _archiveNoteById(id, { card, celebrate: true }); - }; - if (card) { - card.classList.add('note-card-sliding-out'); - card.addEventListener('transitionend', finishRemove, { once: true }); - setTimeout(finishRemove, 400); - } else { - finishRemove(); - } - }); - }); - // Unarchive (optimistic) — only present in archive view - body.querySelectorAll('.note-card-unarchive').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - const idx = _notes.findIndex(n => n.id === id); - if (idx < 0) return; - const removed = _notes.splice(idx, 1)[0]; - _renderNotes(); - _patchNote(id, { archived: false }).then(() => uiModule.showToast('Unarchived')).catch(() => { - _notes.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to unarchive'); - }); - }); - }); - // Delete (optimistic) - body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const id = btn.dataset.noteId; - const idx = _notes.findIndex(n => n.id === id); - if (idx < 0) return; - const removed = _notes.splice(idx, 1)[0]; - _renderNotes(); - _deleteNoteApi(id).catch(() => { - _notes.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to delete'); - }); - }); - }); - // Copy entire checklist (title + items, markdown-style) - body.querySelectorAll('.note-card-copy').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const id = btn.dataset.noteId; - const note = _notes.find(n => n.id === id); - if (!note) return; - const lines = []; - if (note.title) lines.push(note.title); - if (note.content) lines.push(note.content); - if (lines.length) lines.push(''); - for (const it of (note.items || [])) { - if (!it || !(it.text || '').trim()) continue; - lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); - } - const text = lines.join('\n').trim(); - try { - await navigator.clipboard.writeText(text); - uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`); - } catch { - // Fallback for browsers blocking the async API - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy'); uiModule.showToast?.('Copied'); } - catch { uiModule.showError?.('Copy failed'); } - ta.remove(); - } - }); - }); - - // Remove a single checklist item (hover X) - body.querySelectorAll('.note-checkbox-rm').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - if (_selectMode) return; - const noteId = btn.dataset.noteId; - const idx = parseInt(btn.dataset.idx); - const note = _notes.find(n => n.id === noteId); - if (!note || !Array.isArray(note.items) || !note.items[idx]) return; - const removed = note.items[idx]; - note.items = note.items.filter((_, i) => i !== idx); - _renderNotes(); - _patchNote(noteId, { items: note.items }).catch(() => { - note.items.splice(idx, 0, removed); - _renderNotes(); - uiModule.showError('Failed to remove item'); - }); - }); - }); - - function _startChecklistItemEdit(noteId, idx, span) { - if (span.isContentEditable) return; - const note = _notes.find(n => n.id === noteId); - if (!note || !Array.isArray(note.items) || !note.items[idx]) return; - - span.textContent = note.items[idx].text || ''; - span.contentEditable = "true"; - span.spellcheck = false; - span.focus(); - - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(span); - selection.removeAllRanges(); - selection.addRange(range); - - const save = () => { - if (!span.isContentEditable) return; - span.contentEditable = "false"; - const newText = span.textContent.trim(); - const oldText = (note.items[idx].text || '').trim(); - - if (newText === oldText) { - _renderNotes(); - return; - } - - const oldItem = note.items[idx]; - if (!newText) { - note.items.splice(idx, 1); - } else { - note.items[idx].text = newText; - } - - _patchNote(noteId, { items: note.items }).catch(() => { - if (!newText) note.items.splice(idx, 0, oldItem); - else note.items[idx].text = oldText; - _renderNotes(); - uiModule.showError('Failed to update item'); - }); - _renderNotes(); - }; - - const onKeydown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - save(); - } else if (e.key === 'Escape') { - e.preventDefault(); - span.contentEditable = "false"; - _renderNotes(); - } - }; - - span.addEventListener('blur', save, { once: true }); - span.addEventListener('keydown', onKeydown); - } - - // Edit a single checklist item (hover Edit button) - body.querySelectorAll('.note-checkbox-edit').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - if (_selectMode) return; - const noteId = btn.dataset.noteId; - const idx = parseInt(btn.dataset.idx); - const span = btn.parentElement.querySelector('.note-check-text'); - if (span) _startChecklistItemEdit(noteId, idx, span); - }); - }); - - // Prevent clicks from toggling the row while actively editing inline - body.querySelectorAll('.note-check-text').forEach(span => { - span.addEventListener('click', (e) => { - if (span.isContentEditable) { - e.stopPropagation(); - } - }); - }); - - // Quick-add new checklist item (hover input at bottom of todo cards) - body.querySelectorAll('.note-cl-quickadd-input').forEach(input => { - input.addEventListener('click', (e) => e.stopPropagation()); - input.addEventListener('keydown', async (e) => { - e.stopPropagation(); - if (e.key !== 'Enter') return; - e.preventDefault(); - const text = input.value.trim(); - if (!text) return; - const noteId = input.dataset.noteId; - const note = _notes.find(n => n.id === noteId); - if (!note) return; - const items = Array.isArray(note.items) ? [...note.items] : []; - items.push({ id: _uid(), text, done: false }); - note.items = items; - input.value = ''; - _renderNotes(); - // Refocus the input on the same card - setTimeout(() => { - const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`); - if (next) next.focus(); - }, 0); - _patchNote(noteId, { items }).catch(() => { - note.items = items.slice(0, -1); - _renderNotes(); - uiModule.showError('Failed to add item'); - }); - }); - }); - - // Checkboxes (dot toggle, optimistic) — disabled in select mode - body.querySelectorAll('.note-checkbox').forEach(el => { - el.addEventListener('click', (e) => { - if (_selectMode) return; // let card-level handler take over - e.preventDefault(); - e.stopPropagation(); - const noteId = el.dataset.noteId; - const idx = parseInt(el.dataset.idx); - const note = _notes.find(n => n.id === noteId); - if (!note || !note.items || !note.items[idx]) return; - const wasAllDone = note.items.length > 0 && note.items.every(it => it.done); - note.items[idx].done = !note.items[idx].done; - el.classList.toggle('done', note.items[idx].done); - const isAllDone = note.items.length > 0 && note.items.every(it => it.done); - if (!wasAllDone && isAllDone) { - const card = el.closest('.note-card'); - if (card) { - const r = card.getBoundingClientRect(); - spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60); - } - } - _patchNote(noteId, { items: note.items }).catch(() => { - note.items[idx].done = !note.items[idx].done; - el.classList.toggle('done', note.items[idx].done); - }); - }); - }); - - // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom - // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is - // unreliable on touch browsers and can compete with the long-press flow. - if (!_isNotesMobileMode()) { - body.querySelectorAll('.note-card').forEach(card => { - card.addEventListener('dragstart', (e) => { - if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) { - e.preventDefault(); - return; - } - card.classList.add('dragging'); - body.classList.add('drag-active'); - e.dataTransfer.effectAllowed = 'move'; - try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {} - }); - card.addEventListener('dragend', async () => { - card.classList.remove('dragging'); - body.classList.remove('drag-active'); - body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); - const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); - try { await fetch(`${API_BASE}/api/notes/reorder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); } - catch {} - }); - }); - } - // Track which card we last swapped with so a single hover-over triggers - // one swap, not a jitter as the pointer keeps moving inside the same card. - let _lastSwapId = null; - function _maybeSwap(dragging, clientX, clientY) { - const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card'); - if (!target || target === dragging || !body.contains(target)) return; - const id = target.dataset.noteId; - if (id === _lastSwapId) return; - // FLIP across ALL siblings. In list view only `target` moves visually, but - // in grid view (2-col) and when the pinned-section grid-column-start rule - // shifts, several cards reflow at once. Capture every card's pre-swap rect, - // do the DOM swap, then animate any that actually moved. The dragging card - // is excluded — it's already finger-tracked via translate3d. - const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging); - const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()])); - const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling; - body.insertBefore(dragging, target); - body.insertBefore(target, draggingNext); - for (const c of cards) { - const prev = prevRects.get(c); - const next = c.getBoundingClientRect(); - const dx = prev.left - next.left; - const dy = prev.top - next.top; - if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue; - c.style.transition = 'none'; - c.style.transform = `translate(${dx}px, ${dy}px)`; - requestAnimationFrame(() => { - c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)'; - c.style.transform = ''; - c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true }); - }); - } - _lastSwapId = id; - } - body.addEventListener('dragover', (e) => { - e.preventDefault(); - const dragging = body.querySelector('.note-card.dragging'); - if (!dragging) return; - _maybeSwap(dragging, e.clientX, e.clientY); - }); - body.addEventListener('dragend', () => { _lastSwapId = null; }); - - // Legacy touch drag for larger touch devices only. Phone-sized Notes uses - // the placeholder sorter wired by `_bindLongPressDrag`; running both flows - // makes one press start two independent drag sessions. - if (!_isNotesMobileMode() && 'ontouchstart' in window && !body.dataset.touchDragBound) { - body.dataset.touchDragBound = '1'; - let dragCard = null; - let isDragging = false; - let longPressTimer = null; - let startX = 0, startY = 0; - const LONG_PRESS_MS = 350; - const MOVE_THRESHOLD_PX = 8; - const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a'; - - // Anchor for the finger-follow transform. Recomputed after every swap so - // the card stays under the finger across reorderings. - let anchorX = 0, anchorY = 0; - const _follow = (clientX, clientY) => { - if (!dragCard) return; - const dx = clientX - anchorX; - const dy = clientY - anchorY; - // Compose with the CSS .dragging transform (scale + rotate). - dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`; - }; - const _reanchor = (clientX, clientY) => { - if (!dragCard) return; - // Clear any prior translate so the card resettles in its natural slot, - // then anchor at the finger's current position. Subsequent _follow calls - // translate by (finger - anchor), keeping the finger at the same relative - // spot on the card it had when we re-anchored. - dragCard.style.transform = ''; - anchorX = clientX; - anchorY = clientY; - }; - - const _endDrag = (committed) => { - clearTimeout(longPressTimer); - longPressTimer = null; - if (dragCard) { - dragCard.classList.remove('dragging'); - dragCard.style.transform = ''; - } - body.classList.remove('drag-active'); - _lastSwapId = null; - if (isDragging) { - document.documentElement.style.touchAction = ''; - if (committed) { - const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId); - fetch(`${API_BASE}/api/notes/reorder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }).catch(() => {}); - } - } - dragCard = null; - isDragging = false; - }; - - body.addEventListener('touchstart', (e) => { - if (_selectMode) return; - const card = e.target.closest('.note-card'); - if (!card) return; - if (e.target.closest(_selectorSkip)) return; - const t = e.touches[0]; - startX = t.clientX; startY = t.clientY; - dragCard = card; - longPressTimer = setTimeout(() => { - if (!dragCard) return; - isDragging = true; - dragCard.classList.add('dragging'); - body.classList.add('drag-active'); - document.documentElement.style.touchAction = 'none'; - _reanchor(startX, startY); - try { if (navigator.vibrate) navigator.vibrate(15); } catch {} - }, LONG_PRESS_MS); - }, { passive: true }); - - body.addEventListener('touchmove', (e) => { - if (!dragCard) return; - const t = e.touches[0]; - if (!isDragging) { - // Movement before long-press fires = user is scrolling; cancel pickup. - if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) { - clearTimeout(longPressTimer); - longPressTimer = null; - dragCard = null; - } - return; - } - e.preventDefault(); - // Live-follow the finger first, then check for a swap. After a swap the - // card's natural slot moves, so we re-anchor and re-apply the offset. - _follow(t.clientX, t.clientY); - const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); - _maybeSwap(dragCard, t.clientX, t.clientY); - const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard); - if (before !== after) { - _reanchor(t.clientX, t.clientY); - _follow(t.clientX, t.clientY); - } - }, { passive: false }); - - body.addEventListener('touchend', () => _endDrag(true)); - body.addEventListener('touchcancel', () => _endDrag(false)); - } -} - -// ── Draft autosave ────────────────────────────────────────────────── -// While a note is open in the editor, its form is snapshotted to -// localStorage on every change. If the connection drops, the -// tab closes, or the page reloads before Save is hit, reopening that note -// restores the unsaved text. Clear only after confirmed persistence. -const _DRAFT_PREFIX = 'odysseus-note-draft-'; -function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); } -function _loadDraft(id) { - try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; } -} -function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} } -function _collectFormDraft(form) { - if (!form) return null; - const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || form.dataset.noteType || 'note'; - const d = { - _ts: Date.now(), - note_type: type, - color: form.dataset.noteColor || '', - title: form.querySelector('.note-form-title')?.value || '', - label: form.querySelector('.note-form-label')?.value || '', - due_date: form.querySelector('.note-form-due')?.value || null, - repeat: form.querySelector('.note-form-repeat')?.value || 'none', - }; - if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || ''; - else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); } - else d.items = _collectItems(form); - return d; -} -function _isDraftEmpty(d) { - if (!d) return true; - if ((d.title || '').trim()) return false; - if ((d.content || '').trim()) return false; - if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false; - return true; -} -function _wireDraftAutosave(form, id) { - let t = null; - let pending = Promise.resolve(); - let stopped = false; - let warned = false; - const save = () => { - const d = _collectFormDraft(form); - // An empty edit can be intentional: don't restore stale server text over it. - try { localStorage.setItem(_draftKey(id), JSON.stringify(d)); } - catch { - if (!warned) uiModule.showError('Draft backup unavailable. Keep this note open until saved.'); - warned = true; - } - return d; - }; - const persist = (d) => { - if (!id || id === '__new__' || d.note_type === 'draw') return pending; - const { _ts, ...payload } = d; - payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null; - pending = pending.then(async () => { - try { - await _patchNote(id, payload); - form._autosaveFailed = false; - const note = _notes.find(n => n.id === id); - if (note) Object.assign(note, payload); - if (localStorage.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id); - } catch { - if (!form._autosaveFailed) uiModule.showError('Note not synced. Your edits are kept on this device; retry Save.'); - form._autosaveFailed = true; - } - }); - return pending; - }; - form._flushDraft = () => { clearTimeout(t); return save(); }; - form._stopDraftAutosave = () => { stopped = true; clearTimeout(t); return pending; }; - const sched = () => { - const d = save(); - if (stopped) return; - clearTimeout(t); - t = setTimeout(() => persist(d), 500); - }; - form.addEventListener('input', sched); - form.addEventListener('change', sched); - form.addEventListener('click', (event) => { - if (event.target.closest('.note-cl-dot, .note-cl-rm')) sched(); - }); -} - -// Commit whatever in-place editor is open (called when the panel closes -// or another note is opened) so edits aren't lost when the user navigates -// away without clicking Save. Empty notes are discarded instead of saved. -function _commitOpenInPlaceEditor() { - const form = document.querySelector('#notes-pane .note-form'); - if (!form) return; - const d = _collectFormDraft(form); - if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; } - form.querySelector('.note-form-save')?.click(); -} -// Merge a stored draft over a note so _buildForm renders the unsaved edits. -function _applyDraftToNote(note, id) { - const d = _loadDraft(id); - if (!d) return { note, restored: false }; - const merged = { ...(note || {}) }; - ['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => { - if (d[k] !== undefined) merged[k] = d[k]; - }); - return { note: merged, restored: true }; -} - -// ---- Create / Edit Form ---- - -function _buildForm(note = null) { - const isEdit = note && note.id; - const type = note?.note_type === 'checklist' ? 'todo' : (note?.note_type || 'note'); - const color = note?.color || ''; - const items = note?.items || [{ id: _uid(), text: '', done: false }]; - - const form = document.createElement('div'); - form.className = 'note-form'; - form.dataset.noteType = type; - form.dataset.noteColor = color || ''; - if (color && !_isBgImage(color)) form.classList.add('note-color-' + color); - if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color)); - let currentImageUrl = _safeImgSrc(note?.image_url || ''); - form.innerHTML = ` -
- - - - -
- ${currentImageUrl && type !== 'draw' ? `
` : ''} -
- ${type === 'note' - ? `` - : type === 'draw' - ? _buildDrawHtml() - : type === 'goal' - ? _buildGoalHtml(note, items) - : _buildChecklistHtml(items)} -
-
-
-
- - - -
- - -
- ${COLORS.map(c => ``).join('')} -
- -
- ${isEdit ? ` - - - ` : ''} - - - -
-
- `; - - let currentType = type; - let currentColor = color; - // Stash original-form values so round-trips (Note→Todo→Note) restore the - // user's hand-formatted text instead of a join of generated items. Same the - // other way: if you started in todo, switch to note, switch back, items - // come back unchanged. - let _stashedNoteText = (type === 'note') ? (note?.content || '') : null; - let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null; - // Goal mode kept its own pair of stashes (description + steps) so a - // Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in - // the type picker was later removed, so the only entry point now is - // *editing* an existing goal-typed note — but the switch handler still - // accepts Goal→Todo/Note transitions (downgrading legacy goals), so - // these stashes still earn their keep. - let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null; - let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null; - - // Drawing also stashes the saved image URL so it survives Note↔Draw flips. - let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null; - const _refreshFormLayout = () => { - const body = form.closest('.notes-pane-body'); - if (!body) return; - _applyMasonry(body); - requestAnimationFrame(() => { - _applyMasonry(body); - requestAnimationFrame(() => _applyMasonry(body)); - }); - }; - - // Type segmented control — Note | Todo | Draw - form.querySelectorAll('.note-form-type-pill').forEach(pill => { - pill.addEventListener('click', () => { - const newType = pill.dataset.type; - if (newType === currentType) return; - const bodyEl = form.querySelector('.note-form-body'); - // Stash whatever the user has in the current mode before swapping it - // out, so a subsequent flip back restores their work. - if (currentType === 'note') { - _stashedNoteText = form.querySelector('.note-form-content')?.value || ''; - } else if (currentType === 'todo') { - _stashedTodoItems = _collectItems(form); - } else if (currentType === 'goal') { - _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || ''; - _stashedGoalItems = _collectItems(form); - } else if (currentType === 'draw') { - const c = form.querySelector('.note-form-canvas'); - if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} } - } - // Render the new mode's body and re-wire its inputs. - if (newType === 'todo') { - let nextItems; - if (_stashedTodoItems && _stashedTodoItems.length) { - nextItems = _stashedTodoItems; - } else if (_stashedGoalItems && _stashedGoalItems.length) { - // Going Goal→Todo keeps the AI-generated steps as a plain checklist. - nextItems = _stashedGoalItems; - } else if (_stashedNoteText) { - const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean); - nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }]; - } else { - nextItems = [{ id: _uid(), text: '', done: false }]; - } - bodyEl.innerHTML = _buildChecklistHtml(nextItems); - _wireChecklist(bodyEl); - } else if (newType === 'draw') { - bodyEl.innerHTML = _buildDrawHtml(); - // If the user just attached a photo (via the photo button) and then - // toggled to Draw, paint that photo onto the canvas so they can draw - // on top of it. _stashedDrawUrl wins if they were drawing earlier in - // the same edit session. - _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null); - } else { - const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '') - ? _stashedNoteText - : (_stashedGoalDesc && _stashedGoalDesc) - || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n'); - bodyEl.innerHTML = ``; - _wireHashtag(bodyEl.querySelector('.note-form-content')); - } - const focusEl = newType === 'note' - ? bodyEl.querySelector('.note-form-content') - : newType === 'todo' - ? bodyEl.querySelector('.note-cl-text') - : null; - if (focusEl) { - requestAnimationFrame(() => { - focusEl.focus({ preventScroll: true }); - try { - const end = focusEl.value.length; - focusEl.setSelectionRange(end, end); - } catch {} - }); - } - currentType = newType; - const seg = form.querySelector('.note-form-type-seg'); - seg?.classList.toggle('is-todo', newType === 'todo'); - seg?.classList.toggle('is-draw', newType === 'draw'); - form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType)); - // The standalone image preview (form-image-wrap) and the canvas would - // otherwise both show the same image_url when editing a drawn note. - // Hide it in draw mode, restore it when leaving draw mode. - const imgWrap = form.querySelector('.note-form-image-wrap'); - if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : ''; - // The background-color dots set the note card's bg — they make no sense - // for a drawn note (the canvas image IS the card content), so hide them. - const bgPicker = form.querySelector('.note-color-picker'); - if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : ''; - if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) { - form.style.gridColumn = '1 / -1'; - form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64'; - } - _refreshFormLayout(); - }); - }); - - // Slide a finger across the Note/Todo/Draw control to switch modes (mobile). - // On touchmove we find the pill under the finger and click it — reusing the - // pill click handler above, so the body re-renders + content stashing all - // work. Only fires when crossing into a *different* pill. - const _typeSeg = form.querySelector('.note-form-type-seg'); - if (_typeSeg) { - let _sliding = false; - const _activateAt = (x, y) => { - const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill'); - if (pill && !pill.classList.contains('active')) pill.click(); - }; - _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true }); - _typeSeg.addEventListener('touchmove', (e) => { - if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY); - }, { passive: true }); - _typeSeg.addEventListener('touchend', () => { _sliding = false; }); - _typeSeg.addEventListener('touchcancel', () => { _sliding = false; }); - } - - // Color dots — apply to entire form immediately - const _applyFormColor = (newColor) => { - currentColor = newColor || ''; - form.dataset.noteColor = currentColor; - const isBg = _isBgImage(currentColor); - COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); }); - if (currentColor && !isBg) form.classList.add('note-color-' + currentColor); - if (isBg) form.setAttribute('style', _customColorStyle(currentColor)); - else form.removeAttribute('style'); - form.querySelectorAll('.note-color-dot').forEach(d => { - d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor)); - d.style.background = _dotBg(d.dataset.color, currentColor); - }); - form.dispatchEvent(new Event('change', { bubbles: true })); - }; - form.querySelectorAll('.note-color-dot').forEach(dot => { - dot.addEventListener('click', () => { - if (dot.dataset.color === 'custom') { - _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); }); - return; - } - _applyFormColor(dot.dataset.color); - }); - }); - - if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body')); - if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body')); - if (currentType === 'draw') { - _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null); - // Same hides we apply on type-switch — keep them consistent on initial open. - const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none'; - const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none'; - } - - // Auto-grow the plain-note textarea so editing longer notes is - // comfortable — it expands with the content (up to a cap) instead of - // staying a cramped 4-row box. The user can still drag-resize too. - const _contentTa = form.querySelector('.note-form-content'); - if (_contentTa) { - const _grow = () => { - _contentTa.style.height = 'auto'; - // Inline form: cap at ~50vh so a huge note doesn't push the action - // buttons off-screen. Fullscreen mobile overlay: the body scrolls and - // there are no inline buttons crowding, so allow nearly the full height - // — capping at 50vh there clipped longer notes ("part disappears"). - const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay'); - const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5)); - _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px'; - }; - _contentTa.addEventListener('input', _grow); - // Grow on open so existing content is fully visible. Run again after the - // fullscreen overlay's open animation settles — measuring mid-animation - // (the overlay starts scaled/transitioning) can under-size the box. - setTimeout(_grow, 0); - setTimeout(_grow, 360); - } - - // Reminder bell — opens dropdown menu - const remindBtn = form.querySelector('.note-form-remind-btn'); - const dueInput = form.querySelector('.note-form-due'); - const repeatInput = form.querySelector('.note-form-repeat'); - const tagsEl = form.querySelector('.note-form-reminder-tags'); - - function _renderReminderTag() { - if (!tagsEl) return; - const v = dueInput.value; - const rep = repeatInput.value || 'none'; - if (!v) { tagsEl.innerHTML = ''; return; } - const label = _formatReminderTag(v); - const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : ''; - tagsEl.innerHTML = ``; - tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => { - if (e.target.classList.contains('note-reminder-tag-x')) { - dueInput.value = ''; - repeatInput.value = 'none'; - _renderReminderTag(); - return; - } - _openReminderMenu(remindBtn || tagsEl, true); - }); - } - - function _openReminderMenu(anchor, isEdit = false) { - // Close any existing menu - document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove()); - const menu = document.createElement('div'); - menu.className = 'note-reminder-menu'; - document.body.appendChild(menu); - - const presetItems = [ - { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) }, - { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) }, - { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) }, - { label: 'Select date and time', sub: '', action: () => _pickCustomDate() }, - ]; - - // Sub-page state for the repeat picker. null = top page. - // 'weekly' | 'monthly' | 'monthly_nth' - let subMode = null; - // Temporary state for monthly_nth so user can click N then weekday (or vice versa) - // before committing. - let nthDraft = { n: 0, w: -1 }; - - const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; - - function getNorm() { - if (!dueInput.value) return 'none'; - return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value)); - } - - function commit(val) { - repeatInput.value = val; - _renderReminderTag(); - menu.remove(); - } - - // Like commit, but first snaps dueInput.value forward to the next matching - // slot for the chosen recurrence. Use for weekly/monthly variants where the - // current due date may not match the chosen pattern (e.g. user picks - // "weekly on Mondays" while the date is a Wednesday). - function snapAndCommit(val) { - if (dueInput.value) { - const cur = new Date(dueInput.value); - const norm = _normalizeRepeat(val, cur); - const snapped = _snapToRepeat(cur, norm); - if (snapped) { - dueInput.value = _toLocalDatetimeStr(snapped); - if (remindBtn) remindBtn.classList.add('has-date'); - } - } - commit(val); - } - - function reposition() { - const rect = anchor.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - const mw = menu.offsetWidth || 220; - const mh = menu.offsetHeight || 280; - let top = rect.bottom + 4; - let left = rect.left; - if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); - if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); - if (left < 8) left = 8; - menu.style.top = top + 'px'; - menu.style.left = left + 'px'; - } - - function render() { - let html = ''; - - if (subMode === null) { - html += '
Remind me later
'; - for (let i = 0; i < presetItems.length; i++) { - const it = presetItems[i]; - html += ``; - } - if (isEdit && dueInput.value) { - const norm = getNorm(); - html += '
'; - html += '
Repeat
'; - // None - html += ``; - // Daily - html += ``; - // Weekly → - { - const isW = norm.startsWith('weekly:'); - const wd = isW ? parseInt(norm.split(':')[1], 10) : null; - const sub = isW && !isNaN(wd) ? `${_DAYS[wd]}` : ''; - html += ``; - } - // Monthly → - { - const isM = norm.startsWith('monthly:'); - const sub = isM ? `${_monthlyShortDescriptor(norm)}` : ''; - html += ``; - } - // Yearly - html += ``; - } - } else if (subMode === 'weekly') { - const norm = getNorm(); - const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1; - html += ``; - html += '
Weekly on…
'; - html += '
'; - for (let i = 0; i < 7; i++) { - html += ``; - } - html += '
'; - } else if (subMode === 'monthly') { - const norm = getNorm(); - const dueDate = new Date(dueInput.value); - const dayN = dueDate.getDate(); - html += ``; - html += '
Monthly on…
'; - // Day N — uses the chosen date's day. Always offered. - const dayVal = `monthly:day:${dayN}`; - html += ``; - // Nth weekday → - { - const isNth = norm.startsWith('monthly:nth:'); - const sub = isNth ? `${_monthlyShortDescriptor(norm)}` : ''; - html += ``; - } - } else if (subMode === 'monthly_nth') { - // Pick ordinal (1..4) and weekday (0..6); commit when both chosen. - html += ``; - html += '
Nth weekday of month
'; - html += '
Which one
'; - html += '
'; - for (let i = 1; i <= 4; i++) { - html += ``; - } - html += '
'; - html += '
Weekday
'; - html += '
'; - for (let i = 0; i < 7; i++) { - html += ``; - } - html += '
'; - html += '
'; - const ready = nthDraft.n > 0 && nthDraft.w >= 0; - const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday'; - html += ``; - } - - menu.innerHTML = html; - reposition(); - wire(); - } - - function wire() { - menu.querySelectorAll('[data-action]').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - const a = el.dataset.action; - if (a === 'preset') { - const it = presetItems[parseInt(el.dataset.i, 10)]; - it.action(); - menu.remove(); - } else if (a === 'set') { - snapAndCommit(el.dataset.val); - } else if (a === 'sub') { - subMode = el.dataset.sub; - // Seed nth draft from saved value only on first entry — preserve - // in-progress picks across back-trips (Nth → back → Nth again). - if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) { - const norm = getNorm(); - const m = norm.match(/^monthly:nth:(\d):(\d)$/); - if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) }; - } - render(); - } else if (a === 'back') { - subMode = null; - render(); - } else if (a === 'back-monthly') { - subMode = 'monthly'; - render(); - } else if (a === 'weekly-pick') { - snapAndCommit(`weekly:${el.dataset.wd}`); - } else if (a === 'nth-n') { - nthDraft.n = parseInt(el.dataset.n, 10); - render(); - } else if (a === 'nth-w') { - nthDraft.w = parseInt(el.dataset.wd, 10); - render(); - } else if (a === 'nth-save') { - if (nthDraft.n > 0 && nthDraft.w >= 0) { - snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`); - } - } - }); - }); - } - - render(); - // Click outside to close (single global handler attached after first paint) - setTimeout(() => { - const close = (e) => { - if (!menu.isConnected) { document.removeEventListener('click', close); return; } - if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } - }; - document.addEventListener('click', close); - }, 0); - } - - function _monthlyShortDescriptor(norm) { - const parts = norm.split(':'); - if (parts[1] === 'day') return `Day ${parts[2]}`; - if (parts[1] === 'nth') { - const n = parseInt(parts[2], 10); - const wd = parseInt(parts[3], 10); - return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`; - } - if (parts[1] === 'last') { - const wd = parseInt(parts[2], 10); - return `Last ${_DAYS[wd].slice(0, 3)}`; - } - return ''; - } - - function _setReminder(datetimeLocalStr) { - dueInput.value = datetimeLocalStr; - if (remindBtn) { - remindBtn.classList.add('has-date'); - // Jingle the bell. CSS handles the animation; remove + reflow + re-add - // so it replays every time the user sets/changes a reminder. - const _bell = remindBtn.querySelector('svg'); - if (_bell) { - _bell.classList.remove('jingling'); - void _bell.offsetWidth; - _bell.classList.add('jingling'); - setTimeout(() => _bell.classList.remove('jingling'), 700); - } - } - _renderReminderTag(); - _ensureNotificationPermission(); - } - - function _pickCustomDate() { - // Replace the dropdown menu with a small inline picker - document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove); - const menu = document.createElement('div'); - menu.className = 'note-reminder-menu'; - const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate()); - menu.innerHTML = ` -
Pick date and time
-
- -
-
- - `; - document.body.appendChild(menu); - // Position next to the bell button - const anchor = remindBtn || form.querySelector('.note-form-reminder-tags'); - const rect = anchor.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - const mw = menu.offsetWidth || 240; - const mh = menu.offsetHeight || 200; - let top = rect.bottom + 4; - let left = rect.left; - if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4); - if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8); - if (left < 8) left = 8; - menu.style.top = top + 'px'; - menu.style.left = left + 'px'; - const dInput = menu.querySelector('.note-reminder-date-input'); - dInput.focus(); - if (typeof dInput.showPicker === 'function') { - try { dInput.showPicker(); } catch {} - } - const close = bindMenuDismiss(menu, () => { menu.remove(); }); - menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => { - if (dInput.value) _setReminder(dInput.value); - close(); - }); - } - - if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); }); - _renderReminderTag(); - - // Photo upload - const photoBtn = form.querySelector('.note-form-photo-btn'); - const photoInput = form.querySelector('.note-form-photo-input'); - if (photoBtn && photoInput) { - photoBtn.addEventListener('click', () => photoInput.click()); - photoInput.addEventListener('change', async () => { - const file = photoInput.files?.[0]; - if (!file) return; - const fd = new FormData(); - fd.append('files', file); - try { - const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); - const data = await res.json(); - const fileId = data.files?.[0]?.id; - if (!fileId) throw new Error('Upload failed'); - currentImageUrl = `${API_BASE}/api/upload/${fileId}`; - // Only ever keep the latest attached photo — drop any existing wrap - // before inserting a fresh one. Picking a second photo replaces the - // first instead of stacking. - form.querySelector('.note-form-image-wrap')?.remove(); - const wrap = document.createElement('div'); - wrap.className = 'note-form-image-wrap'; - wrap.innerHTML = ``; - // Insert AFTER the whole header (a flex-row), not after the - // title input itself — otherwise the image lands as a sibling - // of the title inside the header and flex puts them side-by-side. - form.querySelector('.note-form-header').after(wrap); - wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; }); - wrap.querySelector('img').src = currentImageUrl; - } catch (err) { uiModule.showError('Image upload failed'); } - photoInput.value = ''; - }); - } - // Existing image remove - form.querySelector('.note-form-image-rm')?.addEventListener('click', () => { - form.querySelector('.note-form-image-wrap')?.remove(); - currentImageUrl = ''; - }); - - // Title Enter -> focus body (textarea or first checklist item) - form.querySelector('.note-form-title').addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - const ta = form.querySelector('.note-form-content'); - if (ta) { ta.focus(); return; } - const firstItem = form.querySelector('.note-cl-text'); - if (firstItem) firstItem.focus(); - } - }); - - // Hashtag → label: typing "#foo " in title/content appends "foo" to the - // space-separated tag list. Repeats are deduplicated, so #foo #foo only - // keeps one. Tags already present in the label field are left alone. - const labelInput = form.querySelector('.note-form-label'); - const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; - function _wireHashtag(el) { - if (!el || !labelInput) return; - el.addEventListener('input', () => { - const m = _hashtagRe.exec(el.value); - if (!m) return; - const tag = m[2]; - // Dedup against the stripped form — labelInput may already hold `#tag` - // (after Enter normalised), so includes(tag) on the raw split would - // miss the duplicate and append a bare `tag` next to `#tag`. - const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); - const stripped = existing.map(t => t.replace(/^#+/, '')); - if (!stripped.includes(tag)) { - existing.push('#' + tag); - labelInput.value = existing.join(' '); - labelInput.classList.add('flash-once'); - setTimeout(() => labelInput.classList.remove('flash-once'), 600); - } - const cut = el.value.length - m[0].length + m[1].length; - el.value = el.value.slice(0, cut); - }); - } - _wireHashtag(form.querySelector('.note-form-title')); - _wireHashtag(form.querySelector('.note-form-content')); - // Pressing Enter in the tag field commits the current word as its own tag - // and parks the cursor after a trailing space, so the next word becomes a - // separate tag rather than overwriting the previous one. - labelInput?.addEventListener('keydown', (e) => { - if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return; - e.preventDefault(); - e.stopPropagation(); - // Strip any leading #s the user typed, dedupe, then re-prepend exactly - // one #. So typing "foo" or "#foo" both end up as "#foo " in the input; - // the save handler keeps stripping #s before storing so DB stays clean. - const tags = [...new Set(labelInput.value.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; - if (!tags.length) return; - labelInput.value = tags.map(t => '#' + t).join(' ') + ' '; - labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length); - labelInput.classList.add('flash-once'); - setTimeout(() => labelInput.classList.remove('flash-once'), 600); - }); - - // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save - // Escape -> cancel edit - form.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) { - e.preventDefault(); - form.querySelector('.note-form-save')?.click(); - } else if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - form.querySelector('.note-form-cancel')?.click(); - } - }); - - // Save. Prevent the button from stealing focus on press: on mobile, the - // first tap would otherwise just blur the focused textarea/input (closing - // the keyboard and shifting layout), so the tap never reached the button and - // you had to tap "Done" twice. mousedown preventDefault keeps focus put while - // still letting the click fire. - const _saveBtnEl0 = form.querySelector('.note-form-save'); - _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault()); - _saveBtnEl0.addEventListener('click', async () => { - // Guard against spam-clicks: the drawing save AWAITS a canvas upload before - // the optimistic re-render removes the form, so without this a slow upload - // let repeated clicks create duplicate notes. - const _saveBtn = form.querySelector('.note-form-save'); - if (_saveBtn._saving) return; - // Mobile: when an existing note is opened and closed without edits, the - // Update (✓) button morphs into Archive (set up below). Route the click - // directly through the archive flow. Using a DOM .click() proxy here was - // fragile on mobile because the real archive button can move from the - // footer to the fullscreen header. - if (_saveBtn.classList.contains('archive-mode')) { - _archiveNoteById(note?.id); - return; - } - _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5'; - try { - form._flushDraft?.(); - await form._stopDraftAutosave?.(); - form._flushDraft?.(); - const title = form.querySelector('.note-form-title').value.trim(); - // Normalize tag input: split on whitespace, strip leading #s, dedupe, - // re-join with single spaces. Empty → null. - const _rawLabel = form.querySelector('.note-form-label')?.value || ''; - const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))]; - if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder'); - const labelVal = _tags.length ? _tags.join(' ') : null; - const payload = { - title, - note_type: currentType, - color: currentColor, - label: labelVal, - due_date: form.querySelector('.note-form-due').value || null, - repeat: form.querySelector('.note-form-repeat')?.value || 'none', - image_url: currentImageUrl || null, - }; - if (currentType === 'note') { - payload.content = form.querySelector('.note-form-content')?.value || ''; - } else if (currentType === 'draw') { - // Upload the canvas PNG before saving so image_url points to a - // persistent file. We block the save until upload completes — drawings - // can't be re-rendered later without the URL. - const canvas = form.querySelector('.note-form-canvas'); - const upload = await _uploadCanvasAsPng(canvas, note?.gallery_id || null); - if (!upload?.url) { uiModule.showError('Failed to save drawing'); return; } - payload.image_url = upload.url; - if (upload.galleryId) payload.gallery_id = upload.galleryId; - } else if (currentType === 'goal') { - // Legacy: existing goal-type notes still edit through this branch. - // No AI involvement — save as a normal note with description + items. - payload.content = form.querySelector('.note-form-goal-desc')?.value || ''; - payload.items = _collectItems(form); - } else { - payload.items = _collectItems(form); - } - if (isEdit) payload.id = note.id; - // Reset fired reminder if due_date changed (so re-arm works), and also - // clear the entry-glow seen flag so the new firing glows again on the - // next time the user opens the panel. - if (isEdit && note.due_date !== payload.due_date) { - const fired = _loadFiredReminders(); - fired.delete(note.id); - _saveFiredReminders(fired); - const glowed = _loadGlowedReminders(); - glowed.delete(note.id); - _saveGlowedReminders(glowed); - _setReminderCardGlow(note.id, false); - } - // Edited notes move to the top of their section (under pinned). Compute - // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above - // siblings; the pin block keeps its own ordering above this. - if (!payload.pinned) { - // Both edits AND newly-created notes anchor above the rest of the - // unpinned section. Without this, freshly created notes sit at the - // bottom because manually-reordered siblings already carry negative - // sort_order values. - const minUnpinned = _notes - .filter(n => !n.pinned && (!isEdit || n.id !== note.id)) - .reduce((m, n) => Math.min(m, n.sort_order || 0), 0); - payload.sort_order = minUnpinned - 1; - } - // Optimistic update — update local state first, render, then save in background - _editingId = null; - const draftId = isEdit ? note.id : '__new__'; - const submittedDraft = JSON.stringify(_loadDraft(draftId)); - if (isEdit) { - const idx = _notes.findIndex(n => n.id === note.id); - if (idx >= 0) _notes[idx] = { ..._notes[idx], ...payload }; - } else { - _notes.unshift({ ...payload, id: 'tmp_' + _uid(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() }); - } - _renderNotes(); - // Background save - _saveNote(payload).then(saved => { - if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId); - if (!isEdit && saved && saved.id) { - // Replace temp ID with real one from server. AND re-render — the - // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign - // bumps the in-memory id, so all subsequent clicks (edit, done, copy, - // archive, delete) silently fail to find the note in `_notes`. - const tmp = _notes.find(n => n.id.startsWith('tmp_')); - if (tmp) Object.assign(tmp, saved); - _renderNotes(); - } - }).catch(err => { - uiModule.showError('Save failed: ' + err.message); - _fetchNotes().then(() => _renderNotes()); - }); - } finally { - // Re-enable on early returns / errors. On success the form is removed by - // the optimistic re-render, so re-enabling the detached button is a no-op. - _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = ''; - } - }); - - // Mobile-only: when editing an existing note, the Update (✓) button starts in - // archive-mode (visually + behaviorally) and flips to Update on the first - // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever - // touching a separate Archive button. - if (isEdit && window.innerWidth <= 768) { - const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label'); - const _isFinishedTodo = () => { - if (currentType !== 'todo') return false; - const rows = [...form.querySelectorAll('.note-cl-row')]; - const withText = rows.filter(row => (row.querySelector('.note-cl-text')?.value || '').trim()); - return withText.length > 0 && withText.every(row => row.classList.contains('done')); - }; - const _enterArchive = () => { - _saveBtnEl0.classList.add('archive-mode'); - const label = _isFinishedTodo() ? 'Finish' : 'Archive'; - if (_saveLabelEl) _saveLabelEl.textContent = label; - _saveBtnEl0.title = label; - }; - const _enterUpdate = () => { - if (!_saveBtnEl0.classList.contains('archive-mode')) return; - _saveBtnEl0.classList.remove('archive-mode'); - if (_saveLabelEl) _saveLabelEl.textContent = 'Update'; - _saveBtnEl0.title = 'Update'; - }; - _enterArchive(); - form.addEventListener('input', _enterUpdate, true); - form.addEventListener('change', _enterUpdate, true); - // Checklist dots use a custom control and dispatch their update before - // this click bubbles. Re-evaluate afterward so a fully completed todo - // offers Finish (which archives/removes it from the active list). - form.addEventListener('click', (event) => { - if (!event.target.closest('.note-cl-dot')) return; - setTimeout(() => { - if (_isFinishedTodo()) _enterArchive(); - else _enterUpdate(); - }, 0); - }, true); - } - - // Cancel - form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); _editingId = null; _renderNotes(); }); - - // Archive / Delete — edit-mode-only buttons, mirror the (now-hidden) card actions. - form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => { - if (!isEdit) return; - _archiveNoteById(note.id); - }); - form.querySelector('.note-form-delete-btn')?.addEventListener('click', async () => { - if (!isEdit) return; - const id = note.id; - if (uiModule.styledConfirm) { - const ok = await uiModule.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }); - if (!ok) return; - } else if (!confirm('Delete this note?')) { - return; - } - const idx = _notes.findIndex(n => n.id === id); - if (idx >= 0) _notes.splice(idx, 1); - _editingId = null; - _renderNotes(); - _deleteNoteApi(id).then(() => uiModule.showToast('Deleted')).catch(() => { - uiModule.showError('Failed to delete'); - _fetchNotes().then(() => _renderNotes()); - }); - }); - - // Autosave a draft to localStorage on every change so unsaved edits - // survive connection loss / reload / accidental close. - _wireDraftAutosave(form, isEdit ? note.id : '__new__'); - - return form; -} - -// Legacy goal-typed notes still render through this branch so existing -// data isn't lost. The "Goal" type is no longer exposed in the form picker -// or quick-add — these notes show with a description + manual checklist -// editor, just like a regular todo with a body. -function _buildGoalHtml(note, items) { - const desc = (note?.content || '').toString(); - return ` -
- - ${_buildChecklistHtml(items)} -
- `; -} - -function _wireGoalForm(form, container) { - if (!container) return; - // _wireHashtag is a closure inside _buildForm — out of scope here. Inline - // the same behavior (type "#foo " in the description → tag added to the - // form's label input) so editing a goal note doesn't ReferenceError. - const desc = container.querySelector('.note-form-goal-desc'); - const labelInput = form?.querySelector('.note-form-label'); - if (desc && labelInput) { - const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/; - desc.addEventListener('input', () => { - const m = tagRe.exec(desc.value); - if (!m) return; - const tag = m[2]; - // Same dedup-after-stripping fix as the plain note hashtag handler. - const existing = labelInput.value.trim().split(/\s+/).filter(Boolean); - const stripped = existing.map(t => t.replace(/^#+/, '')); - if (!stripped.includes(tag)) { - existing.push('#' + tag); - labelInput.value = existing.join(' '); - labelInput.classList.add('flash-once'); - setTimeout(() => labelInput.classList.remove('flash-once'), 600); - } - const cut = desc.value.length - m[0].length + m[1].length; - desc.value = desc.value.slice(0, cut); - }); - } - // Always wire the checklist. The previous gate on a `note-form-goal-fresh` - // class was dead code — nothing ever set that class, so the editor never - // hooked up add/drag/Tab. - _wireChecklist(container); -} - -function _buildChecklistHtml(items) { - let html = '
'; - for (const item of items) { - const indent = Math.min(item.indent || 0, 3); - html += `
- ⋮⋮ - - - -
`; - } - // `type="button"` matters on mobile — without it some browsers treat - // bare
`; - return html; -} - -function _wireRow(row, container) { - row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove()); - row.querySelector('.note-cl-dot')?.addEventListener('click', () => { - const wasDone = row.classList.contains('done'); - row.classList.toggle('done'); - // The custom dot has no native input/change event. Notify the form so - // mobile edit mode switches its action from Archive back to Update. - container.dispatchEvent(new Event('input', { bubbles: true })); - const becameDone = !wasDone; // we just flipped it - const dot = row.querySelector('.note-cl-dot'); - const dRect = (dot || row).getBoundingClientRect(); - // Small confetti burst on each fresh check so the user gets a - // "well done" beat per item, not just the grand-finale on all-done. - if (becameDone) { - spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16); - } - // Bigger burst when the whole list is now done. - const rows = [...container.querySelectorAll('.note-cl-row')]; - const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0); - if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) { - spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60); - } - }); - const txt = row.querySelector('.note-cl-text'); - txt?.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); } - else if (e.key === 'Tab') { - e.preventDefault(); - const cur = parseInt(row.dataset.indent || '0'); - const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1); - row.dataset.indent = String(next); - row.style.paddingLeft = (next * 16) + 'px'; - } else if (e.key === 'Backspace' && txt.value === '') { - e.preventDefault(); - const prev = row.previousElementSibling; - row.remove(); - if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus(); - } - }); - // Drag handlers - row.addEventListener('dragstart', (e) => { - row.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {} - }); - row.addEventListener('dragend', () => { - row.classList.remove('dragging'); - container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); - }); -} - -function _wireChecklist(container) { - if (!container) return; - // Delegate the + Add click off the container so re-renders + mobile - // touch quirks don't leave the button dead. The previous direct - // `addEventListener` on the button silently broke on mobile when - // _wireChecklist ran more than once (or before the button was in DOM). - if (!container._addDelegated) { - container._addDelegated = true; - container.addEventListener('click', (ev) => { - const addBtn = ev.target.closest('.note-cl-add'); - if (!addBtn || !container.contains(addBtn)) return; - ev.preventDefault(); - ev.stopPropagation(); - const inputs = container.querySelector('.note-checklist-inputs'); - if (!inputs) return; - const row = document.createElement('div'); - row.className = 'note-cl-row'; - row.draggable = true; - row.dataset.itemId = _uid(); - row.dataset.indent = '0'; - row.innerHTML = `⋮⋮`; - inputs.insertBefore(row, addBtn); - row.querySelector('.note-cl-text').focus(); - _wireRow(row, container); - }); - } - container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container)); - - // Drag-over handler on the inputs container - const inputs = container.querySelector('.note-checklist-inputs'); - if (inputs) { - inputs.addEventListener('dragover', (e) => { - e.preventDefault(); - const dragging = inputs.querySelector('.note-cl-row.dragging'); - if (!dragging) return; - inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); - const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')]; - const after = rows.find(r => { - const box = r.getBoundingClientRect(); - return e.clientY < box.top + box.height / 2; - }); - if (after) { - after.classList.add('drop-before'); - inputs.insertBefore(dragging, after); - } else if (rows.length) { - rows[rows.length - 1].classList.add('drop-after'); - inputs.insertBefore(dragging, container.querySelector('.note-cl-add')); - } - }); - inputs.addEventListener('dragleave', (e) => { - if (!inputs.contains(e.relatedTarget)) { - inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after')); - } - }); - } -} - -function _collectItems(form) { - const items = []; - form.querySelectorAll('.note-cl-row').forEach(row => { - const text = row.querySelector('.note-cl-text')?.value?.trim(); - if (text) items.push({ - id: row.dataset.itemId || _uid(), - text, - done: row.classList.contains('done'), - indent: parseInt(row.dataset.indent || '0'), - }); - }); - return items; -} - -// ---- Draw mode (canvas) ---- - -function _buildDrawHtml() { - return ` -
- -
- - -
- - -
- - - - -
-
- `; -} - -// Attach drawing handlers to the canvas inside `container`. Optionally loads -// `initialImageUrl` as a background, so editing an existing drawing keeps it. -function _wireCanvas(container, initialImageUrl) { - const canvas = container.querySelector('.note-form-canvas'); - if (!canvas) return; - const resolveThemeColor = (expression, fallback) => { - const probe = document.createElement('span'); - probe.style.color = expression; - probe.style.position = 'fixed'; - probe.style.visibility = 'hidden'; - document.body.appendChild(probe); - const resolved = getComputedStyle(probe).color || fallback; - probe.remove(); - return resolved; - }; - const colorToHex = (color, fallback) => { - try { - const probe = document.createElement('canvas'); - probe.width = probe.height = 1; - const probeCtx = probe.getContext('2d'); - probeCtx.fillStyle = color; - probeCtx.fillRect(0, 0, 1, 1); - const [r, g, b] = probeCtx.getImageData(0, 0, 1, 1).data; - return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`; - } catch { return fallback; } - }; - const surfaceColor = resolveThemeColor('var(--panel, var(--bg))', '#282c34'); - const surfaceColorHex = colorToHex(surfaceColor, '#282c34'); - const highlightColor = colorToHex( - resolveThemeColor('var(--accent, var(--red))', '#e06c75'), - '#e06c75', - ); - const colorInput = container.querySelector('.note-form-draw-color'); - if (colorInput) colorInput.value = highlightColor; - // Bump the backing-store resolution for retina displays so strokes stay - // crisp. Set only style.width — leaving style.height to auto so the canvas - // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions - // are pinned, max-width:100% shrinks the width only, leaving rasterized - // glyphs visibly stretched relative to their on-screen input. - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const cssW = canvas.width; - const cssH = canvas.height; - // Fill the container up to the logical width (don't pin a hard 600px, - // which on a narrow phone forces the card wider than the viewport and - // pushes the drawing outside the note). _pos() scales pointer coords by - // the actual displayed width, so accuracy is preserved at any size. - canvas.style.width = '100%'; - canvas.style.maxWidth = cssW + 'px'; - canvas.style.height = 'auto'; - canvas.style.aspectRatio = cssW + ' / ' + cssH; - canvas.width = cssW * dpr; - canvas.height = cssH * dpr; - const ctx = canvas.getContext('2d'); - ctx.scale(dpr, dpr); - ctx.fillStyle = surfaceColor; - ctx.fillRect(0, 0, cssW, cssH); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - // Load prior drawing as starting point so consecutive edits compose. - const safeInitialImageUrl = _safeImgSrc(initialImageUrl); - if (safeInitialImageUrl) { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} }; - img.src = safeInitialImageUrl; - // Float an X over the canvas so the user can blank it out and go back to - // a clean draw surface. Removes itself once clicked. - const wrap = container.querySelector('.note-form-draw-wrap'); - if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) { - const rm = document.createElement('button'); - rm.type = 'button'; - rm.className = 'note-form-draw-bg-rm'; - rm.title = 'Clear photo (regular draw)'; - rm.innerHTML = '×'; - rm.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - ctx.fillStyle = surfaceColor; - ctx.fillRect(0, 0, cssW, cssH); - rm.remove(); - }); - wrap.appendChild(rm); - } - } - - // Swap the native browser color dialog for the in-house HSV picker - // (same one used by Themes + the gallery editor). Existing `input` event - // listeners + .value reads keep working — see colorPicker.js. - if (colorInput) attachColorPicker(colorInput); - const sizeInput = container.querySelector('.note-form-draw-size'); - const beSeg = container.querySelector('.note-form-draw-be'); - const brushBtn = container.querySelector('.note-form-draw-brush'); - const eraserBtn = container.querySelector('.note-form-draw-eraser'); - const textBtn = container.querySelector('.note-form-draw-text'); - const lineBtn = container.querySelector('.note-form-draw-line'); - const circleBtn = container.querySelector('.note-form-draw-circle'); - const undoBtn = container.querySelector('.note-form-draw-undo'); - // Single source of truth for what clicks/drags do. Other booleans are - // derived from this so we never end up with conflicting "eraser AND text" - // states (the bug that made T appear broken after using the eraser). - // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle' - let mode = 'pen'; - let drawing = false; - let last = null; - // The text tool has three preset sizes (CSS px font-size). - const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 }; - // Line / circle stroke widths in logical pixels — three crisp options. - const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 }; - // Snapshot taken at the start of a shape drag so the preview can repaint - // cleanly on each move without accumulating intermediate strokes. - let _shapeSnapshot = null; - - // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each - // operation — stroke, text commit, or future operations — and pop+restore - // on Undo. Cap to 30 to keep memory bounded. - const _undoStack = []; - const UNDO_LIMIT = 30; - const _syncUndoButton = () => { - const available = _undoStack.length > 0; - undoBtn?.classList.toggle('has-undo', available); - undoBtn?.toggleAttribute('disabled', !available); - undoBtn?.setAttribute('aria-disabled', available ? 'false' : 'true'); - }; - const _markCanvasDirty = () => container.dispatchEvent(new Event('input', { bubbles: true })); - const _snapshot = () => { - try { - const w = canvas.width, h = canvas.height; - _undoStack.push(ctx.getImageData(0, 0, w, h)); - if (_undoStack.length > UNDO_LIMIT) _undoStack.shift(); - _syncUndoButton(); - } catch {} - }; - const _undo = () => { - const prev = _undoStack.pop(); - if (!prev) { _syncUndoButton(); return; } - // Restore against the raw backing store: temporarily reset the active - // ctx scale, paint the snapshot 1:1, then reapply our standard transform. - ctx.save(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.putImageData(prev, 0, 0); - ctx.restore(); - _markCanvasDirty(); - _syncUndoButton(); - }; - - const _pos = (e) => { - // CSS can shrink the canvas (max-width:100%) without changing its logical - // size, so compute the displayed-to-logical scale per axis. Pointer coords - // are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH). - const r = canvas.getBoundingClientRect(); - const sx = cssW / r.width; - const sy = cssH / r.height; - const t = e.touches ? e.touches[0] : e; - return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy }; - }; - const _begin = (e) => { - if (mode.startsWith('text-')) { - // Stop the event so the browser doesn't synthesize a follow-up click - // that would blur the input we're about to create. - e.preventDefault?.(); - e.stopPropagation?.(); - _openTextInput(e); - return; - } - _snapshot(); - last = _pos(e); - drawing = true; - if (mode.startsWith('line-') || mode.startsWith('circle-')) { - // Capture the backing pixels so the preview can replay each move from - // the same starting state (otherwise live shapes accumulate). - try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {} - return; - } - ctx.beginPath(); - ctx.moveTo(last.x, last.y); - }; - - // Drop an HTML input at the click position so the user can type, then - // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form - // annotations work in our doc editor. - let _activeTextInput = null; - const _openTextInput = (e) => { - // Commit any prior pending input before starting a new one — otherwise - // the first click leaves an orphaned input the user thinks "didn't work". - if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} } - const r = canvas.getBoundingClientRect(); - const t = e.touches ? e.touches[0] : e; - // Position is anchored to the wrap, not the canvas; since the canvas is - // the first child of the wrap and the wrap has no padding, they share an - // origin, so we offset from the canvas rect directly. - const px = t.clientX - r.left; - const py = t.clientY - r.top; - const logical = _pos(e); - // Size is decided by which T variant is active (S/M/L), not the stroke - // slider — those are independent dials. - const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm'; - const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m; - const wrap = container.querySelector('.note-form-draw-wrap'); - if (!wrap) return; - if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative'; - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'note-form-draw-textinput'; - input.placeholder = 'type then Enter'; - const color = colorInput?.value || '#222'; - const maxW = Math.max(120, Math.floor(r.width - px - 4)); - input.style.cssText = [ - 'position:absolute', - `left:${px}px`, - `top:${Math.max(0, py - sizeCss * 0.7)}px`, - `font:${sizeCss}px Arial, sans-serif`, - `color:${color}`, - 'background:#ffffff', - 'border:2px solid var(--accent)', - 'border-radius:4px', - 'outline:none', - 'padding:2px 6px', - 'min-width:120px', - `max-width:${maxW}px`, - 'z-index:1000', - 'box-shadow:0 2px 8px rgba(0,0,0,0.25)', - 'pointer-events:auto', - ].join(';'); - wrap.appendChild(input); - _activeTextInput = input; - // Focus synchronously so the call still counts as inside the user gesture - // on iOS / Android, then re-focus on the next frame in case a competing - // synthetic event (touch → click) moved focus away. - input.focus(); - requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); }); - let committed = false; - const commit = () => { - if (committed) return; - committed = true; - const text = input.value; - if (_activeTextInput === input) _activeTextInput = null; - input.remove(); - if (!text) return; - // Snapshot BEFORE rasterizing so Undo removes the text in one step. - _snapshot(); - ctx.save(); - ctx.globalCompositeOperation = 'source-over'; - ctx.fillStyle = color; - // Canvas now scales uniformly (style.height: auto), so a single ratio - // suffices for picking the logical font size that matches what the - // user just saw in the HTML input. - const sx = cssW / r.width; - const logicalSize = sizeCss * sx; - ctx.font = `${logicalSize}px sans-serif`; - ctx.textBaseline = 'top'; - ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7); - ctx.restore(); - _markCanvasDirty(); - }; - input.addEventListener('blur', commit); - input.addEventListener('keydown', (ev) => { - if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } - else if (ev.key === 'Escape') { input.value = ''; input.blur(); } - }); - }; - const _move = (e) => { - if (!drawing) return; - e.preventDefault?.(); - const p = _pos(e); - if (mode.startsWith('line-') || mode.startsWith('circle-')) { - // Restore the pre-shape bitmap, then redraw the preview shape from - // anchor → current pointer. - if (_shapeSnapshot) { - ctx.save(); - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.putImageData(_shapeSnapshot, 0, 0); - ctx.restore(); - } - ctx.globalCompositeOperation = 'source-over'; - ctx.strokeStyle = colorInput?.value || '#222'; - const sizeKey = mode.slice(-1); - ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m; - ctx.beginPath(); - if (mode.startsWith('line-')) { - ctx.moveTo(last.x, last.y); - ctx.lineTo(p.x, p.y); - } else { - const dx = p.x - last.x; - const dy = p.y - last.y; - const radius = Math.hypot(dx, dy); - ctx.arc(last.x, last.y, radius, 0, Math.PI * 2); - } - ctx.stroke(); - return; - } - const erasing = mode === 'eraser'; - ctx.globalCompositeOperation = 'source-over'; - ctx.strokeStyle = erasing ? surfaceColor : (colorInput?.value || highlightColor); - ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1); - ctx.lineTo(p.x, p.y); - ctx.stroke(); - last = p; - }; - const _end = () => { - if (drawing) _markCanvasDirty(); - drawing = false; last = null; _shapeSnapshot = null; - }; - - canvas.addEventListener('mousedown', _begin); - canvas.addEventListener('mousemove', _move); - window.addEventListener('mouseup', _end); - // Non-passive so text mode can preventDefault — otherwise the synthetic - // mousedown/click that follows a touch can blur the freshly-created text - // input on iOS Safari, making T look like a no-op. - canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false }); - canvas.addEventListener('touchmove', _move, { passive: false }); - canvas.addEventListener('touchend', _end); - canvas.addEventListener('touchcancel', _end); - - // Single mode setter — keeps the toolbar, swatch, and cursor in sync, and - // makes sure exiting the eraser restores the user's chosen color whether - // they exit by clicking another tool or by toggling eraser off. - let _preEraseColor = null; - const _setMode = (next) => { - const wasEraser = mode === 'eraser'; - mode = next; - const isEraser = next === 'eraser'; - const isPen = next === 'pen'; - const isText = next.startsWith('text-'); - // Swatch: theme surface while erasing, restored when leaving that mode. - if (isEraser && !wasEraser && colorInput) { - _preEraseColor = colorInput.value; - colorInput.value = surfaceColorHex; - } else if (!isEraser && wasEraser && colorInput && _preEraseColor) { - colorInput.value = _preEraseColor; - _preEraseColor = null; - } - // Brush/Eraser segmented pill — slides to the active side. When a non- - // pen/eraser tool (T / line / circle) is active, neither half is - // highlighted, but the pill still indicates which side the user was on - // last so they can return with one click. - const isLine = next.startsWith('line-'); - const isCircle = next.startsWith('circle-'); - beSeg?.classList.toggle('is-eraser', isEraser); - brushBtn?.classList.toggle('active', isPen); - eraserBtn?.classList.toggle('active', isEraser); - textBtn?.classList.toggle('active', isText); - lineBtn?.classList.toggle('active', isLine); - circleBtn?.classList.toggle('active', isCircle); - // Per-button size badges (S/M/L), driven off the mode suffix. - const tBadge = textBtn?.querySelector('.note-form-draw-text-badge'); - if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : ''; - const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge'); - if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : ''; - const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge'); - if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : ''; - // Reflect the chosen size in the icon itself — thicker line/circle stroke - // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`. - const _sz = next.slice(-1); - [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l')); - if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz); - if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz); - if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz); - canvas.style.cursor = isText ? 'text' : 'crosshair'; - }; - brushBtn?.addEventListener('click', () => _setMode('pen')); - eraserBtn?.addEventListener('click', () => _setMode('eraser')); - // T / Line / Circle: each cycles its own three sizes, then back to pen. - const _cycle = (prefix) => { - const seq = ['s', 'm', 'l']; - if (!mode.startsWith(prefix)) return prefix + 's'; - const cur = mode.slice(-1); - const next = seq[seq.indexOf(cur) + 1]; - return next ? prefix + next : 'pen'; - }; - textBtn?.addEventListener('click', () => _setMode(_cycle('text-'))); - lineBtn?.addEventListener('click', () => _setMode(_cycle('line-'))); - circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-'))); - undoBtn?.addEventListener('click', () => _undo()); - _syncUndoButton(); - - // Stash so the save handler can read it later without re-resolving DOM. - canvas._cssW = cssW; - canvas._cssH = cssH; - return canvas; -} - -// Export the canvas as a PNG dataURL, upload it via the existing /api/upload -// endpoint, and resolve to a persistent URL. Returns null on failure. -async function _uploadCanvasAsPng(canvas, galleryId = null) { - if (!canvas) return null; - const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); - if (!blob) return null; - const fd = new FormData(); - fd.append('files', blob, 'drawing.png'); - if (galleryId) fd.append('gallery_id', galleryId); - try { - const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' }); - const data = await res.json(); - const id = data.files?.[0]?.id; - return id ? { - url: `${API_BASE}/api/upload/${id}`, - galleryId: data.files?.[0]?.gallery_id || null, - } : null; - } catch { return null; } -} - -// ---- Create / Edit / Delete ---- - -function _createNote(type = 'todo') { - const body = document.querySelector('#notes-pane .notes-pane-body'); - if (!body || _editingId === '__new__') return; - _editingId = '__new__'; - // Restore an unsaved new-note draft if one survived a prior close/loss. - const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__'); - const form = _buildForm(_n); - form.classList.add('note-form-new'); - body.prepend(form); - const initialField = type === 'note' - ? form.querySelector('.note-form-content') - : form.querySelector('.note-cl-text, .note-form-title'); - initialField?.focus(); - if (restored) uiModule.showToast('Restored unsaved note'); -} - -export function newNote() { - if (!_open) openPanel(); - requestAnimationFrame(() => _createNote('note')); -} - -// Build the plain-text/markdown form of a note for clipboard copy. -function _serializeNoteForCopy(note) { - const lines = []; - if (note.title) lines.push(note.title); - if (note.content) lines.push(note.content); - if (Array.isArray(note.items) && note.items.length) { - if (lines.length) lines.push(''); - for (const it of note.items) { - if (!it || !(it.text || '').trim()) continue; - lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`); - } - } - return lines.join('\n').trim(); -} - -// Copy a note to the clipboard, briefly swap btnEl's icon to a checkmark, and -// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut. -// ── ⋯ corner menu (Copy + Agent) ─────────────────────────────────── -function _openNoteCornerMenu(btn) { - document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove); - const id = btn.dataset.noteId; - const note = _notes.find(n => n.id === id); - if (!note) return; - const menu = document.createElement('div'); - menu.className = 'note-corner-menu-dropdown'; - menu.innerHTML = ` - - `; - document.body.appendChild(menu); - const r = btn.getBoundingClientRect(); - // Right-align to the ⋯ button, clamped to the viewport. - const mw = 168; - let left = Math.min(r.right - mw, window.innerWidth - mw - 8); - left = Math.max(8, left); - // Drop down by default; flip up if there isn't room below (the button - // sits at the card's bottom edge now). - const mh = menu.offsetHeight || 96; - const below = window.innerHeight - r.bottom; - const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4); - menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`; - const close = bindMenuDismiss(menu, () => { menu.remove(); }); - menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); }); - menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); }); -} - -// Build the prompt the agent gets from a note: title + body, plus any -// not-yet-done checklist items. -function _noteToAgentPrompt(note) { - const parts = []; - if ((note.title || '').trim()) parts.push(note.title.trim()); - if ((note.content || '').trim()) parts.push(note.content.trim()); - if (Array.isArray(note.items)) { - note.items.filter(it => !it.done && (it.text || '').trim()) - .forEach(it => parts.push('- ' + it.text.trim())); - } - const body = parts.join('\n'); - return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : ''; -} - -// Agent-solve: create a chat session server-side, kick off an agent run -// on it IN THE BACKGROUND (the user stays in notes), and link the session -// to the note via a clickable tag. Tapping the tag later opens the chat. -async function _agentSolveNote(id) { - const note = _notes.find(n => n.id === id); - if (!note) return; - const prompt = _noteToAgentPrompt(note); - if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; } - try { - const dc = await (await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' })).json(); - if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; } - - // 1. Create the session server-side (no UI switch). skip_validation - // avoids re-probing — the default-chat endpoint is already known good. - const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40); - const csFd = new FormData(); - csFd.append('name', 'Agent: ' + label); - csFd.append('endpoint_url', dc.endpoint_url); - csFd.append('model', dc.model); - if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id); - csFd.append('skip_validation', 'true'); - const csRes = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: csFd }); - if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; } - const sess = await csRes.json(); - const sid = sess.id; - - // 2. Link the session to the note right away so the tag appears. - const n = _notes.find(x => x.id === id); - if (n) n.agent_session_id = sid; - _renderNotes(); - _patchNote(id, { agent_session_id: sid }).catch(() => {}); - - // 3. Kick off the agent run in the background. POST to chat_stream in - // agent mode and drain the SSE so the server runs the loop to - // completion + saves — without rendering anything in the chat UI. - const fd = new FormData(); - fd.append('message', prompt); - fd.append('session', sid); - fd.append('mode', 'agent'); - fd.append('disabled_tools', JSON.stringify(['manage_notes'])); - fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd }) - .then(async (res) => { - if (!res.ok || !res.body) return; - const reader = res.body.getReader(); - // Drain to completion (server finishes + persists the run). - while (true) { const { done } = await reader.read(); if (done) break; } - if (window.sessionModule && window.sessionModule.markStreamComplete) { - try { window.sessionModule.markStreamComplete(sid); } catch {} - } - }) - .catch(() => {}); - - uiModule.showToast('Agent working in background — tap the Agent tag when ready'); - } catch (e) { - uiModule.showError('Agent failed: ' + (e.message || e)); - } -} - -async function _copyNote(noteId, btnEl) { - const note = _notes.find(n => n.id === noteId); - if (!note) return false; - const text = _serializeNoteForCopy(note); - if (!text) return false; - let ok = false; - try { - await navigator.clipboard.writeText(text); - ok = true; - } catch { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; ta.style.left = '-9999px'; - document.body.appendChild(ta); - ta.select(); - try { ok = document.execCommand('copy'); } catch { ok = false; } - ta.remove(); - } - if (ok) { - if (btnEl && !btnEl._copyFlashing) { - const original = btnEl.innerHTML; - btnEl._copyFlashing = true; - btnEl.innerHTML = ''; - btnEl.classList.add('copied'); - setTimeout(() => { - btnEl.innerHTML = original; - btnEl.classList.remove('copied'); - btnEl._copyFlashing = false; - }, 1200); - } - uiModule.showToast?.('Copied'); - } else { - uiModule.showError?.('Copy failed'); - } - return ok; -} - -function _editNote(id) { - const note = _notes.find(n => n.id === id); - if (!note) return; - _editingId = id; - const card = document.querySelector(`.note-card[data-note-id="${id}"]`); - if (!card) return; - // Restore an unsaved draft (from a prior connection loss / close) over - // the saved note so the user picks up where they left off. - const { note: _n, restored } = _applyDraftToNote(note, id); - const form = _buildForm(_n); - card.replaceWith(form); - if (restored) uiModule.showToast('Restored unsaved changes'); - // Pinned notes live in the first masonry column — the edit form has - // column-span:all, which can leave the form rendered above the fold or - // visually buried under neighboring pinned cards. Bring it into view - // (and onto a higher stacking context) so editing a pinned note always - // pops to the top. - form.style.position = 'relative'; - form.style.zIndex = '5'; - // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px) - // after replaceWith, which is much shorter than the actual form. Recompute - // masonry so the form gets the correct row span and cards below stop - // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as - // the user types / adds checklist items. - const _body = form.closest('.notes-pane-body'); - if (_body) { - _applyMasonry(_body); - requestAnimationFrame(() => _applyMasonry(_body)); - } - requestAnimationFrame(() => { - try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); } - catch { form.scrollIntoView(); } - }); - // Pick the most useful field to focus. On phones especially, the user - // taps Edit to type — landing in the title when there's already a title - // (and likely a body to extend) loses momentum. Prefer the body textarea - // for plain notes, the first checklist item for todos, fall back to title. - const _focusBest = () => { - if (note.note_type === 'note' || !note.note_type) { - const ta = form.querySelector('.note-form-content'); - if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; } - } - if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') { - // Last non-empty checklist row, or the first row if all empty. - const rows = form.querySelectorAll('.note-cl-row .note-cl-text'); - let target = null; - for (const inp of rows) { if ((inp.value || '').trim()) target = inp; } - target = target || rows[0]; - if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; } - } - const titleEl = form.querySelector('.note-form-title'); - if (titleEl) titleEl.focus(); - }; - _focusBest(); -} - -async function _deleteNote(id) { - const ok = uiModule?.styledConfirm - ? await uiModule.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true }) - : confirm('Delete this note?'); - if (!ok) return; - try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); uiModule.showToast('Deleted'); } - catch (err) { uiModule.showError(err.message); } -} - -// ──────────────────────────────────────────────────────────────────── -// MOBILE NOTES UX — fullscreen tap-to-edit + long-press drag-to-reorder. -// On a touch device ≤768px wide, note tiles become read-only previews; -// a single tap opens the note in a full-bleed overlay (where all real -// editing happens), and a long-press flips the whole grid into -// rearrange mode where tiles can be dragged to a new sort_order. -// ──────────────────────────────────────────────────────────────────── - -function _isNotesMobileMode() { - return ('ontouchstart' in window) && window.innerWidth <= 768; -} - -// ── Fullscreen single-note edit overlay ────────────────────────────── -let _mobileFsOverlay = null; -let _mobileFsNoteId = null; - -function _openMobileFullscreenEdit(id, fromCard) { - const note = _notes.find(n => n.id === id); - if (!note) return; - // Tear down any previous overlay (defensive). - _closeMobileFullscreenEdit({ save: false }); - _mobileFsNoteId = id; - _editingId = id; - - const overlay = document.createElement('div'); - overlay.className = 'note-fullscreen-overlay'; - overlay.innerHTML = ` -
- -
-
-
- `; - const body = overlay.querySelector('.note-fullscreen-body'); - // Reuse the same edit form the in-place flow builds. Save buttons, - // checklist toggles, etc. all work as-is. Restore any unsaved draft. - const { note: _n, restored } = _applyDraftToNote(note, id); - const form = _buildForm(_n); - body.appendChild(form); - if (restored) uiModule.showToast('Restored unsaved changes'); - document.body.appendChild(overlay); - _mobileFsOverlay = overlay; - - // Animate up from the tapped tile's position so the transition reads - // as a zoom rather than a hard cut. - if (fromCard) { - const r = fromCard.getBoundingClientRect(); - const vw = window.innerWidth, vh = window.innerHeight; - overlay.style.transformOrigin = - `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`; - } - overlay.classList.add('opening'); - requestAnimationFrame(() => overlay.classList.add('open')); - - // Wire the back button — saves whatever the form has and closes. - // mousedown preventDefault so it doesn't blur the input on first tap (which - // would eat the tap and require a second one). - const _backBtn = overlay.querySelector('.note-fullscreen-back'); - _backBtn.addEventListener('mousedown', (e) => e.preventDefault()); - _backBtn.addEventListener('click', () => { - _closeMobileFullscreenEdit({ save: true }); - }); - - // The form's built-in Cancel only resets the in-place edit state; in - // the overlay context it does nothing visible. Replace its handler so - // Cancel actually dismisses the overlay without saving. - const cancelBtn = form.querySelector('.note-form-cancel'); - if (cancelBtn) { - const fresh = cancelBtn.cloneNode(true); - cancelBtn.parentNode.replaceChild(fresh, cancelBtn); - fresh.addEventListener('mousedown', (e) => e.preventDefault()); - fresh.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - _closeMobileFullscreenEdit({ save: false }); - }); - } - // The built-in Save handler does the API call + refresh, but doesn't - // dismiss our overlay. Augment it (do NOT replace — the original is - // async and we'd lose the API call) to schedule a close once the - // save+render has had time to complete. - const saveBtn = form.querySelector('.note-form-save'); - if (saveBtn) { - saveBtn.addEventListener('click', () => { - setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350); - }); - } - // Make the checklist row drag handle (⋮⋮) actually work on touch. - // The form's default uses HTML5 native draggable which never fires - // on iOS/Android. Wire touch-based reorder for any row inside the - // overlay's checklist. - _wireChecklistTouchReorder(form); - - // For each todo row whose text contains a URL, swap the bare - // for a linkified so URLs are tappable. Tapping anywhere that - // ISN'T a link flips back to the input for editing. - form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode); - - // Move only Delete from the form's footer actions row into the header. - // Archive intentionally stays out of the header: the footer Update button - // already morphs into the mobile Archive action for an untouched note. - const headerActions = overlay.querySelector('.note-fullscreen-actions'); - const archiveBtn = form.querySelector('.note-form-archive-btn'); - const deleteBtn = form.querySelector('.note-form-delete-btn'); - if (headerActions && deleteBtn) { - deleteBtn.classList.remove('note-form-collapsible'); - headerActions.appendChild(deleteBtn); - } - // The built-in archive/delete handlers re-render the notes grid but - // leave THIS overlay sitting in front of it — looks like nothing - // happened. Add follow-up listeners that close the overlay so the - // user sees the action take effect. - if (archiveBtn) { - archiveBtn.addEventListener('click', () => { - setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200); - }); - } - if (deleteBtn) { - deleteBtn.addEventListener('click', () => { - // Delete shows a styled confirm — give it room to resolve before - // we try to dismiss the overlay. - setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500); - }); - } - - // Tuck the tags input into the bottom actions row (Cancel / Update), - // pinned to the LEFT. Frees the meta row of an extra wrapping line - // and groups all the "exit" controls together. - const actionsGroup = form.querySelector('.note-form-actions-group'); - const tagsInput = form.querySelector('.note-form-label'); - if (actionsGroup && tagsInput) { - actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild); - } - - // For checklist-type notes, move the photo (attach image) button into - // the same row as the + Add button (right side) — keeps the meta row - // tidy and puts the camera within thumb-reach of the active edit. - const addBtn = form.querySelector('.note-cl-add'); - const photoBtn = form.querySelector('.note-form-photo-btn'); - if (addBtn && photoBtn) { - const addRow = document.createElement('div'); - addRow.className = 'note-cl-add-row'; - addBtn.parentNode.insertBefore(addRow, addBtn); - addRow.appendChild(addBtn); - addRow.appendChild(photoBtn); - // Tapping anywhere on the row (the empty gap, the dashed border, - // the "+ Add" label) triggers add. The photo button keeps its own - // click target so attach-image isn't ambushed. - addRow.addEventListener('click', (e) => { - if (e.target.closest('.note-form-photo-btn')) return; - if (e.target === addBtn || addBtn.contains(e.target)) return; - addBtn.click(); - }); - // The form's delegated "+ Add" handler does - // inputs.insertBefore(newRow, addBtn) - // — but addBtn is no longer a direct child of `.note-checklist-inputs` - // now that we wrapped it. Bind a fresh handler that does the same - // thing but inserts before the WRAPPING row, and stop propagation - // so the broken delegate never runs. - addBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - const inputs = form.querySelector('.note-checklist-inputs'); - if (!inputs) return; - const newRow = document.createElement('div'); - newRow.className = 'note-cl-row'; - newRow.draggable = true; - newRow.dataset.itemId = _uid(); - newRow.dataset.indent = '0'; - newRow.innerHTML = '⋮⋮'; - inputs.insertBefore(newRow, addRow); - _wireRow(newRow, inputs); - // Touch reorder on the freshly-added row's grip. - _wireChecklistTouchReorder(form); - newRow.querySelector('.note-cl-text')?.focus(); - }, { capture: true }); - } - - // Read-mode overlay for plain notes: render the content as a div with - // clickable hyperlinks, layered above the textarea. Tapping anywhere - // in the overlay that ISN'T a link hides the overlay and focuses the - // textarea so the user can start editing. Tapping a link opens it. - const ta = form.querySelector('.note-form-content'); - if (ta && (note.content || '').trim()) { - const reader = document.createElement('div'); - reader.className = 'note-form-content-reader'; - reader.innerHTML = _linkify(note.content || ''); - ta.style.display = 'none'; - ta.insertAdjacentElement('beforebegin', reader); - reader.addEventListener('click', (e) => { - if (e.target.closest('a')) return; // let links open normally - reader.remove(); - ta.style.display = ''; - // Let the browser place the cursor naturally — forcing - // setSelectionRange right after focus() raced with the underlying - // tap event and produced inconsistent cursor positions on mobile. - ta.focus({ preventScroll: true }); - }); - } - - // Opening an EXISTING note → read mode, no keyboard pop. Only a - // brand-new note (created via the + button) should auto-focus an - // input field. The user can tap the content to switch to edit. - // (New-note creation flows through _createNote, not this function.) -} - -function _closeMobileFullscreenEdit(opts = {}) { - if (!_mobileFsOverlay) return; - const overlay = _mobileFsOverlay; - overlay.querySelector('.note-form')?._flushDraft?.(); - _mobileFsOverlay = null; - // If the form has a Save button, click it on close so edits aren't lost - // when the user uses the back arrow instead of an explicit Save. - if (opts.save) { - const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]'); - if (saveBtn) try { saveBtn.click(); } catch {} - } - overlay.classList.remove('open'); - overlay.classList.add('closing'); - setTimeout(() => { - if (overlay.parentNode) overlay.parentNode.removeChild(overlay); - _mobileFsNoteId = null; - _editingId = null; - // Refresh the grid so any save the user made is reflected. - if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); } - }, 220); -} - -// ── Long-press select ──────────────────────────────────────────────── -function _bindLongPressDrag(card) { - let pressTimer = null; - let startX = 0, startY = 0; - let armed = false; - const CANCEL_PX = 8; - const HOLD_MS = 450; - - card.addEventListener('touchstart', (e) => { - // Don't fight scroll on touchpoints over real interactive children - // (in mobile-mode they're CSS-hidden anyway, but be defensive). - if (e.target.closest('button, input, a, .note-form')) return; - if (e.touches.length !== 1) return; - armed = true; - startX = e.touches[0].clientX; - startY = e.touches[0].clientY; - pressTimer = setTimeout(() => { - if (!armed) return; - try { navigator.vibrate?.(15); } catch {} - _suppressNextNoteTapId = card.dataset.noteId; - _enterSelectMode(card.dataset.noteId); - }, HOLD_MS); - }, { passive: true }); - card.addEventListener('touchmove', (e) => { - if (!armed) return; - const t = e.touches[0]; - if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) { - armed = false; - if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } - } - }, { passive: true }); - const cancel = () => { - armed = false; - if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } - }; - card.addEventListener('touchend', cancel, { passive: true }); - card.addEventListener('touchcancel', cancel, { passive: true }); -} - -// Lift-and-placeholder drag implementation. The dragged card detaches -// from the grid (position:fixed, anchored to the finger) while a same- -// sized placeholder takes its slot. Only the PLACEHOLDER moves between -// siblings as the finger crosses midpoints — the card never re-parents -// during the drag, which eliminates the oscillation/jumping the -// previous swap-on-every-frame implementation had. - -let _dragState = null; // { card, placeholder, grabOffsetX, grabOffsetY, grid, prevStyle } -let _docDragHandlersBound = false; - -function _enterDragMode(initialCard, initialTouch) { - document.body.classList.add('notes-drag-mode'); - document.querySelectorAll('.note-card').forEach(_setupDragForCard); - if (!_docDragHandlersBound) { - document.addEventListener('touchmove', _onDocTouchMove, { passive: false }); - document.addEventListener('touchend', _onDocTouchEnd, { passive: true }); - document.addEventListener('touchcancel', _onDocTouchEnd, { passive: true }); - _docDragHandlersBound = true; - } - // Auto-grab the card the user long-pressed — they're already holding - // their finger on it, so begin the drag straight away. Releasing - // (touchend) commits the reorder AND exits drag mode in one motion. - if (initialCard && initialTouch) { - _beginGrab(initialCard, initialTouch); - } -} - -function _exitDragMode() { - document.body.classList.remove('notes-drag-mode'); - if (_dragState) { - // Defensive: if exit fires while a drag is in flight, snap the card back. - _onDocTouchEnd(); - } - // Leave _docDragHandlersBound true so re-entering drag mode reuses them. -} - -function _setupDragForCard(card) { - if (card.dataset.dragBound === '1') return; - card.dataset.dragBound = '1'; - card.addEventListener('touchstart', (e) => { - if (!document.body.classList.contains('notes-drag-mode')) return; - if (e.touches.length !== 1) return; - if (_dragState) return; - e.preventDefault(); - e.stopPropagation(); - _beginGrab(card, e.touches[0]); - }, { passive: false }); -} - -function _beginGrab(card, touch) { - const rect = card.getBoundingClientRect(); - const prevStyle = card.getAttribute('style') || ''; - // Placeholder fills the card's old slot so the grid layout doesn't reflow. - const placeholder = document.createElement('div'); - placeholder.className = 'note-card-placeholder'; - placeholder.style.width = rect.width + 'px'; - placeholder.style.height = rect.height + 'px'; - placeholder.style.margin = getComputedStyle(card).margin; - if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd; - const grid = card.parentNode; - grid.insertBefore(placeholder, card); - - // Detach the card visually — fixed-position, anchored to the finger. - card.classList.add('note-card-dragging'); - card.style.position = 'fixed'; - card.style.left = rect.left + 'px'; - card.style.top = rect.top + 'px'; - card.style.width = rect.width + 'px'; - card.style.height = rect.height + 'px'; - card.style.margin = '0'; - card.style.zIndex = '10001'; - // pointer-events:none so elementFromPoint sees the card BENEATH the finger - card.style.pointerEvents = 'none'; - - _dragState = { - card, placeholder, grid, prevStyle, - grabOffsetX: touch.clientX - rect.left, - grabOffsetY: touch.clientY - rect.top, - }; - try { navigator.vibrate?.(8); } catch {} -} - -function _onDocTouchMove(e) { - if (!_dragState) return; - if (e.touches.length !== 1) return; - e.preventDefault(); - const touch = e.touches[0]; - const { card, placeholder, grid } = _dragState; - card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px'; - const quickAdd = grid.querySelector('.notes-quick-add'); - const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top; - const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8); - const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY)); - card.style.top = nextTop + 'px'; - - const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY)); - const under = document.elementFromPoint(touch.clientX, hitY); - const target = under && under.closest - ? under.closest('.note-card:not(.note-card-dragging)') - : null; - if (!target || target === card) return; - if (target.parentNode !== grid) return; - - // Move the PLACEHOLDER (not the card) above or below the target depending - // on which half of the target the finger is in. This is the hysteresis - // that stops the oscillation — once the placeholder moves past a card, - // the cursor has to cross THAT card's midpoint in the other direction - // to swap back. - const tRect = target.getBoundingClientRect(); - const targetMidY = tRect.top + tRect.height / 2; - if (touch.clientY < targetMidY) { - if (placeholder.nextElementSibling !== target) { - grid.insertBefore(placeholder, target); - } - } else { - if (target.nextElementSibling !== placeholder) { - grid.insertBefore(placeholder, target.nextElementSibling); - } - } -} - -function _onDocTouchEnd() { - if (!_dragState) return; - const { card, placeholder, grid, prevStyle } = _dragState; - _dragState = null; - // Animate the card from its current fixed position to where the - // placeholder sits, then re-parent and clear inline styles. Drag - // mode auto-exits once the snap finishes — release = done. - const phRect = placeholder.getBoundingClientRect(); - card.style.transition = 'left 0.2s ease, top 0.2s ease'; - card.style.left = phRect.left + 'px'; - card.style.top = phRect.top + 'px'; - setTimeout(() => { - placeholder.parentNode.insertBefore(card, placeholder); - placeholder.remove(); - card.classList.remove('note-card-dragging'); - // Restore the card's pre-drag inline styles. Mobile masonry stores - // grid-row-end inline, and custom backgrounds use inline style too; wiping - // cssText made dropped cards collapse into neighboring notes in grid view. - if (prevStyle) card.setAttribute('style', prevStyle); - else card.removeAttribute('style'); - _applyMasonry(grid); - _commitNoteReorder(); - // One drag, one exit — release ends the rearrange session entirely. - if (document.body.classList.contains('notes-drag-mode')) { - document.body.classList.remove('notes-drag-mode'); - } - }, 210); -} - -// Per-row read mode for todo items — replaces the plain with -// a linkified when the value contains a URL, so tapping a link -// opens it instead of just placing the caret. Tapping non-link area -// restores the input for editing. -function _addRowReadMode(row) { - const txt = row.querySelector('.note-cl-text'); - if (!txt) return; - const val = txt.value || ''; - if (!/(https?:\/\/|www\.)/i.test(val)) return; - if (row.querySelector('.note-cl-text-reader')) return; // already wired - const span = document.createElement('span'); - span.className = 'note-cl-text-reader'; - span.innerHTML = _linkify(val); - txt.style.display = 'none'; - txt.insertAdjacentElement('beforebegin', span); - span.addEventListener('click', (e) => { - if (e.target.closest('a')) return; // let the link open - span.remove(); - txt.style.display = ''; - txt.focus({ preventScroll: true }); - }); -} - -// ── Checklist row reorder via touch (inside fullscreen edit) ──────── -// The default checklist drag uses HTML5 `draggable="true"`, which is -// desktop-mouse-only. Wire touch handlers on each `.note-cl-grip` so -// the user can long-grab a row and drag it to a new position in the -// checklist. Uses the same lift-and-placeholder pattern as the card -// drag (no oscillation when hovering between siblings). - -let _clDrag = null; // { row, placeholder, container, grabOffsetX, grabOffsetY } -let _clDocBound = false; - -function _wireChecklistTouchReorder(form) { - const container = form.querySelector('.note-checklist-inputs'); - if (!container) return; - container.querySelectorAll('.note-cl-grip').forEach(grip => { - if (grip.dataset.touchBound === '1') return; - grip.dataset.touchBound = '1'; - grip.addEventListener('touchstart', (e) => { - if (e.touches.length !== 1) return; - e.preventDefault(); - const row = grip.closest('.note-cl-row'); - if (!row) return; - _beginChecklistGrab(row, container, e.touches[0]); - }, { passive: false }); - }); - if (!_clDocBound) { - document.addEventListener('touchmove', _onClTouchMove, { passive: false }); - document.addEventListener('touchend', _onClTouchEnd, { passive: true }); - document.addEventListener('touchcancel', _onClTouchEnd, { passive: true }); - _clDocBound = true; - } -} - -function _beginChecklistGrab(row, container, touch) { - if (_clDrag) return; - const rect = row.getBoundingClientRect(); - const placeholder = document.createElement('div'); - placeholder.className = 'note-cl-row-placeholder'; - placeholder.style.height = rect.height + 'px'; - container.insertBefore(placeholder, row); - - row.classList.add('note-cl-row-dragging'); - row.style.position = 'fixed'; - row.style.left = rect.left + 'px'; - row.style.top = rect.top + 'px'; - row.style.width = rect.width + 'px'; - row.style.zIndex = '10002'; - row.style.pointerEvents = 'none'; - - _clDrag = { - row, placeholder, container, - grabOffsetX: touch.clientX - rect.left, - grabOffsetY: touch.clientY - rect.top, - }; - try { navigator.vibrate?.(8); } catch {} -} - -function _onClTouchMove(e) { - if (!_clDrag) return; - if (e.touches.length !== 1) return; - e.preventDefault(); - const t = e.touches[0]; - const { row, placeholder, container } = _clDrag; - row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px'; - row.style.top = (t.clientY - _clDrag.grabOffsetY) + 'px'; - - const under = document.elementFromPoint(t.clientX, t.clientY); - const target = under && under.closest - ? under.closest('.note-cl-row:not(.note-cl-row-dragging)') - : null; - if (!target || target === row) return; - if (target.parentNode !== container) return; - - const tRect = target.getBoundingClientRect(); - const targetMidY = tRect.top + tRect.height / 2; - if (t.clientY < targetMidY) { - if (placeholder.nextElementSibling !== target) { - container.insertBefore(placeholder, target); - } - } else { - if (target.nextElementSibling !== placeholder) { - container.insertBefore(placeholder, target.nextElementSibling); - } - } -} - -function _onClTouchEnd() { - if (!_clDrag) return; - const { row, placeholder } = _clDrag; - _clDrag = null; - const phRect = placeholder.getBoundingClientRect(); - row.style.transition = 'left 0.18s ease, top 0.18s ease'; - row.style.left = phRect.left + 'px'; - row.style.top = phRect.top + 'px'; - setTimeout(() => { - placeholder.parentNode.insertBefore(row, placeholder); - placeholder.remove(); - row.classList.remove('note-cl-row-dragging'); - row.style.cssText = ''; - // Order is persisted as part of the form's normal save (rows are - // re-serialized in DOM order via _collectItems). - }, 200); -} - -async function _commitNoteReorder() { - const grid = document.querySelector('#notes-pane .notes-pane-body'); - if (!grid) return; - const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean); - if (!ids.length) return; - try { - await fetch(`${API_BASE}/api/notes/reorder`, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ids }), - }); - // Update local sort_order so subsequent renders agree with the server. - ids.forEach((nid, i) => { - const n = _notes.find(nn => nn.id === nid); - if (n) n.sort_order = i; - }); - } catch (e) { - console.warn('reorder failed', e); - } -} - - -// Background reminder loop — runs whether panel is open or not -async function _initReminders() { - try { - const res = await fetch(`${API_BASE}/api/notes`, { credentials: 'same-origin' }); - if (res.ok) { - const data = await res.json(); - _notes = data.notes || data || []; - _startReminderLoop(); - } - } catch {} -} - -// Open the notes panel and scroll/flash the matching note card. Used -// by chatRenderer.js when the user clicks a [View note](#note-) -// link the agent emits after a manage_notes create. Falls back to -// just opening the panel when the card isn't found (panel still -// loading, note in a different filter, etc.). -function _smoothScrollToNoteCard(card) { - if (!card) return; - const scroller = card.closest('.notes-pane-body'); - if (!scroller) { - try { card.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (_) {} - return; - } - const scrollerRect = scroller.getBoundingClientRect(); - const cardRect = card.getBoundingClientRect(); - const delta = (cardRect.top - scrollerRect.top) - (scroller.clientHeight - cardRect.height) / 2; - scroller.scrollTo({ top: Math.max(0, scroller.scrollTop + delta), behavior: 'smooth' }); -} - -async function openNote(noteId) { - const wasOpen = !!(isPanelOpen && isPanelOpen()); - _showingArchived = false; - _activeLabel = null; - _activeFilter = null; - _searchQuery = ''; - if (!wasOpen) { - openPanel(); - } else { - _bringNotesToFront(); - const searchEl = document.getElementById('notes-search'); - if (searchEl) searchEl.value = ''; - const pane = document.getElementById('notes-pane'); - if (pane) pane.classList.remove('notes-pane-archive'); - const archiveBtn = document.getElementById('notes-archive-toggle'); - if (archiveBtn) archiveBtn.classList.remove('active'); - await _fetchNotes(); - _renderNotes(); - } - // openPanel() kicks off _fetchNotes() asynchronously, so the cards for - // newly-created notes may not be in the DOM yet. Poll until the card exists. - if (!noteId) return; - let tries = 0; - const findAndFlash = () => { - const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`) - || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`); - if (card) { - _smoothScrollToNoteCard(card); - card.classList.add('note-card-flash'); - setTimeout(() => card.classList.remove('note-card-flash'), 1600); - return true; - } - return false; - }; - const tryNext = () => { - if (findAndFlash()) return; - if (++tries < 20) setTimeout(tryNext, 200); - }; - setTimeout(tryNext, 120); -} - -const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, newNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge }; -export default notesModule; -export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen, openNote }; +/** Compatibility URL. All consumers share the canonical Notes feature instance. */ +import notesModule, { startNotesBackground } from './features/notes/index.js'; +export { default, openPanel, closePanel, togglePanel, isPanelOpen, openNote, newNote, refreshDueBadge, dismissFiredReminderDot, openNotes, closeNotes, isNotesOpen } from './features/notes/index.js'; window.notesModule = notesModule; - -// Start reminder loop on module load (after a short delay so app loads first) -if (typeof window !== 'undefined') { - setTimeout(_initReminders, 3000); -} +startNotesBackground(); diff --git a/static/js/shared/ui/confetti.js b/static/js/shared/ui/confetti.js new file mode 100644 index 000000000..205f9acdc --- /dev/null +++ b/static/js/shared/ui/confetti.js @@ -0,0 +1,28 @@ +/** Stateless celebration renderer shared by Notes and Compare. */ +export function spawnConfetti(cx, cy, count) { + const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff']; + for (let i = 0; i < count; i++) { + const el = document.createElement('div'); + el.className = 'confetti-piece'; + const color = colors[Math.floor(Math.random() * colors.length)]; + const size = 5 + Math.random() * 8; + const isCircle = Math.random() > 0.5; + el.style.width = size + 'px'; + el.style.height = (isCircle ? size : size * 0.6) + 'px'; + el.style.background = color; + el.style.borderRadius = isCircle ? '50%' : '2px'; + el.style.left = cx + 'px'; + el.style.top = cy + 'px'; + const angle = Math.random() * Math.PI * 2; + const speed = 60 + Math.random() * 160; + const dx = Math.cos(angle) * speed; + const dy = Math.sin(angle) * speed - 100; + const duration = 1.0 + Math.random() * 1.0; + el.animate([ + { transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 }, + { transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 } + ], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' }); + document.body.appendChild(el); + setTimeout(() => el.remove(), duration * 1000 + 50); + } +} diff --git a/static/sw.js b/static/sw.js index 53973faf0..1cc5c8a78 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v658-publication-assets-restore'; +const CACHE_NAME = 'odysseus-v659-notes-reference'; // KaTeX resolves these from its own stylesheet, so caching the CSS without them // gives offline math fallback glyphs instead of proper typesetting. @@ -115,6 +115,57 @@ const PRECACHE = [ // emailInbox.js has always imported this one; it was never precached. '/static/js/emailLibrary/replyRecipients.js', '/static/js/notes.js?v=20260911notesselectioncancel1', + '/static/js/features/notes/domain/recurrence.js', + // Exact legacy URLs still reached by the Notes graph. + '/static/js/toolWindowZOrder.js', + '/static/js/color/hex.js', + '/static/js/windowDrag.js', + '/static/js/modalSnap.js', + '/static/js/windowResize.js', + '/static/js/modalManager.js', + '/static/js/escMenuStack.js', + '/static/js/tourHintDismiss.js', + '/static/js/shared/ui/confetti.js', + '/static/js/features/notes/application/selection.js', + '/static/js/features/notes/ui/toolbar.js', + '/static/js/features/notes/ui/windowLayout.js', + '/static/js/features/notes/application/cardOperations.js', + '/static/js/features/notes/application/checklistOperations.js', + '/static/js/features/notes/application/submission.js', + '/static/js/features/notes/application/drafts.js', + '/static/js/features/notes/ui/lifecycle.js', + '/static/js/features/notes/ui/text.js', + '/static/js/features/notes/ui/colors.js', + '/static/js/features/notes/ui/panel.js', + '/static/js/features/notes/ui/reminderDisplay.js', + '/static/js/features/notes/ui/selection.js', + '/static/js/features/notes/ui/filters.js', + '/static/js/features/notes/ui/cardRendering.js', + '/static/js/features/notes/ui/layout.js', + '/static/js/features/notes/ui/quickAdd.js', + '/static/js/features/notes/ui/cardInteractions.js', + '/static/js/features/notes/ui/cardChecklist.js', + '/static/js/features/notes/ui/cardReorder.js', + '/static/js/features/notes/ui/draftEditor.js', + '/static/js/features/notes/ui/editor.js', + '/static/js/features/notes/ui/reminderPicker.js', + '/static/js/features/notes/ui/checklist.js', + '/static/js/features/notes/ui/drawing.js', + '/static/js/features/notes/ui/clipboard.js', + '/static/js/features/notes/ui/mobileEditor.js', + '/static/js/features/notes/ui/mobileReorder.js', + '/static/js/features/notes/ui/checklistReorder.js', + '/static/js/features/notes/application/queries.js', + '/static/js/features/notes/application/agentActions.js', + '/static/js/features/notes/application/deletion.js', + '/static/js/features/notes/domain/noteRules.js', + '/static/js/features/notes/index.js', + '/static/js/features/notes/application/reminders.js', + '/static/js/features/notes/infrastructure/notifications.js', + '/static/js/features/notes/application/state.js', + '/static/js/features/notes/application/workflows.js', + '/static/js/features/notes/infrastructure/http.js', + '/static/js/features/notes/infrastructure/storage.js', '/static/js/tasks.js?v=20260914taskmodel1', '/static/js/calendar.js?v=20260914emailsource11', '/static/js/calendar/utils.js', diff --git a/tests/browser/notesNative.mjs b/tests/browser/notesNative.mjs new file mode 100644 index 000000000..5270293c3 --- /dev/null +++ b/tests/browser/notesNative.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { chromium } from 'playwright'; +import { randomUUID } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; + +const origin = process.env.ODYSSEUS_NOTES_RUNTIME_ORIGIN; +const evidence = process.env.NOTES_NATIVE_EVIDENCE_DIR; +if (evidence) fs.mkdirSync(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const credentials = { username: 'notes-reference', password: randomUUID() + 'Aa1!' }; +try { + for (const mobile of [false, true]) { + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, hasTouch: mobile, isMobile: mobile }); + const page = await context.newPage(); + if (!mobile) { + const setup = await context.request.post(`${origin}/api/auth/setup`, { data: credentials }); + assert.equal(setup.ok(), true, await setup.text()); + } + const login = await context.request.post(`${origin}/api/auth/login`, { data: credentials }); + assert.equal(login.ok(), true, await login.text()); + const response = await context.request.post(`${origin}/api/notes`, { data: { title: mobile ? 'Mobile reference' : 'Desktop reference', content: 'Native API draft', note_type: 'note', label: 'reference' } }); + assert.equal(response.ok(), true, await response.text()); + const created = await response.json(); + assert.ok(created.id); + assert.equal(created.owner, credentials.username); + await page.goto(origin); + await page.waitForFunction(() => window.notesModule?.openPanel); + await page.evaluate(() => { localStorage.setItem('odysseus-notes-first-open-hint-v1', '1'); window.notesModule.openPanel(); }); + const card = page.locator(`.note-card[data-note-id="${created.id}"]`); + await card.waitFor(); + if (evidence) await page.screenshot({ path: path.join(evidence, mobile ? 'native-mobile.png' : 'native-desktop.png'), fullPage: true, animations: 'disabled' }); + await page.locator('#notes-select-btn').click(); + await card.locator('.note-card-cb').check({ force: mobile }); + await page.locator('#notes-send-agent').click(); + assert.ok(await page.locator('.note-card-selectmode').count()); + await page.evaluate(async () => { + const sessions = await import('/static/js/sessions.js'); + sessions.createDirectChat('http://127.0.0.1:1', 'reference-model', null); + }); + await page.locator('#notes-send-agent').click(); + assert.equal(await page.locator('.note-card-selectmode').count(), 0); + assert.equal(await page.locator('#selected-notes-context-count').textContent(), '1 note selected'); + await page.evaluate(async () => (await import('/static/js/sessions.js')).createDirectChat('http://127.0.0.1:1', 'another-reference-model', null)); + assert.equal(await page.locator('#selected-notes-context-count').textContent(), ''); + if (mobile) await card.locator('.note-card-title').tap(); + else await card.locator('.note-card-title').click(); + if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } }); + await page.locator('.note-form-content').fill('Saved by the native Notes application'); + await page.waitForFunction(id => !localStorage.getItem('odysseus-note-draft-' + id), created.id); + const fetched = await context.request.get(`${origin}/api/notes`); + const notes = (await fetched.json()).notes; + assert.equal(notes.find(note => note.id === created.id).content, 'Saved by the native Notes application'); + await page.evaluate(() => window.notesModule.closePanel()); + await page.locator('#notes-pane').waitFor({ state: 'detached' }); + await context.close(); + console.log(JSON.stringify({ native: true, mobile, noteId: created.id, crud: true, selectedContext: true })); + } + const smoke = spawnSync(process.env.NOTES_TEST_PYTHON, ['-m', 'pytest', '-q', 'tests/smoke/test_notes_smoke.py'], { + env: { ...process.env, APP_PORT: new URL(origin).port, ODYSSEUS_ADMIN_USER: credentials.username, ODYSSEUS_ADMIN_PASSWORD: credentials.password }, + encoding: 'utf8', timeout: 90000, + }); + assert.equal(smoke.status, 0, smoke.stdout + smoke.stderr); + console.log('PASS: original Notes release smoke test against the disposable authenticated instance'); +} finally { await browser.close(); } diff --git a/tests/browser/notesOffline.mjs b/tests/browser/notesOffline.mjs new file mode 100644 index 000000000..e8d97bf49 --- /dev/null +++ b/tests/browser/notesOffline.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { chromium } from 'playwright'; +import fs from 'node:fs'; + +const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; +const manifest = JSON.parse(fs.readFileSync(new URL('../../frontend/modules.json', import.meta.url), 'utf8')); +const outputs = manifest.modules.map(entry => '/static/' + entry.deployment); +const browser = await chromium.launch({ headless: true }); +try { + const context = await browser.newContext({ serviceWorkers: 'allow' }); + const page = await context.newPage(); + await page.goto(`${origin}/static/js/panels.js`); + const cached = await page.evaluate(async outputs => { + await navigator.serviceWorker.register('/static/sw.js'); + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })); + const missing = []; + for (const output of outputs) if (!(await caches.match(output))?.ok) missing.push(output); + return missing; + }, outputs); + assert.deepEqual(cached, []); + assert.equal(await page.evaluate(async () => { + const shared = await import('/static/js/shared/ui/confetti.js'); + const compare = await import('/static/js/compare/vote.js?v=20260828resendcaldrag1'); + return shared.spawnConfetti === compare.spawnConfetti; + }), true); + await context.setOffline(true); + const offline = await context.newPage(); + offline.on('requestfailed', request => console.log('offline failed:', request.url(), request.failure())); + offline.on('console', message => { if (message.type() === 'error') console.log('browser:', message.text()); }); + await offline.goto(`${origin}/static/js/panels.js`); + const result = await offline.evaluate(async () => { + const facade = await import('/static/js/notes.js?v=20260911notesselectioncancel1'); + const feature = await import('/static/js/features/notes/index.js'); + return { identity: facade.default === feature.default && window.notesModule === feature.default, closed: facade.isPanelOpen() === false, exports: Object.keys(facade).sort() }; + }); + assert.equal(result.identity && result.closed, true); + console.log(JSON.stringify({ ...result, maintainedPrecache: outputs.length })); +} finally { await browser.close(); } diff --git a/tests/browser/notesReference.mjs b/tests/browser/notesReference.mjs new file mode 100644 index 000000000..9397a0eb3 --- /dev/null +++ b/tests/browser/notesReference.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { chromium } from 'playwright'; + +const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; +const evidence = process.env.NOTES_EVIDENCE_DIR; +if (evidence) fs.mkdirSync(evidence, { recursive: true }); +const baseline = process.env.NOTES_BASELINE === '1'; +const browser = await chromium.launch({ headless: true }); +try { + for (const device of ['desktop', 'mobile', 'tablet']) { + const mobile = device === 'mobile'; + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : device === 'tablet' ? { width: 1024, height: 800 } : { width: 1440, height: 1000 }, hasTouch: device !== 'desktop', isMobile: mobile }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.stack)); + if (baseline) { + const body = execFileSync('git', ['show', '1cc56cb9:static/js/notes.js'], { encoding: 'utf8' }); + await page.route('**/static/js/notes.js*', route => route.fulfill({ contentType: 'text/javascript', body })); + } + const requests = []; + const notes = [ + { id: 'plain', title: 'Reference note', note_type: 'note', content: 'Visit https://example.org and keep this draft.', label: 'work', sort_order: 0 }, + { id: 'todo', title: 'Reference checklist', note_type: 'todo', items: [{ text: 'First task', done: false }, { text: 'Second task', done: true }], sort_order: 1 }, + { id: 'goal', title: 'Reference goal', note_type: 'goal', content: 'A bounded goal', items: [{ text: 'Next step', done: false }], sort_order: 2 }, + ]; + await page.route('**/api/**', async route => { + const request = route.request(); + requests.push({ url: new URL(request.url()).pathname, method: request.method(), body: request.postData() }); + const id = new URL(request.url()).pathname.split('/').at(-1); + if (request.method() === 'PUT') Object.assign(notes.find(n => n.id === id) || {}, request.postDataJSON()); + await route.fulfill({ json: request.method() === 'GET' ? { notes } : notes.find(n => n.id === id) || {} }); + }); + await page.goto(`${origin}/static/js/panels.js`); + const shell = fs.readFileSync(new URL('../../static/index.html', import.meta.url), 'utf8'); + const styles = [...shell.matchAll(/]+rel="stylesheet"[^>]*>/g)].map(m => m[0]).join('\n'); + await page.setContent(`${styles}
`); + await page.evaluate(() => { + localStorage.setItem('odysseus-notes-first-open-hint-v1', '1'); + window.referenceIntervals = []; + window.referenceMouseups = new Set(); + const add = window.addEventListener.bind(window), remove = window.removeEventListener.bind(window); + window.addEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.add(fn); add(type, fn, options); }; + window.removeEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.delete(fn); remove(type, fn, options); }; + const interval = window.setInterval.bind(window); + window.setInterval = (fn, ms, ...args) => { const id = interval(fn, ms, ...args); window.referenceIntervals.push({ id, ms }); return id; }; + }); + const publicAPI = await page.evaluate(async () => { + const mod = await import('/static/js/notes.js?v=20260911notesselectioncancel1'); + const again = await import('/static/js/notes.js?v=20260911notesselectioncancel1'); + window.referenceNotes = mod; + mod.openPanel(); + return { names: Object.keys(mod).sort(), defaults: Object.keys(mod.default).sort(), arities: Object.fromEntries(Object.keys(mod).filter(k => k !== 'default').map(k => [k, mod[k].length])), asyncMethods: Object.keys(mod).filter(k => mod[k]?.constructor?.name === 'AsyncFunction').sort(), singleton: again === mod, bridge: window.notesModule === mod.default, aliases: mod.openNotes === mod.openPanel && mod.closeNotes === mod.closePanel && mod.isNotesOpen === mod.isPanelOpen && mod.default.openNotes === mod.openPanel }; + }); + assert.equal(publicAPI.singleton && publicAPI.bridge && publicAPI.aliases, true); + assert.deepEqual(publicAPI.names, ['closeNotes', 'closePanel', 'default', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']); + assert.deepEqual(publicAPI.defaults, ['closeNotes', 'closePanel', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']); + assert.deepEqual(publicAPI.arities, { closeNotes: 1, closePanel: 1, dismissFiredReminderDot: 0, isNotesOpen: 0, isPanelOpen: 0, newNote: 0, openNote: 1, openNotes: 0, openPanel: 0, refreshDueBadge: 0, togglePanel: 0 }); + assert.deepEqual(publicAPI.asyncMethods, ['openNote', 'refreshDueBadge']); + await page.locator('.note-card').first().waitFor(); + assert.equal(await page.locator('.note-card').count(), 3); + if (evidence) await page.screenshot({ path: path.join(evidence, `notes-${device}.png`), fullPage: true, animations: 'disabled' }); + await page.locator('#notes-search').fill('checklist'); + assert.equal(await page.locator('.note-card').count(), 1); + await page.locator('#notes-search').fill(''); + await page.locator('#notes-select-btn').click(); + await page.locator('.note-card-cb').first().check({ force: mobile }); + await page.locator('#notes-send-agent').click(); + assert.equal(await page.locator('.note-card-selectmode').count(), 3, 'rejected selection stays selected'); + await page.evaluate(() => window.addEventListener('odysseus:notes-context-selected', e => { window.referenceSelected = e.detail.noteIds; e.detail.accepted = true; }, { once: true })); + await page.locator('#notes-send-agent').click(); + assert.equal(await page.locator('.note-card-selectmode').count(), 0); + assert.equal((await page.evaluate(() => window.referenceSelected)).length, 1); + await page.locator('#notes-search').fill('checklist'); + await page.evaluate(() => window.referenceNotes.closePanel()); + await page.locator('#notes-pane').waitFor({ state: 'detached' }); + await page.evaluate(() => window.referenceNotes.openPanel()); + await page.locator('.note-card').first().waitFor(); + assert.equal(await page.locator('#notes-search').inputValue(), ''); + assert.equal(await page.locator('.note-card').count(), 3); + if (!baseline && device !== 'desktop') { + const beginTouch = () => page.evaluate(() => { + const card = document.querySelector('.note-card'); + const rect = card.getBoundingClientRect(); + const touch = new Touch({ identifier: 1, target: card, clientX: rect.left + 5, clientY: rect.top + 5 }); + card.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true })); + }); + if (device === 'tablet') { + await beginTouch(); + await page.waitForFunction(() => document.querySelector('.note-card.dragging')); + await page.evaluate(() => document.querySelector('.note-card.dragging').dispatchEvent(new TouchEvent('touchcancel', { bubbles: true }))); + await page.locator('#notes-search').fill('checklist'); + await page.locator('#notes-search').fill(''); + await beginTouch(); + await page.waitForFunction(() => document.querySelector('.note-card.dragging')); + } else { + await beginTouch(); // Close before the selection hold timer can fire. + } + await page.evaluate(() => window.referenceNotes.closePanel()); + await page.locator('#notes-pane').waitFor({ state: 'detached' }); + await page.waitForTimeout(500); + assert.equal(await page.evaluate(() => document.documentElement.style.touchAction), ''); + await page.evaluate(() => window.referenceNotes.openPanel()); + await page.locator('.note-card').first().waitFor(); + assert.equal(await page.locator('.note-card-selectmode').count(), 0); + } + if (mobile) await page.locator('[data-note-id="plain"] .note-card-title').tap(); + else await page.locator('[data-note-id="plain"] .note-card-title').click(); + if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } }); + await page.locator('.note-form-content').fill('Recovered draft'); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-note-draft-plain')).content), 'Recovered draft'); + await page.waitForFunction(() => !localStorage.getItem('odysseus-note-draft-plain')); + assert.ok(requests.some(r => r.method === 'PUT' && JSON.parse(r.body).content === 'Recovered draft')); + await page.locator('.note-form-type-pill[data-type="draw"]').click(); + const canvas = page.locator('.note-form-canvas'); + const box = await canvas.boundingBox(); + assert.ok(box); + await page.mouse.move(box.x + 25, box.y + 25); + await page.mouse.down(); + await page.mouse.move(box.x + 55, box.y + 55, { steps: 5 }); + await page.mouse.up(); + await page.locator('.note-form-draw-undo').waitFor(); + assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), false); + await page.locator('.note-form-draw-undo').click(); + assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), true); + await page.locator('.note-form-type-pill[data-type="note"]').click(); + if (!baseline) await page.waitForFunction(() => window.referenceMouseups.size === 0); + await page.locator('.note-form-remind-btn').click(); + await page.locator('.note-reminder-menu').waitFor(); + await page.evaluate(() => window.referenceNotes.closePanel()); + await page.locator('#notes-pane').waitFor({ state: 'detached' }); + if (!baseline) { + assert.equal(await page.locator('.note-reminder-menu').count(), 0); + assert.equal(await page.evaluate(() => window.referenceIntervals.filter(t => t.ms === 30000).length), 1); + const identity = await page.evaluate(async () => { + const feature = await import('/static/js/features/notes/index.js'); + const unversioned = await import('/static/js/notes.js'); + return feature.default === window.referenceNotes.default && unversioned.default === feature.default && feature.openPanel === window.referenceNotes.openPanel; + }); + assert.equal(identity, true); + } + assert.deepEqual(errors, []); + await context.close(); + console.log(JSON.stringify({ device, mobile, ...publicAPI, requests: requests.length })); + } +} finally { await browser.close(); } diff --git a/tests/fixtures/notesRecurrenceBaseline.mjs b/tests/fixtures/notesRecurrenceBaseline.mjs new file mode 100644 index 000000000..7f22ef5fe --- /dev/null +++ b/tests/fixtures/notesRecurrenceBaseline.mjs @@ -0,0 +1,269 @@ +// Immutable N0 oracle copied from F1 commit 1cc56cb9; used only for differential tests. +const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th']; +const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +function _hasTimeComponent(dateStr) { + return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr); +} + +function _formatDueDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(); + const hasTime = _hasTimeComponent(dateStr); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const diffDays = Math.round((due - today) / 86400000); + const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; + if (hasTime && d < now) return 'overdue'; + if (!hasTime && diffDays < 0) return 'overdue'; + if (diffDays === 0) return hasTime ? timeStr : 'today'; + if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow'; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return hasTime ? `${dateLabel} ${timeStr}` : dateLabel; +} + +function _isDueOverdue(dateStr) { + if (!dateStr) return false; + const d = new Date(dateStr); + if (isNaN(d)) return false; + if (_hasTimeComponent(dateStr)) return d < new Date(); + return d < new Date(new Date().toDateString()); +} + +function _isDueTodayOrOverdue(dateStr) { + if (!dateStr) return false; + const d = new Date(dateStr); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const due = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return due <= today; +} + +function _laterTodayDate() { + const now = new Date(); + const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today + // If less than 1 hour before 6pm, push to "in 3 hours" instead + if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000); + return eight; +} + +function _tomorrowDate() { + const t = new Date(); + t.setDate(t.getDate() + 1); + t.setHours(8, 0, 0, 0); + return t; +} + +function _nextWeekDate() { + const t = new Date(); + const daysUntilMon = (8 - t.getDay()) % 7 || 7; + t.setDate(t.getDate() + daysUntilMon); + t.setHours(8, 0, 0, 0); + return t; +} + +function _toLocalDatetimeStr(d) { + // Format as YYYY-MM-DDTHH:MM (local, no TZ) + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function _formatReminderTag(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + if (isNaN(d)) return ''; + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = d.toDateString() === tomorrow.toDateString(); + const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + if (sameDay) return `Today, ${time}`; + if (isTomorrow) return `Tomorrow, ${time}`; + const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + return `${dateLabel}, ${time}`; +} + +function _nthWeekdayLabel(d) { + const n = Math.ceil(d.getDate() / 7); // 1..5 + return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`; +} + +function _isLastWeekdayOfMonth(d) { + const test = new Date(d); + test.setDate(d.getDate() + 7); + return test.getMonth() !== d.getMonth(); +} + +function _nthWeekdayOfMonth(year, month, weekday, n) { + const first = new Date(year, month, 1); + const offset = (weekday - first.getDay() + 7) % 7; + let day = 1 + offset + (n - 1) * 7; + // Last day of month + const lastDay = new Date(year, month + 1, 0).getDate(); + if (day > lastDay) day -= 7; + return new Date(year, month, day, 0, 0, 0); +} + +function _lastWeekdayOfMonth(year, month, weekday) { + const lastDay = new Date(year, month + 1, 0); + const back = (lastDay.getDay() - weekday + 7) % 7; + return new Date(year, month, lastDay.getDate() - back, 0, 0, 0); +} + +function _snapToRepeat(currentDate, normRepeat) { + const hh = currentDate.getHours(); + const mm = currentDate.getMinutes(); + const now = Date.now(); + const anchor = currentDate.getTime() > now ? currentDate : new Date(); + const parts = normRepeat.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + const targetWd = parseInt(parts[1], 10); + if (isNaN(targetWd)) return null; + const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0); + const delta = (targetWd - d.getDay() + 7) % 7; + d.setDate(d.getDate() + delta); + if (d.getTime() <= now) d.setDate(d.getDate() + 7); + return d; + } + if (kind === 'monthly') { + const sub = parts[1]; + let y = anchor.getFullYear(); + let m = anchor.getMonth(); + // Walk forward up to 14 months to find the next matching slot. + for (let tries = 0; tries < 14; tries++) { + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + if (isNaN(wantDay)) return null; + const lastDay = new Date(y, m + 1, 0).getDate(); + target = new Date(y, m, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + if (isNaN(n) || isNaN(wd)) return null; + target = _nthWeekdayOfMonth(y, m, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + if (isNaN(wd)) return null; + target = _lastWeekdayOfMonth(y, m, wd); + } else { + return null; + } + target.setHours(hh, mm, 0, 0); + if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target; + m++; + if (m > 11) { m = 0; y++; } + } + return null; + } + return null; +} + +function _formatRepeatLabel(repeat, originalDate) { + if (!repeat || repeat === 'none') return ''; + const norm = _normalizeRepeat(repeat, originalDate); + if (norm === 'daily') return 'Daily'; + if (norm === 'yearly') return 'Yearly'; + const parts = norm.split(':'); + if (parts[0] === 'weekly') { + const wd = parseInt(parts[1], 10); + if (isNaN(wd)) return 'Weekly'; + return `Weekly on ${_DAYS[wd]}s`; + } + if (parts[0] === 'monthly') { + if (parts[1] === 'day') return `Monthly on day ${parts[2]}`; + if (parts[1] === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`; + } + if (parts[1] === 'last') { + const wd = parseInt(parts[2], 10); + return `Monthly on last ${_DAYS[wd]}`; + } + } + return norm; +} + +function _normalizeRepeat(repeat, originalDate) { + if (!repeat || repeat === 'none') return 'none'; + if (repeat === 'daily' || repeat === 'yearly') return repeat; + if (/^(weekly|monthly):/.test(repeat)) return repeat; + // Legacy bare values - derive params from the original date + const wd = originalDate.getDay(); + const n = Math.ceil(originalDate.getDate() / 7); + if (repeat === 'weekly') return `weekly:${wd}`; + if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`; + if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`; + if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`; + return repeat; +} + +function _advanceRecurring(dateStr, repeat) { + const orig = new Date(dateStr); + const hh = orig.getHours(); + const mm = orig.getMinutes(); + let d = new Date(orig); + const norm = _normalizeRepeat(repeat, orig); + if (norm === 'none') return null; + + function step() { + if (norm === 'daily') { + d.setDate(d.getDate() + 1); + return; + } + if (norm === 'yearly') { + d.setFullYear(d.getFullYear() + 1); + return; + } + const parts = norm.split(':'); + const kind = parts[0]; + if (kind === 'weekly') { + // Snap to the requested weekday in the next 1-7 days + const targetWd = parseInt(parts[1], 10); + let delta = (targetWd - d.getDay() + 7) % 7; + if (delta === 0) delta = 7; + d.setDate(d.getDate() + delta); + d.setHours(hh, mm, 0, 0); + return; + } + if (kind === 'monthly') { + const sub = parts[1]; + const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0); + const nm = (d.getMonth() + 1) % 12; + let target; + if (sub === 'day') { + const wantDay = parseInt(parts[2], 10); + const lastDay = new Date(ny, nm + 1, 0).getDate(); + target = new Date(ny, nm, Math.min(wantDay, lastDay)); + } else if (sub === 'nth') { + const n = parseInt(parts[2], 10); + const wd = parseInt(parts[3], 10); + target = _nthWeekdayOfMonth(ny, nm, wd, n); + } else if (sub === 'last') { + const wd = parseInt(parts[2], 10); + target = _lastWeekdayOfMonth(ny, nm, wd); + } else { + d = null; return; + } + target.setHours(hh, mm, 0, 0); + d = target; + return; + } + d = null; + } + + step(); + if (d === null) return null; + const now = Date.now(); + // Cap catch-up to avoid runaway on a malformed/very-old date. + let guard = 5000; + while (d.getTime() <= now) { + if (--guard <= 0) return null; + step(); + if (d === null) return null; + } + return _toLocalDatetimeStr(d); +} diff --git a/tests/helpers/notes_source.py b/tests/helpers/notes_source.py new file mode 100644 index 000000000..86103975f --- /dev/null +++ b/tests/helpers/notes_source.py @@ -0,0 +1,10 @@ +"""Read deployed Notes responsibilities for source guards; runtime tests execute their real modules.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def notes_source(*modules): + base = ROOT / "static/js/features/notes" + paths = [base / f"{module}.js" for module in modules] if modules else sorted(base.rglob("*.js")) + return "\n".join(path.read_text(encoding="utf-8") for path in paths) diff --git a/tests/notesApplication.test.mjs b/tests/notesApplication.test.mjs new file mode 100644 index 000000000..8b3ee2eea --- /dev/null +++ b/tests/notesApplication.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNotesState } from '../frontend/src/features/notes/application/state.js'; +import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js'; +import { createLayout } from '../frontend/src/features/notes/ui/layout.js'; + +test('views cannot mutate Notes state, but captured identities observe application updates', () => { + const app = createNotesState(); + app.replaceNotes([{ id: 'a', items: [{ text: 'Task', done: false }] }]); + const note = app.state.notes[0]; + assert.throws(() => { note.items[0].done = true; }, /state operations/); + assert.throws(() => app.state.notes.push({ id: 'other' }), /state operations/); + assert.throws(() => app.state.selectedIds.add('a'), /selection operations/); + assert.throws(() => Object.defineProperty(note, 'title', { value: 'unowned' }), /state operations/); + assert.throws(() => Object.preventExtensions(note), /state operations/); + assert.throws(() => Object.getOwnPropertyDescriptor(note, 'items').value.push({}), /state operations/); + app.updateItem(note, 0, { done: true }); + assert.equal(note.items[0].done, true); + assert.equal(app.selectId('a'), undefined, 'selection operations do not leak the mutable set'); + assert.deepEqual([...app.state.selectedIds], ['a']); + assert.throws(() => app.state.selectedIds.valueOf().clear(), /selection operations/); + app.state.selectedIds.forEach((_value, _key, set) => { + assert.equal(set, app.state.selectedIds); + assert.throws(() => set.clear(), /selection operations/); + }); + app.mergeNote(note, { title: 'Changed' }); + assert.equal(note.title, 'Changed'); +}); + +test('fetch preserves archived query, failures clear notes, and successful fallback shapes', async () => { + const app = createNotesState(), requests = []; + let response = { ok: true, json: async () => [{ id: 'a' }] }; + const workflows = createNotesWorkflows({ app, api: { listNotes: async archived => { requests.push(archived); return response; } }, effects: {} }); + app.setShowingArchived(true); + await workflows._fetchNotes(); + assert.deepEqual(requests, [true]); + assert.equal(app.state.loading, false); + assert.equal(app.state.notes[0].id, 'a'); + response = { ok: false }; + await workflows._fetchNotes(); + assert.equal(app.state.notes.length, 0); + response = { ok: true, json: async () => ({ notes: [{ id: 'b' }] }) }; + await workflows._fetchNotes(); + assert.equal(app.state.notes[0].id, 'b'); +}); + +test('fetch completions retain the original last-completion ordering', async () => { + const app = createNotesState(), releases = []; + const workflows = createNotesWorkflows({ app, api: { listNotes: () => new Promise(resolve => releases.push(resolve)) }, effects: {} }); + const first = workflows._fetchNotes(), second = workflows._fetchNotes(); + releases[1]({ ok: true, json: async () => ({ notes: [{ id: 'second' }] }) }); + await second; + assert.equal(app.state.notes[0].id, 'second'); + releases[0]({ ok: true, json: async () => ({ notes: [{ id: 'first' }] }) }); + await first; + assert.equal(app.state.notes[0].id, 'first'); +}); + +test('writes built from views store raw data, not read-only views', () => { + const app = createNotesState(); + const raw = [{ id: 'a', items: [{ id: 'x', done: false }, { id: 'y', done: false }] }]; + app.replaceNotes(raw); + const note = app.state.notes[0]; + app.mergeNote(note, { items: note.items.filter(item => item.id !== 'x') }); + app.prependNote({ ...note, id: 'b' }); + app.spliceItems(app.state.notes[1], 1, 0, note.items[0]); + assert.doesNotThrow(() => structuredClone(raw)); + raw[1].items[0].done = true; + assert.equal(app.state.notes[1].items[0].done, true); + assert.throws(() => { app.state.notes[1].items[0].done = false; }, /state operations/); +}); + +test('masonry observer is not re-created once Notes is closed', () => { + const created = []; + const previous = { window: globalThis.window, ResizeObserver: globalThis.ResizeObserver }; + class FakeObserver { constructor() { created.push(this); this.live = true; } observe() {} disconnect() { this.live = false; } } + globalThis.ResizeObserver = FakeObserver; + globalThis.window = { ResizeObserver: FakeObserver, matchMedia: () => ({ matches: false }) }; + try { + let open = true; + const layout = createLayout({ isOpen: () => open }); + const body = { isConnected: true, closest: () => ({ classList: { contains: name => name === 'notes-view-grid' } }), querySelector: () => null, querySelectorAll: () => [] }; + layout._applyMasonry(body); + assert.equal(created.filter(o => o.live).length, 1); + layout.dispose(); + open = false; + layout._applyMasonry(body); + assert.equal(created.filter(o => o.live).length, 0); + } finally { + globalThis.window = previous.window; + globalThis.ResizeObserver = previous.ResizeObserver; + } +}); diff --git a/tests/notesCharacterization.test.mjs b/tests/notesCharacterization.test.mjs new file mode 100644 index 000000000..96fb5269e --- /dev/null +++ b/tests/notesCharacterization.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { parse } from 'acorn'; +import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js'; +import { createNotesState } from '../frontend/src/features/notes/application/state.js'; +import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js'; + +const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8'); +const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module' }); +const implementation = fs.readFileSync(new URL('../frontend/src/features/notes/index.js', import.meta.url), 'utf8'); +const implementationAST = parse(implementation, { ecmaVersion: 'latest', sourceType: 'module' }); +function functions(names, ports = {}) { + const datesSource = fs.readFileSync(new URL('../frontend/src/features/notes/domain/recurrence.js', import.meta.url), 'utf8').replaceAll('export ', ''); + const context = vm.createContext(ports); + const datesContext = vm.createContext({ ...ports, clock: { now: () => (ports.Date || Date).now() } }); + vm.runInContext(datesSource, datesContext); + context.dates = datesContext; + context.clock = { now: () => (ports.Date || Date).now() }; + context.api = createNotesApi({ origin: ports.API_BASE, request: ports.fetch }); + context._saveNote = context.api.saveNote; + context._patchNote = context.api.patchNote; + context._deleteNoteApi = context.api.deleteNote; + context.workflows = createNotesWorkflows({ app: createNotesState(), api: context.api, effects: {} }); + for (const statement of implementationAST.body) { + const node = statement.declaration || statement; + if (node.type === 'FunctionDeclaration' && names.includes(node.id.name)) { + vm.runInContext(implementation.slice(node.start, node.end), context); + } + } + return context; +} + +test('legacy public namespace and default API are intentional', () => { + const names = []; + for (const node of ast.body) { + if (node.type !== 'ExportNamedDeclaration') continue; + if (node.declaration) names.push(node.declaration.id.name); + for (const spec of node.specifiers) if (spec.exported.name !== 'default') names.push(spec.exported.name); + } + assert.deepEqual(names.sort(), ['closeNotes', 'closePanel', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']); + assert.match(source, /window.notesModule = notesModule/); +}); + +test('CRUD preserves same-origin credentials, payloads and error contracts', async () => { + const requests = []; + let ok = true; + const api = functions(['_saveNote', '_patchNote', '_deleteNoteApi'], { + API_BASE: 'https://notes.invalid', + fetch: async (url, options) => { + requests.push({ url, ...options }); + return { ok, status: 403, json: async () => ({ id: 'saved' }) }; + }, + }); + await api._saveNote({ title: 'New' }); + await api._saveNote({ id: 'a', title: '' }); + await api._patchNote('a', { due_date: null }); + await api._deleteNoteApi('a'); + assert.deepEqual(requests.map(r => [r.url, r.method, r.credentials]), [ + ['https://notes.invalid/api/notes', 'POST', 'same-origin'], + ['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'], + ['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'], + ['https://notes.invalid/api/notes/a', 'DELETE', 'same-origin'], + ]); + assert.equal(requests[2].body, '{"due_date":null}'); + ok = false; + await assert.rejects(api._saveNote({}), /Failed to save note/); + await assert.rejects(api._patchNote('a', {}), /Failed to update note/); + await assert.rejects(api._deleteNoteApi('a'), /HTTP 403/); +}); + +test('recurrence clock is read after first advancement and never for none', () => { + const calls = []; + class ClockDate extends Date { + static now() { calls.push('clock'); return Date.parse('2024-02-01T12:00'); } + setDate(value) { calls.push('step'); return super.setDate(value); } + } + const domain = functions(['_normalizeRepeat', '_advanceRecurring', '_toLocalDatetimeStr', '_nthWeekdayOfMonth', '_lastWeekdayOfMonth'], { Date: ClockDate }); + assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'daily', domain.clock), '2024-02-02T09:00'); + assert.deepEqual(calls, ['step', 'clock']); + calls.length = 0; + assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'none', domain.clock), null); + assert.deepEqual(calls, []); +}); + +test('archive failure restores position; undo failure rolls back reinsertion', async () => { + const notes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }], errors = []; + const stateOwner = createNotesState(); + stateOwner.replaceNotes(notes); + const app = createNotesWorkflows({ + app: stateOwner, + api: { patchNote: async () => { throw new Error('refused'); } }, + effects: { render: () => {}, showError: e => errors.push(e) }, + }); + assert.equal(app._archiveNoteById('b'), true); + assert.deepEqual(notes.map(n => n.id), ['a', 'c']); + await new Promise(setImmediate); + assert.deepEqual(notes.map(n => n.id), ['a', 'b', 'c']); + notes.splice(1, 1); + app._popAndRunUndo(); + await new Promise(setImmediate); + assert.deepEqual(notes.map(n => n.id), ['a', 'c']); + assert.deepEqual(errors, ['Failed to archive', 'Undo failed']); +}); diff --git a/tests/notesDraftAutosave.test.mjs b/tests/notesDraftAutosave.test.mjs index c12e73a25..00c41e7a3 100644 --- a/tests/notesDraftAutosave.test.mjs +++ b/tests/notesDraftAutosave.test.mjs @@ -1,21 +1,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import vm from 'node:vm'; +import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js'; +import { createNotesState } from '../frontend/src/features/notes/application/state.js'; +import { createNotesDrafts } from '../frontend/src/features/notes/application/drafts.js'; +import { createDraftEditor } from '../frontend/src/features/notes/ui/draftEditor.js'; -const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8'); function harness(patch = async () => ({})) { const data = new Map(), timers = new Map(), errors = []; let nextTimer = 0; - const context = vm.createContext({ + const context = { localStorage: { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v), removeItem: k => data.delete(k) }, setTimeout: f => { timers.set(++nextTimer, f); return nextTimer; }, clearTimeout: id => timers.delete(id), _patchNote: patch, _notes: [{id:'note1'}], _collectItems: form => form.items, uiModule: {showError: message => errors.push(message)}, - }); - vm.runInContext(source.slice(source.indexOf("const _DRAFT_PREFIX"), source.indexOf('// ---- Create / Edit Form ----')), context); + }; + context.persistence = createNotesPersistence({ storage: context.localStorage }); + context.app = createNotesState(); + context.app.replaceNotes(context._notes); + context.state = context.app.state; + Object.assign(context, context.persistence); + const drafts = createNotesDrafts({ app: context.app, api: { patchNote: patch }, persistence: context.persistence, scheduler: context, effects: context.uiModule }); + Object.assign(context, createDraftEditor({ ...context, ...context.persistence, bindDraft: drafts.bindDraft })); const fields = {'.note-form-title':{value:'Title'}, '.note-form-content':{value:'Original'}}; const handlers = {}; const form = { dataset:{noteType:'note'}, items:[], querySelector: s => fields[s], addEventListener:(n,f) => handlers[n]=f }; diff --git a/tests/notesInfrastructure.test.mjs b/tests/notesInfrastructure.test.mjs new file mode 100644 index 000000000..601ed8275 --- /dev/null +++ b/tests/notesInfrastructure.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js'; +import { createNotesPersistence, REMINDER_FIRED_KEY } from '../frontend/src/features/notes/infrastructure/storage.js'; + +test('list, reorder, upload, reminder and agent transports retain exact requests', async () => { + const calls = [], result = { ok: false, status: 403 }; + const api = createNotesApi({ origin: 'https://notes.invalid', request: async (...args) => { calls.push(args); return result; } }); + const body = { opaque: true }; + assert.equal(await api.listNotes(true), result); + await api.listNotes(false); await api.reorder(['b', 'a']); await api.upload(body); + await api.fireReminder({ note_id: 'a' }); await api.defaultChat(); + await api.createSession(body); await api.startChat(body); + assert.deepEqual(calls.map(([url, o]) => [url, o.method || 'GET', o.credentials]), [ + ['https://notes.invalid/api/notes?archived=true', 'GET', 'same-origin'], + ['https://notes.invalid/api/notes', 'GET', 'same-origin'], + ['https://notes.invalid/api/notes/reorder', 'POST', 'same-origin'], + ['https://notes.invalid/api/upload', 'POST', 'same-origin'], + ['/api/notes/fire-reminder', 'POST', 'same-origin'], + ['https://notes.invalid/api/default-chat', 'GET', 'same-origin'], + ['https://notes.invalid/api/session', 'POST', 'same-origin'], + ['https://notes.invalid/api/chat_stream', 'POST', 'same-origin'], + ]); + assert.equal(calls[2][1].body, '{"ids":["b","a"]}'); + assert.equal(calls[3][1].body, body); + assert.equal(api.uploadUrl('file'), 'https://notes.invalid/api/upload/file'); +}); + +test('draft and marker persistence retain keys, formats and malformed fallback', () => { + const values = new Map(); + const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) }; + const persistence = createNotesPersistence({ storage }); + assert.equal(persistence._draftKey(null), 'odysseus-note-draft-__new__'); + persistence._saveFiredReminders(new Set(['a', 'b'])); + assert.equal(values.get(REMINDER_FIRED_KEY), '["a","b"]'); + assert.deepEqual([...persistence._loadFiredReminders()], ['a', 'b']); + values.set(REMINDER_FIRED_KEY, '{broken'); + assert.equal(persistence._loadFiredReminders().size, 0); + storage.setItem(persistence._draftKey('a'), '{"content":"","_ts":1}'); + assert.deepEqual(persistence._loadDraft('a'), { content: '', _ts: 1 }); + persistence._clearDraft('a'); + assert.equal(persistence._loadDraft('a'), null); + const failing = createNotesPersistence({ storage: { getItem() { throw Error('blocked'); }, setItem() { throw Error('full'); } } }); + assert.equal(failing._loadDraft('a'), null); + assert.doesNotThrow(() => failing._saveFiredReminders(new Set(['a']))); + assert.throws(() => failing.setItem('odysseus-note-draft-a', '{}'), /full/, 'draft caller must observe backup failure'); +}); diff --git a/tests/notesRecurrence.test.mjs b/tests/notesRecurrence.test.mjs new file mode 100644 index 000000000..326a8fbca --- /dev/null +++ b/tests/notesRecurrence.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import * as rules from '../frontend/src/features/notes/domain/recurrence.js'; + +const baseline = fs.readFileSync(new URL('./fixtures/notesRecurrenceBaseline.mjs', import.meta.url), 'utf8'); +const timed = new Set(['_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_laterTodayDate', '_tomorrowDate', '_nextWeekDate', '_formatReminderTag', '_snapToRepeat', '_advanceRecurring']); +for (const zone of ['UTC', 'Europe/Lisbon', 'America/New_York', 'Australia/Sydney']) { + test(`recurrence differential oracle in ${zone}`, () => { + const oldZone = process.env.TZ; + process.env.TZ = zone; + try { + const now = Date.parse('2024-03-10T01:59:00'); + class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } } + const original = vm.createContext({ Date: FixedDate }); + vm.runInContext(baseline, original); + const compare = (name, args) => { + const before = original[name](...args); + const after = rules[name](...args, ...(timed.has(name) ? [{ now: () => now }] : [])); + assert.equal(after instanceof Date ? after.getTime() : after, before instanceof Date ? before.getTime() : before, `${name}(${args})`); + }; + const dates = ['2024-01-31T09:17', '2024-02-29T09:17', '2023-02-28T09:17', '2024-03-09T02:30', '2024-11-02T01:30', '2030-07-31T18:42', '1900-01-01T09:00', 'invalid', '2024-03-10']; + const repeats = [null, '', 'none', 'daily', 'weekly', 'monthly', 'yearly', 'monthly_nth_weekday', 'monthly_last_weekday', 'weekly:1', 'weekly:bad', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'monthly:bad', 'unknown']; + for (const date of dates) { + for (const repeat of repeats) { + compare('_advanceRecurring', [date, repeat]); + compare('_normalizeRepeat', [repeat, new Date(date)]); + compare('_formatRepeatLabel', [repeat, new Date(date)]); + } + for (const name of ['_hasTimeComponent', '_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_formatReminderTag']) compare(name, [date]); + for (const repeat of ['weekly:1', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'daily', 'monthly:bad']) compare('_snapToRepeat', [new Date(date), repeat]); + } + for (const name of ['_laterTodayDate', '_tomorrowDate', '_nextWeekDate']) compare(name, []); + for (const year of [2023, 2024]) for (let month = 0; month < 12; month++) for (let wd = 0; wd < 7; wd++) { + compare('_nthWeekdayOfMonth', [year, month, wd, 5]); + compare('_lastWeekdayOfMonth', [year, month, wd]); + } + } finally { if (oldZone === undefined) delete process.env.TZ; else process.env.TZ = oldZone; } + }); +} diff --git a/tests/notesReminders.test.mjs b/tests/notesReminders.test.mjs new file mode 100644 index 000000000..1d5a8c340 --- /dev/null +++ b/tests/notesReminders.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNotesState } from '../frontend/src/features/notes/application/state.js'; +import { createNotesReminders } from '../frontend/src/features/notes/application/reminders.js'; +import { createNotesNotifications } from '../frontend/src/features/notes/infrastructure/notifications.js'; +import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY } from '../frontend/src/features/notes/infrastructure/storage.js'; + +function harness(notes = []) { + const now = Date.parse('2024-03-10T09:00'), app = createNotesState(); + app.replaceNotes(notes); + const values = new Map(), timeouts = new Map(), intervals = new Map(), fired = [], shown = [], highlighted = [], patches = []; + let nextId = 0, response = Promise.resolve({ ok: true, json: async () => ({ synthesis: 'Synthesis' }) }); + const scheduler = { + setTimeout: (fn, ms) => { const id = ++nextId; timeouts.set(id, { fn, ms }); return id; }, clearTimeout: id => timeouts.delete(id), + setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, ms }); return id; }, clearInterval: id => intervals.delete(id), + }; + const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value) } }); + const reminders = createNotesReminders({ + app, clock: { now: () => now }, scheduler, persistence, dismissedKey: REMINDER_DISMISSED_AT_KEY, + notifications: { show: (...args) => shown.push(args) }, fetchNotes: async () => {}, + hasItems: note => ['todo', 'goal', 'checklist'].includes(note.note_type), isNoteFullyDone: () => false, + api: { listNotes: async () => ({ ok: true, json: async () => ({ notes }) }), patchNote: async (...args) => patches.push(args), fireReminder: body => { fired.push(body); return response; } }, + effects: { badge: () => {}, highlight: id => highlighted.push(id) }, + }); + return { now, app, reminders, timeouts, intervals, fired, shown, highlighted, patches, persistence, setResponse: value => { response = value; } }; +} + +test('startup and loop are idempotent, and panel closure leaves background scheduling alive', async () => { + const h = harness(); + h.reminders.start(); h.reminders.start(); + assert.deepEqual([...h.timeouts.values()].map(t => t.ms), [3000]); + await [...h.timeouts.values()][0].fn(); + h.reminders._startReminderLoop(); + assert.deepEqual([...h.intervals.values()].map(t => t.ms), [30000]); + h.app.setOpen(false); + assert.equal(h.intervals.size, 1); + h.reminders.dispose(); + assert.equal(h.intervals.size, 0); + h.reminders._startReminderLoop(); + assert.equal(h.intervals.size, 0); +}); + +test('fresh reminders dispatch once, stale reminders mark silently, recurring reminders advance', async () => { + const now = Date.parse('2024-03-10T09:00'); + const local = value => { const d = new Date(value); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:30`; }; + const h = harness([ + { id: 'fresh', due_date: local(now - 30000), content: 'Body' }, + { id: 'old', due_date: local(now - 180000) }, + { id: 'recurring', due_date: local(now - 180000), repeat: 'daily' }, + { id: 'date', due_date: '2024-03-10' }, + { id: 'archived', archived: true, due_date: local(now - 30000) }, + ]); + h.reminders._checkReminders(); h.reminders._checkReminders(); + await new Promise(setImmediate); + assert.deepEqual(h.fired.map(n => n.note_id), ['fresh']); + assert.deepEqual([...h.persistence._loadFiredReminders()], ['fresh', 'old']); + assert.equal(h.patches.length, 1); + assert.equal(h.app.state.notes[2].due_date, '2024-03-11T08:57'); + assert.deepEqual(h.highlighted, ['fresh']); + assert.equal(h.shown[0][1], 'Synthesis'); +}); + +test('slow synthesis and rejected dispatch use the local fallback exactly once', async () => { + const h = harness(); + let resolve; + h.setResponse(new Promise(r => { resolve = r; })); + h.reminders._fireReminder({ id: 'a', title: 'Title', content: 'Raw' }); + const timer = [...h.timeouts.values()].find(t => t.ms === 1500); + timer.fn(); + resolve({ ok: true, json: async () => ({ synthesis: 'Too late' }) }); + await new Promise(setImmediate); + assert.deepEqual(h.shown, [['Title', 'Raw', 'a']]); + h.setResponse(Promise.reject(Error('offline'))); + h.reminders._fireReminder({ id: 'b', note_type: 'todo', items: [{ text: 'Task', done: false }] }); + await new Promise(setImmediate); + assert.equal(h.shown.length, 2); + assert.equal(h.fired[1].body, 'Pending (1):\n- Task'); +}); + +test('notification denial, unsupported browsers and permission errors remain fail-safe', async () => { + const toast = []; + const absent = createNotesNotifications({ browser: {}, toast: title => toast.push(title) }); + assert.equal(await absent.ensurePermission(), false); + absent.show('Title', 'Body', 'a'); + assert.deepEqual(toast, ['Title']); + const denied = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'denied' } }); + assert.equal(await denied.ensurePermission(), false); + const failed = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'default', requestPermission: async () => { throw Error('blocked'); } } }); + assert.equal(await failed.ensurePermission(), false); +}); diff --git a/tests/notesWorkflows.test.mjs b/tests/notesWorkflows.test.mjs new file mode 100644 index 000000000..59c39d84f --- /dev/null +++ b/tests/notesWorkflows.test.mjs @@ -0,0 +1,147 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createNotesState } from '../frontend/src/features/notes/application/state.js'; +import { createCardOperations } from '../frontend/src/features/notes/application/cardOperations.js'; +import { createChecklistOperations } from '../frontend/src/features/notes/application/checklistOperations.js'; +import { createNotesSubmission } from '../frontend/src/features/notes/application/submission.js'; +import { createSelectionOperations } from '../frontend/src/features/notes/application/selection.js'; +import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js'; +import { createNotesLifecycle } from '../frontend/src/features/notes/ui/lifecycle.js'; +import { createText } from '../frontend/src/features/notes/ui/text.js'; +import { createDeletion } from '../frontend/src/features/notes/application/deletion.js'; + +const drain = () => new Promise(setImmediate); +function owner(notes) { const app = createNotesState(); app.replaceNotes(notes); return app; } + +test('reorder retains desktop persistence-only and mobile post-response state ordering', async () => { + const app = owner([{ id: 'a', sort_order: 9 }, { id: 'b', sort_order: 8 }]), requests = []; + let resolve; + const operations = createCardOperations({ app, api: { reorder: ids => { requests.push(ids); return new Promise(done => { resolve = done; }); } } }); + const desktop = operations.persistOrder(['b', 'a']); + resolve({ ok: false }); await desktop; + assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]); + const mobile = operations.commitMobileOrder(['b', 'a']); + assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]); + resolve({ ok: false }); await mobile; + assert.deepEqual(app.state.notes.map(note => note.sort_order), [1, 0]); + assert.deepEqual(requests, [['b', 'a'], ['b', 'a']]); +}); + +test('editor deletion waits for confirmation and preserves optimistic failure/refetch timing', async () => { + const app = owner([{ id: 'a' }]), events = []; + app.setEditingId('a'); + let confirm, fail; + const operations = createDeletion({ app, state: app.state, + _deleteNoteApi: id => { events.push('delete:' + id); return new Promise((_resolve, reject) => { fail = reject; }); }, + _fetchNotes: async () => { events.push('fetch'); app.replaceNotes([{ id: 'a' }]); }, + _renderNotes: () => events.push('render'), + effects: { styledConfirm: () => new Promise(resolve => { confirm = resolve; }), showToast: () => events.push('toast'), showError: error => events.push(error) }, + }); + const deletion = operations.deleteFromEditor('a'); + assert.equal(app.state.notes.length, 1); + confirm(false); await deletion; + assert.deepEqual(events, []); + const accepted = operations.deleteFromEditor('a'); + confirm(true); await accepted; + assert.equal(app.state.notes.length, 0); + assert.equal(app.state.editingId, null); + assert.deepEqual(events, ['render', 'delete:a']); + fail(Error('403')); await drain(); + assert.equal(app.state.notes[0].id, 'a'); + assert.deepEqual(events, ['render', 'delete:a', 'Failed to delete', 'fetch', 'render']); +}); + +test('pin and delete failures roll back captured state and order', async () => { + const app = owner([{ id: 'a', pinned: false, sort_order: 4 }, { id: 'b', pinned: true, sort_order: -3 }]), errors = []; + const operations = createCardOperations({ app, api: { patchNote: async () => { throw Error('403'); }, deleteNote: async () => { throw Error('403'); } }, effects: { render() {}, showError: error => errors.push(error) } }); + operations.togglePin('a'); + assert.equal(app.state.notes[0].pinned, true); + assert.equal(app.state.notes[0].sort_order, -4); + await drain(); + assert.equal(app.state.notes[0].pinned, false); + assert.equal(app.state.notes[0].sort_order, 4); + operations.deleteOptimistically('a'); + assert.deepEqual(app.state.notes.map(n => n.id), ['b']); + await drain(); + assert.deepEqual(app.state.notes.map(n => n.id), ['a', 'b']); + assert.deepEqual(errors, ['Failed to pin', 'Failed to delete']); +}); + +test('item removal, quick add and checkbox errors preserve checklist contents', async () => { + const app = owner([{ id: 'a', items: [{ id: 'first', text: 'A', done: false }, { id: 'second', text: 'B', done: true }] }]); + const operations = createChecklistOperations({ app, api: { patchNote: async () => { throw Error('offline'); } }, uid: () => 'new', effects: { render() {}, showError() {} } }); + operations.removeItem('a', 0); + assert.equal(app.state.notes[0].items[0].id, 'second'); + await drain(); + assert.deepEqual(app.state.notes[0].items.map(it => it.id), ['first', 'second']); + const done = []; + operations.toggleItem('a', 0, value => done.push(value), () => {}); + await drain(); + assert.deepEqual(done, [true, false]); + const persist = operations.addItem('a', 'C', () => {}); + assert.equal(app.state.notes[0].items.length, 3); + persist(); await drain(); + assert.equal(app.state.notes[0].items.length, 2); + operations.editItem(app.state.notes[0], 1, 'Changed'); + await drain(); + assert.equal(app.state.notes[0].items[1].text, 'B'); +}); + +test('submission preserves top ordering, server ID replacement and newer draft recovery', async () => { + const app = owner([{ id: 'existing', sort_order: -2 }]), values = new Map(); + const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } }); + const draftKey = persistence._draftKey('__new__'); + values.set(draftKey, '{"content":"submitted"}'); + let resolve, renders = 0; + const operations = createNotesSubmission({ app, persistence, clock: { now: () => 0 }, uid: () => 'id', api: { saveNote: () => new Promise(r => { resolve = r; }) }, effects: { render: () => renders++, showError() {} }, fetchNotes: async () => {} }); + operations.submitNote({ title: 'New' }, null, false); + assert.equal(app.state.notes[0].id, 'tmp_id'); + assert.equal(app.state.notes[0].sort_order, -3); + values.set(draftKey, '{"content":"newer"}'); + resolve({ id: 'server-id' }); await drain(); + assert.equal(app.state.notes[0].id, 'server-id'); + assert.equal(values.get(draftKey), '{"content":"newer"}'); + assert.equal(renders, 2); +}); + +test('selection can exit only after synchronous acceptance', () => { + const app = owner([]), order = []; + app.selectId('a'); + let accepted = false; + const operations = createSelectionOperations({ app, sendContext: detail => { order.push('dispatch'); detail.accepted = accepted; }, exitSelection: () => order.push('exit'), effects: { showToast: () => order.push('rejected') } }); + operations.attachSelected(); + assert.deepEqual(order, ['dispatch', 'rejected']); + accepted = true; operations.attachSelected(); + assert.deepEqual(order, ['dispatch', 'rejected', 'dispatch', 'exit']); +}); + +test('listener owners release on replacement and disposal is idempotent', () => { + const previous = { document: globalThis.document, MutationObserver: globalThis.MutationObserver }; + let check, disconnected = 0; + globalThis.document = { body: {} }; + globalThis.MutationObserver = class { constructor(callback) { check = callback; } observe() {} disconnect() { disconnected++; } }; + try { + const lifecycle = createNotesLifecycle(), target = new EventTarget(), canvas = { isConnected: true }; + let calls = 0; + lifecycle.listen(canvas, target, 'mouseup', () => calls++); + let released = 0; + lifecycle.onDispose(canvas, () => released++); + target.dispatchEvent(new Event('mouseup')); + canvas.isConnected = false; check(); + target.dispatchEvent(new Event('mouseup')); + lifecycle.dispose(); lifecycle.dispose(); + assert.equal(calls, 1); assert.equal(disconnected, 1); + assert.equal(released, 1); + } finally { + for (const [name, value] of Object.entries(previous)) if (value === undefined) delete globalThis[name]; else globalThis[name] = value; + } +}); + +test('render helpers reject script-capable media and escape URL attributes', () => { + const helpers = createText({ uiModule: { esc: value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') } }); + for (const value of ['javascript:alert(1)', 'data:image/svg+xml;base64,abc', 'data:text/html;base64,abc']) assert.equal(helpers._safeImgSrc(value), ''); + assert.equal(helpers._safeImgSrc('data:image/png;base64,abc'), 'data:image/png;base64,abc'); + assert.equal(helpers._attrEsc('"\'<>`'), '"'<>`'); + assert.match(helpers._linkify('