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: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"google-auth-library": "^10.2.1",
"lucide-react": "^0.525.0",
"mammoth": "^1.10.0",
"marked": "^17.0.1",
"monaco-editor": "^0.52.2",
"motion": "^12.23.22",
"nanoid": "^5.1.6",
Expand All @@ -108,6 +109,7 @@
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"tokenlens": "^1.3.1",
"turndown": "^7.2.2",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"ws": "^8.18.3",
Expand All @@ -127,6 +129,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/turndown": "^5.0.6",
"@types/ws": "^8.18.1",
"copy-webpack-plugin": "^12.0.2",
"eslint": "^9",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type PageLookupResult = {
contentHash: string | null;
trashedAt: Date | null;
originalParentId: string | null;
contentMode: 'html' | 'markdown';
};

const mockPageLookup = (overrides: Partial<PageLookupResult> = {}): PageLookupResult => ({
Expand Down Expand Up @@ -147,6 +148,7 @@ const mockPageLookup = (overrides: Partial<PageLookupResult> = {}): PageLookupRe
contentHash: null,
trashedAt: null,
originalParentId: null,
contentMode: 'html',
...overrides,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const mockPage: PageWithDetails = {
title: 'Test Page',
type: 'DOCUMENT',
content: '<p>Test content</p>',
contentMode: 'html',
parentId: null,
driveId: mockDriveId,
position: 0,
Expand Down
130 changes: 130 additions & 0 deletions apps/web/src/app/api/pages/[pageId]/convert-content-mode/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { NextResponse } from 'next/server';
import { z } from 'zod/v4';
import { db, pages, eq } from '@pagespace/db';
import { canUserEditPage, createPageVersion } from '@pagespace/lib/server';
import { loggers } from '@pagespace/lib/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { applyPageMutation } from '@/services/api/page-mutation-service';
import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket';
import TurndownService from 'turndown';
import { marked } from 'marked';

const AUTH_OPTIONS = { allow: ['session'] as const, requireCSRF: true };

const convertSchema = z.object({
targetMode: z.enum(['markdown', 'html']),
});

export async function POST(
request: Request,
context: { params: Promise<{ pageId: string }> }
) {
const { pageId } = await context.params;
const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS);
if (isAuthError(auth)) return auth.error;
const userId = auth.userId;

try {
const body = await request.json();
const { targetMode } = convertSchema.parse(body);

// Check permissions
const canEdit = await canUserEditPage(userId, pageId, { bypassCache: true });
if (!canEdit) {
return NextResponse.json(
{ error: 'You need edit permission to convert this page' },
{ status: 403 }
);
}

// Fetch current page
const page = await db.query.pages.findFirst({
where: eq(pages.id, pageId),
});

if (!page) {
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
}

if (page.type !== 'DOCUMENT') {
return NextResponse.json(
{ error: 'Content mode conversion is only available for DOCUMENT pages' },
{ status: 400 }
);
}

if (page.contentMode === targetMode) {
return NextResponse.json(
{ error: `Page is already in ${targetMode} mode` },
{ status: 400 }
);
}

// Create version snapshot before conversion
await createPageVersion({
pageId: page.id,
driveId: page.driveId,
createdBy: userId,
source: 'system',
content: page.content || '',
pageRevision: page.revision,
stateHash: page.stateHash || '',
metadata: { reason: `pre-conversion to ${targetMode}` },
});

// Convert content
// Note: Content is already trusted as it comes from our database where it was
// sanitized on original save. Turndown/marked handle format conversion safely.
let convertedContent: string;
if (targetMode === 'markdown') {
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
convertedContent = turndown.turndown(page.content || '');
} else {
convertedContent = await marked.parse(page.content || '');
}

// Atomic update: content + contentMode
await applyPageMutation({
pageId: page.id,
operation: 'update',
updates: {
content: convertedContent,
contentMode: targetMode,
},
updatedFields: ['content', 'contentMode'],
expectedRevision: page.revision,
context: { userId },
});

// Broadcast content update
await broadcastPageEvent(
createPageEventPayload(page.driveId, page.id, 'content-updated', {
title: page.title,
})
);

// Refetch updated page
const updatedPage = await db.query.pages.findFirst({
where: eq(pages.id, pageId),
});

return NextResponse.json({
success: true,
content: updatedPage?.content,
contentMode: updatedPage?.contentMode,
revision: updatedPage?.revision,
});
} catch (error) {
loggers.api.error('Error converting page content mode:', error as Error);
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 });
}
return NextResponse.json(
{ error: 'Failed to convert content mode' },
{ status: 500 }
);
}
}
10 changes: 8 additions & 2 deletions apps/web/src/app/api/pages/[pageId]/export/docx/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { generateDOCX, sanitizeFilename } from '@pagespace/lib';
import { loggers } from '@pagespace/lib/server';
import { trackPageOperation } from '@pagespace/lib/activity-tracker';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { marked } from 'marked';

const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const };

Expand Down Expand Up @@ -46,8 +47,13 @@ export async function GET(req: Request, context: { params: Promise<{ pageId: str
);
}

// Get the HTML content
const htmlContent = page.content || '<p>No content</p>';
// Get the HTML content (convert markdown to HTML if needed)
let htmlContent: string;
if (page.contentMode === 'markdown') {
htmlContent = await marked.parse(page.content || '') || '<p>No content</p>';
} else {
htmlContent = page.content || '<p>No content</p>';
}

// Generate DOCX
const docxBuffer = await generateDOCX(htmlContent, page.title);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/pages/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const mockPage: PageData = {
title: 'New Page',
type: 'DOCUMENT',
content: '<p></p>',
contentMode: 'html',
parentId: null,
driveId: mockDriveId,
position: 1,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/pages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const createPageSchema = z.object({
driveId: z.string().min(1, 'Drive ID is required'),
parentId: z.string().nullable().optional(),
content: z.string().optional(),
contentMode: z.enum(['html', 'markdown']).optional(),
systemPrompt: z.string().optional(),
enabledTools: z.array(z.string()).optional(),
aiProvider: z.string().optional(),
Expand Down Expand Up @@ -61,6 +62,7 @@ export async function POST(request: Request) {
driveId: validatedData.driveId,
parentId: validatedData.parentId,
content: validatedData.content,
contentMode: validatedData.contentMode,
systemPrompt: validatedData.systemPrompt,
enabledTools: validatedData.enabledTools,
aiProvider: validatedData.aiProvider,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/api/settings/display-preferences/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true };
const DISPLAY_PREFERENCE_TYPES = [
'SHOW_TOKEN_COUNTS',
'SHOW_CODE_TOGGLE',
'DEFAULT_MARKDOWN_MODE',
] as const;

type DisplayPreferenceType = typeof DISPLAY_PREFERENCE_TYPES[number];
Expand All @@ -33,6 +34,7 @@ export async function GET(request: Request) {
const response = {
showTokenCounts: preferenceMap.get('SHOW_TOKEN_COUNTS') ?? false,
showCodeToggle: preferenceMap.get('SHOW_CODE_TOGGLE') ?? false,
defaultMarkdownMode: preferenceMap.get('DEFAULT_MARKDOWN_MODE') ?? false,
};

return NextResponse.json(response);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/tasks/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const createPageFixture = (overrides: Partial<{
extractionMethod: null,
extractionMetadata: null,
contentHash: null,
contentMode: 'html' as const,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
revision: 0,
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/app/settings/display/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { toast } from 'sonner';
import { Eye, Loader2, ArrowLeft, Info } from 'lucide-react';

interface DisplaySetting {
id: 'SHOW_TOKEN_COUNTS' | 'SHOW_CODE_TOGGLE';
id: 'SHOW_TOKEN_COUNTS' | 'SHOW_CODE_TOGGLE' | 'DEFAULT_MARKDOWN_MODE';
label: string;
description: string;
preferenceKey: 'showTokenCounts' | 'showCodeToggle';
preferenceKey: 'showTokenCounts' | 'showCodeToggle' | 'defaultMarkdownMode';
}

const DISPLAY_SETTINGS: DisplaySetting[] = [
Expand All @@ -32,6 +32,12 @@ const DISPLAY_SETTINGS: DisplaySetting[] = [
description: 'Display Rich/Code toggle buttons for document and canvas pages',
preferenceKey: 'showCodeToggle',
},
{
id: 'DEFAULT_MARKDOWN_MODE',
label: 'Default to Markdown for new documents',
description: 'New document pages will store content as markdown instead of HTML',
preferenceKey: 'defaultMarkdownMode',
},
];

export default function DisplaySettingsPage() {
Expand Down
41 changes: 26 additions & 15 deletions apps/web/src/components/editors/RichEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ interface RichEditorProps {
onEditorChange: (editor: Editor | null) => void;
readOnly?: boolean;
isPaginated?: boolean;
contentMode?: 'html' | 'markdown';
}

const RichEditor = ({ value, onChange, onEditorChange, readOnly = false, isPaginated = false }: RichEditorProps) => {
const RichEditor = ({ value, onChange, onEditorChange, readOnly = false, isPaginated = false, contentMode = 'html' }: RichEditorProps) => {
const router = useRouter();

// Subscribe to navigation events from TipTap mentions
Expand Down Expand Up @@ -85,8 +86,12 @@ const RichEditor = ({ value, onChange, onEditorChange, readOnly = false, isPagin
autofocus: readOnly ? false : undefined,
onUpdate: ({ editor }) => {
if (!readOnly) {
const html = editor.getHTML();
onChange(html);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storage = editor.storage as Record<string, any>;
const serialized = contentMode === 'markdown'
? storage.markdown.getMarkdown()
: editor.getHTML();
onChange(serialized);
}
},
editorProps: {
Expand All @@ -100,25 +105,31 @@ const RichEditor = ({ value, onChange, onEditorChange, readOnly = false, isPagin
scrollThreshold: 80,
scrollMargin: 80,
},
}, [isPaginated]); // Recreate editor when pagination changes
}, [isPaginated, contentMode]); // Recreate editor when pagination or content mode changes

useEffect(() => {
if (editor) {
const currentHTML = editor.getHTML();
// Check if value is empty and current HTML is just the default empty paragraph
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storage = editor.storage as Record<string, any>;
const currentSerialized = contentMode === 'markdown'
? storage.markdown.getMarkdown()
: editor.getHTML();
// Check if value is empty and current content is just the default empty state
const isEmptyValue = !value || value.trim() === '';
const isDefaultEmptyHTML = currentHTML === '<p></p>' ||
currentHTML === '<p><br></p>' ||
currentHTML === '<p><br/></p>' ||
currentHTML === '<p><br /></p>';

const isDefaultEmpty = contentMode === 'markdown'
? (!currentSerialized || currentSerialized.trim() === '')
: (currentSerialized === '<p></p>' ||
currentSerialized === '<p><br></p>' ||
currentSerialized === '<p><br/></p>' ||
currentSerialized === '<p><br /></p>');

// Only update if there's a meaningful difference
if (isEmptyValue && isDefaultEmptyHTML) {
if (isEmptyValue && isDefaultEmpty) {
// Both are effectively empty, no need to update
return;
}
if (value !== currentHTML) {

if (value !== currentSerialized) {
// Save current view state before updating content
const { from, to } = editor.state.selection;
// Get current selection position
Expand Down Expand Up @@ -174,7 +185,7 @@ const RichEditor = ({ value, onChange, onEditorChange, readOnly = false, isPagin
});
}
}
}, [value, editor]);
}, [value, editor, contentMode]);

useEffect(() => {
onEditorChange(editor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { PageType, Page, getDefaultContent } from '@pagespace/lib/client-safe';
import { toast } from 'sonner';
import { Upload } from 'lucide-react';
import { post, fetchWithAuth } from '@/lib/auth/auth-fetch';
import { useDisplayPreferences } from '@/hooks/useDisplayPreferences';

interface CreatePageDialogProps {
parentId: string | null;
Expand All @@ -34,6 +35,7 @@ export default function CreatePageDialog({ parentId, isOpen, setIsOpen, onPageCr
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { preferences } = useDisplayPreferences();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand Down Expand Up @@ -91,12 +93,17 @@ export default function CreatePageDialog({ parentId, isOpen, setIsOpen, onPageCr
// Use centralized default content
const content = getDefaultContent(type);

// Apply user's markdown preference for new document pages
const contentMode = (type === PageType.DOCUMENT && preferences.defaultMarkdownMode)
? 'markdown' : undefined;

const newPage = await post<Page>('/api/pages', {
title,
type,
parentId: parentId,
driveId: driveId,
content
content,
...(contentMode && { contentMode }),
});

onPageCreated(newPage);
Expand Down
Loading
Loading