feat: Add internal note links with live note picker dialog, Evernote link resolution, navigation history, and copy note link
This commit is contained in:
1 parent
94ae568385
commit
3de36ff39b
4 files changed
+549
-10
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user