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
Empty file removed .npmrc
Empty file.
8 changes: 7 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useAutoUpdate } from "~/hooks/useAutoUpdate";
import { useAutoSave } from "~/hooks/useAutoSave";
import { useFileSystem } from "~/hooks/useFileSystem";
import { useRename } from "~/hooks/useRename";
import { useTitleVisibility } from "~/hooks/useTitleVisibility";
import { getFileStem } from "~/utils/file";

function App() {
Expand Down Expand Up @@ -113,6 +114,7 @@ function App() {

const activeFileName = activePath ? getFileStem(activePath) : "Untitled";
const titleValue = isRenaming ? renameValue : activeFileName;
const titleSwitching = useTitleVisibility(activePath);
const handleTopbarPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
void getCurrentWindow().startDragging();
Expand All @@ -122,13 +124,17 @@ function App() {
<div className="app">
<div className="app-topbar">
<div className="app-topbar-drag" data-tauri-drag-region="" onPointerDown={handleTopbarPointerDown} />
<div className="app-title-wrap">
<div className={`app-title-wrap${!activePath ? " is-untitled" : ""}${titleSwitching ? " is-switching" : ""}`}>
<input
ref={renameInputRef}
className={`app-title-input${isRenaming ? " is-editing" : ""}`}
value={titleValue}
size={Math.max(1, titleValue.length)}
readOnly={!isRenaming}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
onDoubleClick={startRename}
onChange={(e) => {
if (isRenaming) setRenameValue(e.target.value);
Expand Down
63 changes: 52 additions & 11 deletions apps/desktop/src/editor/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,46 @@
import { baseKeymap, chainCommands, exitCode, setBlockType, toggleMark, wrapIn } from "prosemirror-commands";
import { history, redo, undo } from "prosemirror-history";
import { inputRules, textblockTypeInputRule, wrappingInputRule } from "prosemirror-inputrules";
import { InputRule, inputRules, textblockTypeInputRule, wrappingInputRule } from "prosemirror-inputrules";
import { keymap } from "prosemirror-keymap";
import type { MarkType } from "prosemirror-model";
import { liftListItem, sinkListItem, splitListItem, wrapInList } from "prosemirror-schema-list";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useEffect, useRef } from "react";
import { caretPlugin } from "./plugins/caret";
import { parseMarkdown, schema, serializeMarkdown } from "./markdown";

function markRule(pattern: RegExp, markType: MarkType): InputRule {
return new InputRule(pattern, (state, match, start, end) => {
const [full, inner] = match;
const markStart = start + full.indexOf(inner);
const markEnd = markStart + inner.length;
const mark = markType.create();
const tr = state.tr
.addMark(markStart, markEnd, mark)
.delete(markEnd, end)
.delete(start, markStart)
.removeStoredMark(mark);
return tr;
});
}

function insertHorizontalRule() {
return new InputRule(/^---$/, (state, _match, start, end) => {
const { horizontal_rule, paragraph } = schema.nodes;
const tr = state.tr.replaceWith(start - 1, end, [
horizontal_rule.create(),
paragraph.create(),
]);
return tr;
});
}

function buildInputRules() {
const { strong, em, code, strikethrough } = schema.marks;
return inputRules({
rules: [
// block rules
textblockTypeInputRule(/^(#{1,3})\s$/, schema.nodes.heading, (match) => ({ level: match[1].length })),
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
Expand All @@ -22,12 +51,19 @@ function buildInputRules() {
(match) => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1],
),
insertHorizontalRule(),
// inline mark rules
markRule(/_([^_]+)_(?=\s|$)/, em), // _italic_
markRule(/\*([^*]+)\*(?=\s|$)/, strong), // *bold*
markRule(/~~([^~]+)~~(?=\s|$)/, strikethrough), // ~~strikethrough~~
markRule(/~([^~]+)~(?=\s|$)/, strikethrough), // ~strikethrough~
markRule(/`([^`]+)`(?=\s|$)/, code), // `code`
],
});
}

function buildKeymap() {
const { strong, em, code, strikethrough } = schema.marks;
const { strong, em, code, strikethrough, underline } = schema.marks;
const { heading, code_block, blockquote, bullet_list, ordered_list, list_item, hard_break, paragraph } = schema.nodes;

const insertHardBreak = chainCommands(exitCode, (state, dispatch) => {
Expand All @@ -40,31 +76,33 @@ function buildKeymap() {
"Mod-z": undo,
"Mod-Shift-z": redo,

// inline marks — standard across iA Writer, Notion, Bear
// inline marks
"Mod-b": toggleMark(strong),
"Mod-i": toggleMark(em),
"Mod-e": toggleMark(code), // inline code (matches Bear/Notion)
"Mod-u": toggleMark(underline),
"Mod-e": toggleMark(code),
"Mod-Shift-s": toggleMark(strikethrough),

// headings — Notion / Bear standard
// headings
"Mod-Alt-1": setBlockType(heading, { level: 1 }),
"Mod-Alt-2": setBlockType(heading, { level: 2 }),
"Mod-Alt-3": setBlockType(heading, { level: 3 }),
"Mod-Alt-0": setBlockType(paragraph),

// blocks
"Mod-Shift-b": wrapIn(blockquote), // blockquote
"Mod-Shift-b": wrapIn(blockquote),
"Mod-Alt-c": setBlockType(code_block),

// lists — standard across editors
// lists
"Mod-Shift-7": wrapInList(ordered_list),
"Mod-Shift-8": wrapInList(bullet_list),
Enter: splitListItem(list_item),
"Mod-[": liftListItem(list_item), // outdent
"Mod-]": sinkListItem(list_item), // indent
"Mod-[": liftListItem(list_item),
"Mod-]": sinkListItem(list_item),

// line break
// line break — both Shift-Enter and Cmd-Enter
"Shift-Enter": insertHardBreak,
"Mod-Enter": insertHardBreak,
});
}

Expand Down Expand Up @@ -115,7 +153,10 @@ export function Editor({ initialMarkdown, onChange }: EditorProps) {
});

syncEmptyClass(viewRef.current);
requestAnimationFrame(() => viewRef.current?.focus());
requestAnimationFrame(() => {
viewRef.current?.focus();
mountRef.current?.classList.add("is-mounted");
});

return () => {
viewRef.current?.destroy();
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src/editor/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type Token from "markdown-it/lib/token.mjs";
import { defaultMarkdownSerializer, MarkdownParser, MarkdownSerializer } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";

// schema: CommonMark nodes + strikethrough mark
// schema: CommonMark nodes + strikethrough + underline marks
export const schema = new Schema({
nodes: {
doc: { content: "block+" },
Expand Down Expand Up @@ -164,6 +164,10 @@ export const schema = new Schema({
toDOM: () => ["s", 0],
parseDOM: [{ tag: "s" }, { tag: "del" }, { tag: "strike" }],
},
underline: {
toDOM: () => ["u", 0],
parseDOM: [{ tag: "u" }],
},
},
});

Expand Down Expand Up @@ -220,10 +224,11 @@ export const markdownParser = new MarkdownParser(schema, md, {
s: { mark: "strikethrough" },
});

// serializer: default + strikethrough → ~~text~~
// serializer: default + strikethrough → ~~text~~, underline → plain text (no MD syntax)
export const markdownSerializer = new MarkdownSerializer(defaultMarkdownSerializer.nodes, {
...defaultMarkdownSerializer.marks,
strikethrough: { open: "~~", close: "~~", mixable: true, expelEnclosingWhitespace: true },
underline: { open: "", close: "", mixable: true, expelEnclosingWhitespace: true },
});

export function parseMarkdown(text: string) {
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/hooks/useTitleVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useEffect, useRef, useState } from "react";

const FLASH_DURATION = 1200;

/**
* Returns true briefly when activePath changes, so the title flashes
* visible on file switch, then hides again.
*/
export function useTitleVisibility(activePath: string | null): boolean {
const [visible, setVisible] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevRef = useRef(activePath);

useEffect(() => {
if (prevRef.current === activePath) return;
prevRef.current = activePath;

if (timerRef.current) clearTimeout(timerRef.current);
setVisible(true);
timerRef.current = setTimeout(() => setVisible(false), FLASH_DURATION);

return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [activePath]);

return visible;
}
18 changes: 10 additions & 8 deletions apps/desktop/src/styles/command.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@
width: 480px;
max-width: 90vw;
background: var(--bg);
border: 1px solid color-mix(in srgb, var(--fg) 18%, transparent);
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--fg) 14%, transparent);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.22), 0 2px 8px rgba(0, 0, 0, 0.10);
}

[cmdk-input] {
width: 100%;
padding: 12px 16px;
padding: 14px 16px;
font-family: var(--font);
font-size: var(--font-size);
color: var(--fg);
background: transparent;
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--fg) 12%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--fg) 10%, transparent);
outline: none;
}

Expand All @@ -38,21 +38,23 @@
[cmdk-list] {
max-height: 320px;
overflow-y: auto;
padding: 4px 0;
padding: 6px 0;
}

[cmdk-item] {
display: flex;
align-items: center;
padding: 8px 16px;
padding: 8px 14px;
margin: 0 4px;
border-radius: 6px;
font-family: var(--font);
font-size: var(--font-size);
color: var(--fg);
cursor: pointer;
}

[cmdk-item][data-selected="true"] {
background: color-mix(in srgb, var(--fg) 8%, transparent);
background: color-mix(in srgb, var(--fg) 7%, transparent);
}

[cmdk-empty] {
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/src/styles/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
position: relative;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 120ms ease;
}

.editor-mount.is-mounted {
opacity: 1;
}

.ProseMirror {
Expand All @@ -13,7 +19,7 @@
caret-color: transparent;
font-family: var(--font);
font-size: var(--font-size);
line-height: 1.7;
line-height: 1.75;
letter-spacing: 0.01em;
color: inherit;
word-break: break-word;
Expand All @@ -28,7 +34,7 @@
}

.ProseMirror p {
margin: 0 0 0.75em;
margin: 0 0 0.5em;
}

.ProseMirror h1,
Expand All @@ -39,6 +45,12 @@
margin: 1.5em 0 0.4em;
}

.ProseMirror > h1:first-child,
.ProseMirror > h2:first-child,
.ProseMirror > h3:first-child {
margin-top: 0;
}

.ProseMirror h1 {
font-size: 2rem;
}
Expand Down Expand Up @@ -73,12 +85,12 @@

.ProseMirror ul,
.ProseMirror ol {
margin: 0 0 0.75em 1.5em;
margin: 0 0 0.5em 1.5em;
padding: 0;
}

.ProseMirror li {
margin-bottom: 0.25em;
margin-bottom: 0.2em;
}

.ProseMirror li > p {
Expand Down Expand Up @@ -116,6 +128,11 @@
opacity: 0.5;
}

.ProseMirror u {
text-decoration: underline;
text-underline-offset: 3px;
}

.ProseMirror-selectednode {
outline: 2px solid rgba(128, 128, 128, 0.4);
border-radius: 2px;
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ body {
align-items: center;
max-width: calc(100vw - 180px);
pointer-events: auto;
opacity: 0;
transition: opacity 400ms ease;
}

.app-topbar:hover .app-title-wrap,
.app-title-wrap.is-untitled,
.app-title-wrap.is-switching,
.app-title-wrap:has(.is-editing) {
opacity: 1;
}

.app-title-input {
Expand All @@ -92,7 +101,6 @@ body {
line-height: 1.1;
text-align: center;
padding: 0;
opacity: 0.62;
letter-spacing: 0.01em;
}

Expand Down