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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/images/markdown-dark.png">
<img src="docs/images/markdown-light.png" alt="MarkPad editing Markdown: a recent-files sidebar on the left with a modified file marked, the formatting toolbar and language toggle above a line-numbered editor pane, and the live preview rendering the same document on the right">
<img src="docs/images/markdown-light.png" alt="MarkPad searching a Markdown document: the in-app find bar shows one of three matches for preview, every match is highlighted in the line-numbered editor, and the live rendered preview appears alongside it">
</picture>

## Why MarkPad?
Expand All @@ -59,6 +59,7 @@ side-by-side work:

- **Live split-pane preview.** Edit Markdown in a line-numbered editor on the left, see it rendered on the right.
- **Synced scrolling.** In split view the panes follow each other — scroll either one and the other tracks the same part of the document, staying aligned even across tall images and long code blocks.
- **Find in the active document.** Use the toolbar magnifier or `Ctrl/⌘+F` to open MarkPad's own search bar instead of the webview's full-interface find. Every match is highlighted with a live position/count; `Enter` and `Shift+Enter` move forward and backward with wraparound, and `Escape` closes search. Searching from preview-only mode reveals the editor so the active match stays visible.
- **In-document link navigation.** Headings get anchor ids, so clicking an in-page link in the preview — like a table of contents `[Section](#section)` — smooth-scrolls to that heading within the preview pane.
- **Diagrams from fenced code.** A ` ```mermaid ` block renders as a diagram in the preview — flowcharts, sequence, class, state, ER, gantt, pie, mindmap, timeline, git graphs and the rest of [Mermaid](https://mermaid.js.org/)'s catalogue — and ` ```dot ` (or `graphviz`, `gv`) renders [Graphviz](https://graphviz.org/) DOT source. Both engines run entirely on your machine, follow the app's light/dark theme, and load only when a document actually has a diagram in it. Source that does not parse shows the engine's message inline with the block, so a half-typed diagram never blanks the preview.
- **Formatting toolbar.** One-click Markdown formatting from the editor header — bold, italic, strikethrough, inline code, headings, bullet/numbered lists, quotes, links, images, code blocks, diagrams, tables, and horizontal rules — with shortcuts for the common ones (`Ctrl/⌘+B`, `+I`, `+E`, `+K`, and more). Buttons toggle the mark off when reapplied and light up to show the formatting at the cursor.
Expand Down
Binary file modified docs/images/markdown-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/markdown-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.7",
"@lezer/common": "^1.5.2",
Expand Down
96 changes: 94 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ import {
resolveLanguage,
type DocumentLanguage,
} from "./lib/documentLanguage";
import {
EMPTY_SEARCH_STATUS,
isDocumentSearchShortcut,
type SearchStatus,
} from "./lib/documentSearch";
import {
getAutoSave,
getSidebarCollapsed,
Expand Down Expand Up @@ -191,6 +196,12 @@ function App() {
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() =>
getSidebarCollapsed(),
);
const [searchItemId, setSearchItemId] = useState<ItemId | null>(null);
const [searchFocusRequest, setSearchFocusRequest] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [searchStatus, setSearchStatus] = useState<SearchStatus>(
EMPTY_SEARCH_STATUS,
);

const editorStatesRef = useRef<Map<ItemId, ItemSnapshot>>(new Map());
const editorRef = useRef<EditorHandle>(null);
Expand Down Expand Up @@ -223,6 +234,7 @@ function App() {
const activeSaving =
activeItem !== null && (savingById[activeItem.id] ?? false);
const saveEnabled = activeItem !== null && !activeSaving;
const searchOpen = activeId !== null && searchItemId === activeId;

const recentEntries = useMemo<RecentEntry[]>(
() =>
Expand Down Expand Up @@ -257,6 +269,9 @@ function App() {
activationSeqRef.current++;
const prevId = activeIdRef.current;
if (prevId === null) return;
editorRef.current?.clearSearch();
setSearchItemId(null);
setSearchStatus(EMPTY_SEARCH_STATUS);
const prev = itemsRef.current.find((t) => t.id === prevId);
const willRelease =
prev != null && prev.kind === "file" && prev.text === prev.savedText;
Expand Down Expand Up @@ -894,6 +909,9 @@ function App() {
return out;
});
if (activeWasRemoved) {
editorRef.current?.clearSearch();
setSearchItemId(null);
setSearchStatus(EMPTY_SEARCH_STATUS);
// Fall through to the closest survivor in display order: down first, then
// up — a batch close can wipe out several neighbours at once.
const idx = displayed.findIndex((t) => t.id === activeIdRef.current);
Expand Down Expand Up @@ -966,6 +984,46 @@ function App() {
persistViewMode(mode);
}

function handleOpenSearch() {
if (activeItem === null) return;
if (!isDataLanguage(activeLanguage) && viewMode === "preview") {
handleSetViewMode("editor");
}
const selected = searchOpen
? ""
: (editorRef.current?.getSelectedText() ?? "");
const nextQuery =
selected.length <= 200 && !selected.includes("\n")
? selected || searchQuery
: searchQuery;
setSearchQuery(nextQuery);
setSearchStatus(
editorRef.current?.search(nextQuery) ?? EMPTY_SEARCH_STATUS,
);
setSearchItemId(activeItem.id);
setSearchFocusRequest((request) => request + 1);
}

function handleSearchQueryChange(next: string) {
setSearchQuery(next);
setSearchStatus(editorRef.current?.search(next) ?? EMPTY_SEARCH_STATUS);
}

function handleFindNext() {
setSearchStatus(editorRef.current?.findNext() ?? EMPTY_SEARCH_STATUS);
}

function handleFindPrevious() {
setSearchStatus(editorRef.current?.findPrevious() ?? EMPTY_SEARCH_STATUS);
}

function handleCloseSearch() {
editorRef.current?.clearSearch();
setSearchItemId(null);
setSearchStatus(EMPTY_SEARCH_STATUS);
editorRef.current?.focus();
}

function handleSetLanguage(language: DocumentLanguage) {
const id = activeIdRef.current;
if (id === null) return;
Expand Down Expand Up @@ -1017,13 +1075,17 @@ function App() {
const handleNewFileRef = useRef(handleNewFile);
const handleOpenFileRef = useRef(handleOpenFile);
const handleToggleSidebarRef = useRef(handleToggleSidebar);
const handleOpenSearchRef = useRef(handleOpenSearch);
const handleCloseSearchRef = useRef(handleCloseSearch);
const checkAgainstDiskRef = useRef(checkOpenFilesAgainstDisk);

useEffect(() => {
handleSaveRef.current = handleSave;
handleNewFileRef.current = handleNewFile;
handleOpenFileRef.current = handleOpenFile;
handleToggleSidebarRef.current = handleToggleSidebar;
handleOpenSearchRef.current = handleOpenSearch;
handleCloseSearchRef.current = handleCloseSearch;
checkAgainstDiskRef.current = checkOpenFilesAgainstDisk;
});

Expand All @@ -1048,6 +1110,12 @@ function App() {

useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (isDocumentSearchShortcut(e)) {
e.preventDefault();
e.stopPropagation();
handleOpenSearchRef.current();
return;
}
const mod = e.ctrlKey || e.metaKey;
if (!mod || e.shiftKey || e.altKey) return;
const key = e.key.toLowerCase();
Expand All @@ -1065,10 +1133,22 @@ function App() {
handleToggleSidebarRef.current();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, []);

useEffect(() => {
if (!searchOpen) return;
function onEscape(e: KeyboardEvent) {
if (e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
handleCloseSearchRef.current();
}
window.addEventListener("keydown", onEscape, true);
return () => window.removeEventListener("keydown", onEscape, true);
}, [searchOpen]);

const pendingRemoveItem =
items.find((t) => t.id === pendingRemove) ?? null;
const pendingRemoveName = pendingRemoveItem?.name ?? "Untitled";
Expand Down Expand Up @@ -1113,10 +1193,13 @@ function App() {
saving={activeSaving}
autoSave={autoSave}
sidebarCollapsed={sidebarCollapsed}
searchEnabled={activeItem !== null}
modKey={modKey}
onToggleSidebar={handleToggleSidebar}
onNewFile={handleNewFile}
onOpenFile={handleOpenFile}
onSave={handleSave}
onFind={handleOpenSearch}
onToggleAutoSave={handleToggleAutoSave}
onSetViewMode={handleSetViewMode}
onToggleTheme={handleToggleTheme}
Expand Down Expand Up @@ -1148,6 +1231,15 @@ function App() {
onDataActionResult={setError}
onLanguageChange={handleSetLanguage}
modKey={modKey}
searchOpen={searchOpen}
searchFocusRequest={searchFocusRequest}
searchQuery={searchQuery}
searchStatus={searchStatus}
onSearchQueryChange={handleSearchQueryChange}
onFindNext={handleFindNext}
onFindPrevious={handleFindPrevious}
onSearchResultChange={setSearchStatus}
onCloseSearch={handleCloseSearch}
editorRef={editorRef}
previewRef={previewRef}
/>
Expand Down
74 changes: 74 additions & 0 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ import { runDataAction, type DataAction } from "../lib/dataActions";
import { jsonTypingExtensions } from "../lib/jsonAutoEdit";
import { yamlDiagnostics } from "../lib/yamlActions";
import { yamlTypingExtensions } from "../lib/yamlAutoEdit";
import {
clearDocumentSearch,
documentSearchExtension,
getDocumentSearchStatus,
moveDocumentSearch,
setDocumentSearch,
type SearchStatus,
} from "../lib/documentSearch";

export type EditorHandle = {
getState(): EditorState;
Expand All @@ -48,6 +56,11 @@ export type EditorHandle = {
applyScrollSnapshot(effect: StateEffect<unknown>): void;
format(action: FormatAction): void;
runDataAction(action: DataAction): void;
search(query: string): SearchStatus;
findNext(): SearchStatus;
findPrevious(): SearchStatus;
clearSearch(): void;
getSelectedText(): string;
focus(): void;
getScrollTop(): number;
/** Source line shown at the top of the viewport, or null before mount. */
Expand All @@ -64,6 +77,10 @@ type EditorProps = {
success so the app can clear a previously shown banner. The editor pane has
no chrome of its own for messages. */
onDataActionResult?: (error: string | null) => void;
/** Active app-level query, reapplied after an external document replacement. */
searchQuery?: string;
/** Keeps the find bar's match counter in sync while the document changes. */
onSearchResultChange?: (status: SearchStatus) => void;
/** Fired on every scroll of the editor, user-driven or programmatic. */
onScroll?: () => void;
};
Expand Down Expand Up @@ -97,6 +114,14 @@ const editorTheme = EditorView.theme({
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--accent)",
},
".cm-searchMatch": {
backgroundColor: "var(--accent-soft)",
borderRadius: "2px",
boxShadow: "inset 0 0 0 1px var(--accent)",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "var(--selection)",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: "var(--selection)",
Expand Down Expand Up @@ -190,6 +215,8 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
onChange,
onActiveFormatsChange,
onDataActionResult,
searchQuery,
onSearchResultChange,
onScroll,
},
ref,
Expand All @@ -199,6 +226,8 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
const onChangeRef = useRef(onChange);
const onActiveFormatsChangeRef = useRef(onActiveFormatsChange);
const onDataActionResultRef = useRef(onDataActionResult);
const searchQueryRef = useRef(searchQuery);
const onSearchResultChangeRef = useRef(onSearchResultChange);
const onScrollRef = useRef(onScroll);
const lastActiveKeyRef = useRef<string | null>(null);

Expand All @@ -214,6 +243,14 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
onDataActionResultRef.current = onDataActionResult;
}, [onDataActionResult]);

useEffect(() => {
searchQueryRef.current = searchQuery;
}, [searchQuery]);

useEffect(() => {
onSearchResultChangeRef.current = onSearchResultChange;
}, [onSearchResultChange]);

useEffect(() => {
onScrollRef.current = onScroll;
}, [onScroll]);
Expand Down Expand Up @@ -313,6 +350,7 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
extensions: [
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
documentSearchExtension,
// Shared by every language; listed before perLanguageConf so the
// number gutter stays left of the data languages' fold gutter.
lineNumbers(),
Expand All @@ -325,6 +363,9 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
}
if (update.docChanged || update.selectionSet) {
emitActiveFormats(update.state);
onSearchResultChangeRef.current?.(
getDocumentSearchStatus(update.state),
);
}
}),
],
Expand Down Expand Up @@ -359,6 +400,34 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
// A toolbar click moves focus to the button; return it to the document.
view.focus();
},
search: (query) => {
const view = viewRef.current;
return view
? setDocumentSearch(view, query)
: { current: 0, total: 0 };
},
findNext: () => {
const view = viewRef.current;
return view
? moveDocumentSearch(view, "next")
: { current: 0, total: 0 };
},
findPrevious: () => {
const view = viewRef.current;
return view
? moveDocumentSearch(view, "previous")
: { current: 0, total: 0 };
},
clearSearch: () => {
const view = viewRef.current;
if (view) clearDocumentSearch(view);
},
getSelectedText: () => {
const view = viewRef.current;
if (!view) return "";
const { from, to } = view.state.selection.main;
return view.state.sliceDoc(from, to);
},
focus: () => viewRef.current?.focus(),
getScrollTop: () => viewRef.current?.scrollDOM.scrollTop ?? 0,
// CodeMirror measures block geometry relative to the document's top,
Expand Down Expand Up @@ -437,6 +506,11 @@ const Editor = forwardRef<EditorHandle, EditorProps>(function Editor(
if (view.state.doc.toString() !== value) {
view.setState(buildState(value, language));
emitActiveFormats(view.state);
if (searchQueryRef.current) {
onSearchResultChangeRef.current?.(
setDocumentSearch(view, searchQueryRef.current),
);
}
} else if (stateLanguage(view.state) !== language) {
view.dispatch({
effects: perLanguageConf.reconfigure(languageExtensions(language)),
Expand Down
Loading