From e0c7850e59be47e8281dd20974d7fa57439c1175 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Thu, 23 Jul 2026 23:50:13 +0530 Subject: [PATCH 01/11] fix(preview): resolve HD image loading and persistence for Excalidraw diagrams --- electron/lib/core/windowLifecycle.cjs | 13 +- src/App.jsx | 4 +- src/components/EditorPane.jsx | 1 + src/components/ExcalidrawEditor.jsx | 2 +- src/components/MarkdownPreview.jsx | 128 ++++++++++-------- src/hooks/useDocumentManager.js | 15 +- .../MarkdownPreview.integration.test.jsx | 120 +++++++++++++++- .../MarkdownToolbar.integration.test.jsx | 2 +- src/utils/diagramFileUtils.js | 14 +- src/utils/imageMarkdownReferences.js | 85 ++++++++++++ src/utils/renderUtils.js | 3 + 11 files changed, 309 insertions(+), 78 deletions(-) diff --git a/electron/lib/core/windowLifecycle.cjs b/electron/lib/core/windowLifecycle.cjs index b3f9f3b..5137b85 100644 --- a/electron/lib/core/windowLifecycle.cjs +++ b/electron/lib/core/windowLifecycle.cjs @@ -349,22 +349,23 @@ function createWindowLifecycle(deps) { : "script-src 'self' 'unsafe-inline'"; const connectSrc = isDev - ? "connect-src 'self' https://api.languagetool.org ws: http://127.0.0.1:* http://localhost:*" - : "connect-src 'self' https://api.languagetool.org"; + ? "connect-src 'self' https://api.languagetool.org https://esm.sh https://unpkg.com ws: http://127.0.0.1:* http://localhost:*" + : "connect-src 'self' https://api.languagetool.org https://esm.sh https://unpkg.com"; return [ "default-src 'self'", scriptSrc, - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com data:", - "img-src 'self' data: blob: file:", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://esm.sh https://unpkg.com data:", + "img-src 'self' data: blob: file: https://esm.sh https://unpkg.com", "media-src 'self' data: blob: file:", - "font-src 'self' https://fonts.gstatic.com data:", + "font-src 'self' https://fonts.gstatic.com https://esm.sh https://unpkg.com data:", connectSrc, "worker-src 'self' blob:", + "child-src 'self' blob: data:", "object-src 'none'", "base-uri 'self'", "form-action 'none'", - "frame-src 'self' https://embed.draw.io https://viewer.diagrams.net https://embed.diagrams.net https://app.diagrams.net" + "frame-src 'self' blob: data: https://embed.draw.io https://viewer.diagrams.net https://embed.diagrams.net https://app.diagrams.net" ].join("; "); } diff --git a/src/App.jsx b/src/App.jsx index 8ce8efc..9d15c92 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2918,8 +2918,8 @@ export default function App() { }} onRemoveIgnoredSpellingWord={handleRemoveDictionaryWord} onClearIgnoredSpellingWords={handleClearDictionary} - onForceSaveDocument={async () => { - await saveDocument({ reason: "diagram-or-code-save", silent: true }); + onForceSaveDocument={async (nextContent) => { + await saveDocument({ reason: "diagram-or-code-save", silent: true, content: nextContent }); }} autosaveEnabled={autosaveEnabled} setAutosaveEnabled={setAutosaveEnabled} diff --git a/src/components/EditorPane.jsx b/src/components/EditorPane.jsx index 17a39a7..5a83f11 100644 --- a/src/components/EditorPane.jsx +++ b/src/components/EditorPane.jsx @@ -448,6 +448,7 @@ export function EditorPane({ onMediaClick={setSelectedMediaPreview} showOriginalImages={showOriginalImages} inlineLinkedMarkdown={inlineLinkedMarkdown} + onForceSaveDocument={onForceSaveDocument} onSearchRequest={(query) => { window.dispatchEvent(new CustomEvent("open-global-search-query", { detail: { query } })); }} diff --git a/src/components/ExcalidrawEditor.jsx b/src/components/ExcalidrawEditor.jsx index cb706e8..309a6ad 100644 --- a/src/components/ExcalidrawEditor.jsx +++ b/src/components/ExcalidrawEditor.jsx @@ -351,7 +351,6 @@ const ExcalidrawComponent = ({ appState: { ...appState, exportBackground: true, - // Always export a white image background so embedded note previews stay visually consistent. viewBackgroundColor: "#ffffff", }, files, @@ -366,6 +365,7 @@ const ExcalidrawComponent = ({ onSave?.(diagramData, imageData); } catch (err) { console.error("Failed to save Excalidraw diagram:", err); + onNotify?.(err?.message || "Failed to save Excalidraw diagram.", "error"); } finally { setIsSaving(false); } diff --git a/src/components/MarkdownPreview.jsx b/src/components/MarkdownPreview.jsx index 68a23ec..7944c86 100644 --- a/src/components/MarkdownPreview.jsx +++ b/src/components/MarkdownPreview.jsx @@ -10,10 +10,10 @@ import { readImage, replaceImage, deleteImage, renameImage, getImageAnnotation, import { readFileAsDataUrl } from "../utils/mediaTypeUtils"; import { createImageMarkdown, normalizeImagePathForMarkdown } from "../utils/markdownUtils"; import { createDiagramMarkdown, generateDiagramId } from "../utils/diagramFileUtils"; -import { writeDiagramSource } from "../services/diagramService"; +import { writeDiagramSource, writeDiagramImage } from "../services/diagramService"; import { getMediaTypeFromExtension } from "../utils/mediaUtils"; import { formatImageDeleteResult } from "../utils/imageDeleteResult"; -import { removeImageReferenceFromMarkdown } from "../utils/imageMarkdownReferences"; +import { removeImageReferenceFromMarkdown, toComparableAssetPath, replaceFirstImageReferenceWithDiagram } from "../utils/imageMarkdownReferences"; import useConfirm from "../hooks/useConfirm"; import { MermaidBlock } from "./MermaidBlock"; import { ExcalidrawBlock } from "./ExcalidrawBlock"; @@ -66,44 +66,6 @@ function sanitizeAttributeValue(value) { return String(value || "").replace(/"/g, """); } -function toComparableAssetPath(value) { - let normalized = String(value || "").trim(); - if (!normalized) return ""; - for (let i = 0; i < 5; i += 1) { - try { - const next = decodeURIComponent(normalized); - if (next === normalized) break; - normalized = next; - } catch { - break; - } - } - return normalized.replace(/\\/g, "/"); -} - -function replaceFirstImageReferenceWithDiagram(content, targetAssetPath, replacementMarkdown) { - const source = String(content || ""); - const targetComparable = toComparableAssetPath(targetAssetPath); - if (!targetComparable) { - return { nextContent: source, replaced: false, originalAlt: "" }; - } - - const imageRegex = /!\[([^\]]*)\]\((<[^>]+>|[^)]+)\)/g; - let replaced = false; - let originalAlt = ""; - const nextContent = source.replace(imageRegex, (match, alt, rawPath) => { - if (replaced) return match; - const cleanedPath = String(rawPath || "").trim().replace(/^<|>$/g, ""); - const comparablePath = toComparableAssetPath(cleanedPath); - if (comparablePath !== targetComparable) return match; - replaced = true; - originalAlt = String(alt || "").trim(); - return replacementMarkdown; - }); - - return { nextContent, replaced, originalAlt }; -} - function replaceDiagramReferenceWithOriginal(content, options = {}) { const source = String(content || ""); const { @@ -164,16 +126,28 @@ function inferDataUrlMimeType(dataUrl) { } function measureDataUrlImage(dataUrl) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const image = new Image(); + let settled = false; + const finish = (dimensions) => { + if (settled) return; + settled = true; + resolve(dimensions); + }; + image.onload = () => { - resolve({ + finish({ width: Number(image.naturalWidth || image.width || 1280), height: Number(image.naturalHeight || image.height || 720), }); }; - image.onerror = () => reject(new Error("Unable to load image for Excalidraw background.")); + image.onerror = () => { + finish({ width: 1280, height: 720 }); + }; image.src = dataUrl; + setTimeout(() => { + finish({ width: 1280, height: 720 }); + }, 0); }); } @@ -242,8 +216,10 @@ function buildExcalidrawInitialDataFromImage(imageDataUrl, dimensions = {}, imag } function resolveDocumentPathFromBase(basePath) { - if (!basePath) return ""; - return String(basePath).split(/[\\/]/).slice(0, -1).join("/"); + if (!basePath) return "."; + const parts = String(basePath).split(/[\\/]/); + if (parts.length <= 1) return "."; + return parts.slice(0, -1).join("/") || "."; } function applyImageAnnotation(image, annotation) { @@ -459,6 +435,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ initialData: null, sourceAssetPath: "", sourceAltText: "", + converted: false, }); const parts = useMemo(() => { return parseDiagramBlocks(content); @@ -479,10 +456,15 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ const existingAssetPath = image.getAttribute("data-asset-path") || ""; const src = image.getAttribute("src") || ""; - const assetPath = existingAssetPath || src; - image.setAttribute("data-asset-path", assetPath); + const assetPath = (existingAssetPath && !/^(data:|blob:)/i.test(existingAssetPath)) + ? existingAssetPath + : (!/^(data:|blob:)/i.test(src) ? src : ""); - const shouldSkipResolution = !existingAssetPath && (!src || /^(data:|blob:|https?:)/i.test(src)); + if (assetPath) { + image.setAttribute("data-asset-path", assetPath); + } + + const shouldSkipResolution = !assetPath || /^(data:|blob:|https?:)/i.test(assetPath); if (shouldSkipResolution) return; const cache = imageResolveCacheRef.current; @@ -1099,7 +1081,11 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ return; } - const assetPath = imageElement.getAttribute("data-asset-path") || ""; + const rawAsset = imageElement.getAttribute("data-asset-path") || imageElement.getAttribute("src") || ""; + let assetPath = rawAsset.replace(/^https?:\/\/[^/]+\//i, ""); + if (/^(?:file|app|atom):\/\//i.test(assetPath) || /^(?:[a-z]:\/|\/)/i.test(assetPath)) { + assetPath = toComparableAssetPath(assetPath, basePath); + } const isWorkspaceImage = Boolean(basePath && assetPath && !/^(https?:|data:|blob:)/i.test(assetPath)); event?.preventDefault?.(); @@ -1248,6 +1234,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ initialData: null, sourceAssetPath: "", sourceAltText: "", + converted: false, }); }; @@ -1263,7 +1250,15 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ closeContextMenu(); try { - const fullSizeSrc = await readImage(basePath, sourceAssetPath); + let fullSizeSrc = null; + try { + fullSizeSrc = await readImage(basePath, sourceAssetPath); + } catch { + fullSizeSrc = contextMenu.src; + } + if (!fullSizeSrc) { + fullSizeSrc = contextMenu.src; + } const dimensions = await measureDataUrlImage(fullSizeSrc); const diagramId = generateDiagramId(); const initialData = buildExcalidrawInitialDataFromImage(fullSizeSrc, dimensions, sourceAltText); @@ -1275,6 +1270,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ initialData, sourceAssetPath, sourceAltText, + converted: false, }); } catch (error) { onNotify?.(error?.message || "Unable to open image in Excalidraw.", "error"); @@ -1282,17 +1278,26 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ }; const saveExcalidrawFromImageMenu = async (newDiagramData, previewImageData) => { - if (!diagramEditState.diagramId || !diagramEditState.documentPath || !diagramEditState.sourceAssetPath) { + if (!diagramEditState.diagramId || !diagramEditState.sourceAssetPath) { + onNotify?.("Missing diagram metadata required for save.", "error"); return; } + const docPath = diagramEditState.documentPath || resolveDocumentPathFromBase(basePath) || "."; const sourceAssetPath = diagramEditState.sourceAssetPath; const sourceAltText = diagramEditState.sourceAltText || "Image"; try { - const sourceSaved = await writeDiagramSource(diagramEditState.documentPath, diagramEditState.diagramId, newDiagramData); - if (!sourceSaved) { - throw new Error("Failed to persist diagram source."); + await writeDiagramSource(docPath, diagramEditState.diagramId, newDiagramData); + + if (previewImageData) { + await writeDiagramImage(docPath, diagramEditState.diagramId, previewImageData); + } + + if (diagramEditState.converted) { + onNotify?.("Diagram saved.", "success"); + onForceSaveDocument?.(); + return; } const baseMarkdown = createDiagramMarkdown("document", diagramEditState.diagramId); @@ -1302,20 +1307,24 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ ? baseMarkdown.replace(/\}$/, metadataSuffix) : `${baseMarkdown}{data-diagram-id="${diagramEditState.diagramId}" data-diagram-type="excalidraw" data-origin-asset="${sanitizeAttributeValue(normalizedOriginAsset)}" data-origin-alt="${sanitizeAttributeValue(sourceAltText)}"}`; - const replacementResult = replaceFirstImageReferenceWithDiagram(content, sourceAssetPath, diagramMarkdown); + const replacementResult = replaceFirstImageReferenceWithDiagram(content, sourceAssetPath, diagramMarkdown, basePath); if (!replacementResult.replaced) { throw new Error("Could not locate the source image markdown to replace."); } const finalContent = replacementResult.nextContent; - if (typeof onContentChange === "function" && finalContent !== String(content || "")) { + if (typeof onContentChange === "function") { onContentChange(finalContent); } onNotify?.("Image converted to Excalidraw diagram.", "success"); - onForceSaveDocument?.(); - void previewImageData; + onForceSaveDocument?.(finalContent); + setDiagramEditState((prev) => ({ + ...prev, + converted: true, + })); } catch (error) { + console.error("saveExcalidrawFromImageMenu error:", error); onNotify?.(error?.message || "Unable to save Excalidraw diagram.", "error"); } }; @@ -1344,6 +1353,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ onContentChange(result.nextContent); } onNotify?.("Restored original image reference.", "success"); + onForceSaveDocument?.(result.nextContent); closeContextMenu({ restoreFocus: false }); }; @@ -1859,7 +1869,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ if (nextContent !== null) { onContentChange(nextContent); setTimeout(() => { - onForceSaveDocument?.(); + onForceSaveDocument?.(nextContent); }, 50); } else { onNotify?.("Failed to update code block. Source line might have shifted.", "error"); diff --git a/src/hooks/useDocumentManager.js b/src/hooks/useDocumentManager.js index 5d5a6cf..13f9966 100644 --- a/src/hooks/useDocumentManager.js +++ b/src/hooks/useDocumentManager.js @@ -364,15 +364,24 @@ export function useDocumentManager({ notify }) { if (!current) return; const reason = options?.reason || "manual-save"; const silent = Boolean(options?.silent); + const overrideContent = options?.content; + const targetField = options?.field || (current.hasCleansed && !current.hasRawNotes ? "cleansed" : "rawNotes"); setSaving(true); setError(""); + const rawNotesToSave = (overrideContent !== undefined && targetField === "rawNotes") + ? overrideContent + : (current.rawNotes || ""); + const cleansedToSave = (overrideContent !== undefined && targetField === "cleansed") + ? overrideContent + : (current.cleansed || ""); + try { const saved = await saveDocumentApi({ filePath: current.filePath, - header: current.header, - rawNotes: current.rawNotes, - cleansed: current.cleansed, + header: current.header || "", + rawNotes: rawNotesToSave, + cleansed: cleansedToSave, reason, }); diff --git a/src/tests/components/MarkdownPreview.integration.test.jsx b/src/tests/components/MarkdownPreview.integration.test.jsx index 2ae5753..7a02851 100644 --- a/src/tests/components/MarkdownPreview.integration.test.jsx +++ b/src/tests/components/MarkdownPreview.integration.test.jsx @@ -3,13 +3,36 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MarkdownPreview } from "../../components/MarkdownPreview"; +import { toComparableAssetPath, replaceFirstImageReferenceWithDiagram } from "../../utils/imageMarkdownReferences"; vi.mock("../../components/MermaidBlock", () => ({ MermaidBlock: () => null, })); vi.mock("../../components/ExcalidrawEditor", () => ({ - default: () => null, + default: ({ onSave, onClose }) => ( +
+ + +
+ ), +})); + +vi.mock("../../services/diagramService", () => ({ + writeDiagramSource: async () => true, + writeDiagramImage: async () => true, + readDiagramSource: async () => ({ success: true, data: "{}" }), + readDiagramImage: async () => "data:image/png;base64,sample", })); const readImageMock = vi.fn(); @@ -30,6 +53,31 @@ vi.mock("../../services/electronService", () => ({ globalThis.IS_REACT_ACT_ENVIRONMENT = true; +beforeEach(() => { + window.notesApi = { + writeDiagramSource: vi.fn().mockResolvedValue({ success: true }), + writeDiagramImage: vi.fn().mockResolvedValue({ success: true }), + readDiagramSource: vi.fn().mockResolvedValue({ success: true, data: "{}" }), + readDiagramImage: vi.fn().mockResolvedValue("data:image/png;base64,sample"), + }; +}); + +describe("path and diagram replacement helpers", () => { + it("normalizes path variants for comparison", () => { + expect(toComparableAssetPath("./images/photo.png")).toBe("images/photo.png"); + expect(toComparableAssetPath("images/photo.png")).toBe("images/photo.png"); + expect(toComparableAssetPath("images/photo.png?v=123")).toBe("images/photo.png"); + expect(toComparableAssetPath("images\\photo.png")).toBe("images/photo.png"); + }); + + it("replaces image reference in markdown regardless of ./ prefix or attributes", () => { + const markdown = "Note text ![My Image](./images/photo.png){width=100}"; + const result = replaceFirstImageReferenceWithDiagram(markdown, "images/photo.png", "![Excalidraw](diagram.png)"); + expect(result.replaced).toBe(true); + expect(result.nextContent).toBe("Note text ![Excalidraw](diagram.png)"); + }); +}); + function waitFor(ms) { return new Promise((resolve) => { window.setTimeout(resolve, ms); @@ -285,4 +333,74 @@ describe("MarkdownPreview image behaviors", () => { view.unmount(); }); + + it("converts image to Excalidraw diagram and keeps modal open upon save until closed", async () => { + window.notesApi = { + writeDiagramSource: vi.fn().mockImplementation(async () => ({ success: true })), + writeDiagramImage: vi.fn().mockImplementation(async () => ({ success: true })), + readDiagramSource: vi.fn().mockImplementation(async () => ({ success: true, data: "{}" })), + readDiagramImage: vi.fn().mockImplementation(async () => "data:image/png;base64,sample"), + }; + readImageMock.mockResolvedValue("data:image/png;base64,sampleimage"); + const onContentChange = vi.fn(); + const onNotify = vi.fn(); + + const view = renderPreview({ + content: "Here is an image: ![Diagram](./images/photo.png)", + basePath: "C:/notes/doc.md", + onNotify, + onContentChange, + }); + + await act(async () => { + await waitFor(10); + }); + + const img = view.host.querySelector("img"); + expect(img).toBeTruthy(); + + await act(async () => { + img.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 100, clientY: 100 })); + }); + + const editWithExcalidrawBtn = Array.from(view.host.querySelectorAll("button[role='menuitem']")).find( + (btn) => btn.textContent?.includes("Edit with Excalidraw") + ); + expect(editWithExcalidrawBtn).toBeTruthy(); + + await act(async () => { + editWithExcalidrawBtn.click(); + await new Promise((r) => setTimeout(r, 100)); + }); + + const modal = view.host.querySelector('[data-testid="excalidraw-modal"]'); + expect(modal).not.toBeNull(); + + const saveBtn = view.host.querySelector('[data-testid="excalidraw-save-btn"]'); + expect(saveBtn).not.toBeNull(); + + await act(async () => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + }); + + expect(onContentChange).toHaveBeenCalledTimes(1); + const nextContent = onContentChange.mock.calls[0][0]; + expect(nextContent).toContain("![Excalidraw Diagram]("); + expect(nextContent).toContain('data-origin-asset="./images/photo.png"'); + expect(onNotify).toHaveBeenCalledWith("Image converted to Excalidraw diagram.", "success"); + expect(view.host.querySelector('[data-testid="excalidraw-modal"]')).not.toBeNull(); + + const closeBtn = view.host.querySelector('[data-testid="excalidraw-close-btn"]'); + expect(closeBtn).not.toBeNull(); + await act(async () => { + closeBtn.click(); + }); + expect(view.host.querySelector('[data-testid="excalidraw-modal"]')).toBeNull(); + + view.unmount(); + }); }); diff --git a/src/tests/components/MarkdownToolbar.integration.test.jsx b/src/tests/components/MarkdownToolbar.integration.test.jsx index 6dbff4b..33b4d08 100644 --- a/src/tests/components/MarkdownToolbar.integration.test.jsx +++ b/src/tests/components/MarkdownToolbar.integration.test.jsx @@ -548,7 +548,7 @@ describe("MarkdownToolbar validation panel interactions", () => { expect(onChange).toHaveBeenCalled(); const inserted = String(onChange.mock.calls.at(-1)?.[0] || ""); - expect(inserted).toContain("![Excalidraw Diagram](media/diagrams/"); + expect(inserted).toMatch(/!\[Excalidraw Diagram\]\((?:\.notes-app\/excali-diagrams|media\/diagrams)\//); expect(inserted).not.toContain("media/diagrams/architecture-note/"); expect(inserted).toContain('.png){data-diagram-id="'); expect(inserted).toContain('data-diagram-type="excalidraw"}'); diff --git a/src/utils/diagramFileUtils.js b/src/utils/diagramFileUtils.js index 21271c3..991e6dd 100644 --- a/src/utils/diagramFileUtils.js +++ b/src/utils/diagramFileUtils.js @@ -47,8 +47,8 @@ export function getDiagramSourcePath(docSlug, diagramId) { * @param {string} diagramId - Diagram identifier * @returns {string} Path to rendered PNG image */ -export function getDiagramImagePath(docSlug, diagramId) { - return `media/diagrams/${diagramId}.png`; +export function getDiagramImagePath(_docSlug, diagramId) { + return `.notes-app/excali-diagrams/${diagramId}/diagram.png`; } /** @@ -72,12 +72,12 @@ export function parseDiagramReference(markdownRef) { // - .notes-app/excali-diagrams/diagramId/diagram.png (current) // - excali-diagrams/diagramId/diagram.png (legacy) // - excali-diagrams/docSlug/diagramId/diagram.png (legacy slugged) - const match = markdownRef.match(/!\[.*?\]\(((?:\.notes-app\/)?excali-diagrams\/(?:(?:([^/]+)\/)?([^/]+))\/diagram\.png)\)\s*(?:\{[^}]*\})?/); + const match = markdownRef.match(/!\[.*?\]\(((?:\.notes-app\/)?excali-diagrams\/(?:(?:([^/]+)\/)?([^/]+))\/diagram\.png|media\/diagrams\/([^/.]+)\.png)\)\s*(?:\{[^}]*\})?/); if (match) { return { docSlug: match[2] || null, - diagramId: match[3], + diagramId: match[4] || match[3], fullPath: match[1], }; } @@ -91,7 +91,11 @@ export function parseDiagramReference(markdownRef) { * @returns {boolean} */ export function isDiagramReference(imagePath) { - return imagePath && imagePath.includes('excali-diagrams') && imagePath.includes('diagram.png'); + return ( + Boolean(imagePath) && + (imagePath.includes('excali-diagrams') || imagePath.includes('media/diagrams')) && + (imagePath.includes('diagram.png') || imagePath.endsWith('.png')) + ); } /** diff --git a/src/utils/imageMarkdownReferences.js b/src/utils/imageMarkdownReferences.js index 71e4667..c5c236a 100644 --- a/src/utils/imageMarkdownReferences.js +++ b/src/utils/imageMarkdownReferences.js @@ -23,4 +23,89 @@ export function removeImageReferenceFromMarkdown(source, assetPath) { : unwrapped; return normalizeImageAssetKey(current) === target ? "" : match; }); +} + +export function toComparableAssetPath(value, _basePath = "") { + if (!value) return ""; + const cleaned = String(value) + .trim() + .replace(/^<|>$/g, "") + .split(/\s+/)[0] + .split(/[?#]/)[0] + .replace(/^(?:https?|file|app|atom):\/\/(?:[^/]+\/)?/i, ""); + + return normalizeImageAssetKey(cleaned); +} + +export function replaceFirstImageReferenceWithDiagram(content, targetAssetPath, replacementMarkdown, basePath = "") { + const source = String(content || ""); + const targetComparable = toComparableAssetPath(targetAssetPath, basePath); + + const safeDecode = (str) => { + if (!str) return ""; + try { + return decodeURIComponent(str); + } catch { + return str; + } + }; + + const getCleanFilename = (p) => { + if (!p) return ""; + const raw = p.split(/[\\/]/).pop() || ""; + return safeDecode(raw).toLowerCase().split(/[?#]/)[0].trim(); + }; + + const targetFilename = getCleanFilename(targetAssetPath); + + const isMatch = (pathA, pathB) => { + if (!pathA || !pathB) return false; + const normA = safeDecode(pathA).toLowerCase(); + const normB = safeDecode(pathB).toLowerCase(); + if (normA === normB) return true; + if (normA.endsWith("/" + normB) || normB.endsWith("/" + normA)) return true; + const nameA = normA.split("/").pop(); + const nameB = normB.split("/").pop(); + return Boolean(nameA && nameB && nameA === nameB && (normA.includes(normB) || normB.includes(normA))); + }; + + const imageRegex = /!\[([^\]]*)\]\((<[^>]+>|[^)]+)\)(\s*\{[^}]*\})?/g; + let replaced = false; + let originalAlt = ""; + + // Tier 1 & 2: Match by relative path comparison or filename match + let nextContent = source.replace(imageRegex, (match, alt, rawPath) => { + if (replaced) return match; + const cleanedPath = String(rawPath || "").trim().replace(/^<|>$/g, "").split(/\s+/)[0]; + const comparablePath = toComparableAssetPath(cleanedPath, basePath); + const cleanedFilename = getCleanFilename(cleanedPath); + + const pathMatches = targetComparable && isMatch(comparablePath, targetComparable); + const filenameMatches = targetFilename && cleanedFilename && targetFilename === cleanedFilename; + + if (!pathMatches && !filenameMatches) return match; + + replaced = true; + originalAlt = String(alt || "").trim(); + return replacementMarkdown; + }); + + // Fallback Tier 2: Match HTML element matching target asset path or filename + if (!replaced) { + const htmlImgRegex = /]*src=["']([^"']+)["'][^>]*\/?>/gi; + nextContent = source.replace(htmlImgRegex, (match, srcAttr) => { + if (replaced) return match; + const comparablePath = toComparableAssetPath(srcAttr, basePath); + const srcFilename = getCleanFilename(srcAttr); + + const pathMatches = targetComparable && isMatch(comparablePath, targetComparable); + const filenameMatches = targetFilename && srcFilename && targetFilename === srcFilename; + + if (!pathMatches && !filenameMatches) return match; + replaced = true; + return replacementMarkdown; + }); + } + + return { nextContent, replaced, originalAlt }; } \ No newline at end of file diff --git a/src/utils/renderUtils.js b/src/utils/renderUtils.js index 54c96fe..cc53b7d 100644 --- a/src/utils/renderUtils.js +++ b/src/utils/renderUtils.js @@ -134,6 +134,9 @@ md.core.ruler.push("notely-source-lines", (state) => { md.renderer.rules.image = (tokens, idx, options, env, self) => { const token = tokens[idx]; const src = token.attrGet("src") || ""; + if (src && !token.attrGet("data-asset-path") && !/^(data:|blob:)/i.test(src)) { + token.attrSet("data-asset-path", src); + } const label = getImageDisplayName(src, token.content || token.attrGet("alt") || "Image"); const imageHtml = defaultImageRenderer(tokens, idx, options, env, self); return `${imageHtml}${escapeHtml(label)}`; From 98e7a8e28b721b0f2be7955bad38950feae33970 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Thu, 23 Jul 2026 23:52:19 +0530 Subject: [PATCH 02/11] Added Test case --- .../components/MarkdownPreview.integration.test.jsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/tests/components/MarkdownPreview.integration.test.jsx b/src/tests/components/MarkdownPreview.integration.test.jsx index 7a02851..c5dbc70 100644 --- a/src/tests/components/MarkdownPreview.integration.test.jsx +++ b/src/tests/components/MarkdownPreview.integration.test.jsx @@ -387,6 +387,7 @@ describe("MarkdownPreview image behaviors", () => { await new Promise((r) => setTimeout(r, 100)); }); + expect(readImageMock).toHaveBeenCalledWith("C:/notes/doc.md", "images/photo.png"); expect(onContentChange).toHaveBeenCalledTimes(1); const nextContent = onContentChange.mock.calls[0][0]; expect(nextContent).toContain("![Excalidraw Diagram]("); @@ -394,6 +395,15 @@ describe("MarkdownPreview image behaviors", () => { expect(onNotify).toHaveBeenCalledWith("Image converted to Excalidraw diagram.", "success"); expect(view.host.querySelector('[data-testid="excalidraw-modal"]')).not.toBeNull(); + // Verify subsequent save while modal stays open updates diagram without duplicate markdown conversion + await act(async () => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(onNotify).toHaveBeenCalledWith("Diagram saved.", "success"); + expect(onContentChange).toHaveBeenCalledTimes(1); + const closeBtn = view.host.querySelector('[data-testid="excalidraw-close-btn"]'); expect(closeBtn).not.toBeNull(); await act(async () => { From a05182698e1bdd50466289616dc7921adeb2d04b Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Thu, 23 Jul 2026 23:55:38 +0530 Subject: [PATCH 03/11] Fixed Test Case --- src/tests/components/MarkdownPreview.integration.test.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/components/MarkdownPreview.integration.test.jsx b/src/tests/components/MarkdownPreview.integration.test.jsx index c5dbc70..44be631 100644 --- a/src/tests/components/MarkdownPreview.integration.test.jsx +++ b/src/tests/components/MarkdownPreview.integration.test.jsx @@ -387,7 +387,7 @@ describe("MarkdownPreview image behaviors", () => { await new Promise((r) => setTimeout(r, 100)); }); - expect(readImageMock).toHaveBeenCalledWith("C:/notes/doc.md", "images/photo.png"); + expect(readImageMock).toHaveBeenNthCalledWith(2, "C:/notes/doc.md", "./images/photo.png"); expect(onContentChange).toHaveBeenCalledTimes(1); const nextContent = onContentChange.mock.calls[0][0]; expect(nextContent).toContain("![Excalidraw Diagram]("); From 0080cc1f30ecefb06f41a4f4c4ed5a0aad83353d Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 24 Jul 2026 01:19:17 +0530 Subject: [PATCH 04/11] feat(editor): enhance split view, table editor, paste & outline jump - Add persistent link hover options card with extended grace timers - Synchronize split view toolbar height and spacer alignment (33px) - Add View menu dropdown with Table Editor and Document Outline toggles - Align table editor modal bottom buttons cleanly - Support clipboard paste for images, doc attachments & HTML tables to GFM - Fix scroll jumping on document save with lastScrollTopRef tracking - Fix outline jump to place single cursor without block selection - Fix task summary popover hiding on mouse movement --- src/App.jsx | 2 + src/components/DocumentDetail.jsx | 119 +++++++---- src/components/EditorPane.jsx | 19 +- src/components/ExcalidrawEditor.jsx | 1 + src/components/MarkdownEditor.jsx | 113 ++++++++++- src/components/MarkdownPreview.jsx | 155 +++++++++++--- src/components/MarkdownTableEditor.jsx | 268 +++++++++++++++++-------- src/components/MarkdownToolbar.jsx | 69 ++++++- src/components/NoteTabBar.jsx | 34 ++++ src/hooks/useClipboardPaste.js | 93 +++++++++ src/hooks/useDocumentManager.js | 9 +- src/styles/components.css | 61 +++++- src/styles/editor.css | 83 ++------ src/utils/tableUtils.js | 47 +++++ src/utils/taskUtils.js | 2 + 15 files changed, 853 insertions(+), 222 deletions(-) create mode 100644 src/hooks/useClipboardPaste.js diff --git a/src/App.jsx b/src/App.jsx index 9d15c92..cfacdbc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -450,6 +450,7 @@ export default function App() { handleOpenReferencedDocument, handleLandingNavigateTo, openTabs, + handleReorderTabs, activeTabPath, tabStates, handleCloseTab, @@ -2881,6 +2882,7 @@ export default function App() { { + if (initialLine != null) { + setTargetLine(initialLine); + } + }, [initialLine]); + const workspaceLayoutRef = useRef(null); const clampOutlineWidth = (w) => Math.min(Math.max(w, 150), 350); @@ -1126,11 +1137,13 @@ export function DocumentDetail({ requestAnimationFrame(restore); const lateRestoreA = window.setTimeout(restore, 80); const lateRestoreB = window.setTimeout(restore, 220); + const lateRestoreC = window.setTimeout(restore, 300); return () => { canceled = true; window.clearTimeout(lateRestoreA); window.clearTimeout(lateRestoreB); + window.clearTimeout(lateRestoreC); }; }; @@ -1211,32 +1224,54 @@ export function DocumentDetail({ const jumpToLine = (line) => { const safeLine = Math.max(Number(line) || 1, 1); - if (mode !== "edit" && mode !== "split") { + setTargetLine(safeLine); + + if (mode === "preview") { + const previewEl = window.document.querySelector(".markdown-preview, .preview-container"); + if (previewEl) { + const targetNode = previewEl.querySelector(`[data-source-line="${safeLine}"]`) || + Array.from(previewEl.querySelectorAll("[data-source-line]")).find(el => Number(el.getAttribute("data-source-line")) >= safeLine); + if (targetNode) { + targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + } setEditorMode("edit", { announce: false, force: true }); requestAnimationFrame(() => jumpToLine(safeLine)); return; } const editor = textareaRef?.current; - if (!editor) return; + if (editor) { + const lines = (content || "").split(/\r?\n/); + let startIndex = 0; + for (let index = 0; index < Math.min(safeLine - 1, lines.length); index += 1) { + startIndex += lines[index].length + 1; + } - const lines = (content || "").split(/\r?\n/); - let startIndex = 0; - for (let index = 0; index < Math.min(safeLine - 1, lines.length); index += 1) { - startIndex += lines[index].length + 1; + editor.focus(); + editor.selectionStart = startIndex; + editor.selectionEnd = startIndex; + + const lineHeight = typeof editor.getLineHeight === "function" + ? editor.getLineHeight() + : parseFloat(window.getComputedStyle(editor).lineHeight) || 20; + const viewportHeight = Number(editor.clientHeight) || lineHeight * 20; + const targetTop = (safeLine - 1) * lineHeight - viewportHeight * 0.66; + const maxScroll = Math.max(0, (Number(editor.scrollHeight) || 0) - viewportHeight); + editor.scrollTop = Math.max(0, Math.min(targetTop, maxScroll)); } - editor.focus(); - editor.selectionStart = startIndex; - editor.selectionEnd = startIndex; - - const lineHeight = typeof editor.getLineHeight === "function" - ? editor.getLineHeight() - : parseFloat(window.getComputedStyle(editor).lineHeight) || 20; - const viewportHeight = Number(editor.clientHeight) || lineHeight * 20; - const targetTop = (safeLine - 1) * lineHeight - viewportHeight * 0.66; - const maxScroll = Math.max(0, (Number(editor.scrollHeight) || 0) - viewportHeight); - editor.scrollTop = Math.max(0, Math.min(targetTop, maxScroll)); + if (mode === "split") { + const previewEl = window.document.querySelector(".markdown-preview, .preview-container"); + if (previewEl) { + const targetNode = previewEl.querySelector(`[data-source-line="${safeLine}"]`) || + Array.from(previewEl.querySelectorAll("[data-source-line]")).find(el => Number(el.getAttribute("data-source-line")) >= safeLine); + if (targetNode) { + targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + } }; const openFindPanel = ({ showReplace = false } = {}) => { @@ -1707,6 +1742,7 @@ export function DocumentDetail({ {!isFocusMode && ( {taskCounts.total > 0 && (
setIsTaskSummaryOpen(true)} - onMouseLeave={() => setIsTaskSummaryOpen(false)} + className={`detail-task-summary${isTaskSummaryOpen ? " open" : ""}`} + onMouseEnter={() => { + if (taskPopoverTimerRef.current) { + clearTimeout(taskPopoverTimerRef.current); + taskPopoverTimerRef.current = null; + } + setIsTaskSummaryOpen(true); + }} + onMouseLeave={() => { + if (taskPopoverTimerRef.current) clearTimeout(taskPopoverTimerRef.current); + taskPopoverTimerRef.current = setTimeout(() => { + setIsTaskSummaryOpen(false); + }, 450); + }} onFocus={() => setIsTaskSummaryOpen(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) { @@ -1784,7 +1831,13 @@ export function DocumentDetail({ Open
    {openTaskItems.map((task) => ( -
  • +
  • jumpToLine(task.line)} + style={{ cursor: "pointer" }} + title="Click to jump to task in editor" + > [ ] {task.text}
  • @@ -1797,7 +1850,13 @@ export function DocumentDetail({ Closed
      {closedTaskItems.map((task) => ( -
    • +
    • jumpToLine(task.line)} + style={{ cursor: "pointer" }} + title="Click to jump to task in editor" + > [x] {task.text}
    • @@ -2096,8 +2155,10 @@ export function DocumentDetail({ onRemoveIgnoredSpellingWord={onRemoveIgnoredSpellingWord} onClearIgnoredSpellingWords={onClearIgnoredSpellingWords} onForceSaveDocument={onForceSaveDocument} - initialLine={initialLine} + initialLine={targetLine ?? initialLine} onLineJumped={onLineJumped} + outlineEnabled={outlineEnabled} + onOutlineEnabledChange={onOutlineEnabledChange} /> @@ -2144,17 +2205,7 @@ export function DocumentDetail({ {aiSidebar}
)} - {!aiPanelVisible && aiEnabled ? ( - - ) : null} + diff --git a/src/components/EditorPane.jsx b/src/components/EditorPane.jsx index 5a83f11..d5baaa5 100644 --- a/src/components/EditorPane.jsx +++ b/src/components/EditorPane.jsx @@ -35,6 +35,8 @@ export function EditorPane({ activeFindMatchIndex = -1, showOriginalImages = false, inlineLinkedMarkdown = false, + outlineEnabled = true, + onOutlineEnabledChange, typoCheckEnabled = true, screenCaptureMode = "auto", ignoredSpellingWords = [], @@ -50,6 +52,15 @@ export function EditorPane({ const [editorReadyTick, setEditorReadyTick] = useState(0); const [selectedMediaPreview, setSelectedMediaPreview] = useState(null); const [scrollSyncEnabled, setScrollSyncEnabled] = useState(true); + const [tableEditorEnabled, setTableEditorEnabled] = useState(() => { + return localStorage.getItem("notes:table-editor-enabled") !== "false"; + }); + + const handleTableEditorToggle = useCallback((nextValue) => { + const value = Boolean(nextValue); + setTableEditorEnabled(value); + localStorage.setItem("notes:table-editor-enabled", String(value)); + }, []); const jumpToLine = useCallback((line) => { const editor = textareaRef?.current; @@ -378,6 +389,7 @@ export function EditorPane({ setEditorReadyTick((value) => value + 1)} onSearchRequest={(query) => { window.dispatchEvent(new CustomEvent("open-global-search-query", { detail: { query } })); @@ -420,6 +433,10 @@ export function EditorPane({ ignoredSpellingWords, onIgnoreSpellingWord: onIgnoreSpellingWord, screenCaptureMode, + tableEditorEnabled, + onTableEditorToggle: handleTableEditorToggle, + outlineEnabled, + onOutlineEnabledChange, }; const renderToolbar = () => ( @@ -496,7 +513,7 @@ export function EditorPane({ Editor {showToolbar ? renderToolbar() : null} - {showToolbar ? : null} +
{markdownEditor}
{ const excalidrawAPIRef = useRef(null); const lastSavedElementsRef = useRef(initialData?.elements || []); diff --git a/src/components/MarkdownEditor.jsx b/src/components/MarkdownEditor.jsx index 8d64e57..4df9b8c 100644 --- a/src/components/MarkdownEditor.jsx +++ b/src/components/MarkdownEditor.jsx @@ -11,6 +11,7 @@ import { insertMediaFromFiles } from "../services/imageService"; import { applyMarkdownQuickFix, applyValidationSuggestion, getIssueFixType } from "../utils/markdownQuickFix"; import { editorTheme } from "../utils/editorTheme"; import { generateDiagramId } from "../utils/diagramFileUtils"; +import { useClipboardPaste } from "../hooks/useClipboardPaste"; function getLineStartIndex(text, lineNumber) { const targetLine = Math.max(lineNumber, 1); @@ -274,8 +275,28 @@ export const MarkdownEditor = memo(function MarkdownEditorContent({ activeFindMatchIndex = -1, onEditorReady, onInlineAIContinue, + tableEditorEnabled = true, + basePath, }) { const viewRef = useRef(null); + + const handleInsertMarkdownAtCursor = useCallback((mdText) => { + if (!viewRef.current || !mdText) return; + const view = viewRef.current; + const selection = view.state.selection.main; + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: mdText }, + selection: { anchor: selection.from + mdText.length }, + }); + onChange?.(view.state.doc.toString()); + }, [onChange]); + + const { handlePaste } = useClipboardPaste({ + enabled: !readOnly, + basePath, + onInsertMarkdown: handleInsertMarkdownAtCursor, + onNotify, + }); const menuRef = useRef(null); const [contextMenu, setContextMenu] = useState(null); const [slashMenu, setSlashMenu] = useState(null); @@ -336,6 +357,56 @@ export const MarkdownEditor = memo(function MarkdownEditorContent({ } }, [slashMenu, viewRef, SLASH_COMMANDS, onNotify]); + const lastScrollTopRef = useRef(0); + + useEffect(() => { + const view = viewRef.current; + if (!view?.scrollDOM) return; + const handleScroll = () => { + if (view?.scrollDOM) { + lastScrollTopRef.current = view.scrollDOM.scrollTop; + } + }; + const scrollDOM = view.scrollDOM; + scrollDOM.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollDOM.removeEventListener("scroll", handleScroll); + }, []); + + useEffect(() => { + if (viewRef.current?.scrollDOM && lastScrollTopRef.current > 0) { + const targetScroll = lastScrollTopRef.current; + requestAnimationFrame(() => { + if (viewRef.current?.scrollDOM) { + viewRef.current.scrollDOM.scrollTop = targetScroll; + } + }); + window.setTimeout(() => { + if (viewRef.current?.scrollDOM) { + viewRef.current.scrollDOM.scrollTop = targetScroll; + } + }, 60); + } + }, [value]); + + useEffect(() => { + if (viewRef.current && Number.isFinite(focusedLine) && focusedLine > 0) { + const view = viewRef.current; + const safeLine = Math.max(1, Math.min(focusedLine, view.state.doc.lines)); + const lineObj = view.state.doc.line(safeLine); + + // Single cursor at start of line without selecting/highlighting block! + view.dispatch({ + selection: { anchor: lineObj.from }, + scrollIntoView: true, + }); + + if (view.scrollDOM) { + const block = view.lineBlockAt(lineObj.from); + view.scrollDOM.scrollTop = Math.max(0, block.top - view.scrollDOM.clientHeight / 3); + } + } + }, [focusedLine]); + const [themeMode, setThemeMode] = useState(() => { return document.documentElement.getAttribute("data-theme") || "light"; }); @@ -351,6 +422,8 @@ export const MarkdownEditor = memo(function MarkdownEditorContent({ const valueLength = String(value || "").length; const decorationsSynced = docLength === valueLength; + const [isDragOver, setIsDragOver] = useState(false); + const dragCounterRef = useRef(0); // Explicit hotkey trigger for inline AI completion (Alt-\) useEffect(() => { @@ -513,6 +586,7 @@ export const MarkdownEditor = memo(function MarkdownEditorContent({ requestAnimationFrame(restoreViewport); window.setTimeout(restoreViewport, 80); window.setTimeout(restoreViewport, 220); + window.setTimeout(restoreViewport, 300); }; applyChange(scheduleViewportRestore); @@ -853,6 +927,10 @@ export const MarkdownEditor = memo(function MarkdownEditorContent({ } return false; }, + paste(event) { + void handlePaste(event); + return false; + }, drop(event, view) { const files = event.dataTransfer?.files || []; if (!files.length) return false; @@ -1002,10 +1080,29 @@ export const MarkdownEditor = memo(function MarkdownEditorContent({ }, }, ]), - ], [findMatchDecorations, ghostSuggestionDecorations, onChange, onNotify, onOpenFind, onRedo, onToggleFind, onUndo, validationDecorations, validationIssues, _activeLine, aiEnabled, onAcceptInlineGhost, onRejectInlineGhost, ghostSuggestion, slashMenu, activeSlashIndex, SLASH_COMMANDS, triggerSlashCommand]); + ], [findMatchDecorations, ghostSuggestionDecorations, handlePaste, onChange, onNotify, onOpenFind, onRedo, onToggleFind, onUndo, validationDecorations, validationIssues, _activeLine, aiEnabled, onAcceptInlineGhost, onRejectInlineGhost, ghostSuggestion, slashMenu, activeSlashIndex, SLASH_COMMANDS, triggerSlashCommand]); return ( -
+
{ + if (e.dataTransfer?.types?.includes("Files")) { + dragCounterRef.current += 1; + setIsDragOver(true); + } + }} + onDragLeave={() => { + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsDragOver(false); + } + }} + onDrop={() => { + dragCounterRef.current = 0; + setIsDragOver(false); + }} + > { if (viewRef.current) { + const scrollTop = viewRef.current.scrollDOM.scrollTop; viewRef.current.dispatch({ - changes: { from: activeTableInfo.from, to: activeTableInfo.to, insert: newMarkdown } + changes: { from: activeTableInfo.from, to: activeTableInfo.to, insert: newMarkdown }, + scrollIntoView: false, }); + // Restore scroll after dispatch since CodeMirror may reset it + const restore = () => { + if (viewRef.current) viewRef.current.scrollDOM.scrollTop = scrollTop; + }; + requestAnimationFrame(restore); + setTimeout(restore, 300); } setActiveTableInfo(null); viewRef.current?.focus(); diff --git a/src/components/MarkdownPreview.jsx b/src/components/MarkdownPreview.jsx index 7944c86..4ae43c1 100644 --- a/src/components/MarkdownPreview.jsx +++ b/src/components/MarkdownPreview.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState, memo } from "react"; -import { Search, Copy, ExternalLink, Pencil, RefreshCw, Trash2, RotateCcw } from "lucide-react"; +import { createPortal } from "react-dom"; +import { Search, Copy, ExternalLink, Pencil, RefreshCw, Trash2, RotateCcw, Download } from "lucide-react"; import { renderMarkdown, parseDiagramBlocks, @@ -415,6 +416,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ const [contextMenu, setContextMenu] = useState(null); const [activeLinkPopup, setActiveLinkPopup] = useState(null); const linkHideTimerRef = useRef(null); + const isPopupHoveredRef = useRef(false); const handleLinkNavigateRef = useRef(null); const handleCopyLinkFromPreview = (href) => { @@ -424,6 +426,22 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ setActiveLinkPopup(null); }; + const handleDownloadFileFromPreview = async (href) => { + if (!href) return; + const resolvedPath = resolveMarkdownLinkPath(basePath, href) || href; + try { + if (typeof window.notesApi?.openFolder === "function") { + await window.notesApi.openFolder(resolvedPath); + onNotify?.(`Opened file location: ${resolvedPath}`, "success"); + } else { + onNotify?.(`File path: ${resolvedPath}`, "info"); + } + } catch (err) { + onNotify?.(err?.message || "Failed to open file.", "error"); + } + setActiveLinkPopup(null); + }; + const [menuIndex, setMenuIndex] = useState(0); const [cropSaving, setCropSaving] = useState(false); const [replaceState, setReplaceState] = useState({ busy: false, assetPath: "" }); @@ -473,17 +491,29 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ if (cache.has(cacheKey)) { const cached = cache.get(cacheKey); if (!cancelled && cached) image.src = cached; - try { - const annotation = await getImageAnnotation(basePath, assetPath); - if (!cancelled) applyImageAnnotation(image, annotation); - } catch { - if (!cancelled) applyImageAnnotation(image, null); + const annotationKey = `annotation:${assetPath}`; + if (cache.has(annotationKey)) { + if (!cancelled) applyImageAnnotation(image, cache.get(annotationKey)); + } else { + try { + const annotation = await getImageAnnotation(basePath, assetPath); + cache.set(annotationKey, annotation); + if (!cancelled) applyImageAnnotation(image, annotation); + } catch { + if (!cancelled) applyImageAnnotation(image, null); + } } - try { - const originalStatus = await getImageOriginalStatus(basePath, assetPath); - if (!cancelled) applyImageOriginalBadge(image, Boolean(originalStatus?.hasOriginal)); - } catch { - if (!cancelled) applyImageOriginalBadge(image, false); + const originalKey = `original:${assetPath}`; + if (cache.has(originalKey)) { + if (!cancelled) applyImageOriginalBadge(image, Boolean(cache.get(originalKey))); + } else { + try { + const originalStatus = await getImageOriginalStatus(basePath, assetPath); + cache.set(originalKey, Boolean(originalStatus?.hasOriginal)); + if (!cancelled) applyImageOriginalBadge(image, Boolean(originalStatus?.hasOriginal)); + } catch { + if (!cancelled) applyImageOriginalBadge(image, false); + } } return; } @@ -500,13 +530,16 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ try { const annotation = await getImageAnnotation(basePath, assetPath); + cache.set(`annotation:${assetPath}`, annotation); if (!cancelled) applyImageAnnotation(image, annotation); } catch { if (!cancelled) applyImageAnnotation(image, null); } try { const originalStatus = await getImageOriginalStatus(basePath, assetPath); - if (!cancelled) applyImageOriginalBadge(image, Boolean(originalStatus?.hasOriginal)); + const hasOrig = Boolean(originalStatus?.hasOriginal); + cache.set(`original:${assetPath}`, hasOrig); + if (!cancelled) applyImageOriginalBadge(image, hasOrig); } catch { if (!cancelled) applyImageOriginalBadge(image, false); } @@ -671,22 +704,37 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ let annotation = null; let hasOriginal = false; + const cache = imageResolveCacheRef.current; + try { fullSizeSrc = await readImage(basePath, assetPath); } catch { // Fall back to the rendered preview image if the full-size read fails. } - try { - annotation = await getImageAnnotation(basePath, assetPath); - } catch { - annotation = null; + const annotationKey = `annotation:${assetPath}`; + if (cache.has(annotationKey)) { + annotation = cache.get(annotationKey); + } else { + try { + annotation = await getImageAnnotation(basePath, assetPath); + cache.set(annotationKey, annotation); + } catch { + annotation = null; + } } - try { - const originalStatus = await getImageOriginalStatus(basePath, assetPath); - hasOriginal = Boolean(originalStatus?.hasOriginal); - } catch { - hasOriginal = false; + + const originalKey = `original:${assetPath}`; + if (cache.has(originalKey)) { + hasOriginal = Boolean(cache.get(originalKey)); + } else { + try { + const originalStatus = await getImageOriginalStatus(basePath, assetPath); + hasOriginal = Boolean(originalStatus?.hasOriginal); + cache.set(originalKey, hasOriginal); + } catch { + hasOriginal = false; + } } setCropState({ @@ -936,10 +984,26 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ } }; + const handleMediaDblClick = (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const figure = target.closest("figure.markdown-code-block"); + if (figure) { + const editBtn = figure.querySelector('[data-code-edit="true"]'); + if (editBtn) { + event.preventDefault(); + event.stopPropagation(); + editBtn.click(); + } + } + }; + previewElement.addEventListener("click", handleMediaClick); + previewElement.addEventListener("dblclick", handleMediaDblClick); return () => { previewElement.removeEventListener("click", handleMediaClick); + previewElement.removeEventListener("dblclick", handleMediaDblClick); }; }, [basePath, inlineLinkedMarkdown, onMediaClick, onNotify, content, onContentChange, confirm]); @@ -948,7 +1012,7 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ if (!previewElement) return; const handleMouseOver = (e) => { - const link = e.target.closest("a"); + const link = e.target?.closest?.("a"); if (link && previewElement.contains(link)) { if (linkHideTimerRef.current) { clearTimeout(linkHideTimerRef.current); @@ -972,16 +1036,25 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ }; const handleMouseOut = (e) => { - const link = e.target.closest("a"); + const link = e.target?.closest?.("a"); if (link) { + if (linkHideTimerRef.current) clearTimeout(linkHideTimerRef.current); linkHideTimerRef.current = setTimeout(() => { - setActiveLinkPopup(null); - }, 500); + if (!isPopupHoveredRef.current) { + setActiveLinkPopup(null); + } + }, 1200); } }; const handleScroll = () => { - setActiveLinkPopup(null); + // Don't close immediately on micro-scrolls; close only if not hovered + if (linkHideTimerRef.current) clearTimeout(linkHideTimerRef.current); + linkHideTimerRef.current = setTimeout(() => { + if (!isPopupHoveredRef.current) { + setActiveLinkPopup(null); + } + }, 400); }; previewElement.addEventListener("mouseover", handleMouseOver); @@ -1876,21 +1949,29 @@ export const MarkdownPreview = memo(function MarkdownPreviewContent({ } }} /> - {activeLinkPopup && ( + {activeLinkPopup && createPortal(
{ + isPopupHoveredRef.current = true; if (linkHideTimerRef.current) { clearTimeout(linkHideTimerRef.current); linkHideTimerRef.current = null; } }} onMouseLeave={() => { - setActiveLinkPopup(null); + isPopupHoveredRef.current = false; + if (linkHideTimerRef.current) clearTimeout(linkHideTimerRef.current); + linkHideTimerRef.current = setTimeout(() => { + if (!isPopupHoveredRef.current) { + setActiveLinkPopup(null); + } + }, 800); }} > -
+ {!/^https?:\/\/|^\/\//i.test(activeLinkPopup.href || "") && ( + <> +
+ + + )} +
, + document.body )} ); diff --git a/src/components/MarkdownTableEditor.jsx b/src/components/MarkdownTableEditor.jsx index 9124a1b..8882779 100644 --- a/src/components/MarkdownTableEditor.jsx +++ b/src/components/MarkdownTableEditor.jsx @@ -1,12 +1,30 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { parseMarkdownTable, serializeMarkdownTable } from '../utils/tableUtils'; -import { Plus, Trash2, AlignLeft, AlignCenter, AlignRight, Check, X, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Eraser } from 'lucide-react'; +import { + Plus, + Trash2, + AlignLeft, + AlignCenter, + AlignRight, + Check, + X, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + Eraser, + Maximize2, + Minimize2, + Table2, +} from 'lucide-react'; +import AppButton from './AppButton'; -export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel, style }) { +export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel }) { const [tableData, setTableData] = useState({ headers: [], alignments: [], rows: [] }); const [activeCell, setActiveCell] = useState(null); // { row, col } const [isDirty, setIsDirty] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); const containerRef = useRef(null); const handleActionPointerDown = (event, action) => { @@ -53,25 +71,15 @@ export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel, style onCommit(newMarkdown); }, [initialMarkdown, isDirty, onCancel, onCommit, tableData]); - // Click outside to commit - useEffect(() => { - function handleClickOutside(event) { - // Allow context menu clicks to pass through without closing - if (event.target.closest('.editor-context-menu')) return; - - if (containerRef.current && !containerRef.current.contains(event.target)) { - commitChanges(); - } + const handleBackdropClick = (event) => { + if (event.target === event.currentTarget) { + commitChanges(); } - - // Use pointerdown to catch clicks before focus changes - document.addEventListener('pointerdown', handleClickOutside); - return () => document.removeEventListener('pointerdown', handleClickOutside); - }, [commitChanges]); + }; const updateHeader = (colIndex, value) => { const newHeaders = [...tableData.headers]; - newHeaders[colIndex] = value.replace(/\|/g, ''); // prevent breaking markdown + newHeaders[colIndex] = value.replace(/\|/g, ''); setTableData({ ...tableData, headers: newHeaders }); setIsDirty(true); }; @@ -93,11 +101,11 @@ export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel, style const addColumn = (afterIndex) => { const newHeaders = [...tableData.headers]; newHeaders.splice(afterIndex + 1, 0, 'New Column'); - + const newAlignments = [...tableData.alignments]; newAlignments.splice(afterIndex + 1, 0, ''); - - const newRows = tableData.rows.map(row => { + + const newRows = tableData.rows.map((row) => { const newRow = [...row]; newRow.splice(afterIndex + 1, 0, ''); return newRow; @@ -108,11 +116,11 @@ export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel, style }; const deleteColumn = (colIndex) => { - if (tableData.headers.length <= 1) return; // Don't delete last column + if (tableData.headers.length <= 1) return; const newHeaders = tableData.headers.filter((_, i) => i !== colIndex); const newAlignments = tableData.alignments.filter((_, i) => i !== colIndex); - const newRows = tableData.rows.map(row => row.filter((_, i) => i !== colIndex)); + const newRows = tableData.rows.map((row) => row.filter((_, i) => i !== colIndex)); const fallbackCol = activeCell?.col ?? colIndex; const nextCol = Math.max(0, Math.min(newHeaders.length - 1, fallbackCol > colIndex ? fallbackCol - 1 : fallbackCol)); @@ -150,7 +158,7 @@ export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel, style }; const clearTable = () => { - const newRows = tableData.rows.map(row => row.map(() => '')); + const newRows = tableData.rows.map((row) => row.map(() => '')); const newHeaders = tableData.headers.map(() => ''); setTableData({ headers: newHeaders, alignments: tableData.alignments, rows: newRows }); setIsDirty(true); @@ -167,77 +175,169 @@ export function MarkdownTableEditor({ initialMarkdown, onCommit, onCancel, style if (!tableData.headers.length) return null; return createPortal( -
-
- - - - {tableData.headers.map((header, colIndex) => ( - - ))} - - - - {tableData.rows.map((row, rowIndex) => ( - - {row.map((cell, colIndex) => ( - + {tableData.rows.map((row, rowIndex) => ( + + {row.map((cell, colIndex) => ( + + ))} + + ))} + +
-
- updateHeader(colIndex, e.target.value)} - onFocus={() => setActiveCell({ row: -1, col: colIndex })} - /> - {activeCell?.row === -1 && activeCell?.col === colIndex && ( -
- - - -
- - - -
- )} -
-
+
+
+
+ + Table Editor + + ({tableData.headers.length} cols × {tableData.rows.length} rows) + +
+
+ setIsMaximized(!isMaximized)} + > + {isMaximized ? : } + + + + +
+
+ +
+ + + + {tableData.headers.map((header, colIndex) => ( + ))} - ))} - -
- updateCell(rowIndex, colIndex, e.target.value)} - onFocus={() => setActiveCell({ row: rowIndex, col: colIndex })} + updateHeader(colIndex, e.target.value)} + onFocus={() => setActiveCell({ row: -1, col: colIndex })} /> - {activeCell?.row === rowIndex && activeCell?.col === colIndex && ( -
- - - + {activeCell?.row === -1 && activeCell?.col === colIndex && ( +
+ + + +
+ + +
)}
- +
-
+ +
+
+ updateCell(rowIndex, colIndex, e.target.value)} + onFocus={() => setActiveCell({ row: rowIndex, col: colIndex })} + /> + {activeCell?.row === rowIndex && activeCell?.col === colIndex && ( +
+ + + +
+ )} +
+
+
-
- - -
- -
- - +
+ addRow(tableData.rows.length - 1)}> + + Row + + addColumn(tableData.headers.length - 1)}> + + Column + +
+ + + Clear + +
+ + + Cancel + + + + Save Table + +
, document.body diff --git a/src/components/MarkdownToolbar.jsx b/src/components/MarkdownToolbar.jsx index 91d68fd..7699541 100644 --- a/src/components/MarkdownToolbar.jsx +++ b/src/components/MarkdownToolbar.jsx @@ -19,6 +19,7 @@ import { Workflow, PenTool, Grid, + SlidersHorizontal, } from "lucide-react"; import AppSelect from "./AppSelect"; import { applySnippet, createMediaMarkdown, insertTextAtCursor, normalizeImagePathForMarkdown } from "../utils/markdownUtils"; @@ -164,6 +165,10 @@ export function MarkdownToolbar({ canRedo = false, onIgnoreSpellingWord, screenCaptureMode = "auto", + tableEditorEnabled = true, + onTableEditorToggle, + outlineEnabled = true, + onOutlineEnabledChange, }) { const imageInputRef = useRef(null); const mermaidPopoverRef = useRef(null); @@ -172,12 +177,14 @@ export function MarkdownToolbar({ const webLinkPopoverRef = useRef(null); const tablePopoverRef = useRef(null); const validationPopoverRef = useRef(null); + const viewMenuPopoverRef = useRef(null); const [showMermaidBuilder, setShowMermaidBuilder] = useState(false); const [showAssetLinker, setShowAssetLinker] = useState(false); const [showReferenceLinker, setShowReferenceLinker] = useState(false); const [showWebLinker, setShowWebLinker] = useState(false); const [showTableBuilder, setShowTableBuilder] = useState(false); const [showValidationPanel, setShowValidationPanel] = useState(false); + const [showViewMenu, setShowViewMenu] = useState(false); const [availableAssets, setAvailableAssets] = useState([]); const [availableReferenceNotes, setAvailableReferenceNotes] = useState([]); const [assetsLoading, setAssetsLoading] = useState(false); @@ -245,6 +252,7 @@ export function MarkdownToolbar({ setShowWebLinker(false); setShowTableBuilder(false); setShowValidationPanel(false); + setShowViewMenu(false); setDiagramMode("picker"); }; @@ -255,6 +263,7 @@ export function MarkdownToolbar({ if (panel === "web") return showWebLinker; if (panel === "table") return showTableBuilder; if (panel === "validation") return showValidationPanel; + if (panel === "view") return showViewMenu; return false; }; @@ -265,6 +274,7 @@ export function MarkdownToolbar({ if (panel === "web") setShowWebLinker(true); if (panel === "table") setShowTableBuilder(true); if (panel === "validation") setShowValidationPanel(true); + if (panel === "view") setShowViewMenu(true); }; const toggleToolbarPanel = (panel) => { @@ -282,7 +292,8 @@ export function MarkdownToolbar({ showReferenceLinker || showWebLinker || showTableBuilder || - showValidationPanel; + showValidationPanel || + showViewMenu; useEffect(() => { if (!anyPopoverOpen) { @@ -296,13 +307,15 @@ export function MarkdownToolbar({ const insideWebLinker = webLinkPopoverRef.current?.contains(event.target); const insideTableBuilder = tablePopoverRef.current?.contains(event.target); const insideValidation = validationPopoverRef.current?.contains(event.target); + const insideViewMenu = viewMenuPopoverRef.current?.contains(event.target); if ( !insideMermaid && !insideAssetLinker && !insideReferenceLinker && !insideWebLinker && !insideTableBuilder && - !insideValidation + !insideValidation && + !insideViewMenu ) { closeToolbarPanels(); } @@ -941,10 +954,60 @@ export function MarkdownToolbar({ - + toggleToolbarPanel("view")} title="View settings (Table editor, Outline)" aria-label="View settings" className={showViewMenu ? "active" : ""}> + + View + + + {validationIssues.length > 0 && + {showViewMenu && ( +
+
+ View Settings +
+ + +
+ )} + {showValidationPanel && (
{ + setDraggedTabPath(filePath); + e.dataTransfer.setData("text/plain", filePath); + e.dataTransfer.effectAllowed = "move"; + }; + + const handleTabDragOver = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }; + + const handleTabDrop = (e, targetPath) => { + e.preventDefault(); + if (!draggedTabPath || draggedTabPath === targetPath) return; + + const fromIdx = openTabs.indexOf(draggedTabPath); + const toIdx = openTabs.indexOf(targetPath); + + if (fromIdx !== -1 && toIdx !== -1) { + const nextTabs = [...openTabs]; + const [moved] = nextTabs.splice(fromIdx, 1); + nextTabs.splice(toIdx, 0, moved); + onReorderTabs?.(nextTabs); + } + setDraggedTabPath(null); + }; + if (!openTabs.length) return null; return ( @@ -158,6 +188,10 @@ export function NoteTabBar({ role="tab" aria-selected={isActive} title={filePath} + draggable={true} + onDragStart={(e) => handleTabDragStart(e, filePath)} + onDragOver={handleTabDragOver} + onDrop={(e) => handleTabDrop(e, filePath)} onContextMenu={(e) => handleContextMenu(e, filePath)} style={meta.color ? { "--custom-bg-color": meta.color, diff --git a/src/hooks/useClipboardPaste.js b/src/hooks/useClipboardPaste.js new file mode 100644 index 0000000..7695712 --- /dev/null +++ b/src/hooks/useClipboardPaste.js @@ -0,0 +1,93 @@ +import { useCallback } from "react"; +import { createMediaMarkdown } from "../utils/markdownUtils"; +import { insertMediaFromFile } from "../services/imageService"; +import { htmlTableToMarkdown } from "../utils/tableUtils"; + +export function useClipboardPaste({ + enabled = true, + basePath, + onInsertMarkdown, + onNotify, +}) { + const handlePaste = useCallback( + async (event) => { + if (!enabled) return; + + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + // 1. Check for image item + const items = Array.from(clipboardData.items || []); + const imageItem = items.find((item) => item.type.startsWith("image/")); + + if (imageItem) { + const file = imageItem.getAsFile(); + if (file) { + event.preventDefault(); + event.stopPropagation(); + onNotify?.("Saving pasted image...", "info"); + try { + const mediaResult = await insertMediaFromFile(file, basePath); + if (mediaResult?.mediaPath || mediaResult?.imagePath) { + const md = createMediaMarkdown( + mediaResult.altText || "Pasted image", + mediaResult.mediaPath || mediaResult.imagePath + ); + onInsertMarkdown?.(md); + onNotify?.("Pasted image inserted.", "success"); + } + } catch (err) { + onNotify?.(err?.message || "Failed to save pasted image.", "error"); + } + return; + } + } + + // 2. Check for pasted files (docx, xlsx, pptx, pdf, etc.) + const files = Array.from(clipboardData.files || []); + if (files.length > 0 && !imageItem) { + event.preventDefault(); + event.stopPropagation(); + onNotify?.("Processing pasted file...", "info"); + try { + const insertedBlocks = []; + for (const file of files) { + const mediaResult = await insertMediaFromFile(file, basePath); + const path = mediaResult?.mediaPath || mediaResult?.imagePath; + if (path) { + const ext = file.name.split(".").pop()?.toLowerCase(); + if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) { + insertedBlocks.push(createMediaMarkdown(file.name, path)); + } else { + insertedBlocks.push(`[${file.name}](${path})`); + } + } + } + if (insertedBlocks.length) { + onInsertMarkdown?.(insertedBlocks.join("\n\n")); + onNotify?.("Pasted file inserted.", "success"); + } + } catch (err) { + onNotify?.(err?.message || "Failed to process pasted file.", "error"); + } + return; + } + + // 3. Check for HTML table (Excel or Web copied table) + const htmlText = clipboardData.getData("text/html"); + if (htmlText && / { + if (Array.isArray(nextTabs)) { + setOpenTabs(nextTabs); + } + }, [setOpenTabs]); + return { documents, setDocuments, @@ -1205,6 +1211,7 @@ export function useDocumentManager({ notify }) { handleLandingNavigateTo, openTabs, setOpenTabs, + handleReorderTabs, activeTabPath, setActiveTabPath, tabStates, diff --git a/src/styles/components.css b/src/styles/components.css index b94b503..d7f51d4 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -959,8 +959,20 @@ transform 120ms ease; } +.detail-task-popover::before { + content: ""; + position: absolute; + top: -14px; + left: 0; + right: 0; + height: 16px; + background: transparent; +} + .detail-task-summary:hover .detail-task-popover, -.detail-task-summary:focus-within .detail-task-popover { +.detail-task-summary:focus-within .detail-task-popover, +.detail-task-summary.open .detail-task-popover, +.detail-task-popover:hover { opacity: 1; pointer-events: auto; transform: translateY(0); @@ -3174,6 +3186,10 @@ display: flex; justify-content: flex-end; padding: 3px 10px; + height: 33px; + min-height: 33px; + max-height: 33px; + box-sizing: border-box; border-bottom: 1px solid #d9dedb; background: #f7f8f7; } @@ -3212,7 +3228,10 @@ } .pane-toolbar-spacer { + height: 33px; min-height: 33px; + max-height: 33px; + box-sizing: border-box; border-bottom: 1px solid #d9dedb; background: #f7f8f7; } @@ -3228,6 +3247,13 @@ overflow: hidden; min-height: 360px; border: 0; + transition: outline 0.15s ease, box-shadow 0.15s ease; +} + +.markdown-editor.cm-drop-active { + outline: 2px dashed var(--accent-solid, #0969da); + outline-offset: -4px; + box-shadow: inset 0 0 12px rgba(9, 105, 218, 0.15); } /* In focus-mode the editor must be fully constrained by the grid cell so mouse-scroll works */ @@ -3303,6 +3329,29 @@ color: var(--status-danger-text); } +.toolbar-validation-summary.has-issues { + color: var(--status-warning-text, #b45309); + font-weight: 800; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.toolbar-issue-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + animation: issue-pulse 2s ease-in-out infinite; +} + +@keyframes issue-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + .editor-toolbar button.toolbar-btn-capture { position: relative; } @@ -4682,6 +4731,16 @@ animation: fadeIn 0.12s ease-out; } +.link-hover-popup::after { + content: ""; + position: absolute; + bottom: -10px; + left: 0; + right: 0; + height: 12px; + background: transparent; +} + .link-hover-popup-btn { background: transparent; border: none; diff --git a/src/styles/editor.css b/src/styles/editor.css index d9cf704..163532e 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -29,6 +29,22 @@ border-radius: 6px; background: #f6f8fa; overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.preview .markdown-code-block:hover { + border-color: #8c959f; +} + +.preview .markdown-code-block:hover [data-code-edit="true"] { + border-color: var(--accent-solid, #0969da); + background: var(--accent-subtle, #ddf4ff); + color: var(--accent-solid, #0969da); + font-weight: bold; +} + +.preview .markdown-code-pre { + cursor: pointer; } .preview .markdown-code-header { @@ -3130,70 +3146,9 @@ .table-editor-toolbar { display: flex; align-items: center; - gap: 6px; -} - -.table-editor-toolbar button { - display: flex; - align-items: center; - gap: 4px; - background: rgba(0, 0, 0, 0.04); - border: 1px solid rgba(0,0,0,0.02); - color: var(--text-primary); - padding: 0 8px; - min-height: 24px; - border-radius: var(--radius-md); - font-size: 11px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} - -.table-editor-toolbar .action-chip { - line-height: 1; -} - -[data-theme='dark'] .table-editor-toolbar button { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255,255,255,0.02); -} - -.table-editor-toolbar button:hover { - background: rgba(0, 0, 0, 0.08); - transform: translateY(-1px); -} - -[data-theme='dark'] .table-editor-toolbar button:hover { - background: rgba(255, 255, 255, 0.1); -} - -.table-editor-toolbar button:active { - transform: translateY(0); -} - -.table-editor-toolbar .primary-btn { - background: linear-gradient(135deg, var(--accent-color, #3b82f6) 0%, #2563eb 100%); - color: white; - border: none; - box-shadow: 0 6px 16px rgba(37, 99, 235, 0.25); - text-shadow: 0 1px 2px rgba(0,0,0,0.1); -} - -.table-editor-toolbar .primary-btn:hover { - background: linear-gradient(135deg, #4f46e5 0%, #3b82f6 100%); - box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35); - transform: translateY(-2px); -} - -.table-editor-toolbar .cancel-btn { - padding: 6px 10px; - background: transparent; - border: 1px solid transparent; -} - -.table-editor-toolbar .cancel-btn:hover { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; + gap: 8px; + margin-top: auto; + flex-shrink: 0; } .table-editor-grid-container { diff --git a/src/utils/tableUtils.js b/src/utils/tableUtils.js index 04d5dda..487beb0 100644 --- a/src/utils/tableUtils.js +++ b/src/utils/tableUtils.js @@ -194,3 +194,50 @@ export function serializeMarkdownTable({ headers, alignments, rows }, options = const tableArray = [headers, ...rows]; return markdownTable(tableArray, { align: alignments }); } + +export function htmlTableToMarkdown(htmlString) { + if (!htmlString || !/
{ + const cells = Array.from(row.querySelectorAll("th, td")).map((cell) => + cell.textContent.trim().replace(/\|/g, "\\|").replace(/\s+/g, " ") + ); + if (cells.length) matrix.push(cells); + }); + + if (!matrix.length) return null; + + const maxCols = Math.max(...matrix.map((row) => row.length)); + if (maxCols === 0) return null; + + const normalized = matrix.map((row) => { + const copy = [...row]; + while (copy.length < maxCols) copy.push(""); + return copy; + }); + + const header = normalized[0]; + const body = normalized.slice(1); + const separator = Array(maxCols).fill("---"); + + const mdLines = [ + `| ${header.join(" | ")} |`, + `| ${separator.join(" | ")} |`, + ...body.map((row) => `| ${row.join(" | ")} |`), + ]; + + return mdLines.join("\n"); + } catch { + return null; + } +} diff --git a/src/utils/taskUtils.js b/src/utils/taskUtils.js index c91dc2a..425af1d 100644 --- a/src/utils/taskUtils.js +++ b/src/utils/taskUtils.js @@ -22,11 +22,13 @@ function extractTaskMatches(text, regex, status) { regex.lastIndex = 0; let match; while ((match = regex.exec(source)) !== null) { + const line = source.slice(0, match.index).split(/\r?\n/).length; tasks.push({ id: `${status}:${match.index}`, status, text: String(match[1] || "").trim(), index: match.index, + line, }); } From 733852e3e25e4a24e2c2f07c03511a1b9c30ebb1 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 24 Jul 2026 01:21:39 +0530 Subject: [PATCH 05/11] Test case fixed --- tests/ai/auditTools.spec.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/ai/auditTools.spec.js b/tests/ai/auditTools.spec.js index c51d9e3..7dd64b6 100644 --- a/tests/ai/auditTools.spec.js +++ b/tests/ai/auditTools.spec.js @@ -23,10 +23,14 @@ describe('AI Subsystem Technical Audit Tests', () => { }); afterAll(() => { - db.close(); - graphDb.close(); + db?.close?.(); + graphDb?.close?.(); if (fs.existsSync(tempDir)) { - fs.rmSync(tempDir, { recursive: true, force: true }); + try { + fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (_err) { + // Ignore ephemeral Windows file lock cleanup error + } } }); From a76ec34cec2e7883de83000dac134bcd5808dd61 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 24 Jul 2026 01:24:57 +0530 Subject: [PATCH 06/11] Fixed case again :) --- tests/ai/auditTools.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ai/auditTools.spec.js b/tests/ai/auditTools.spec.js index 7dd64b6..0a250a9 100644 --- a/tests/ai/auditTools.spec.js +++ b/tests/ai/auditTools.spec.js @@ -11,7 +11,7 @@ describe('AI Subsystem Technical Audit Tests', () => { let graphDb; beforeAll(() => { - tempDir = path.join(__dirname, 'temp-audit-test'); + tempDir = path.join(__dirname, `temp-audit-test-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } From 572efb2901da337d0d4ce29802f28623055f1301 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 24 Jul 2026 01:48:05 +0530 Subject: [PATCH 07/11] Fixed disk reload --- electron/lib/core/appMenu.cjs | 10 + src/App.jsx | 25 +- src/components/DocumentDetail.jsx | 12 +- src/components/LandingListControls.jsx | 16 +- src/components/NoteTabBar.jsx | 14 +- src/components/layout/LandingView.jsx | 2 + src/hooks/useDocumentManager.js | 80 +++++- .../ReloadFunctionality.integration.test.jsx | 237 ++++++++++++++++++ 8 files changed, 375 insertions(+), 21 deletions(-) create mode 100644 src/tests/components/ReloadFunctionality.integration.test.jsx diff --git a/electron/lib/core/appMenu.cjs b/electron/lib/core/appMenu.cjs index a09fc2e..d86743a 100644 --- a/electron/lib/core/appMenu.cjs +++ b/electron/lib/core/appMenu.cjs @@ -143,6 +143,11 @@ function buildAppMenuTemplate(win, context = {}) { submenu: openRecentSubmenu }, { type: "separator" }, + { + label: "Reload Workspace from Disk", + accelerator: "CmdOrCtrl+Alt+R", + click: () => sendMenuAction(win, "reload-workspace") + }, { label: "Export/Import note package", click: () => sendMenuAction(win, "open-export-import") @@ -479,6 +484,11 @@ function buildAppMenuTemplate(win, context = {}) { accelerator: "CmdOrCtrl+Shift+A", click: () => sendMenuAction(win, "open-workspace-activity") }, + { + label: "Reload Workspace", + accelerator: "CmdOrCtrl+Alt+R", + click: () => sendMenuAction(win, "reload-workspace") + }, { type: "separator" }, { label: "Open Workspace in VS Code", diff --git a/src/App.jsx b/src/App.jsx index cfacdbc..0e13209 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -433,6 +433,8 @@ export default function App() { openDocument, saveDocument, handleReloadCurrentFromDisk, + reloadDocument, + handleReloadWorkspace, handleDeleteCurrentDocument, handleDeleteCurrentFolder, handleRemoveListEntry, @@ -900,8 +902,8 @@ export default function App() { } } - async function handleOpenReferencedDocumentFromUI(filePath, lineNumber) { - await handleOpenReferencedDocument(filePath, lineNumber); + async function handleOpenReferencedDocumentFromUI(filePath, optionsOrLineNumber) { + await handleOpenReferencedDocument(filePath, optionsOrLineNumber); setLandingAssetsOpen(false); } @@ -1694,6 +1696,11 @@ export default function App() { return; } + if (action === "reload-workspace") { + handleReloadWorkspace(); + return; + } + if (action === "remove-document") { handleDeleteCurrentDocument(); return; @@ -1942,6 +1949,8 @@ export default function App() { { id: "open-global-search", label: "Open Global Search", group: "Search", shortcut: "Ctrl/Cmd+Shift+F", aliases: "find everywhere search all notes quick open jump" }, { id: "open-shortcuts", label: "Open Keyboard Shortcuts", group: "Help", shortcut: "Ctrl/Cmd+/", aliases: "hotkeys keymap shortcuts" }, { id: "open-workspace", label: "Open Workspace", group: "Workspace", shortcut: "Ctrl/Cmd+Shift+N", aliases: "open workspace folder notes root path" }, + { id: "reload-workspace", label: "Reload Workspace from Disk", group: "Workspace", shortcut: "Ctrl/Cmd+Alt+R", aliases: "refresh reload workspace disk" }, + { id: "reload-document", label: "Reload Current Note from Disk", group: "Editor", shortcut: "Ctrl/Cmd+Shift+R", disabled: !current, aliases: "refresh reload note file disk" }, { id: "export-workspace-zip", label: "Export Workspace as Zip", group: "Workspace", aliases: "export backup archive zip workspace" }, { id: "open-workspace-graph", label: "Open Workspace Graph", group: "Navigation", aliases: "graph map relationships links topology" }, { id: "open-tasks-panel", label: "Open Tasks Panel", group: "Navigation", aliases: "tasks todos checkboxes unchecked open items" }, @@ -2225,6 +2234,16 @@ export default function App() { return; } + if (resolvedCommandId === "reload-document") { + await handleReloadCurrentFromDisk(); + return; + } + + if (resolvedCommandId === "reload-workspace") { + await handleReloadWorkspace(); + return; + } + if (resolvedCommandId === "new-note") { setNoteDialogOpen(true); return; @@ -2847,6 +2866,7 @@ export default function App() { onShowUpdateModal={() => setShowUpdateModal(true)} onDismissUpdate={() => setUpdateStatus("dismissed")} onCopyLinkPath={handleCopyLinkPath} + onReloadWorkspace={handleReloadWorkspace} /> {landingAssetsOpen ? ( setLandingAssetsOpen(false)} ariaLabel="Assets" cardClassName="assets-dialog-card"> @@ -2970,6 +2990,7 @@ export default function App() { onOutlineEnabledChange={setOutlineEnabled} focusModeEnabled={focusModeEnabled} onFocusModeChange={setFocusModeEnabled} + onReloadFromDisk={(filePath) => handleReloadCurrentFromDisk(filePath)} aiSidebar={aiSidebarComponent} /> diff --git a/src/components/DocumentDetail.jsx b/src/components/DocumentDetail.jsx index c89c97d..45bf822 100644 --- a/src/components/DocumentDetail.jsx +++ b/src/components/DocumentDetail.jsx @@ -723,6 +723,7 @@ export function DocumentDetail({ onOpenInEditor, onRevealInExplorer, onCopyLinkPath, + onReloadFromDisk, }) { const { confirm } = useConfirm(); const MAX_EDITOR_HISTORY = 200; @@ -878,11 +879,13 @@ export function DocumentDetail({ const handleReloadFromDisk = async () => { try { - if (typeof onOpenDocument === "function") { - await onOpenDocument(document.filePath); - setChangedOnDisk(false); - onNotify?.("Note reloaded from disk.", "success"); + if (typeof onReloadFromDisk === "function") { + await onReloadFromDisk(document.filePath); + } else if (typeof onOpenDocument === "function") { + await onOpenDocument(document.filePath, { forceReload: true, preserveActiveTab: true }); } + setChangedOnDisk(false); + onNotify?.("Note reloaded from disk.", "success"); } catch (err) { onNotify?.(err?.message || "Failed to reload document.", "error"); } @@ -1757,6 +1760,7 @@ export function DocumentDetail({ onOpenInEditor={onOpenInEditor} onRevealInExplorer={onRevealInExplorer} onCopyLinkPath={onCopyLinkPath} + onReloadFromDisk={onReloadFromDisk} /> )} {!isFocusMode && ( diff --git a/src/components/LandingListControls.jsx b/src/components/LandingListControls.jsx index a341112..eceaea9 100644 --- a/src/components/LandingListControls.jsx +++ b/src/components/LandingListControls.jsx @@ -1,4 +1,4 @@ -import { FilePlus2, FileText, Folder } from "lucide-react"; +import { FilePlus2, FileText, Folder, RefreshCw } from "lucide-react"; import AppButton from "./AppButton"; import AppSelect from "./AppSelect"; @@ -16,6 +16,7 @@ export function LandingListControls({ visibleNoteCount, totalNoteCount, onCreateNote, + onReloadWorkspace, }) { return (
@@ -91,6 +92,19 @@ export function LandingListControls({
+ {onReloadWorkspace && ( + + + Reload + + )} + {onCreateNote && (
+
) : modelStatus.isDownloading ? (
- Downloading local weights... + Downloading GLiNER & GLiREL weights... {modelStatus.progress}%
@@ -567,7 +585,7 @@ export const AISettingsContent = ({ _onClose }) => {
) : (
- Qwen ONNX model not found. Click the button below or go to Knowledge Graph tab to download. + GLiNER + GLiREL ONNX models not found. Click below or go to Knowledge Graph tab to download.
)} -
- ⚠️ Hardware Warning: Running text models locally executes inference directly on your CPU. This requires a modern processor and at least 8GB-16GB RAM. Performance may cause temporary UI lag/freezes during generation. -
) : (
diff --git a/src/components/KnowledgeGraph.jsx b/src/components/KnowledgeGraph.jsx index dd959d2..0d79f59 100644 --- a/src/components/KnowledgeGraph.jsx +++ b/src/components/KnowledgeGraph.jsx @@ -57,11 +57,19 @@ const TYPE_COLORS = { Project: { background: 'var(--kg-project-bg)', border: 'var(--kg-project-border)', text: 'var(--kg-project-border)' }, Technology: { background: 'var(--kg-tech-bg)', border: 'var(--kg-tech-border)', text: 'var(--kg-tech-border)' }, Company: { background: 'var(--kg-company-bg)', border: 'var(--kg-company-border)', text: 'var(--kg-company-border)' }, + Organization: { background: 'var(--kg-company-bg)', border: 'var(--kg-company-border)', text: 'var(--kg-company-border)' }, Concept: { background: 'var(--kg-concept-bg)', border: 'var(--kg-concept-border)', text: 'var(--kg-concept-border)' }, Task: { background: 'var(--kg-task-bg)', border: 'var(--kg-task-border)', text: 'var(--kg-task-border)' }, Image: { background: 'var(--kg-image-bg)', border: 'var(--kg-image-border)', text: 'var(--kg-image-border)' }, Document: { background: 'var(--kg-doc-bg)', border: 'var(--kg-doc-border)', text: 'var(--kg-doc-border)' }, - ExternalURL: { background: 'var(--kg-url-bg)', border: 'var(--kg-url-border)', text: 'var(--kg-url-border)' } + ExternalURL: { background: 'var(--kg-url-bg)', border: 'var(--kg-url-border)', text: 'var(--kg-url-border)' }, + CodeBlock: { background: 'var(--kg-tech-bg)', border: 'var(--kg-tech-border)', text: 'var(--kg-tech-border)' }, + Diagram: { background: 'var(--kg-image-bg)', border: 'var(--kg-image-border)', text: 'var(--kg-image-border)' }, + Section: { background: 'var(--kg-concept-bg)', border: 'var(--kg-concept-border)', text: 'var(--kg-concept-border)' }, + Tag: { background: 'var(--kg-project-bg)', border: 'var(--kg-project-border)', text: 'var(--kg-project-border)' }, + KeyTerm: { background: 'var(--kg-person-bg)', border: 'var(--kg-person-border)', text: 'var(--kg-person-border)' }, + Formula: { background: 'var(--kg-company-bg)', border: 'var(--kg-company-border)', text: 'var(--kg-company-border)' }, + Callout: { background: 'var(--kg-task-bg)', border: 'var(--kg-task-border)', text: 'var(--kg-task-border)' } }; const DEFAULT_COLOR = { background: 'var(--kg-default-bg)', border: 'var(--kg-default-border)', text: 'var(--text-strong)' }; @@ -342,12 +350,16 @@ export default function KnowledgeGraph({ onBack }) { try { setError(''); setIsRebuilding(true); - setGraphStatus(prev => ({ ...prev, isBuilding: true, current: 0, noteName: 'Initializing ModernBERT worker...' })); + setNodes([]); + setEdges([]); + setGraphStatus(prev => ({ ...prev, isBuilding: true, current: 0, noteName: 'Initializing GLiNER/GLiREL worker...' })); const rebuildRes = await aiBuildGraph(); if (!rebuildRes.success) { setError(rebuildRes.error || 'Rebuild failed.'); setIsRebuilding(false); setGraphStatus(prev => ({ ...prev, isBuilding: false })); + } else { + loadGraphData(); } } catch (err) { setError(err.message || 'Failed to rebuild Knowledge Graph.'); diff --git a/src/components/KnowledgeGraphSettings.jsx b/src/components/KnowledgeGraphSettings.jsx index a5571fd..c82b0cb 100644 --- a/src/components/KnowledgeGraphSettings.jsx +++ b/src/components/KnowledgeGraphSettings.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Database, Download, AlertCircle, Save, Trash2, Cpu } from 'lucide-react'; +import { Database, Download, AlertCircle, Save, Trash2, Cpu, Sliders } from 'lucide-react'; import AppSelect from './AppSelect'; import { aiGetGraphModelStatus, @@ -12,7 +12,7 @@ import { export default function KnowledgeGraphSettings() { const [loading, setLoading] = useState(false); - const [preferences, setPreferences] = useState({ graphProvider: 'local', graphConfidence: 0.60 }); + const [preferences, setPreferences] = useState({ graphProvider: 'gliner-glirel', graphConfidence: 0.60 }); const [modelStatus, setModelStatus] = useState({ downloaded: false, isDownloading: false, progress: 0 }); useEffect(() => { @@ -48,7 +48,7 @@ export default function KnowledgeGraphSettings() { }; }, []); - const handleProviderSave = async () => { + const handlePreferencesSave = async () => { try { setLoading(true); await aiSetPreferences({ @@ -57,7 +57,7 @@ export default function KnowledgeGraphSettings() { graphConfidence: preferences.graphConfidence }); window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Knowledge Graph preferences saved.`, type: 'success' } + detail: { message: `Knowledge Graph preferences saved successfully.`, type: 'success' } })); } catch (err) { console.error(err); @@ -87,13 +87,13 @@ export default function KnowledgeGraphSettings() { }; const handleDeleteModel = async () => { - if (!window.confirm('Delete local ModernBERT ONNX model weights (NER + RE) from disk? You can redownload anytime.')) return; + if (!window.confirm('Delete local GLiNER and GLiREL ONNX model weights from disk? You can redownload anytime.')) return; try { setLoading(true); await aiDeleteGraphModel(); setModelStatus({ downloaded: false, isDownloading: false, progress: 0 }); window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: 'Local ModernBERT NER & RE model weights deleted successfully.', type: 'info' } + detail: { message: 'Local GLiNER & GLiREL ONNX model weights deleted successfully.', type: 'info' } })); } catch (err) { console.error(err); @@ -105,6 +105,8 @@ export default function KnowledgeGraphSettings() { } }; + const activeProvider = (preferences.graphProvider === 'text-provider') ? 'text-provider' : 'gliner-glirel'; + return (
@@ -117,25 +119,25 @@ export default function KnowledgeGraphSettings() {
{ const newProvider = e.target.value; const updated = { ...preferences, graphProvider: newProvider }; setPreferences(updated); await aiSetPreferences(updated); window.dispatchEvent(new CustomEvent('app:toast', { - detail: { message: `Graph extraction provider set to ${newProvider === 'local' ? 'ModernBERT Local ONNX 2-Model Pipeline' : 'Cloud Text Provider'}.`, type: 'success' } + detail: { message: `Graph extraction engine set to ${newProvider === 'gliner-glirel' ? 'GLiNER + GLiREL Model-Driven Pipeline' : 'Cloud AI Provider'}.`, type: 'success' } })); }} disabled={loading} style={{ flex: 1 }} > - - + +
Active Extraction Engine - {preferences.graphProvider === 'local' ? 'ModernBERT 2-Model (NER + RE) ONNX + Structural Parser' : 'Cloud LLM Text Provider'} + + {activeProvider === 'gliner-glirel' ? 'GLiNER Zero-Shot NER + GLiREL Zero-Shot RE ONNX' : 'Cloud LLM Text Provider'} + +
+
+ +
+ +
+ + setPreferences({ ...preferences, graphConfidence: parseFloat(e.target.value) })} + style={{ flex: 1 }} + />
- {preferences.graphProvider === 'local' && ( + {activeProvider === 'gliner-glirel' && (

- Local 2-Model Status (ModernBERT NER + RE ONNX) + Offline Model Status (GLiNER + GLiREL ONNX)

{modelStatus.downloaded ? (
- ModernBERT NER & RE ONNX weights (~70MB) downloaded and ready offline. + GLiNER Zero-Shot NER & GLiREL Zero-Shot RE ONNX weights downloaded and ready offline.
)} diff --git a/src/components/OnboardingFlow.jsx b/src/components/OnboardingFlow.jsx index e61e578..5e648b4 100644 --- a/src/components/OnboardingFlow.jsx +++ b/src/components/OnboardingFlow.jsx @@ -453,13 +453,13 @@ export function OnboardingFlow({ {selectedAIProvider === "local" ? (
- Local Knowledge Graph Engine (ModernBERT ONNX) + Local Knowledge Graph Engine (GLiNER + GLiREL ONNX) {graphModelStatus.downloaded ? ( -
✓ ModernBERT model weights downloaded & ready offline!
+
✓ GLiNER & GLiREL model weights downloaded & ready offline!
) : graphModelStatus.isDownloading ? (
- Downloading ModernBERT weights... + Downloading GLiNER & GLiREL weights... {graphModelStatus.progress}%
@@ -468,7 +468,7 @@ export function OnboardingFlow({
) : (
- ~70 MB download for offline graph extraction. + Offline zero-shot graph extraction weights.
) : ( diff --git a/tests/ai/benchmark.spec.js b/tests/ai/benchmark.spec.js new file mode 100644 index 0000000..379219a --- /dev/null +++ b/tests/ai/benchmark.spec.js @@ -0,0 +1,81 @@ +const assert = require('assert'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const GraphDB = require('../../ai/graph/GraphDB'); +const GraphService = require('../../ai/graph/GraphService'); +const GLiNERGLiRELPipeline = require('../../ai/graph/GLiNERGLiRELPipeline'); + +describe('GLiNER + GLiREL Benchmark Performance Tests', () => { + let tmpDir; + let graphDb; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gliner-benchmark-')); + graphDb = new GraphDB(tmpDir); + graphDb.initialize(); + }); + + afterEach(() => { + if (graphDb) graphDb.close(); + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should benchmark pipeline initialization latency', async () => { + const start = performance.now(); + const pipeline = new GLiNERGLiRELPipeline(tmpDir); + await pipeline.load(); + const durationMs = performance.now() - start; + + console.log(`[Benchmark] Pipeline initialization took ${durationMs.toFixed(2)} ms`); + assert.ok(durationMs >= 0); + }); + + it('should benchmark note extraction latency and memory delta', async () => { + const service = new GraphService({ appDataDir: tmpDir }, graphDb); + const notePath = path.join(tmpDir, 'benchmark-note.md'); + + // Sample multi-sentence markdown note (~500 words) + const content = ` +# Quantum Computing Overview +Quantum computing relies on qubits to perform parallel calculations. +Superconducting circuits and trapped ions are primary hardware methodologies. +Shor's Algorithm offers exponential speedup for integer factorization. +Qiskit and Cirq are open-source software SDKs written in Python. +IBM, Google, and Rigetti are leading organizations building quantum systems. + `.repeat(5); + + const memBefore = process.memoryUsage().heapUsed; + const start = performance.now(); + + await service.processNote(notePath, content); + + const durationMs = performance.now() - start; + const memAfter = process.memoryUsage().heapUsed; + const memDeltaMB = (memAfter - memBefore) / (1024 * 1024); + + console.log(`[Benchmark] 500-word note extraction took ${durationMs.toFixed(2)} ms | Heap Delta: ${memDeltaMB.toFixed(2)} MB`); + assert.ok(durationMs < 5000, `Extraction exceeded 5000ms threshold: ${durationMs}ms`); + }); + + it('should benchmark note batch throughput per minute', async () => { + const service = new GraphService({ appDataDir: tmpDir }, graphDb); + const notesCount = 10; + const start = performance.now(); + + for (let i = 0; i < notesCount; i++) { + const notePath = path.join(tmpDir, `note-${i}.md`); + const content = `# Note ${i}\nEntityAlpha links to [[EntityBeta-${i}]] and relies on Python.`; + await service.processNote(notePath, content); + } + + const durationSec = (performance.now() - start) / 1000; + const notesPerMin = Math.round((notesCount / durationSec) * 60); + + console.log(`[Benchmark] Processed ${notesCount} notes in ${durationSec.toFixed(2)} s (${notesPerMin} notes/min)`); + assert.ok(notesPerMin > 0); + }); +}); diff --git a/tests/ai/gliner_glirel.spec.js b/tests/ai/gliner_glirel.spec.js new file mode 100644 index 0000000..2ed327d --- /dev/null +++ b/tests/ai/gliner_glirel.spec.js @@ -0,0 +1,176 @@ +const assert = require('assert'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const GraphDB = require('../../ai/graph/GraphDB'); +const GLiNERExtractor = require('../../ai/graph/GLiNERExtractor'); +const GLiRELExtractor = require('../../ai/graph/GLiRELExtractor'); +const GLiNERGLiRELPipeline = require('../../ai/graph/GLiNERGLiRELPipeline'); +const GraphModelDownloader = require('../../ai/graph/GraphModelDownloader'); +const GraphService = require('../../ai/graph/GraphService'); + +describe('GLiNER + GLiREL Model-Driven Pipeline Tests', () => { + let tmpDir; + let graphDb; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gliner-test-')); + graphDb = new GraphDB(tmpDir); + graphDb.initialize(); + }); + + afterEach(() => { + if (graphDb) graphDb.close(); + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should initialize GLiNERExtractor and segment sentences', () => { + const gliner = new GLiNERExtractor(tmpDir); + const sentences = gliner.segmentSentences('React is a JavaScript framework. Node.js is a runtime.'); + assert.ok(sentences.length >= 2); + }); + + it('should extract entities dynamically using GLiNERExtractor', async () => { + const gliner = new GLiNERExtractor(tmpDir); + const text = 'React is a popular framework developed by Facebook.'; + const labels = ['React', 'Facebook', 'framework']; + + const entities = await gliner.extractEntities(text, labels, { confidenceThreshold: 0.60 }); + assert.ok(entities.length >= 1); + assert.strictEqual(entities[0].name, 'React'); + }); + + it('should extract Person entities and author relationships from note body text', async () => { + const pipeline = new GLiNERGLiRELPipeline(tmpDir); + const text = 'Bikash Panda created the architecture for Notely. Hari Mohan reviewed the system.'; + const ast = { + sections: [{ title: 'Overview' }], + keyTerms: [{ term: 'Bikash Panda' }, { term: 'Hari Mohan' }, { term: 'Notely' }], + tags: [], + links: [] + }; + + const results = await pipeline.extractEntitiesAndRelations(text, ast, { confidenceThreshold: 0.50 }); + const personEnt = results.entities.find(e => e.name === 'Bikash Panda' || e.name === 'Hari Mohan'); + assert.ok(personEnt, 'Should detect person entity from note text'); + }); + + it('should extract relations between entity pairs using GLiRELExtractor', async () => { + const glirel = new GLiRELExtractor(tmpDir); + const text = 'React depends on JavaScript.'; + const sentences = [{ text, index: 0, length: text.length }]; + const entities = [ + { name: 'React', type: 'Technology', spanStart: 0, spanEnd: 5 }, + { name: 'JavaScript', type: 'Technology', spanStart: 17, spanEnd: 27 } + ]; + + const relations = await glirel.extractRelations(text, sentences, entities, { confidenceThreshold: 0.60 }); + assert.ok(relations.length >= 1); + assert.strictEqual(relations[0].source_name, 'React'); + assert.strictEqual(relations[0].target_name, 'JavaScript'); + assert.strictEqual(relations[0].type, 'depends_on'); + }); + + it('should execute full GLiNERGLiRELPipeline with dynamic AST label discovery', async () => { + const pipeline = new GLiNERGLiRELPipeline(tmpDir); + const text = '# Overview\nReact depends on JavaScript. Tagged #webdev.'; + const ast = { + tags: [{ name: 'webdev' }], + sections: [{ title: 'Overview' }], + keyTerms: [{ term: 'React' }, { term: 'JavaScript' }], + links: [] + }; + + const results = await pipeline.extractEntitiesAndRelations(text, ast, { confidenceThreshold: 0.50 }); + assert.ok(results); + assert.ok(Array.isArray(results.entities)); + assert.ok(Array.isArray(results.relationships)); + }); + + it('should report correct status in GraphModelDownloader', () => { + const downloader = new GraphModelDownloader(tmpDir); + const status = downloader.getStatus(); + assert.strictEqual(status.downloaded, false); + assert.strictEqual(status.isDownloading, false); + assert.strictEqual(status.progress, 0); + }); + + it('should process note end-to-end in GraphService using GLiNER/GLiREL pipeline', async () => { + const service = new GraphService({ appDataDir: tmpDir }, graphDb); + const notePath = path.join(tmpDir, 'test-note.md'); + const content = '# Machine Learning\nPython depends on NumPy for mathematical operations.'; + + await service.processNote(notePath, content); + + const stats = graphDb.getStatus(); + assert.ok(stats.nodeCount > 0); + }); + + it('should extract entities and expected relationships from a large paragraph', async () => { + const pipeline = new GLiNERGLiRELPipeline(tmpDir); + const bigParagraph = ` +# Artificial Intelligence Systems +Modern artificial intelligence applications rely heavily on **Python** as their primary programming language. +The **PyTorch** framework depends on **Python** to build deep neural network architectures for computer vision and natural language processing. +Similarly, **TensorFlow** created by **Google** offers high-performance tensor computations across distributed GPU clusters. +In production environments, **Kubernetes** manages containerized microservices created by software engineering teams. +Furthermore, **PostgreSQL** handles relational data persistence while **Redis** provides high-speed in-memory caching. + `; + + const ast = { + sections: [{ title: 'Artificial Intelligence Systems' }], + keyTerms: [ + { term: 'Python' }, + { term: 'PyTorch' }, + { term: 'TensorFlow' }, + { term: 'Google' }, + { term: 'Kubernetes' }, + { term: 'PostgreSQL' }, + { term: 'Redis' } + ], + tags: [{ name: 'ai' }, { name: 'infrastructure' }], + links: [] + }; + + const results = await pipeline.extractEntitiesAndRelations(bigParagraph, ast, { confidenceThreshold: 0.50 }); + + assert.ok(results.entities.length >= 5, `Expected at least 5 entities, found ${results.entities.length}`); + assert.ok(results.relationships.length >= 3, `Expected at least 3 relationships, found ${results.relationships.length}`); + + const extractedEntityNames = results.entities.map(e => e.name); + assert.ok(extractedEntityNames.includes('Python'), 'Entities should contain Python'); + assert.ok(extractedEntityNames.includes('PyTorch'), 'Entities should contain PyTorch'); + assert.ok(extractedEntityNames.includes('Google'), 'Entities should contain Google'); + + const hasPyTorchRel = results.relationships.some(r => + (r.source_name === 'PyTorch' && r.target_name === 'Python') || + (r.source_name === 'Python' && r.target_name === 'PyTorch') + ); + assert.ok(hasPyTorchRel, 'Should find relationship between PyTorch and Python'); + + const hasTensorFlowRel = results.relationships.some(r => + (r.source_name === 'TensorFlow' && r.target_name === 'Google') || + (r.source_name === 'Google' && r.target_name === 'TensorFlow') + ); + assert.ok(hasTensorFlowRel, 'Should find relationship between TensorFlow and Google'); + }); + + it('should ignore system section headings like # Cleansed and # RawNotes during extraction', async () => { + const pipeline = new GLiNERGLiRELPipeline(tmpDir); + const text = '# RawNotes\nReact relies on JavaScript.\n# Cleansed\nReact is structured.'; + const ast = { + sections: [{ title: 'RawNotes' }, { title: 'Cleansed' }, { title: 'React Overview' }], + keyTerms: [{ term: 'React' }, { term: 'JavaScript' }], + tags: [], + links: [] + }; + + const results = await pipeline.extractEntitiesAndRelations(text, ast, { confidenceThreshold: 0.50 }); + const entityNames = results.entities.map(e => e.name.toLowerCase()); + assert.strictEqual(entityNames.includes('rawnotes'), false, 'rawnotes should not be an entity'); + assert.strictEqual(entityNames.includes('cleansed'), false, 'cleansed should not be an entity'); + }); +}); diff --git a/tests/ai/knowledgeGraph.spec.js b/tests/ai/knowledgeGraph.spec.js index af11740..0c10ffb 100644 --- a/tests/ai/knowledgeGraph.spec.js +++ b/tests/ai/knowledgeGraph.spec.js @@ -42,6 +42,20 @@ describe('Knowledge Graph Architecture Tests', () => { assert.strictEqual(ast.codeBlocks[0].language, 'javascript'); }); + it('should parse YAML frontmatter and header metadata (Tags, Name, Location, Time)', () => { + const parser = new MarkdownASTParser(); + const markdown = `---\nTags: meeting, hello\n - guide\n - diagrams\n---\nName: Hari Mohan, Bikash panda\nTime: 09:57, 24 Jul 2026 to 09:57, 25 Jul 2026\nLocation: Delhi\n# Discussion\nContent text here.`; + + const ast = parser.parse('/test/meeting.md', markdown); + assert.ok(ast.tags.some(t => t.name === 'meeting')); + assert.ok(ast.tags.some(t => t.name === 'hello')); + assert.ok(ast.tags.some(t => t.name === 'guide')); + assert.ok(ast.metadataEntities.some(e => e.name === 'Hari Mohan' && e.type === 'Person')); + assert.ok(ast.metadataEntities.some(e => e.name === 'Bikash panda' && e.type === 'Person')); + assert.ok(ast.metadataEntities.some(e => e.name === 'Delhi' && e.type === 'Location')); + assert.strictEqual(ast.rootEntity.properties.metadata.location, 'Delhi'); + }); + it('should save and query raw sentence evidence in EvidenceStore', () => { const store = new EvidenceStore(graphDb); const evId = store.addEvidence({ From e06f3d5c8d50e30954dd895b1dd9194062e26733 Mon Sep 17 00:00:00 2001 From: Bikash Panda Date: Fri, 24 Jul 2026 10:16:07 +0530 Subject: [PATCH 10/11] Glitch fixed --- docs/ai/knowledge-graph.md | 26 ++++++++------ src/components/MarkdownToolbar.jsx | 57 ++---------------------------- 2 files changed, 17 insertions(+), 66 deletions(-) diff --git a/docs/ai/knowledge-graph.md b/docs/ai/knowledge-graph.md index d4e80fe..a911bd2 100644 --- a/docs/ai/knowledge-graph.md +++ b/docs/ai/knowledge-graph.md @@ -70,11 +70,16 @@ sequenceDiagram ## Key Components & Concepts -### 1. Markdown AST Parser (Structure Only) +### 1. Markdown AST Parser (Structure & Metadata) The structural parser converts raw Markdown text into a structural AST tree without imposing domain semantics. - **Root Note Entity**: Uniquely identifies the document by path hash. +- **Frontmatter & Header Key-Value Metadata**: Automatically extracts YAML block frontmatter and top key-value lines (`Tags:`, `Name:`, `Location:`, `Time:`): + - `Tags:` / `- tag` $\rightarrow$ Generates `#tag` (`Tag`) nodes linked to Note. + - `Name: Person A, Person B` $\rightarrow$ Generates `Person` entities linked via `has_person`. + - `Location: City` $\rightarrow$ Generates `Location` entities linked via `located_in`. + - `Time: DateRange` $\rightarrow$ Preserved in `Note.properties.metadata`. - **Wikilinks (`[[Target]]`)**: Links documents to target notes with bidirectional edge weights. - **Section Headings (`# Heading`)**: Captures document hierarchy (`contains_section`) with level-attenuated weights ($H_1 = 1.4, H_2 = 1.3, \dots, H_6 = 0.9$). Built-in Notely system sections (`# RawNotes`, `# Cleansed`) are automatically excluded from becoming section nodes. - **Tags (`#tag`)**: Categorizes concepts (`tagged`). @@ -93,23 +98,22 @@ Semantic extraction uses two offline ONNX models (~70MB each) executing via loca ```mermaid graph LR - subgraph Pass 1: NER - A[Raw Sentence] --> B[ModernBERT Tokenizer] - B --> C[ONNX NER Model] - C --> D[Entity Spans & Scores] + subgraph Pass 1: GLiNER NER + A[Raw Sentence] --> B[GLiNER ONNX Session] + B --> C[Zero-Shot Entity Spans & Scores] end - subgraph Pass 2: RE - D --> E[Co-occurring Entity Pair Matrix] - E --> F[ONNX RE Model] - F --> G[Typed Relationships & Confidence] + subgraph Pass 2: GLiREL RE + C --> D[Co-occurring Entity Pair Matrix] + D --> E[GLiREL ONNX Session] + E --> F[Typed Relationships & Confidence] end ``` 1. **Pass 1 — Named Entity Recognition (NER)**: - Segments document using `Intl.Segmenter` and classifies tokens to locate entities with confidence scores $\ge 0.60$. Automatically merges WordPiece subword tokens (`['React', '##Native']` $\rightarrow$ `ReactNative`) and dynamically formats model entity labels without hardcoded word lists, preserving complete domain independence across engineering, medicine, finance, and law. + Segments document using `Intl.Segmenter` and runs zero-shot GLiNER ONNX session to locate entities with confidence scores $\ge 0.50$. Dynamically maps candidates to standard entity categories (`Person`, `Organization`, `Technology`, `Location`, `Concept`, `Product`, `Event`, `Document`, `Diagram`, `Task`) without hardcoded taxonomies or word lists, preserving complete domain independence across engineering, medicine, finance, and law. 2. **Pass 2 — Relation Extraction (RE)**: - Evaluates co-occurring entity pairs within sentence windows, running ONNX relation classification tensors to score edge connection strength. + Evaluates co-occurring entity pairs within sentence windows, running zero-shot GLiREL ONNX relation classification tensors to score edge connection strength (`depends_on`, `uses`, `created_by`, `contains`, `is_a`, `related_to`). --- diff --git a/src/components/MarkdownToolbar.jsx b/src/components/MarkdownToolbar.jsx index 7699541..c044b70 100644 --- a/src/components/MarkdownToolbar.jsx +++ b/src/components/MarkdownToolbar.jsx @@ -19,7 +19,6 @@ import { Workflow, PenTool, Grid, - SlidersHorizontal, } from "lucide-react"; import AppSelect from "./AppSelect"; import { applySnippet, createMediaMarkdown, insertTextAtCursor, normalizeImagePathForMarkdown } from "../utils/markdownUtils"; @@ -252,7 +251,6 @@ export function MarkdownToolbar({ setShowWebLinker(false); setShowTableBuilder(false); setShowValidationPanel(false); - setShowViewMenu(false); setDiagramMode("picker"); }; @@ -263,7 +261,6 @@ export function MarkdownToolbar({ if (panel === "web") return showWebLinker; if (panel === "table") return showTableBuilder; if (panel === "validation") return showValidationPanel; - if (panel === "view") return showViewMenu; return false; }; @@ -274,7 +271,6 @@ export function MarkdownToolbar({ if (panel === "web") setShowWebLinker(true); if (panel === "table") setShowTableBuilder(true); if (panel === "validation") setShowValidationPanel(true); - if (panel === "view") setShowViewMenu(true); }; const toggleToolbarPanel = (panel) => { @@ -292,8 +288,7 @@ export function MarkdownToolbar({ showReferenceLinker || showWebLinker || showTableBuilder || - showValidationPanel || - showViewMenu; + showValidationPanel; useEffect(() => { if (!anyPopoverOpen) { @@ -307,15 +302,13 @@ export function MarkdownToolbar({ const insideWebLinker = webLinkPopoverRef.current?.contains(event.target); const insideTableBuilder = tablePopoverRef.current?.contains(event.target); const insideValidation = validationPopoverRef.current?.contains(event.target); - const insideViewMenu = viewMenuPopoverRef.current?.contains(event.target); if ( !insideMermaid && !insideAssetLinker && !insideReferenceLinker && !insideWebLinker && !insideTableBuilder && - !insideValidation && - !insideViewMenu + !insideValidation ) { closeToolbarPanels(); } @@ -954,10 +947,6 @@ export function MarkdownToolbar({ - toggleToolbarPanel("view")} title="View settings (Table editor, Outline)" aria-label="View settings" className={showViewMenu ? "active" : ""}> - - View - - {showViewMenu && ( -
-
- View Settings -
- - -
- )} - {showValidationPanel && (
Date: Fri, 24 Jul 2026 10:41:00 +0530 Subject: [PATCH 11/11] Lint Fixed --- ai/graph/GLiNERGLiRELPipeline.js | 1 - src/App.jsx | 2 +- src/components/MarkdownToolbar.jsx | 6 ------ 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/ai/graph/GLiNERGLiRELPipeline.js b/ai/graph/GLiNERGLiRELPipeline.js index 9efabac..8a450a5 100644 --- a/ai/graph/GLiNERGLiRELPipeline.js +++ b/ai/graph/GLiNERGLiRELPipeline.js @@ -1,4 +1,3 @@ -const path = require('path'); const { createLogger } = require('../core/logger'); const GLiNERExtractor = require('./GLiNERExtractor'); const GLiRELExtractor = require('./GLiRELExtractor'); diff --git a/src/App.jsx b/src/App.jsx index 0e13209..2cb3b40 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -433,7 +433,7 @@ export default function App() { openDocument, saveDocument, handleReloadCurrentFromDisk, - reloadDocument, + reloadDocument: _reloadDocument, handleReloadWorkspace, handleDeleteCurrentDocument, handleDeleteCurrentFolder, diff --git a/src/components/MarkdownToolbar.jsx b/src/components/MarkdownToolbar.jsx index c044b70..30fbdcb 100644 --- a/src/components/MarkdownToolbar.jsx +++ b/src/components/MarkdownToolbar.jsx @@ -164,10 +164,6 @@ export function MarkdownToolbar({ canRedo = false, onIgnoreSpellingWord, screenCaptureMode = "auto", - tableEditorEnabled = true, - onTableEditorToggle, - outlineEnabled = true, - onOutlineEnabledChange, }) { const imageInputRef = useRef(null); const mermaidPopoverRef = useRef(null); @@ -176,14 +172,12 @@ export function MarkdownToolbar({ const webLinkPopoverRef = useRef(null); const tablePopoverRef = useRef(null); const validationPopoverRef = useRef(null); - const viewMenuPopoverRef = useRef(null); const [showMermaidBuilder, setShowMermaidBuilder] = useState(false); const [showAssetLinker, setShowAssetLinker] = useState(false); const [showReferenceLinker, setShowReferenceLinker] = useState(false); const [showWebLinker, setShowWebLinker] = useState(false); const [showTableBuilder, setShowTableBuilder] = useState(false); const [showValidationPanel, setShowValidationPanel] = useState(false); - const [showViewMenu, setShowViewMenu] = useState(false); const [availableAssets, setAvailableAssets] = useState([]); const [availableReferenceNotes, setAvailableReferenceNotes] = useState([]); const [assetsLoading, setAssetsLoading] = useState(false);