Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
if: matrix.platform == 'mac'
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
# Code signing
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
Expand All @@ -78,14 +78,14 @@ jobs:
if: matrix.platform == 'win'
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: pnpm dist:win --publish always

- name: Build distributables (Linux)
if: matrix.platform == 'linux'
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: pnpm dist:linux --publish always

- name: Upload artifacts
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.0",
"remark-gfm": "^4.0.1",
"unist-util-visit": "^5.0.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/mdast": "^4.0.4",
"@types/react": "^18.2.79",
"@types/react-dom": "^18.2.25",
"@vitejs/plugin-react": "^4.2.1",
Expand Down Expand Up @@ -139,7 +141,8 @@
"publish": {
"provider": "github",
"owner": "tomymaritano",
"repo": "readide"
"repo": "readide",
"private": true
}
}
}
9 changes: 6 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,12 @@ function registerIpcHandlers(): void {
const repo = noteRepository;

// Create note
ipcMain.handle('notes:create', async (_event, input: { content: string; id?: string }) => {
return createNoteOperation(input, repo);
});
ipcMain.handle(
'notes:create',
async (_event, input: { content: string; id?: string; notebookId?: string }) => {
return createNoteOperation(input, repo);
}
);

// Get note
ipcMain.handle('notes:get', async (_event, id: string) => {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export interface ReadiedAPI {
content: string;
id?: string;
title?: string;
notebookId?: string;
}) => Promise<Result<NoteSnapshot>>;
/** Get a note by ID */
get: (id: string) => Promise<Result<NoteSnapshot>>;
Expand Down
27 changes: 23 additions & 4 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,16 @@ function NotesApp() {
}, 300);
}, []);

// Create new note
// Create new note (respects current navigation context)
const handleNewNote = useCallback(async () => {
const newNote = await createNote.mutateAsync({ content: '# Untitled\n\n' });
const newNote = await createNote.mutateAsync({
content: '# Untitled\n\n',
notebookId: selectedNotebookId ?? undefined,
});
setSelectedNote(newNote);
goToAllNotes();
setSearchQuery('');
setDebouncedSearch('');
}, [createNote, goToAllNotes]);
}, [createNote, selectedNotebookId]);

// Select note
const handleSelectNote = useCallback(async (id: string) => {
Expand All @@ -126,6 +128,22 @@ function NotesApp() {
}
}, []);

// Handle wikilink click - best-effort navigation by title
const handleWikilinkClick = useCallback(
async (title: string) => {
const notes = await window.readied.notes.search(title);
if (notes.length > 0) {
// Find exact match (case-insensitive)
const match = notes.find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
handleSelectNote(match.id);
}
}
// No-op if note doesn't exist (future: could show toast or create note)
},
[handleSelectNote]
);

// Update note content
const handleUpdateNote = useCallback(
async (content: string) => {
Expand Down Expand Up @@ -311,6 +329,7 @@ function NotesApp() {
onStatusChange={handleStatusChange}
onDuplicate={selectedNote ? () => handleDuplicateNote(selectedNote.id) : undefined}
onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined}
onWikilinkClick={handleWikilinkClick}
/>
</Panel>
</Group>
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/renderer/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
undoChange,
redoChange,
} from './editor/toolbar-commands';
import { wikilinkExtension } from './editor/wikilink-extension';

/** Dark theme matching Readied's design */
const darkTheme = EditorView.theme(
Expand Down Expand Up @@ -300,6 +301,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
// Syntax highlighting
syntaxHighlighting(markdownHighlighting),

// Wikilink [[note]] highlighting
wikilinkExtension,

// Dark theme
darkTheme,

Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
} from './editor';
import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor';
import type { MarkdownEditorHandle } from './MarkdownEditor';
import type { NoteSnapshot, NoteStatus } from '../../preload/index';

Check warning on line 14 in apps/desktop/src/renderer/components/NoteEditor.tsx

View workflow job for this annotation

GitHub Actions / lint

`../../preload/index` type import should occur before import of `./TitleInput`
import { useEditorPreferencesStore } from '../stores/editorPreferencesStore';

Check warning on line 15 in apps/desktop/src/renderer/components/NoteEditor.tsx

View workflow job for this annotation

GitHub Actions / lint

`../stores/editorPreferencesStore` import should occur before import of `./TitleInput`
import { useScrollSync } from '../hooks/useScrollSync';

Check warning on line 16 in apps/desktop/src/renderer/components/NoteEditor.tsx

View workflow job for this annotation

GitHub Actions / lint

`../hooks/useScrollSync` import should occur before import of `./TitleInput`
import { noteKeys } from '../hooks/useNotes';

Check warning on line 17 in apps/desktop/src/renderer/components/NoteEditor.tsx

View workflow job for this annotation

GitHub Actions / lint

`../hooks/useNotes` import should occur before import of `./TitleInput`

// Lazy load the markdown editor for better initial load performance
const MarkdownEditor = lazy(() =>
Expand All @@ -38,6 +38,7 @@
onStatusChange?: (status: NoteStatus) => void;
onDuplicate?: () => void;
onDelete?: () => void;
onWikilinkClick?: (target: string) => void;
}

export function NoteEditor({
Expand All @@ -48,6 +49,7 @@
onStatusChange,
onDuplicate,
onDelete,
onWikilinkClick,
}: NoteEditorProps) {
const queryClient = useQueryClient();
const debounceRef = useRef<NodeJS.Timeout | null>(null);
Expand Down Expand Up @@ -273,6 +275,7 @@
createdAt={note.createdAt}
updatedAt={note.updatedAt}
onReady={handlePreviewReady}
onWikilinkClick={onWikilinkClick}
/>
</div>
)}
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect } from 'react';
import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect, useCallback } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { remarkWikilink } from './remark-wikilink';

interface MarkdownPreviewProps {
readonly content: string;
readonly createdAt?: string;
readonly updatedAt?: string;
readonly onReady?: () => void;
readonly onWikilinkClick?: (target: string) => void;
}

/** Imperative handle for scroll sync */
Expand All @@ -24,9 +26,24 @@ export interface MarkdownPreviewHandle {
* Exposes scroll methods via ref for sync with editor.
*/
export const MarkdownPreview = forwardRef<MarkdownPreviewHandle, MarkdownPreviewProps>(
function MarkdownPreview({ content, createdAt, updatedAt, onReady }, ref) {
function MarkdownPreview({ content, createdAt, updatedAt, onReady, onWikilinkClick }, ref) {
const containerRef = useRef<HTMLDivElement>(null);

// Delegated click handler for wikilinks
const handleClick = useCallback(
(e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('.wikilink');
if (target) {
const noteTitle = target.getAttribute('data-target');
if (noteTitle && onWikilinkClick) {
e.preventDefault();
onWikilinkClick(noteTitle);
}
}
},
[onWikilinkClick]
);

// Notify parent when mounted
useEffect(() => {
onReady?.();
Expand Down Expand Up @@ -86,9 +103,9 @@ export const MarkdownPreview = forwardRef<MarkdownPreviewHandle, MarkdownPreview
}, [createdAt, updatedAt]);

return (
<div ref={containerRef} className="markdown-preview">
<div ref={containerRef} className="markdown-preview" onClick={handleClick}>
<Markdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkWikilink]}
skipHtml={true}
components={{
// Custom checkbox rendering for task lists
Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/renderer/components/editor/remark-wikilink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Remark plugin for wikilink [[note]] syntax in Markdown preview
*
* Transforms [[target]] and [[target|display]] into clickable spans.
* Navigation is handled by parent component via click delegation.
*/

import { visit } from 'unist-util-visit';
import type { Root, Text, Parent } from 'mdast';

// Pattern: [[target]] or [[target|display]]
const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;

interface WikilinkNode {
type: 'element';
tagName: 'span';
properties: {
className: string[];
'data-target': string;
};
children: Array<{ type: 'text'; value: string }>;
}

export function remarkWikilink() {
return (tree: Root) => {
visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => {
if (index === undefined || !parent) return;

const value = node.value;
if (!value.includes('[[')) return;

const children: Array<{ type: string; value?: string } | WikilinkNode> = [];
let lastIndex = 0;

// IMPORTANTE: resetear lastIndex para regex global
WIKILINK_PATTERN.lastIndex = 0;

let match;
while ((match = WIKILINK_PATTERN.exec(value)) !== null) {
// Text before match
if (match.index > lastIndex) {
children.push({ type: 'text', value: value.slice(lastIndex, match.index) });
}

// Wikilink element
const target = match[1]!.trim();
const display = match[2]?.trim() || target;
children.push({
type: 'element',
tagName: 'span',
properties: {
className: ['wikilink'],
'data-target': target,
},
children: [{ type: 'text', value: display }],
} as WikilinkNode);

lastIndex = match.index + match[0].length;
}

// Remaining text
if (lastIndex < value.length) {
children.push({ type: 'text', value: value.slice(lastIndex) });
}

if (children.length > 0) {
// Cast needed: we're inserting hast-like nodes into mdast parent
// This works because react-markdown handles the hybrid tree
(parent.children as unknown[]).splice(index, 1, ...children);
// IMPORTANTE: retornar nuevo index para no romper traversal
return index + children.length;
}
});
};
}
61 changes: 61 additions & 0 deletions apps/desktop/src/renderer/components/editor/wikilink-extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* CodeMirror extension for wikilink [[note]] syntax highlighting
*
* Highlights [[target]] and [[target|display]] patterns in the editor.
* Does NOT handle click navigation - that's the preview's job.
*/

import { ViewPlugin, Decoration, DecorationSet, EditorView } from '@codemirror/view';
import type { ViewUpdate } from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';

// Pattern: [[target]] or [[target|display]]
const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;

const wikilinkMark = Decoration.mark({ class: 'cm-wikilink' });

const wikilinkTheme = EditorView.baseTheme({
'.cm-wikilink': {
color: '#5eead4',
borderBottom: '1px solid rgba(94, 234, 212, 0.3)',
borderRadius: '2px',
},
});

class WikilinkHighlighter {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = this.build(view);
}

update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.build(update.view);
}
}

build(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const { from, to } = view.viewport;
const text = view.state.sliceDoc(from, to);

// IMPORTANTE: resetear lastIndex para regex global
WIKILINK_PATTERN.lastIndex = 0;

let match;
while ((match = WIKILINK_PATTERN.exec(text)) !== null) {
const start = from + match.index;
const end = start + match[0].length;
builder.add(start, end, wikilinkMark);
}

return builder.finish();
}
}

const wikilinkHighlighter = ViewPlugin.fromClass(WikilinkHighlighter, {
decorations: v => v.decorations,
});

export const wikilinkExtension = [wikilinkTheme, wikilinkHighlighter];
Loading
Loading