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;
|
text-align: left;
|
||||||
background-color: #f9fafb;
|
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,
|
Menu,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
ArrowRight,
|
||||||
|
Link2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SidebarData,
|
SidebarData,
|
||||||
@@ -101,6 +103,8 @@ export default function EvercloneWorkspace() {
|
|||||||
const [triageModalOpen, setTriageModalOpen] = useState(false);
|
const [triageModalOpen, setTriageModalOpen] = useState(false);
|
||||||
const [canInstallPwa, setCanInstallPwa] = useState(false);
|
const [canInstallPwa, setCanInstallPwa] = useState(false);
|
||||||
const [shareToast, setShareToast] = useState<string | null>(null);
|
const [shareToast, setShareToast] = useState<string | null>(null);
|
||||||
|
const [historyStack, setHistoryStack] = useState<string[]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||||
|
|
||||||
const activeFilterCount =
|
const activeFilterCount =
|
||||||
(filterNotebookId ? 1 : 0) +
|
(filterNotebookId ? 1 : 0) +
|
||||||
@@ -185,6 +189,68 @@ export default function EvercloneWorkspace() {
|
|||||||
return () => window.removeEventListener('keydown', handler);
|
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 () => {
|
const fetchSidebar = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sidebar');
|
const res = await fetch('/api/sidebar');
|
||||||
@@ -2487,6 +2553,28 @@ export default function EvercloneWorkspace() {
|
|||||||
<span>Notes</span>
|
<span>Notes</span>
|
||||||
</button>
|
</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 */}
|
{/* Notebook Mover Dropdown */}
|
||||||
<div
|
<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"
|
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';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import LinkModal from './LinkModal';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Underline from '@tiptap/extension-underline';
|
import Underline from '@tiptap/extension-underline';
|
||||||
@@ -46,6 +47,17 @@ import {
|
|||||||
Crop,
|
Crop,
|
||||||
} from 'lucide-react';
|
} 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 {
|
interface RichNoteEditorProps {
|
||||||
noteId: string;
|
noteId: string;
|
||||||
contentHtml: string;
|
contentHtml: string;
|
||||||
@@ -53,6 +65,7 @@ interface RichNoteEditorProps {
|
|||||||
onUploadFiles: (files: File[]) => Promise<Array<{ id: string; filename: string; mimeType: string }>>;
|
onUploadFiles: (files: File[]) => Promise<Array<{ id: string; filename: string; mimeType: string }>>;
|
||||||
onSetThumbnail?: (src: string) => void;
|
onSetThumbnail?: (src: string) => void;
|
||||||
onOpenThumbnailEditor?: (src: string) => void;
|
onOpenThumbnailEditor?: (src: string) => void;
|
||||||
|
onNavigateNote?: (noteId: string) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -64,6 +77,7 @@ export default function RichNoteEditor({
|
|||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
onSetThumbnail,
|
onSetThumbnail,
|
||||||
onOpenThumbnailEditor,
|
onOpenThumbnailEditor,
|
||||||
|
onNavigateNote,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
children,
|
children,
|
||||||
}: RichNoteEditorProps) {
|
}: RichNoteEditorProps) {
|
||||||
@@ -73,6 +87,9 @@ export default function RichNoteEditor({
|
|||||||
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const timerRef = useRef<NodeJS.Timeout | 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({
|
const editor = useEditor({
|
||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
@@ -87,7 +104,7 @@ export default function RichNoteEditor({
|
|||||||
Highlight.configure({ multicolor: true }),
|
Highlight.configure({ multicolor: true }),
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
Link.configure({
|
Link.configure({
|
||||||
openOnClick: true,
|
openOnClick: false,
|
||||||
autolink: true,
|
autolink: true,
|
||||||
}),
|
}),
|
||||||
Image.configure({
|
Image.configure({
|
||||||
@@ -110,6 +127,61 @@ export default function RichNoteEditor({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
content: contentHtml || '<p></p>',
|
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 }) => {
|
onUpdate: ({ editor: ed }) => {
|
||||||
onChange(ed.getHTML());
|
onChange(ed.getHTML());
|
||||||
},
|
},
|
||||||
@@ -148,16 +220,34 @@ export default function RichNoteEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetLink = () => {
|
const handleOpenLinkModal = () => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const prev = editor.getAttributes('link').href || '';
|
const { from, to } = editor.state.selection;
|
||||||
const url = window.prompt('Enter link URL:', prev);
|
const selectedText = editor.state.doc.textBetween(from, to, ' ');
|
||||||
if (url === null) return;
|
const prevUrl = editor.getAttributes('link').href || '';
|
||||||
if (url === '') {
|
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
setLinkModalUrl(prevUrl);
|
||||||
return;
|
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 () => {
|
const toggleAudioRecording = async () => {
|
||||||
@@ -423,7 +513,7 @@ export default function RichNoteEditor({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSetLink}
|
onClick={handleOpenLinkModal}
|
||||||
title="Insert Link"
|
title="Insert Link"
|
||||||
className={`p-1.5 rounded hover:bg-gray-200 ${
|
className={`p-1.5 rounded hover:bg-gray-200 ${
|
||||||
editor.isActive('link') ? 'bg-gray-200 text-evernote-green' : ''
|
editor.isActive('link') ? 'bg-gray-200 text-evernote-green' : ''
|
||||||
@@ -566,6 +656,16 @@ export default function RichNoteEditor({
|
|||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LinkModal
|
||||||
|
isOpen={linkModalOpen}
|
||||||
|
initialUrl={linkModalUrl}
|
||||||
|
initialText={linkModalText}
|
||||||
|
currentNoteId={noteId}
|
||||||
|
onClose={() => setLinkModalOpen(false)}
|
||||||
|
onApplyLink={handleApplyLink}
|
||||||
|
onUnlink={handleUnlink}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user