feat: Add internal note links with live note picker dialog, Evernote link resolution, navigation history, and copy note link

This commit is contained in:
chrisf committed 2026-10-22 17:54:43 -06:00
1 parent 94ae568385
commit 3de36ff39b
4 files changed
+549 -10

No files matched your search

+37
View File
@@ -193,3 +193,40 @@
text-align: left;
background-color: #f9fafb;
}
/* Internal Everclone note links (Evernote style) */
.ProseMirror a[href^="everclone://"],
.ProseMirror a[href*="noteId="],
.ProseMirror a[href^="#note-"],
.ProseMirror a[href^="evernote:///view"] {
color: #00a82d;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgba(0, 168, 45, 0.4);
text-underline-offset: 3px;
cursor: pointer;
background-color: rgba(0, 168, 45, 0.08);
padding: 0.1rem 0.4rem;
border-radius: 0.35rem;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.ProseMirror a[href^="everclone://"]:hover,
.ProseMirror a[href*="noteId="]:hover,
.ProseMirror a[href^="#note-"]:hover,
.ProseMirror a[href^="evernote:///view"]:hover {
background-color: rgba(0, 168, 45, 0.18);
text-decoration-color: #00a82d;
}
.ProseMirror a[href^="everclone://"]::before,
.ProseMirror a[href*="noteId="]::before,
.ProseMirror a[href^="#note-"]::before,
.ProseMirror a[href^="evernote:///view"]::before {
content: "📄";
font-size: 0.85em;
text-decoration: none;
}
+88
View File
@@ -42,6 +42,8 @@ import {
Menu,
ArrowLeft,
Wand2,
ArrowRight,
Link2,
} from 'lucide-react';
import {
SidebarData,
@@ -101,6 +103,8 @@ export default function EvercloneWorkspace() {
const [triageModalOpen, setTriageModalOpen] = useState(false);
const [canInstallPwa, setCanInstallPwa] = useState(false);
const [shareToast, setShareToast] = useState<string | null>(null);
const [historyStack, setHistoryStack] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const activeFilterCount =
(filterNotebookId ? 1 : 0) +
@@ -185,6 +189,68 @@ export default function EvercloneWorkspace() {
return () => window.removeEventListener('keydown', handler);
}, []);
const handleNavigateToNote = useCallback(
(targetNoteId: string) => {
if (!targetNoteId || targetNoteId === selectedNoteId) return;
setHistoryStack((prev) => {
const next = prev.slice(0, historyIndex + 1);
return [...next, targetNoteId];
});
setHistoryIndex((prev) => prev + 1);
setSelectedNoteId(targetNoteId);
setMobilePane('editor');
},
[selectedNoteId, historyIndex]
);
const handleHistoryBack = useCallback(() => {
if (historyIndex > 0) {
const prevId = historyStack[historyIndex - 1];
setHistoryIndex((prev) => prev - 1);
setSelectedNoteId(prevId);
setMobilePane('editor');
}
}, [historyIndex, historyStack]);
const handleHistoryForward = useCallback(() => {
if (historyIndex < historyStack.length - 1) {
const nextId = historyStack[historyIndex + 1];
setHistoryIndex((prev) => prev + 1);
setSelectedNoteId(nextId);
setMobilePane('editor');
}
}, [historyIndex, historyStack]);
const handleCopyNoteLink = useCallback(async () => {
if (!activeNote) return;
const internalUrl = `everclone://note/${activeNote.id}`;
const webUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/?noteId=${activeNote.id}`;
const htmlSnippet = `<a href="${internalUrl}">${activeNote.title || "Untitled Note"}</a>`;
const mdSnippet = `[${activeNote.title || "Untitled Note"}](${internalUrl})`;
try {
if (typeof window !== "undefined" && navigator.clipboard && window.ClipboardItem) {
const textBlob = new Blob([mdSnippet], { type: "text/plain" });
const htmlBlob = new Blob([htmlSnippet], { type: "text/html" });
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": textBlob,
"text/html": htmlBlob,
}),
]);
} else {
await navigator.clipboard.writeText(webUrl);
}
setShareToast("Note link copied! Paste into any note to create an internal link.");
setTimeout(() => setShareToast(null), 4000);
} catch {
await navigator.clipboard.writeText(webUrl);
setShareToast("Note link copied!");
setTimeout(() => setShareToast(null), 4000);
}
}, [activeNote]);
const fetchSidebar = useCallback(async () => {
try {
const res = await fetch('/api/sidebar');
@@ -2487,6 +2553,28 @@ export default function EvercloneWorkspace() {
<span>Notes</span>
</button>
{/* Note Link Navigation History (Evernote Back / Forward) */}
<div className="hidden sm:flex items-center gap-0.5 bg-gray-100/90 p-0.5 rounded-lg border border-gray-200 shrink-0">
<button
type="button"
onClick={handleHistoryBack}
disabled={historyIndex <= 0}
title="Previous visited note (Back)"
className="p-1 rounded text-gray-600 hover:text-gray-900 hover:bg-white disabled:opacity-25 disabled:hover:bg-transparent transition"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleHistoryForward}
disabled={historyIndex >= historyStack.length - 1}
title="Next visited note (Forward)"
className="p-1 rounded text-gray-600 hover:text-gray-900 hover:bg-white disabled:opacity-25 disabled:hover:bg-transparent transition"
>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
{/* Notebook Mover Dropdown */}
<div
className="flex items-center gap-1 text-xs text-gray-700 bg-gray-100 hover:bg-emerald-50 border border-gray-200/80 hover:border-emerald-200 px-2 py-1 rounded-lg min-w-0 max-w-[130px] sm:max-w-[220px] transition"
+314
View File
@@ -0,0 +1,314 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import {
Search,
FileText,
Globe,
X,
Link2,
Check,
Loader2,
Folder,
ArrowRight,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface NoteSearchItem {
id: string;
title: string;
excerpt: string;
updatedAt: string;
notebook?: {
id: string;
name: string;
};
}
interface LinkModalProps {
isOpen: boolean;
initialUrl?: string;
initialText?: string;
currentNoteId?: string;
onClose: () => void;
onApplyLink: (url: string, text?: string) => void;
onUnlink: () => void;
}
export default function LinkModal({
isOpen,
initialUrl = '',
initialText = '',
currentNoteId,
onClose,
onApplyLink,
onUnlink,
}: LinkModalProps) {
const isInternal = initialUrl.startsWith('everclone://note/') || initialUrl.includes('noteId=');
const [tab, setTab] = useState<'internal' | 'web'>(isInternal ? 'internal' : 'internal');
// Internal search state
const [searchQuery, setSearchQuery] = useState('');
const [notes, setNotes] = useState<NoteSearchItem[]>([]);
const [loadingNotes, setLoadingNotes] = useState(false);
const [selectedNote, setSelectedNote] = useState<NoteSearchItem | null>(null);
// Web link state
const [webUrl, setWebUrl] = useState(isInternal ? '' : initialUrl);
const [displayText, setDisplayText] = useState(initialText);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setDisplayText(initialText);
const isInternalInitial = initialUrl.startsWith('everclone://note/') || initialUrl.includes('noteId=');
setTab(isInternalInitial ? 'internal' : 'internal');
setWebUrl(isInternalInitial ? '' : initialUrl);
setSearchQuery('');
setSelectedNote(null);
loadRecentNotes('');
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}, [isOpen, initialUrl, initialText]);
const loadRecentNotes = async (q: string) => {
setLoadingNotes(true);
try {
const sp = new URLSearchParams();
if (q.trim()) sp.set('q', q.trim());
sp.set('limit', '15');
const res = await fetch(`/api/notes?${sp.toString()}`);
if (res.ok) {
const data = await res.json();
const list: NoteSearchItem[] = data.notes || [];
// Exclude current note from linking to itself
setNotes(list.filter((n) => n.id !== currentNoteId));
}
} catch (err) {
console.error('Failed to search notes:', err);
} finally {
setLoadingNotes(false);
}
};
const handleSearchChange = (val: string) => {
setSearchQuery(val);
loadRecentNotes(val);
};
const handleSelectInternalNote = (note: NoteSearchItem) => {
const internalUrl = `everclone://note/${note.id}`;
const textToInsert = displayText.trim() || note.title || 'Untitled Note';
onApplyLink(internalUrl, textToInsert);
onClose();
};
const handleApplyWebLink = (e: React.FormEvent) => {
e.preventDefault();
let url = webUrl.trim();
if (!url) return;
if (!/^https?:\/\//i.test(url) && !url.startsWith('/') && !url.startsWith('mailto:')) {
url = `https://${url}`;
}
onApplyLink(url, displayText.trim() || undefined);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl border border-gray-200 w-full max-w-lg overflow-hidden flex flex-col max-h-[85vh]">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between bg-gradient-to-r from-emerald-50 to-teal-50">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-evernote-green/15 text-evernote-green flex items-center justify-center">
<Link2 className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-gray-900 text-sm">
Insert Link
</h3>
<p className="text-[11px] text-gray-500">
Link to another note in your library or an external web page
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-700 rounded-lg hover:bg-white transition"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Switcher */}
<div className="flex border-b border-gray-200 bg-gray-50/70 px-6 text-xs font-semibold">
<button
type="button"
onClick={() => setTab('internal')}
className={`flex items-center gap-1.5 py-2.5 border-b-2 transition ${
tab === 'internal'
? 'border-evernote-green text-evernote-green'
: 'border-transparent text-gray-500 hover:text-gray-900'
}`}
>
<FileText className="w-3.5 h-3.5" />
<span>Link to Note (Internal)</span>
</button>
<button
type="button"
onClick={() => setTab('web')}
className={`flex items-center gap-1.5 py-2.5 ml-6 border-b-2 transition ${
tab === 'web'
? 'border-evernote-green text-evernote-green'
: 'border-transparent text-gray-500 hover:text-gray-900'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Web URL</span>
</button>
</div>
{/* Display Text Input */}
<div className="px-6 pt-3 pb-1 border-b border-gray-100">
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Text to display
</label>
<input
type="text"
value={displayText}
onChange={(e) => setDisplayText(e.target.value)}
placeholder="Selected text or custom label"
className="w-full px-3 py-1.5 text-xs bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-evernote-green focus:bg-white"
/>
</div>
{/* Tab 1: Internal Note Search */}
{tab === 'internal' && (
<div className="flex-1 flex flex-col min-h-0 p-6 space-y-3">
<div className="relative">
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search notes by title or keywords..."
className="w-full pl-9 pr-8 py-2 text-xs bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-1 focus:ring-evernote-green"
/>
{loadingNotes && (
<Loader2 className="w-3.5 h-3.5 text-evernote-green animate-spin absolute right-3 top-1/2 -translate-y-1/2" />
)}
</div>
<div className="text-[11px] font-bold uppercase tracking-wider text-gray-400">
{searchQuery.trim() ? 'Matching Notes' : 'Recent Notes'}
</div>
<div className="flex-1 overflow-y-auto space-y-1.5 max-h-60 pr-1">
{notes.length === 0 ? (
<div className="py-8 text-center text-gray-400 text-xs">
{loadingNotes ? 'Searching notes...' : 'No notes found'}
</div>
) : (
notes.map((n) => (
<button
key={n.id}
type="button"
onClick={() => handleSelectInternalNote(n)}
className="w-full p-2.5 rounded-xl border border-gray-100 hover:border-emerald-200 hover:bg-emerald-50/60 text-left transition flex items-start justify-between gap-2 group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-evernote-green shrink-0" />
<span className="font-bold text-xs text-gray-900 truncate group-hover:text-evernote-green">
{n.title || 'Untitled Note'}
</span>
</div>
<p className="text-[11px] text-gray-500 truncate mt-0.5 pl-5">
{n.excerpt || 'No preview text'}
</p>
</div>
<div className="flex flex-col items-end shrink-0 text-[10px] text-gray-400">
{n.notebook?.name && (
<span className="flex items-center gap-1 text-gray-500 font-medium">
<Folder className="w-2.5 h-2.5" />
<span>{n.notebook.name}</span>
</span>
)}
<span className="mt-0.5">
{formatDistanceToNow(new Date(n.updatedAt), { addSuffix: true })}
</span>
</div>
</button>
))
)}
</div>
</div>
)}
{/* Tab 2: External Web URL */}
{tab === 'web' && (
<form onSubmit={handleApplyWebLink} className="p-6 space-y-4">
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Web URL *
</label>
<input
type="text"
required
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
placeholder="https://example.com"
className="w-full px-3 py-2 text-xs bg-white border border-gray-300 rounded-xl focus:outline-none focus:ring-1 focus:ring-evernote-green"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs font-semibold text-gray-600 hover:text-gray-900 rounded-lg hover:bg-gray-100 transition"
>
Cancel
</button>
<button
type="submit"
disabled={!webUrl.trim()}
className="px-5 py-2 bg-evernote-green hover:bg-evernote-greenHover text-white rounded-xl text-xs font-bold transition disabled:opacity-50"
>
Apply Link
</button>
</div>
</form>
)}
{/* Footer with Unlink option if existing link was present */}
{initialUrl && (
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between text-xs">
<span className="text-gray-400 font-mono text-[10px] truncate max-w-[300px]">
Current: {initialUrl}
</span>
<button
type="button"
onClick={() => {
onUnlink();
onClose();
}}
className="text-red-500 hover:text-red-700 font-semibold"
>
Remove Link
</button>
</div>
)}
</div>
</div>
);
}
+110 -10
View File
@@ -1,6 +1,7 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import LinkModal from './LinkModal';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
@@ -46,6 +47,17 @@ import {
Crop,
} from 'lucide-react';
export function extractInternalNoteId(href: string): string | null {
if (!href) return null;
const m1 = /^everclone:\/\/note\/([a-zA-Z0-9_-]+)/i.exec(href);
if (m1) return m1[1];
const m2 = /[?&]noteId=([a-zA-Z0-9_-]+)/i.exec(href);
if (m2) return m2[1];
const m3 = /^#note-([a-zA-Z0-9_-]+)/i.exec(href);
if (m3) return m3[1];
return null;
}
interface RichNoteEditorProps {
noteId: string;
contentHtml: string;
@@ -53,6 +65,7 @@ interface RichNoteEditorProps {
onUploadFiles: (files: File[]) => Promise<Array<{ id: string; filename: string; mimeType: string }>>;
onSetThumbnail?: (src: string) => void;
onOpenThumbnailEditor?: (src: string) => void;
onNavigateNote?: (noteId: string) => void;
readOnly?: boolean;
children?: React.ReactNode;
}
@@ -64,6 +77,7 @@ export default function RichNoteEditor({
onUploadFiles,
onSetThumbnail,
onOpenThumbnailEditor,
onNavigateNote,
readOnly = false,
children,
}: RichNoteEditorProps) {
@@ -73,6 +87,9 @@ export default function RichNoteEditor({
const [recordingSeconds, setRecordingSeconds] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [linkModalOpen, setLinkModalOpen] = useState(false);
const [linkModalUrl, setLinkModalUrl] = useState('');
const [linkModalText, setLinkModalText] = useState('');
const editor = useEditor({
immediatelyRender: false,
@@ -87,7 +104,7 @@ export default function RichNoteEditor({
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Link.configure({
openOnClick: true,
openOnClick: false,
autolink: true,
}),
Image.configure({
@@ -110,6 +127,61 @@ export default function RichNoteEditor({
}),
],
content: contentHtml || '<p></p>',
editorProps: {
handleClick: (view, pos, event) => {
const target = event.target as HTMLElement | null;
const link = target?.closest ? target.closest('a') : null;
if (!link) return false;
const href = link.getAttribute('href') || link.href || '';
if (!href) return false;
// 1. Internal Everclone note link
const internalId = extractInternalNoteId(href);
if (internalId && onNavigateNote) {
event.preventDefault();
event.stopPropagation();
onNavigateNote(internalId);
return true;
}
// 2. Legacy Evernote ENEX internal links (evernote:///view/...)
if (href.startsWith('evernote:///view') && onNavigateNote) {
event.preventDefault();
event.stopPropagation();
const title = link.textContent?.trim();
if (title) {
fetch(`/api/notes?q=${encodeURIComponent(title)}&limit=1`)
.then((r) => r.json())
.then((data) => {
const found = data.notes?.[0];
if (found) {
onNavigateNote(found.id);
}
})
.catch(() => {});
}
return true;
}
// 3. Web URL: If Ctrl/Cmd is pressed or readOnly, open in new window
if (event.ctrlKey || event.metaKey || readOnly) {
event.preventDefault();
window.open(href, '_blank', 'noopener,noreferrer');
return true;
}
return false;
},
handleKeyDown: (view, event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
handleOpenLinkModal();
return true;
}
return false;
},
},
onUpdate: ({ editor: ed }) => {
onChange(ed.getHTML());
},
@@ -148,16 +220,34 @@ export default function RichNoteEditor({
}
};
const handleSetLink = () => {
const handleOpenLinkModal = () => {
if (!editor) return;
const prev = editor.getAttributes('link').href || '';
const url = window.prompt('Enter link URL:', prev);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
const prevUrl = editor.getAttributes('link').href || '';
setLinkModalUrl(prevUrl);
setLinkModalText(selectedText);
setLinkModalOpen(true);
};
const handleApplyLink = (url: string, text?: string) => {
if (!editor) return;
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
if (from === to && text) {
editor.chain().focus().insertContent(`<a href="${url}">${text}</a> `).run();
} else if (text && text !== selectedText) {
editor.chain().focus().insertContent(`<a href="${url}">${text}</a>`).run();
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
const handleUnlink = () => {
if (!editor) return;
editor.chain().focus().extendMarkRange('link').unsetLink().run();
};
const toggleAudioRecording = async () => {
@@ -423,7 +513,7 @@ export default function RichNoteEditor({
</button>
<button
type="button"
onClick={handleSetLink}
onClick={handleOpenLinkModal}
title="Insert Link"
className={`p-1.5 rounded hover:bg-gray-200 ${
editor.isActive('link') ? 'bg-gray-200 text-evernote-green' : ''
@@ -566,6 +656,16 @@ export default function RichNoteEditor({
<EditorContent editor={editor} />
{children}
</div>
<LinkModal
isOpen={linkModalOpen}
initialUrl={linkModalUrl}
initialText={linkModalText}
currentNoteId={noteId}
onClose={() => setLinkModalOpen(false)}
onApplyLink={handleApplyLink}
onUnlink={handleUnlink}
/>
</div>
);
}