Skip to content

Commit 37e0392

Browse files
feat: add export and import actions to slash command menu (#1165) (#1168)
Co-authored-by: Ona <no-reply@ona.com>
1 parent dac5b74 commit 37e0392

7 files changed

Lines changed: 118 additions & 5 deletions

File tree

.agents/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ src/
459459
│ ├── editor/ # Lexical block editor
460460
│ │ ├── editor.tsx # Main editor: LexicalComposer, plugins, auto-save to Supabase
461461
│ │ ├── theme.ts # EditorThemeClasses mapping Lexical nodes to Tailwind classes
462-
│ │ ├── slash-command-plugin.tsx # "/" typeahead: paragraph, h1-h3, lists, code, quote, divider, table, image, callout, toggle
462+
│ │ ├── slash-command-plugin.tsx # "/" typeahead: paragraph, h1-h3, lists, code, quote, divider, table, image, callout, toggle, export, import
463463
│ │ ├── font-family.ts # Font family options (sans-serif, serif, monospace) and CSS value mapping
464464
│ │ ├── floating-toolbar-plugin.tsx # Selection toolbar: font family, bold, italic, underline, strikethrough, code, link
465465
│ │ ├── floating-link-editor-plugin.tsx # Link preview/edit/remove popover (⌘+K)

.agents/quality.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,6 @@ Tracks code quality per domain. Updated by automations as a side effect of featu
167167
| 2026-05-19 | Add keyboard navigation to sidebar page tree (#1150). Added WAI-ARIA Treeview keyboard navigation (ArrowUp/Down, ArrowLeft/Right expand/collapse, Enter, Home, End) with roving tabindex pattern. Added `getVisibleItems` and `findParentNode` to `page-tree.ts`. Updated `page-tree.test.ts` (37→46): 9 new tests for visible item traversal and parent lookup. Added 1 new E2E spec: `e2e/sidebar-keyboard-nav.spec.ts` (10 tests). Test totals: 144 Vitest files (1971 tests), 86 E2E specs (425 tests). |
168168
| 2026-05-21 | Add undo button to page delete toast (#1163). Updated `use-page-tree-actions.test.ts` (35→39): 4 new tests for undo toast action, restore RPC, navigate-back, and error handling. Updated `e2e/trash.spec.ts` (7→9): 2 new E2E tests for undo restore and undo navigate-back. Test totals: 144 Vitest files (1975 tests), 86 E2E specs (427 tests). |
169169
| 2026-05-21 | Add inline rename to sidebar page tree context menu (#1164). Added `handleRename` to `usePageTreeActions`. Added inline rename input to `PageTreeItem` with Enter/Escape/blur handling. Added "Rename" menu item to context menu. Added `Renaming` Storybook story. Added 1 new E2E spec: `e2e/sidebar-rename.spec.ts` (5 tests). Test totals: 144 Vitest files (1975 tests), 87 E2E specs (432 tests). |
170+
| 2026-05-21 | Add export/import actions to slash command menu (#1165). Added "Export as Markdown" and "Import Markdown" options to `slash-command-plugin.tsx` via optional `onExport`/`onImport` callbacks. Wired callbacks in `page-view-client.tsx` reusing existing `exportEditorToMarkdown`/`downloadMarkdown` and `useMarkdownImport`. Added `FilteredExportImport` Storybook story and visual regression baseline. No new test files. Test totals: 144 Vitest files (1975 tests), 87 E2E specs (432 tests). |
170171

171172

14 KB
Loading

src/components/editor/editor.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ interface EditorProps {
143143
initialContent: SerializedEditorState | null;
144144
editorRef?: React.MutableRefObject<LexicalEditor | null>;
145145
readOnly?: boolean;
146+
/** Called when the user selects "Export as Markdown" from the slash menu. */
147+
onSlashExport?: () => void;
148+
/** Called when the user selects "Import Markdown" from the slash menu. */
149+
onSlashImport?: () => void;
146150
}
147151

148152
function validateUrl(url: string): boolean {
@@ -172,7 +176,7 @@ function EditorRefPlugin({
172176
return null;
173177
}
174178

175-
export function Editor({ pageId, workspaceId, initialContent, editorRef, readOnly }: EditorProps) {
179+
export function Editor({ pageId, workspaceId, initialContent, editorRef, readOnly, onSlashExport, onSlashImport }: EditorProps) {
176180
const [saveStatus, setSaveStatus] = useState<
177181
"idle" | "saving" | "saved" | "error"
178182
>("idle");
@@ -416,7 +420,7 @@ export function Editor({ pageId, workspaceId, initialContent, editorRef, readOnl
416420
onChange={handleChange}
417421
ignoreSelectionChange
418422
/>
419-
<SlashCommandPlugin />
423+
<SlashCommandPlugin onExport={onSlashExport} onImport={onSlashImport} />
420424
<TurnIntoPlugin />
421425
{floatingAnchorElem && (
422426
<>

src/components/editor/slash-command-plugin.stories.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
Link,
1818
Grid3X3,
1919
Table2,
20+
Download,
21+
Upload,
2022
} from "lucide-react";
2123

2224
// Static representation of the slash command menu. The actual plugin uses
@@ -47,6 +49,8 @@ const allCommands: CommandOption[] = [
4749
{ title: "Toggle", description: "Collapsible section", icon: <ChevronRight className="h-5 w-5" /> },
4850
{ title: "Link to page", description: "Insert a link to another page", icon: <Link className="h-5 w-5" /> },
4951
{ title: "Database", description: "Embed an inline database view", icon: <Table2 className="h-5 w-5" /> },
52+
{ title: "Export as Markdown", description: "Download page as .md file", icon: <Download className="h-5 w-5" /> },
53+
{ title: "Import Markdown", description: "Import content from a .md file", icon: <Upload className="h-5 w-5" /> },
5054
];
5155

5256
function StaticSlashCommandMenu({
@@ -146,6 +150,18 @@ export const MiddleItemHighlighted: Story = {
146150
render: () => <StaticSlashCommandMenu items={allCommands} selectedIndex={7} />,
147151
};
148152

153+
/** Menu filtered to show export/import options — typing "export" or "import". */
154+
export const FilteredExportImport: Story = {
155+
render: () => {
156+
const filtered = allCommands.filter(
157+
(cmd) =>
158+
cmd.title.toLowerCase().includes("export") ||
159+
cmd.title.toLowerCase().includes("import"),
160+
);
161+
return <StaticSlashCommandMenu items={filtered} selectedIndex={0} />;
162+
},
163+
};
164+
149165
/** Menu shown in context below a slash trigger. */
150166
export const InContext: Story = {
151167
render: () => {

src/components/editor/slash-command-plugin.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import {
3939
ChevronRight,
4040
Link,
4141
Grid3X3,
42+
Download,
43+
Upload,
4244
} from "lucide-react";
4345
import { INSERT_TABLE_COMMAND } from "@lexical/table";
4446
import type { JSX, ReactElement } from "react";
@@ -78,7 +80,14 @@ class SlashCommandOption extends MenuOption {
7880
}
7981
}
8082

81-
export function SlashCommandPlugin(): JSX.Element | null {
83+
interface SlashCommandPluginProps {
84+
/** Called when the user selects "Export as Markdown" from the slash menu. */
85+
onExport?: () => void;
86+
/** Called when the user selects "Import Markdown" from the slash menu. */
87+
onImport?: () => void;
88+
}
89+
90+
export function SlashCommandPlugin({ onExport, onImport }: SlashCommandPluginProps = {}): JSX.Element | null {
8291
const [editor] = useLexicalComposerContext();
8392
const [queryString, setQueryString] = useState<string | null>(null);
8493
const menuRef = useRef<HTMLDivElement>(null);
@@ -269,8 +278,31 @@ export function SlashCommandPlugin(): JSX.Element | null {
269278
},
270279
}),
271280
),
281+
// Export / Import — only shown when callbacks are provided
282+
...(onExport
283+
? [
284+
new SlashCommandOption("Export as Markdown", {
285+
description: "Download page as .md file",
286+
icon: <Download className="h-5 w-5" />,
287+
onSelect: () => {
288+
onExport();
289+
},
290+
}),
291+
]
292+
: []),
293+
...(onImport
294+
? [
295+
new SlashCommandOption("Import Markdown", {
296+
description: "Import content from a .md file",
297+
icon: <Upload className="h-5 w-5" />,
298+
onSelect: () => {
299+
onImport();
300+
},
301+
}),
302+
]
303+
: []),
272304
],
273-
[editor]
305+
[editor, onExport, onImport]
274306
);
275307

276308
// Filter separately so base option objects (and their refs) persist.

src/components/page-view-client.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
import { useCallback, useRef, useState } from "react";
44
import dynamic from "next/dynamic";
55
import type { LexicalEditor, SerializedEditorState } from "lexical";
6+
import { toast } from "@/lib/toast";
7+
import { lazyCaptureException } from "@/lib/sentry";
8+
import { getClient } from "@/lib/supabase/lazy-client";
9+
import { trackEventClient } from "@/lib/track-event";
10+
import {
11+
exportEditorToMarkdown,
12+
downloadMarkdown,
13+
} from "@/components/editor/markdown-utils";
14+
import { useMarkdownImport } from "@/lib/use-markdown-import";
615
import { PageTitle } from "@/components/page-title";
716
import { PageIcon } from "@/components/page-icon";
817
import { PageCover } from "@/components/page-cover";
@@ -92,6 +101,47 @@ export function PageViewClient({
92101
editorRef.current?.focus();
93102
}, []);
94103

104+
// Slash-menu export: reuses the same flow as the page menu export
105+
const handleSlashExport = useCallback(() => {
106+
const editor = editorRef.current;
107+
if (!editor) {
108+
toast.error("Editor not ready", { duration: 8000 });
109+
return;
110+
}
111+
112+
try {
113+
const markdown = exportEditorToMarkdown(editor);
114+
const filename = (pageTitle.trim() || "Untitled") + ".md";
115+
downloadMarkdown(markdown, filename);
116+
117+
getClient()
118+
.then((supabase) => {
119+
trackEventClient(supabase, "editor.export", userId, {
120+
workspaceId,
121+
metadata: { page_id: pageId, source: "slash-menu" },
122+
});
123+
})
124+
.catch(() => {
125+
// Client init failed — skip tracking silently
126+
});
127+
} catch (error) {
128+
lazyCaptureException(error);
129+
toast.error("Export failed", { duration: 8000 });
130+
}
131+
}, [pageTitle, userId, workspaceId, pageId]);
132+
133+
// Slash-menu import: reuses the same useMarkdownImport hook as the page menu
134+
const {
135+
fileInputRef: slashImportInputRef,
136+
triggerFileInput: handleSlashImport,
137+
handleFileChange: handleSlashImportFileChange,
138+
} = useMarkdownImport({
139+
workspaceId,
140+
workspaceSlug,
141+
userId,
142+
source: "slash-menu",
143+
});
144+
95145
// When previewing a version, show a read-only editor with that content
96146
const isPreviewMode = previewContent !== null;
97147

@@ -135,6 +185,8 @@ export function PageViewClient({
135185
workspaceId={workspaceId}
136186
initialContent={initialContent}
137187
editorRef={editorRef}
188+
onSlashExport={handleSlashExport}
189+
onSlashImport={handleSlashImport}
138190
/>
139191
)}
140192
</div>
@@ -146,6 +198,14 @@ export function PageViewClient({
146198
onRestore={handleRestore}
147199
onExitPreview={handleExitPreview}
148200
/>
201+
<input
202+
ref={slashImportInputRef}
203+
type="file"
204+
accept=".md,.markdown"
205+
className="hidden"
206+
onChange={handleSlashImportFileChange}
207+
aria-label="Import markdown file from slash menu"
208+
/>
149209
</>
150210
);
151211
}

0 commit comments

Comments
 (0)