Skip to content
Open
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
476 changes: 475 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
Expand Down Expand Up @@ -58,8 +60,10 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"happy-dom": "20.11.1",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"vite": "^7.0.4",
"vitest": "4.1.10"
}
}
53 changes: 53 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,23 @@ pub struct Settings {
pub interface_zoom: Option<f32>,
#[serde(rename = "customEditorWidthPx")]
pub custom_editor_width_px: Option<u32>,
#[serde(rename = "editorWidthResizeEnabled")]
pub editor_width_resize_enabled: Option<bool>,
#[serde(rename = "editorToolbarVisible")]
pub editor_toolbar_visible: Option<bool>,
#[serde(rename = "titleBarModifiedDateVisible")]
pub title_bar_modified_date_visible: Option<bool>,
#[serde(rename = "titleBarFilenameVisible")]
pub title_bar_filename_visible: Option<bool>,
/// Custom sidebar width in px; `None` means the default width is used.
#[serde(rename = "sidebarWidthPx")]
pub sidebar_width_px: Option<u32>,
#[serde(rename = "ollamaModel")]
pub ollama_model: Option<String>,
#[serde(rename = "foldersEnabled")]
pub folders_enabled: Option<bool>,
#[serde(rename = "sidebarSortOrder")]
pub sidebar_sort_order: Option<String>,
#[serde(rename = "ignoredPatterns")]
pub ignored_patterns: Option<Vec<String>>,
#[serde(rename = "customColorsLight")]
Expand Down Expand Up @@ -3994,3 +4004,46 @@ fn set_title_bar_theme(
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::Settings;

#[test]
fn settings_preserve_sidebar_note_sort_order() {
let settings: Settings = serde_json::from_str(
r#"{"theme":{"mode":"system"},"sidebarSortOrder":"oldest"}"#,
)
.expect("settings should deserialize");

assert_eq!(settings.sidebar_sort_order.as_deref(), Some("oldest"));

let serialized = serde_json::to_value(settings).expect("settings should serialize");
assert_eq!(serialized["sidebarSortOrder"], "oldest");
}

#[test]
fn settings_preserve_editor_display_preferences() {
let settings: Settings = serde_json::from_str(
r#"{
"theme":{"mode":"system"},
"editorWidthResizeEnabled":false,
"editorToolbarVisible":true,
"titleBarModifiedDateVisible":false,
"titleBarFilenameVisible":true
}"#,
)
.expect("settings should deserialize");

assert_eq!(settings.editor_width_resize_enabled, Some(false));
assert_eq!(settings.editor_toolbar_visible, Some(true));
assert_eq!(settings.title_bar_modified_date_visible, Some(false));
assert_eq!(settings.title_bar_filename_visible, Some(true));

let serialized = serde_json::to_value(settings).expect("settings should serialize");
assert_eq!(serialized["editorWidthResizeEnabled"], false);
assert_eq!(serialized["editorToolbarVisible"], true);
assert_eq!(serialized["titleBarModifiedDateVisible"], false);
assert_eq!(serialized["titleBarFilenameVisible"], true);
}
}
56 changes: 40 additions & 16 deletions src/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { EditorWidthHandles } from "./EditorWidthHandle";
import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions";
import { cn } from "../../lib/utils";
import { plainTextFromMarkdown } from "../../lib/plainText";
import { getTitleBarNoteInfoText } from "../../lib/titleBarNoteInfo";
import { Button, IconButton, ToolbarButton, Tooltip } from "../ui";
import * as notesService from "../../services/notes";
import { downloadPdf, downloadMarkdown } from "../../services/pdf";
Expand Down Expand Up @@ -546,12 +547,28 @@ export function Editor({
const pinNote = notesCtx?.pinNote;
const unpinNote = notesCtx?.unpinNote;
const notes = notesCtx?.notes;
const { textDirection } = useTheme();
const {
textDirection,
editorWidthResizeEnabled,
editorToolbarVisible,
titleBarModifiedDateVisible,
titleBarFilenameVisible,
} = useTheme();
const [isSaving, setIsSaving] = useState(false);
// Force re-render when selection changes to update toolbar active states
const [, setSelectionKey] = useState(0);
const [copyMenuOpen, setCopyMenuOpen] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const titleBarNoteInfo = currentNote
? getTitleBarNoteInfoText(
{
modifiedDateVisible: titleBarModifiedDateVisible,
filenameVisible: titleBarFilenameVisible,
},
currentNote,
formatDateTime,
)
: null;
// Delay transition classes until after initial mount to avoid format bar height animation on note load
const [hasTransitioned, setHasTransitioned] = useState(false);
useEffect(() => {
Expand Down Expand Up @@ -2257,9 +2274,11 @@ export function Editor({
<PanelLeftIcon className="w-4.5 h-4.5 stroke-[1.5]" />
</IconButton>
)}
<span className="text-xs text-text-muted mb-px truncate">
{formatDateTime(currentNote.modified)}
</span>
{titleBarNoteInfo && (
<span className="text-xs text-text-muted mb-px truncate">
{titleBarNoteInfo}
</span>
)}
</div>
<div
className={`titlebar-no-drag flex items-center gap-px shrink-0 transition-opacity duration-400 ${needsSidebarDelay ? "delay-200" : ""} ${focusMode ? "opacity-0 pointer-events-none" : "opacity-100"}`}
Expand Down Expand Up @@ -2432,22 +2451,27 @@ export function Editor({
</div>

{/* Format Bar – transition only after initial mount to avoid height animation on note load */}
<div
data-format-bar
className={`${focusMode || sourceMode ? "opacity-0 max-h-0 overflow-hidden pointer-events-none" : "opacity-100 max-h-20"} ${hasTransitioned ? `transition-all duration-400 ${needsSidebarDelay ? "delay-200" : ""}` : ""}`}
>
<FormatBar
editor={editor}
onAddLink={handleAddLink}
onAddBlockMath={handleAddBlockMath}
onAddImage={handleAddImage}
/>
</div>
{editorToolbarVisible && (
<div
data-format-bar
className={`${focusMode || sourceMode ? "opacity-0 max-h-0 overflow-hidden pointer-events-none" : "opacity-100 max-h-20"} ${hasTransitioned ? `transition-all duration-400 ${needsSidebarDelay ? "delay-200" : ""}` : ""}`}
>
<FormatBar
editor={editor}
onAddLink={handleAddLink}
onAddBlockMath={handleAddBlockMath}
onAddImage={handleAddImage}
/>
</div>
)}

{/* Editor content area with resize handles overlay */}
<div data-editor-content-area className="flex-1 relative overflow-hidden">
{!focusMode && !sourceMode && (
<EditorWidthHandles containerRef={scrollContainerRef} />
<EditorWidthHandles
containerRef={scrollContainerRef}
enabled={editorWidthResizeEnabled}
/>
)}
<div
data-editor-scroll
Expand Down
136 changes: 136 additions & 0 deletions src/components/editor/EditorWidthHandle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { act, createRef } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import {
EditorWidthHandles,
getRenderedEditorWidth,
} from "./EditorWidthHandle";

vi.mock("../../context/ThemeContext", () => ({
useTheme: () => ({
editorWidth: "normal",
customEditorWidthPx: 768,
setEditorWidth: vi.fn(),
setCustomEditorWidthPx: vi.fn(),
setEditorMaxWidthLive: vi.fn(),
}),
}));

(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;

describe("EditorWidthHandles", () => {
it("measures the rendered page instead of its unconstrained max-width", () => {
const container = document.createElement("div");
const editor = document.createElement("div");
editor.className = "ProseMirror";
editor.style.maxWidth = "576px";
editor.getBoundingClientRect = () => ({
x: 37,
y: 0,
left: 37,
top: 0,
right: 563,
bottom: 800,
width: 526,
height: 800,
toJSON: () => ({}),
});
Object.defineProperty(container, "clientWidth", { value: 600 });
container.append(editor);

expect(getRenderedEditorWidth(container)).toBe(526);
});

it("caps the measured width at the container width", () => {
const container = document.createElement("div");
const editor = document.createElement("div");
editor.className = "ProseMirror";
editor.getBoundingClientRect = () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 700,
bottom: 800,
width: 700,
height: 800,
toJSON: () => ({}),
});
Object.defineProperty(container, "clientWidth", { value: 600 });
container.append(editor);

expect(getRenderedEditorWidth(container)).toBe(600);
});

it("mounts no resize interaction when mouse resizing is disabled", () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);

act(() => {
root.render(
<EditorWidthHandles
enabled={false}
containerRef={createRef<HTMLDivElement>()}
/>,
);
});

expect(container.childElementCount).toBe(0);

act(() => root.unmount());
container.remove();
});

it("mounts both resize handles when mouse resizing is enabled", () => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
const editorContainer = document.createElement("div");
const editor = document.createElement("div");
editor.className = "ProseMirror";
editor.getBoundingClientRect = () => ({
x: 216,
y: 0,
left: 216,
top: 0,
right: 984,
bottom: 800,
width: 768,
height: 800,
toJSON: () => ({}),
});
Object.defineProperty(editorContainer, "clientWidth", { value: 1200 });
editorContainer.append(editor);

const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);

act(() => {
root.render(
<EditorWidthHandles
enabled
containerRef={{ current: editorContainer }}
/>,
);
});

expect(container.querySelectorAll('[role="separator"]')).toHaveLength(2);
expect(
container.querySelector('[aria-label="Resize editor width (left)"]'),
).not.toBeNull();
expect(
container.querySelector('[aria-label="Resize editor width (right)"]'),
).not.toBeNull();

act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
});
50 changes: 31 additions & 19 deletions src/components/editor/EditorWidthHandle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,33 @@ const SNAP_THRESHOLD = 20;

interface EditorWidthHandlesProps {
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
}

export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) {
export function getRenderedEditorWidth(
container: HTMLDivElement,
): number | null {
const proseMirror = container.querySelector<HTMLElement>(".ProseMirror");
if (!proseMirror) return null;

const renderedWidth = proseMirror.getBoundingClientRect().width;
if (!Number.isFinite(renderedWidth) || renderedWidth <= 0) return null;

return Math.min(renderedWidth, container.clientWidth);
}

export function EditorWidthHandles({
containerRef,
enabled,
}: EditorWidthHandlesProps) {
if (!enabled) return null;

return <ActiveEditorWidthHandles containerRef={containerRef} />;
}

function ActiveEditorWidthHandles({
containerRef,
}: Pick<EditorWidthHandlesProps, "containerRef">) {
const {
editorWidth,
customEditorWidthPx,
Expand All @@ -48,17 +72,10 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) {
const updateHandleOffset = useCallback(() => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.clientWidth;
const proseMirror =
containerRef.current.querySelector<HTMLElement>(".ProseMirror");
if (proseMirror) {
const maxWidth = getComputedStyle(proseMirror).maxWidth;
if (maxWidth && maxWidth !== "none") {
const editorPx =
maxWidth === "100%" ? containerWidth : parseFloat(maxWidth);
const clampedEditor = Math.min(editorPx, containerWidth);
setHandleOffset((containerWidth - clampedEditor) / 2);
return;
}
const renderedWidth = getRenderedEditorWidth(containerRef.current);
if (renderedWidth !== null) {
setHandleOffset((containerWidth - renderedWidth) / 2);
return;
}
setHandleOffset(0);
}, [containerRef]);
Expand All @@ -75,13 +92,8 @@ export function EditorWidthHandles({ containerRef }: EditorWidthHandlesProps) {

const getCurrentEditorWidth = useCallback((): number => {
if (!containerRef.current) return 768;
const proseMirror = containerRef.current.querySelector(".ProseMirror");
if (proseMirror) {
const maxWidth = getComputedStyle(proseMirror).maxWidth;
if (maxWidth && maxWidth !== "none" && maxWidth !== "100%") {
return parseFloat(maxWidth);
}
}
const renderedWidth = getRenderedEditorWidth(containerRef.current);
if (renderedWidth !== null) return renderedWidth;
if (editorWidth === "custom") return customEditorWidthPx;
if (editorWidth === "full") return containerRef.current.clientWidth;
const preset = PRESET_PX.find((p) => p.width === editorWidth);
Expand Down
Loading