Add Markdown copy, paste, and per-page export endpoint (#30)
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m46s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m41s
CI / Auth e2e pack (push) Successful in 1m46s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 10s
Wires docToMarkdown/markdownToDoc into the editor clipboard: copying selected content puts Markdown on text/plain alongside the browser's own HTML (so pasting into a plain-text destination yields Markdown), and pasting plain text that looks like a Markdown document converts it to rich nodes; content with real HTML on the clipboard is left to ProseMirror's normal HTML-based paste, and the heuristic requires two or more distinct Markdown-shaped lines (or a fenced code block) so ordinary prose is never mangled. Both directions need the parsed/selected doc re-hydrated against whichever schema instance is on the other side of the boundary: the canonical editorSchema (packages/shared) for markdownToDoc's output before inserting it into the live view, and the live view's schema wrapped back into editorSchema before handing a slice to docToMarkdown — they're structurally identical but not the same object, and ProseMirror's content checks are identity-based. Adds GET /pages/:id/export/markdown (downloads <slug>.md), serving the already-derived page_content_cache.markdown (#23) rather than re-decoding the Yjs state. "Copy as Markdown" and "Download as Markdown" actions in the page header both read from that same endpoint, so they always agree with each other and with the last saved state. Closes #30
This commit is contained in:
parent
b5cc4c34b8
commit
c9011cb44f
@ -9,6 +9,7 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Req,
|
Req,
|
||||||
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
CreatePageInput,
|
CreatePageInput,
|
||||||
@ -20,6 +21,7 @@ import {
|
|||||||
savePageStateInputSchema,
|
savePageStateInputSchema,
|
||||||
updatePageInputSchema,
|
updatePageInputSchema,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
@ -49,6 +51,19 @@ export class PagesController {
|
|||||||
return this.pages.getState(request.user!, id);
|
return this.pages.getState(request.user!, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Markdown export (issue #30) — downloads `<slug>.md`. */
|
||||||
|
@Get('pages/:id/export/markdown')
|
||||||
|
async exportMarkdown(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
): Promise<string> {
|
||||||
|
const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id);
|
||||||
|
response.set('Content-Type', 'text/markdown; charset=utf-8');
|
||||||
|
response.set('Content-Disposition', `attachment; filename="${slug}.md"`);
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route
|
/** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route
|
||||||
* navigates to (issue #25); the pond id must already be known to the caller
|
* navigates to (issue #25); the pond id must already be known to the caller
|
||||||
* (e.g. from `GET /ponds/:slug`). */
|
* (e.g. from `GET /ponds/:slug`). */
|
||||||
|
|||||||
@ -147,6 +147,34 @@ describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
|
|||||||
expect(cache.html).toBe(`<p>hello from ${suffix}</p>`);
|
expect(cache.html).toBe(`<p>hello from ${suffix}</p>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exports the page as a downloadable Markdown file (issue #30)', async () => {
|
||||||
|
const created = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ title: `Export Me ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await api()
|
||||||
|
.put(`/api/v1/pages/${created.body.id}/state`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.send({ state: stateWithText(`markdown export ${suffix}`) })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const res = await api()
|
||||||
|
.get(`/api/v1/pages/${created.body.id}/export/markdown`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.expect(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/markdown');
|
||||||
|
expect(res.headers['content-disposition']).toBe(
|
||||||
|
`attachment; filename="${created.body.slug}.md"`,
|
||||||
|
);
|
||||||
|
expect(res.text).toBe(`markdown export ${suffix}`);
|
||||||
|
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/pages/${created.body.id}/export/markdown`)
|
||||||
|
.set('Cookie', outsiderCookie)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects state saves beyond the document size limit', async () => {
|
it('rejects state saves beyond the document size limit', async () => {
|
||||||
const created = await api()
|
const created = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/pages`)
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
||||||
|
|||||||
@ -230,6 +230,16 @@ export class PagesService {
|
|||||||
return this.viewOf(updated);
|
return this.viewOf(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Markdown export (issue #30) — serves the already-derived
|
||||||
|
* `page_content_cache.markdown` (refreshed on every state save, #23)
|
||||||
|
* rather than re-decoding the Yjs state, so export always matches what
|
||||||
|
* the app itself considers the page's current Markdown representation. */
|
||||||
|
async exportMarkdown(user: User, id: string): Promise<{ slug: string; markdown: string }> {
|
||||||
|
const page = await this.findVisiblePage(user, id);
|
||||||
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||||
|
return { slug: page.slug, markdown: cache?.markdown ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
async softDelete(user: User, id: string): Promise<void> {
|
async softDelete(user: User, id: string): Promise<void> {
|
||||||
const page = await this.findModifiablePage(user, id);
|
const page = await this.findModifiablePage(user, id);
|
||||||
await this.prisma.page.update({
|
await this.prisma.page.update({
|
||||||
|
|||||||
127
apps/web/e2e/markdown.spec.ts
Normal file
127
apps/web/e2e/markdown.spec.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown copy/paste/export pack (issue #30). Runs against the local dev
|
||||||
|
* stack (api + web); no Mailpit needed.
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
async function createPage(
|
||||||
|
context: Awaited<ReturnType<typeof contextForUser>>,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ pondSlug: string; pageSlug: string; pageId: string }> {
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||||
|
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title },
|
||||||
|
});
|
||||||
|
const page = await created.json();
|
||||||
|
return { pondSlug: pond.slug, pageSlug: page.slug, pageId: page.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterEditMode(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('copying a heading+list selection yields Markdown on the clipboard', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MD Copy ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
await page.keyboard.type('# Title');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('one');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('two');
|
||||||
|
|
||||||
|
// Turn the second/third lines into a bullet list, then copy everything.
|
||||||
|
await page.keyboard.press('Shift+Home');
|
||||||
|
await page.keyboard.press('Shift+ArrowUp');
|
||||||
|
await page.getByRole('button', { name: /bullet list|aufzählungsliste/i }).click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+a');
|
||||||
|
await page.keyboard.press('ControlOrMeta+c');
|
||||||
|
|
||||||
|
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(clipboardText).toContain('- one');
|
||||||
|
expect(clipboardText).toContain('- two');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasting a Markdown document into an empty page recreates structure', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MD Paste ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.ProseMirror');
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.setData('text/plain', '# Welcome\n\n- alpha\n- beta\n\n1. first\n2. second\n');
|
||||||
|
el!.dispatchEvent(
|
||||||
|
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await expect(content.locator('h1')).toHaveText('Welcome');
|
||||||
|
await expect(content.locator('ul li')).toHaveCount(2);
|
||||||
|
await expect(content.locator('ol li')).toHaveCount(2);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plain-text paste is not mangled into rich structure', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug } = await createPage(context, `E2E MD Plain ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
|
||||||
|
const plainText = "Just a *reminder* to buy milk - don't forget!";
|
||||||
|
await page.evaluate((text) => {
|
||||||
|
const el = document.querySelector('.ProseMirror');
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.setData('text/plain', text);
|
||||||
|
el!.dispatchEvent(
|
||||||
|
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
}, plainText);
|
||||||
|
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await expect(content).toContainText(plainText);
|
||||||
|
await expect(content.locator('ul, ol, h1, h2, h3, h4')).toHaveCount(0);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page menu downloads the page as Markdown matching its content', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const { pondSlug, pageSlug, pageId } = await createPage(context, `E2E MD Export ${Date.now()}`);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pondSlug}/${pageSlug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
await page.keyboard.type('export me please');
|
||||||
|
await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 });
|
||||||
|
|
||||||
|
const exported = await context.request.get(`/api/v1/pages/${pageId}/export/markdown`);
|
||||||
|
expect(exported.headers()['content-disposition']).toContain(`${pageSlug}.md`);
|
||||||
|
expect(await exported.text()).toBe('export me please');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import type { AnyExtension } from '@tiptap/core';
|
import type { AnyExtension } from '@tiptap/core';
|
||||||
|
|
||||||
|
import { MarkdownClipboard } from './markdown-clipboard';
|
||||||
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
import { Bold, CodeMark, Italic, LinkMark, Strikethrough } from './marks';
|
||||||
import { Image } from './nodes/image';
|
import { Image } from './nodes/image';
|
||||||
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||||
@ -45,4 +46,5 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
CodeMark,
|
CodeMark,
|
||||||
Strikethrough,
|
Strikethrough,
|
||||||
LinkMark,
|
LinkMark,
|
||||||
|
MarkdownClipboard,
|
||||||
];
|
];
|
||||||
|
|||||||
33
apps/web/src/editor/markdown-clipboard.test.ts
Normal file
33
apps/web/src/editor/markdown-clipboard.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { looksLikeMarkdown } from './markdown-clipboard';
|
||||||
|
|
||||||
|
describe('looksLikeMarkdown (issue #30)', () => {
|
||||||
|
it('recognizes a heading + list document', () => {
|
||||||
|
expect(looksLikeMarkdown('# Title\n\n- one\n- two')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes a fenced code block on its own', () => {
|
||||||
|
expect(looksLikeMarkdown('```\nconst x = 1;\n```')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes an ordered list and a table', () => {
|
||||||
|
expect(looksLikeMarkdown('1. first\n2. second')).toBe(true);
|
||||||
|
expect(looksLikeMarkdown('| a | b |\n| --- | --- |\n| 1 | 2 |')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mangle plain prose that merely contains markdown-ish characters', () => {
|
||||||
|
expect(looksLikeMarkdown("Just a *reminder* to buy milk - don't forget!")).toBe(false);
|
||||||
|
expect(looksLikeMarkdown('Meeting at 3pm. Bring #2 pencils and a laptop.')).toBe(false);
|
||||||
|
expect(looksLikeMarkdown('Rated 5/5 - would recommend.')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least two signals for a lone weak marker', () => {
|
||||||
|
expect(looksLikeMarkdown('# Just one heading, nothing else')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty or whitespace-only text as not markdown', () => {
|
||||||
|
expect(looksLikeMarkdown('')).toBe(false);
|
||||||
|
expect(looksLikeMarkdown(' \n ')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
apps/web/src/editor/markdown-clipboard.ts
Normal file
94
apps/web/src/editor/markdown-clipboard.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { docToMarkdown, editorSchema, markdownToDoc } from '@dorfteich/shared';
|
||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
import { Plugin } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
const MARKDOWN_LINE_PATTERNS = [
|
||||||
|
/^#{1,6}\s+\S/, // heading
|
||||||
|
/^[-*+]\s+\S/, // bullet list item
|
||||||
|
/^\d+\.\s+\S/, // ordered list item
|
||||||
|
/^>\s*\S/, // blockquote
|
||||||
|
/^\|.+\|\s*$/, // table row
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conservative "does this look like Markdown, not prose" heuristic (issue
|
||||||
|
* #30). A single matching line is too weak a signal on its own — prose
|
||||||
|
* often starts a line with a hyphen, a number, or a `>` — so two or more
|
||||||
|
* are required, except a fenced code block, which is unambiguous by itself.
|
||||||
|
*/
|
||||||
|
export function looksLikeMarkdown(text: string): boolean {
|
||||||
|
if (text.includes('```')) return true;
|
||||||
|
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||||
|
const matches = lines.filter((line) =>
|
||||||
|
MARKDOWN_LINE_PATTERNS.some((pattern) => pattern.test(line)),
|
||||||
|
);
|
||||||
|
return matches.length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown on the clipboard, both ways (issue #30, ADR 0004/0009): copying
|
||||||
|
* puts Markdown on `text/plain` alongside the browser's own HTML (so
|
||||||
|
* pasting into a plain-text destination yields Markdown, not a naive text
|
||||||
|
* dump), and pasting text that looks like a Markdown document converts it
|
||||||
|
* to rich nodes instead of one flat paragraph. Clipboard content that
|
||||||
|
* carries real HTML (anything copied from a rich-text source, including
|
||||||
|
* Dorfteich itself) is left entirely to ProseMirror's own HTML-based
|
||||||
|
* paste — the heuristic only ever applies to plain text.
|
||||||
|
*/
|
||||||
|
export const MarkdownClipboard = Extension.create({
|
||||||
|
name: 'markdownClipboard',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
clipboardTextSerializer(slice) {
|
||||||
|
try {
|
||||||
|
// Mirror image of the `handlePaste` re-hydration below: the
|
||||||
|
// selected slice's nodes belong to the live view's own schema
|
||||||
|
// instance, not the canonical `editorSchema` from
|
||||||
|
// packages/shared — wrapping the fragment directly in an
|
||||||
|
// `editorSchema` doc node fails schema-identity validation
|
||||||
|
// (`RangeError: Invalid content for node doc`), so it has to
|
||||||
|
// go through JSON first.
|
||||||
|
const doc = ProseMirrorNode.fromJSON(editorSchema, {
|
||||||
|
type: 'doc',
|
||||||
|
content: slice.content.toJSON() ?? [],
|
||||||
|
});
|
||||||
|
return docToMarkdown(doc);
|
||||||
|
} catch {
|
||||||
|
// Falls back to a plain-text join rather than ever throwing
|
||||||
|
// out of a copy/cut — worst case, the clipboard just isn't
|
||||||
|
// Markdown-formatted.
|
||||||
|
return slice.content.textBetween(0, slice.content.size, '\n\n', ' ');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handlePaste(view, event) {
|
||||||
|
const html = event.clipboardData?.getData('text/html');
|
||||||
|
if (html && html.trim() !== '') return false;
|
||||||
|
const text = event.clipboardData?.getData('text/plain');
|
||||||
|
if (!text || !looksLikeMarkdown(text)) return false;
|
||||||
|
|
||||||
|
let doc: ProseMirrorNode;
|
||||||
|
try {
|
||||||
|
// `markdownToDoc` builds nodes against the canonical schema
|
||||||
|
// instance from packages/shared, but TipTap builds its own
|
||||||
|
// separate (structurally identical) `Schema` object for the
|
||||||
|
// live editor (see spec-utils.ts) — `tr.replaceWith` silently
|
||||||
|
// drops content whose node types aren't `===` the state's own
|
||||||
|
// schema, so the parsed doc has to be re-hydrated against
|
||||||
|
// `view.state.schema` before it can be inserted.
|
||||||
|
doc = ProseMirrorNode.fromJSON(view.state.schema, markdownToDoc(text).toJSON());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { selection } = view.state;
|
||||||
|
view.dispatch(view.state.tr.replaceWith(selection.from, selection.to, doc.content));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -74,3 +74,22 @@ export async function apiUploadFile<T>(path: string, file: File): Promise<T> {
|
|||||||
export function fetchHealth(): Promise<HealthResponse> {
|
export function fetchHealth(): Promise<HealthResponse> {
|
||||||
return apiGet<HealthResponse>('/healthz');
|
return apiGet<HealthResponse>('/healthz');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plain-text response (issue #30's Markdown export) — every other endpoint
|
||||||
|
* here returns JSON, so this can't share `requestJson`'s `JSON.parse`. */
|
||||||
|
export async function apiGetText(path: string): Promise<string> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api/v1${path}`);
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, { code: 'network', message: 'network error' });
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { Toolbar } from '../editor/Toolbar';
|
|||||||
import { usePageStateAutosave } from '../editor/use-page-autosave';
|
import { usePageStateAutosave } from '../editor/use-page-autosave';
|
||||||
import { decodeBase64 } from '../editor/yjs-base64';
|
import { decodeBase64 } from '../editor/yjs-base64';
|
||||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiGetText, apiPatch } from '../lib/api';
|
||||||
|
|
||||||
type Mode = 'view' | 'edit';
|
type Mode = 'view' | 'edit';
|
||||||
|
|
||||||
@ -75,6 +75,42 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Markdown export actions (issue #30) — both read from the server-cached
|
||||||
|
* `page_content_cache.markdown` (via the export endpoint), so "copy" and
|
||||||
|
* "download" always agree with each other and with the last saved state. */
|
||||||
|
function PageMenu({ pageId, slug }: { pageId: string; slug: string }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('editor');
|
||||||
|
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
|
|
||||||
|
async function copyMarkdown(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const markdown = await apiGetText(`/pages/${pageId}/export/markdown`);
|
||||||
|
await navigator.clipboard.writeText(markdown);
|
||||||
|
setCopyStatus('copied');
|
||||||
|
} catch {
|
||||||
|
setCopyStatus('error');
|
||||||
|
}
|
||||||
|
setTimeout(() => setCopyStatus('idle'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-page__actions">
|
||||||
|
<button type="button" className="button" onClick={() => void copyMarkdown()}>
|
||||||
|
{copyStatus === 'idle' && t('page.copyMarkdown')}
|
||||||
|
{copyStatus === 'copied' && t('page.markdownCopied')}
|
||||||
|
{copyStatus === 'error' && t('page.markdownCopyFailed')}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
className="button"
|
||||||
|
href={`/api/v1/pages/${pageId}/export/markdown`}
|
||||||
|
download={`${slug}.md`}
|
||||||
|
>
|
||||||
|
{t('page.downloadMarkdown')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PageEditorPage(): React.JSX.Element {
|
export function PageEditorPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
|
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
|
||||||
@ -128,6 +164,7 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
||||||
</button>
|
</button>
|
||||||
|
<PageMenu pageId={page.data.id} slug={page.data.slug} />
|
||||||
</div>
|
</div>
|
||||||
<PageEditor page={page.data} mode={mode} />
|
<PageEditor page={page.data} mode={mode} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -435,6 +435,11 @@ button {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-page__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-shell {
|
.editor-shell {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|||||||
@ -65,5 +65,11 @@
|
|||||||
"medium": "Mittel",
|
"medium": "Mittel",
|
||||||
"full": "Voll"
|
"full": "Voll"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"copyMarkdown": "Als Markdown kopieren",
|
||||||
|
"markdownCopied": "Kopiert!",
|
||||||
|
"markdownCopyFailed": "Kopieren fehlgeschlagen",
|
||||||
|
"downloadMarkdown": "Als Markdown herunterladen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,5 +65,11 @@
|
|||||||
"medium": "Medium",
|
"medium": "Medium",
|
||||||
"full": "Full"
|
"full": "Full"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"copyMarkdown": "Copy as Markdown",
|
||||||
|
"markdownCopied": "Copied!",
|
||||||
|
"markdownCopyFailed": "Copy failed",
|
||||||
|
"downloadMarkdown": "Download as Markdown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user