From 66360b07249ecd947d1b0b147eb76c6d934fd4c8 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Mon, 30 Mar 2026 10:24:26 -0300 Subject: [PATCH] Add quick note search and viewer #22 --- packages/frontend/src/actions/actions.ts | 29 +++ .../src/components/shared/NoteFloatModal.vue | 8 +- .../src/components/shared/NoteSearchModal.vue | 188 ++++++++++++++++++ .../src/components/shared/SearchInput.vue | 63 ++++++ .../components/shared/SearchNoteViewer.vue | 112 +++++++++++ .../src/components/shared/SearchResults.vue | 58 ++++++ .../frontend/src/composables/useDraggable.ts | 102 ++++++++++ .../frontend/src/composables/useNoteModal.ts | 83 +------- .../src/composables/useSearchModal.ts | 181 +++++++++++++++++ packages/frontend/src/env.d.ts | 4 + packages/frontend/src/index.ts | 9 + packages/frontend/src/types.ts | 5 + .../frontend/src/utils/injectEditorStyles.ts | 12 ++ 13 files changed, 772 insertions(+), 82 deletions(-) create mode 100644 packages/frontend/src/components/shared/NoteSearchModal.vue create mode 100644 packages/frontend/src/components/shared/SearchInput.vue create mode 100644 packages/frontend/src/components/shared/SearchNoteViewer.vue create mode 100644 packages/frontend/src/components/shared/SearchResults.vue create mode 100644 packages/frontend/src/composables/useDraggable.ts create mode 100644 packages/frontend/src/composables/useSearchModal.ts create mode 100644 packages/frontend/src/env.d.ts create mode 100644 packages/frontend/src/utils/injectEditorStyles.ts diff --git a/packages/frontend/src/actions/actions.ts b/packages/frontend/src/actions/actions.ts index 3c752f2..04f06e5 100644 --- a/packages/frontend/src/actions/actions.ts +++ b/packages/frontend/src/actions/actions.ts @@ -2,6 +2,7 @@ import { type NoteContentItem } from "shared"; import { createApp, h } from "vue"; import NoteFloatModal from "@/components/shared/NoteFloatModal.vue"; +import NoteSearchModal from "@/components/shared/NoteSearchModal.vue"; import { SDKPlugin } from "@/plugins/sdk"; import { useNotesStore } from "@/stores/notes"; import type { FrontendSDK } from "@/types"; @@ -44,6 +45,34 @@ export const showNoteModal = (sdk: FrontendSDK) => { modalApp.mount(modalContainer); }; +/** + * Shows the search modal for finding and viewing existing notes + */ +export const showSearchModal = (sdk: FrontendSDK) => { + const modalContainer = document.createElement("div"); + modalContainer.id = "note-search-modal-container"; + document.body.appendChild(modalContainer); + + const position = { + x: Math.max(0, window.innerWidth / 2 - 250), + y: Math.max(0, window.innerHeight / 2 - 200), + }; + + const modalApp = createApp({ + render: () => + h(NoteSearchModal, { + initialPosition: position, + onClose: () => { + modalApp.unmount(); + modalContainer.remove(); + }, + }), + }); + + modalApp.use(SDKPlugin, sdk); + modalApp.mount(modalContainer); +}; + /** * Sends selected text to the currently open note */ diff --git a/packages/frontend/src/components/shared/NoteFloatModal.vue b/packages/frontend/src/components/shared/NoteFloatModal.vue index 7baa64e..3f3cc9e 100644 --- a/packages/frontend/src/components/shared/NoteFloatModal.vue +++ b/packages/frontend/src/components/shared/NoteFloatModal.vue @@ -4,17 +4,13 @@ import { onBeforeUnmount, onMounted } from "vue"; import { useNoteModal } from "@/composables/useNoteModal"; import { useSDK } from "@/plugins/sdk"; +import type { ModalPosition } from "@/types"; const sdk = useSDK(); -interface Position { - x: number; - y: number; -} - const props = defineProps({ initialPosition: { - type: Object as () => Position, + type: Object as () => ModalPosition, default: () => ({ x: 100, y: 100 }), }, }); diff --git a/packages/frontend/src/components/shared/NoteSearchModal.vue b/packages/frontend/src/components/shared/NoteSearchModal.vue new file mode 100644 index 0000000..57d4831 --- /dev/null +++ b/packages/frontend/src/components/shared/NoteSearchModal.vue @@ -0,0 +1,188 @@ + + + diff --git a/packages/frontend/src/components/shared/SearchInput.vue b/packages/frontend/src/components/shared/SearchInput.vue new file mode 100644 index 0000000..5eee68f --- /dev/null +++ b/packages/frontend/src/components/shared/SearchInput.vue @@ -0,0 +1,63 @@ + + + diff --git a/packages/frontend/src/components/shared/SearchNoteViewer.vue b/packages/frontend/src/components/shared/SearchNoteViewer.vue new file mode 100644 index 0000000..85b7464 --- /dev/null +++ b/packages/frontend/src/components/shared/SearchNoteViewer.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/frontend/src/components/shared/SearchResults.vue b/packages/frontend/src/components/shared/SearchResults.vue new file mode 100644 index 0000000..94fbfbf --- /dev/null +++ b/packages/frontend/src/components/shared/SearchResults.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/frontend/src/composables/useDraggable.ts b/packages/frontend/src/composables/useDraggable.ts new file mode 100644 index 0000000..150cd60 --- /dev/null +++ b/packages/frontend/src/composables/useDraggable.ts @@ -0,0 +1,102 @@ +import { reactive } from "vue"; + +import type { ModalPosition } from "@/types"; + +interface Size { + width: number; + height: number; +} + +interface DraggableOptions { + initialPosition?: ModalPosition; + initialSize?: Size; + minWidth?: number; + minHeight?: number; +} + +export function useDraggable(options: DraggableOptions = {}) { + const position = reactive({ + x: options.initialPosition?.x ?? 100, + y: options.initialPosition?.y ?? 100, + }); + + const size = reactive({ + width: options.initialSize?.width ?? 400, + height: options.initialSize?.height ?? 150, + }); + + const minWidth = options.minWidth ?? 200; + const minHeight = options.minHeight ?? 150; + + let isDragging = false; + let isResizing = false; + let dragOffset = { x: 0, y: 0 }; + + function startDrag(event: MouseEvent) { + const target = event.target as Element; + if ( + target.closest(".resize-handle") || + target.closest("select") || + target.closest("input") + ) { + return; + } + + isDragging = true; + dragOffset = { + x: event.clientX - position.x, + y: event.clientY - position.y, + }; + + document.addEventListener("mousemove", handleDrag); + document.addEventListener("mouseup", stopDrag); + } + + function handleDrag(event: MouseEvent) { + if (isDragging) { + position.x = event.clientX - dragOffset.x; + position.y = event.clientY - dragOffset.y; + } + } + + function stopDrag() { + isDragging = false; + document.removeEventListener("mousemove", handleDrag); + document.removeEventListener("mouseup", stopDrag); + } + + function startResize(event: MouseEvent) { + isResizing = true; + event.preventDefault(); + document.addEventListener("mousemove", handleResize); + document.addEventListener("mouseup", stopResize); + } + + function handleResize(event: MouseEvent) { + if (isResizing) { + size.width = Math.max(minWidth, event.clientX - position.x); + size.height = Math.max(minHeight, event.clientY - position.y); + } + } + + function stopResize() { + isResizing = false; + document.removeEventListener("mousemove", handleResize); + document.removeEventListener("mouseup", stopResize); + } + + function cleanup() { + document.removeEventListener("mousemove", handleDrag); + document.removeEventListener("mouseup", stopDrag); + document.removeEventListener("mousemove", handleResize); + document.removeEventListener("mouseup", stopResize); + } + + return { + position, + size, + startDrag, + startResize, + cleanup, + }; +} diff --git a/packages/frontend/src/composables/useNoteModal.ts b/packages/frontend/src/composables/useNoteModal.ts index 9861718..a017f7a 100644 --- a/packages/frontend/src/composables/useNoteModal.ts +++ b/packages/frontend/src/composables/useNoteModal.ts @@ -1,8 +1,10 @@ import type { Folder, Note, NoteContentItem, NoteModalSaveData } from "shared"; -import { computed, reactive, ref, watch } from "vue"; +import { computed, ref, watch } from "vue"; +import { useDraggable } from "@/composables/useDraggable"; import { useSDK } from "@/plugins/sdk"; import { useNotesStore } from "@/stores/notes"; +import type { ModalPosition } from "@/types"; import { currentReplayTabData } from "@/utils/caido"; import { addParagraphToContent, @@ -11,18 +13,8 @@ import { createTextParagraph, } from "@/utils/noteUtils"; -interface Position { - x: number; - y: number; -} - -interface Size { - width: number; - height: number; -} - interface NoteModalOptions { - initialPosition?: Position; + initialPosition?: ModalPosition; onClose?: () => void; onSave?: (data: NoteModalSaveData) => void; } @@ -36,20 +28,11 @@ export function useNoteModal(options: NoteModalOptions = {}) { const textarea = ref(undefined); const isReplayPage = computed(() => window.location.hash === "#/replay"); - const position = reactive({ - x: options.initialPosition?.x || 100, - y: options.initialPosition?.y || 100, + const { position, size, startDrag, startResize } = useDraggable({ + initialPosition: options.initialPosition, + initialSize: { width: 400, height: 150 }, }); - const size = reactive({ - width: 400, - height: 150, - }); - - let isDragging = false; - let isResizing = false; - let dragOffset = { x: 0, y: 0 }; - const availableNotes = computed(() => { if (!notesStore.tree) return []; @@ -71,58 +54,6 @@ export function useNoteModal(options: NoteModalOptions = {}) { return notes; }); - function startDrag(event: MouseEvent) { - if ( - (event.target as Element).closest(".resize-handle") || - (event.target as Element).closest("select") - ) - return; - - isDragging = true; - dragOffset = { - x: event.clientX - position.x, - y: event.clientY - position.y, - }; - - document.addEventListener("mousemove", handleDrag); - document.addEventListener("mouseup", stopDrag); - } - - function handleDrag(event: MouseEvent) { - if (isDragging) { - position.x = event.clientX - dragOffset.x; - position.y = event.clientY - dragOffset.y; - } - } - - function stopDrag() { - isDragging = false; - document.removeEventListener("mousemove", handleDrag); - document.removeEventListener("mouseup", stopDrag); - } - - function startResize(event: MouseEvent) { - isResizing = true; - event.preventDefault(); - document.addEventListener("mousemove", handleResize); - document.addEventListener("mouseup", stopResize); - } - - function handleResize(event: MouseEvent) { - if (isResizing) { - const newWidth = event.clientX - position.x; - const newHeight = event.clientY - position.y; - size.width = Math.max(200, newWidth); - size.height = Math.max(150, newHeight); - } - } - - function stopResize() { - isResizing = false; - document.removeEventListener("mousemove", handleResize); - document.removeEventListener("mouseup", stopResize); - } - function close() { options.onClose?.(); } diff --git a/packages/frontend/src/composables/useSearchModal.ts b/packages/frontend/src/composables/useSearchModal.ts new file mode 100644 index 0000000..0b41106 --- /dev/null +++ b/packages/frontend/src/composables/useSearchModal.ts @@ -0,0 +1,181 @@ +import type { Folder, Note, NoteContent } from "shared"; +import { computed, ref } from "vue"; + +import { useDraggable } from "@/composables/useDraggable"; +import { useSDK } from "@/plugins/sdk"; +import { useNotesRepository } from "@/repositories/notes"; +import { useNotesStore } from "@/stores/notes"; +import type { ModalPosition } from "@/types"; + +interface SearchModalOptions { + initialPosition?: ModalPosition; + onClose?: () => void; +} + +export function useSearchModal(options: SearchModalOptions = {}) { + const notesStore = useNotesStore(); + const repository = useNotesRepository(); + const sdk = useSDK(); + + const { position, size, startDrag, startResize } = useDraggable({ + initialPosition: options.initialPosition, + initialSize: { width: 500, height: 400 }, + minWidth: 300, + minHeight: 250, + }); + + const searchQuery = ref(""); + const searchResults = ref([]); + const selectedIndex = ref(0); + const viewedNote = ref(undefined); + const isLoading = ref(false); + + const isViewingNote = computed(() => viewedNote.value !== undefined); + + async function search(query: string) { + searchQuery.value = query; + selectedIndex.value = 0; + + if (!query.trim()) { + await loadAllNotes(); + return; + } + + try { + const results = await notesStore.searchNotes(query); + searchResults.value = results; + } catch { + searchResults.value = []; + } + } + + async function loadAllNotes() { + if (!notesStore.tree) { + await notesStore.initialize(); + } + + const notes: Note[] = []; + const collectNotes = (folder: Folder) => { + if (!folder?.children) return; + for (const child of folder.children) { + if (child.type === "note") { + notes.push(child as Note); + } else if (child.type === "folder") { + collectNotes(child as Folder); + } + } + }; + + if (notesStore.tree) { + collectNotes(notesStore.tree); + } + searchResults.value = notes; + } + + let savedSearchSize = { width: 500, height: 400 }; + + async function openNote(notePath: string) { + isLoading.value = true; + try { + const note = await repository.getNote(notePath); + if (note) { + savedSearchSize = { width: size.width, height: size.height }; + viewedNote.value = note; + + const viewWidth = Math.max(700, size.width); + const viewHeight = Math.max(500, size.height); + const dx = viewWidth - size.width; + const dy = viewHeight - size.height; + size.width = viewWidth; + size.height = viewHeight; + position.x = Math.max(0, position.x - dx / 2); + position.y = Math.max(0, position.y - dy / 2); + } + } catch (error) { + sdk.window.showToast(`Error loading note: ${error}`, { + variant: "error", + }); + } finally { + isLoading.value = false; + } + } + + async function goBackToSearch() { + viewedNote.value = undefined; + searchQuery.value = ""; + + const dx = size.width - savedSearchSize.width; + const dy = size.height - savedSearchSize.height; + size.width = savedSearchSize.width; + size.height = savedSearchSize.height; + position.x = position.x + dx / 2; + position.y = position.y + dy / 2; + + await notesStore.refreshTree(); + await loadAllNotes(); + } + + async function saveNoteContent(content: NoteContent) { + if (!viewedNote.value) return; + + try { + await repository.updateNote(viewedNote.value.path, { content }); + viewedNote.value.content = content; + + if (notesStore.currentNotePath === viewedNote.value.path) { + await notesStore.refreshTree(); + await notesStore.loadNote(viewedNote.value.path); + } + } catch (error) { + sdk.window.showToast(`Error saving note: ${error}`, { + variant: "error", + }); + } + } + + function close() { + options.onClose?.(); + } + + function moveSelection(direction: 1 | -1) { + const newIndex = selectedIndex.value + direction; + if (newIndex >= 0 && newIndex < searchResults.value.length) { + selectedIndex.value = newIndex; + } + } + + async function openSelectedNote() { + const note = searchResults.value[selectedIndex.value]; + if (note) { + await openNote(note.path); + } + } + + async function initialize() { + if (!notesStore.tree) { + await notesStore.initialize(); + } + await loadAllNotes(); + } + + return { + position, + size, + searchQuery, + searchResults, + selectedIndex, + viewedNote, + isViewingNote, + isLoading, + startDrag, + startResize, + search, + openNote, + goBackToSearch, + saveNoteContent, + close, + moveSelection, + openSelectedNote, + initialize, + }; +} diff --git a/packages/frontend/src/env.d.ts b/packages/frontend/src/env.d.ts new file mode 100644 index 0000000..96e5d27 --- /dev/null +++ b/packages/frontend/src/env.d.ts @@ -0,0 +1,4 @@ +declare module "*.css?raw" { + const css: string; + export default css; +} diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 89a4c11..27a09fd 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -13,6 +13,7 @@ import { sendReplaySessionToNote, sendSelectedTextToNote, showNoteModal, + showSearchModal, } from "@/actions/actions"; import { emitter } from "@/utils/eventBus"; import { convertMarkdownToTipTap } from "@/utils/markdownToJSON"; @@ -57,9 +58,16 @@ export const init = (sdk: FrontendSDK) => { run: () => sendReplaySessionToNote(sdk), }); + sdk.commands.register("notesplusplus:search-notes", { + name: "Search Notes", + group: "Notes++", + run: () => showSearchModal(sdk), + }); + sdk.commandPalette.register("notesplusplus:floating-modal"); sdk.commandPalette.register("notesplusplus:send-selected-text"); sdk.commandPalette.register("notesplusplus:send-replay-session"); + sdk.commandPalette.register("notesplusplus:search-notes"); sdk.menu.registerItem({ type: "Request", @@ -74,6 +82,7 @@ export const init = (sdk: FrontendSDK) => { }); sdk.shortcuts.register("notesplusplus:floating-modal", ["cmd", "shift", "N"]); + sdk.shortcuts.register("notesplusplus:search-notes", ["cmd", "shift", "S"]); sdk.shortcuts.register("notesplusplus:send-selected-text", [ "ctrl", "shift", diff --git a/packages/frontend/src/types.ts b/packages/frontend/src/types.ts index eee4824..81927e7 100644 --- a/packages/frontend/src/types.ts +++ b/packages/frontend/src/types.ts @@ -2,3 +2,8 @@ import { type Caido } from "@caido/sdk-frontend"; import { type API, type BackendEvents } from "backend"; export type FrontendSDK = Caido; + +export interface ModalPosition { + x: number; + y: number; +} diff --git a/packages/frontend/src/utils/injectEditorStyles.ts b/packages/frontend/src/utils/injectEditorStyles.ts new file mode 100644 index 0000000..5722a11 --- /dev/null +++ b/packages/frontend/src/utils/injectEditorStyles.ts @@ -0,0 +1,12 @@ +import editorCSS from "@/components/content/editor/editor.css?raw"; + +const STYLE_ID = "notesplusplus-editor-styles"; + +export function injectEditorStyles() { + if (document.getElementById(STYLE_ID)) return; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = editorCSS; + document.head.appendChild(style); +}