M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138
24
.claude/settings.json
Normal file
24
.claude/settings.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Grep",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/Users/stwaidele/.local/bin/graphify hook-guard search"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/Users/stwaidele/.local/bin/graphify hook-guard read"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -14,3 +14,6 @@ apps/api/data/
|
||||
# Font catalog WOFF2 + generated stylesheet — fetched at build time
|
||||
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
|
||||
apps/web/public/fonts/
|
||||
|
||||
# graphify knowledge graph (generated)
|
||||
graphify-out/
|
||||
|
||||
@ -15,3 +15,8 @@ fixtures/import/*.src.html
|
||||
# as pandoc reads the exported document back — Prettier would break them.
|
||||
fixtures/export/*.expected.md
|
||||
packages/plugins/*/vendor/
|
||||
# Agent/tool config generated by Claude Code + `graphify claude install`
|
||||
# (regenerated on demand, not hand-formatted source) — keep out of Prettier so
|
||||
# a re-install never breaks the lint gate.
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
9
CLAUDE.md
Normal file
9
CLAUDE.md
Normal file
@ -0,0 +1,9 @@
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
@ -277,10 +277,12 @@ describe.skipIf(!hasTestDb)('LinksService.pondGraph (db, issue #111)', () => {
|
||||
{ from: a, to: secret },
|
||||
].sort((x, y) => x.to.localeCompare(y.to)),
|
||||
);
|
||||
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([
|
||||
`ghost-${suffix}`,
|
||||
`ghost-secret-${suffix}`,
|
||||
]);
|
||||
// Sort BOTH sides: phantom order is not guaranteed, and the two slugs share
|
||||
// the random suffix so their sort order flips on ~1/5 of suffixes (the
|
||||
// expected array was previously left unsorted — a latent flake).
|
||||
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual(
|
||||
[`ghost-${suffix}`, `ghost-secret-${suffix}`].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('slices every collection to the reader — no hidden page leaks anywhere', async () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import type { PageCommentsView } from '@dorfteich/shared';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||
@ -24,6 +25,16 @@ export class PublicController {
|
||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
|
||||
}
|
||||
|
||||
@Get(':pondSlug/:pageSlug/comments')
|
||||
@Public()
|
||||
async comments(
|
||||
@Param('pondSlug') pondSlug: string,
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PageCommentsView> {
|
||||
return this.publicPages.comments(request.user ?? null, pondSlug, pageSlug);
|
||||
}
|
||||
|
||||
@Get(':pondSlug/:pageSlug')
|
||||
@Public()
|
||||
async html(
|
||||
|
||||
@ -115,12 +115,67 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
|
||||
await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404);
|
||||
});
|
||||
|
||||
it('404s both endpoints once the public grant is removed', async () => {
|
||||
it('serves a page’s comments read-only to an anonymous visitor (issue #133)', async () => {
|
||||
const page = await prisma.page.findFirstOrThrow({ where: { pondId, slug: pageSlug } });
|
||||
const root = await prisma.comment.create({
|
||||
data: { pageId: page.id, authorId: ownerId, body: 'A public remark' },
|
||||
});
|
||||
await prisma.comment.create({
|
||||
data: { pageId: page.id, parentId: root.id, authorId: ownerId, body: 'A public reply' },
|
||||
});
|
||||
|
||||
const res = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(200);
|
||||
const body = res.body as {
|
||||
threads: { root: { body: string }; replies: { body: string }[] }[];
|
||||
openCount: number;
|
||||
};
|
||||
expect(body.openCount).toBe(1);
|
||||
expect(body.threads).toHaveLength(1);
|
||||
const [thread] = body.threads;
|
||||
expect(thread!.root.body).toBe('A public remark');
|
||||
expect(thread!.replies.map((r) => r.body)).toEqual(['A public reply']);
|
||||
|
||||
// A non-public page never reveals its comments either.
|
||||
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404);
|
||||
});
|
||||
|
||||
it('expands a page embed to the target’s content, cycle-safe (issue #135)', async () => {
|
||||
const embeddedSlug = `embedded-${suffix}`;
|
||||
const hostSlug = `host-${suffix}`;
|
||||
// The embedded page embeds the host back — the expansion must terminate.
|
||||
await makePage(
|
||||
pondId,
|
||||
embeddedSlug,
|
||||
'Embedded',
|
||||
`<p>Body of the embedded page.</p>` +
|
||||
`<div class="dt-transclusion" data-transclusion="${hostSlug}">Host</div>`,
|
||||
);
|
||||
await makePage(
|
||||
pondId,
|
||||
hostSlug,
|
||||
'Host',
|
||||
`<p>Before.</p>` +
|
||||
`<div class="dt-transclusion" data-transclusion="${embeddedSlug}">Embedded</div>` +
|
||||
`<div class="dt-transclusion" data-transclusion="ghost-${suffix}">Missing</div>`,
|
||||
);
|
||||
|
||||
const res = await api().get(`/api/v1/public/${pondSlug}/${hostSlug}/content`).expect(200);
|
||||
const html = (res.body as { html: string }).html;
|
||||
// The embedded page's body is spliced in, wrapped as an embed.
|
||||
expect(html).toContain('Body of the embedded page.');
|
||||
expect(html).toContain('class="dt-embed"');
|
||||
// No raw placeholder survives; a missing target degrades to a link.
|
||||
expect(html).not.toContain('dt-transclusion');
|
||||
expect(html).toContain(`data-wikilink="ghost-${suffix}"`);
|
||||
});
|
||||
|
||||
it('404s all public endpoints once the public grant is removed', async () => {
|
||||
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
|
||||
// The API route invalidates on its own mutations; this test deletes
|
||||
// directly, so drop the cached pond context to mirror that (issue #39).
|
||||
app.get(PondPermissionCache).invalidate(pondId);
|
||||
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404);
|
||||
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404);
|
||||
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CommentsModule } from '../comments/comments.module';
|
||||
import { PluginsModule } from '../plugins/plugins.module';
|
||||
|
||||
import { PublicController } from './public.controller';
|
||||
import { PublicService } from './public.service';
|
||||
import { ReadContentController } from './read-content.controller';
|
||||
|
||||
/**
|
||||
* Public read access (issue #56): anonymous-reachable page endpoints on top of
|
||||
@ -12,8 +14,8 @@ import { PublicService } from './public.service';
|
||||
* marks `GET /media/:fileId` public too.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PluginsModule],
|
||||
controllers: [PublicController],
|
||||
imports: [PluginsModule, CommentsModule],
|
||||
controllers: [PublicController, ReadContentController],
|
||||
providers: [PublicService],
|
||||
})
|
||||
export class PublicModule {}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { PageCommentsView } from '@dorfteich/shared';
|
||||
import { Pond, User } from '@prisma/client';
|
||||
|
||||
import { CommentsService } from '../comments/comments.service';
|
||||
import { PermissionService } from '../permissions/permission.service';
|
||||
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@ -38,6 +40,7 @@ export class PublicService {
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly fallbacks: PluginFallbackRenderer,
|
||||
private readonly settings: InstanceSettingsService,
|
||||
private readonly commentsService: CommentsService,
|
||||
) {}
|
||||
|
||||
private async resolve(
|
||||
@ -58,25 +61,98 @@ export class PublicService {
|
||||
return { pond, page };
|
||||
}
|
||||
|
||||
/** The page content for the SPA's read-only public view. */
|
||||
/** The page content for the read view (public and authenticated, issue #56). */
|
||||
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
|
||||
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
|
||||
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||
// Plugin blocks render their static form (#79), and the pond's active
|
||||
// section-style CSS travels inline — the public view loads no plugin
|
||||
// runtime, and the CSS passed the install gate's scoping rules.
|
||||
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
|
||||
// The pond's active section-style CSS travels inline — the read view loads
|
||||
// no plugin runtime, and the CSS passed the install gate's scoping rules.
|
||||
const styleTag = await this.fallbacks.sectionStyleTag(pond.id);
|
||||
// Plugin blocks render their static form (#79) and page embeds expand to the
|
||||
// target's rendered HTML (#135), then media is resolved once over the whole
|
||||
// tree. `visited` seeds with this page so an embed of self is not expanded.
|
||||
const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug]));
|
||||
return {
|
||||
pondName: pond.name,
|
||||
pondSlug: pond.slug,
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
html: styleTag + resolveMediaUrls(withFallbacks),
|
||||
html: styleTag + resolveMediaUrls(body),
|
||||
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Longest embed chain we follow before falling back to a link (issue #135). */
|
||||
private static readonly MAX_EMBED_DEPTH = 2;
|
||||
|
||||
/**
|
||||
* A page's body HTML: cached HTML + plugin fallbacks + expanded page embeds,
|
||||
* but WITHOUT media resolution or the style tag — those are applied once at
|
||||
* the top of {@link content} so nested embeds are not double-processed.
|
||||
*/
|
||||
private async renderBody(
|
||||
user: User | null,
|
||||
pondId: string,
|
||||
page: { id: string },
|
||||
depth: number,
|
||||
visited: Set<string>,
|
||||
): Promise<string> {
|
||||
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
|
||||
return this.expandEmbeds(withFallbacks, user, pondId, depth, visited);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces each `dt-transclusion` placeholder (issue #135) with the target
|
||||
* page's rendered body. Same-pond only, read-permission-checked; a missing,
|
||||
* unreadable, cyclic, or too-deep target degrades to a plain link so the page
|
||||
* never leaks existence and never loops.
|
||||
*/
|
||||
private async expandEmbeds(
|
||||
html: string,
|
||||
user: User | null,
|
||||
pondId: string,
|
||||
depth: number,
|
||||
visited: Set<string>,
|
||||
): Promise<string> {
|
||||
const placeholder = /<div class="dt-transclusion" data-transclusion="([^"]+)">[^<]*<\/div>/g;
|
||||
return replaceAsync(html, placeholder, async (_match, rawSlug) => {
|
||||
const slug = rawSlug as string;
|
||||
const target = await this.prisma.page.findFirst({
|
||||
where: { pondId, slug, deletedAt: null },
|
||||
select: { id: true, pondId: true, slug: true, title: true },
|
||||
});
|
||||
const readable = target && (await this.permissions.canAccessPage(user, target, 'read'));
|
||||
if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) {
|
||||
return embedLink(slug, target?.title ?? slug);
|
||||
}
|
||||
const inner = await this.renderBody(
|
||||
user,
|
||||
pondId,
|
||||
target,
|
||||
depth + 1,
|
||||
new Set(visited).add(slug),
|
||||
);
|
||||
return (
|
||||
`<div class="dt-embed"><div class="dt-embed__title">` +
|
||||
`<a class="wikilink" href="${escapeHtml(slug)}" data-wikilink="${escapeHtml(slug)}">` +
|
||||
`${escapeHtml(target.title)}</a></div>${inner}</div>`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's comments for the anonymous public view (issue #133), read-only.
|
||||
* `resolve()` enforces (possibly anonymous) read access — a non-public page
|
||||
* 404s here too, so comments never leak. `list` builds the same
|
||||
* `PageCommentsView` the authenticated endpoint returns; the SPA renders it
|
||||
* without any composer or action controls.
|
||||
*/
|
||||
async comments(user: User | null, pondSlug: string, pageSlug: string): Promise<PageCommentsView> {
|
||||
const { page } = await this.resolve(user, pondSlug, pageSlug);
|
||||
return this.commentsService.list(page.id, 'all');
|
||||
}
|
||||
|
||||
/** A complete, self-contained HTML document for crawlers / PDF export. */
|
||||
async html(
|
||||
user: User | null,
|
||||
@ -112,3 +188,35 @@ function resolveMediaUrls(html: string): string {
|
||||
'src="/api/v1/media/$1" data-file-id="$1"',
|
||||
);
|
||||
}
|
||||
|
||||
/** The fallback for an embed that cannot expand (missing/unreadable/cyclic/too
|
||||
* deep, issue #135): a plain wikilink, so the page never loops or leaks. */
|
||||
function embedLink(slug: string, label: string): string {
|
||||
const safeSlug = escapeHtml(slug);
|
||||
return (
|
||||
`<div class="dt-embed dt-embed--link">` +
|
||||
`<a class="wikilink" href="${safeSlug}" data-wikilink="${safeSlug}">${escapeHtml(label)}</a>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/** `String.replace` with an async replacer (issue #135): resolves every match's
|
||||
* replacement in parallel, then splices them back in match order. */
|
||||
async function replaceAsync(
|
||||
input: string,
|
||||
regex: RegExp,
|
||||
replacer: (match: string, ...groups: string[]) => Promise<string>,
|
||||
): Promise<string> {
|
||||
const matches = [...input.matchAll(regex)];
|
||||
if (matches.length === 0) return input;
|
||||
const replacements = await Promise.all(
|
||||
matches.map((match) => replacer(match[0], ...match.slice(1))),
|
||||
);
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
matches.forEach((match, i) => {
|
||||
result += input.slice(lastIndex, match.index) + replacements[i];
|
||||
lastIndex = (match.index ?? 0) + match[0].length;
|
||||
});
|
||||
return result + input.slice(lastIndex);
|
||||
}
|
||||
|
||||
31
apps/api/src/public/read-content.controller.ts
Normal file
31
apps/api/src/public/read-content.controller.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Controller, Get, Param, Req } from '@nestjs/common';
|
||||
|
||||
import { AuthedRequest } from '../auth/auth.guard';
|
||||
import { AuthenticatedOnly } from '../permissions/permission.decorators';
|
||||
import { PublicPageContent, PublicService } from './public.service';
|
||||
|
||||
/**
|
||||
* Authenticated read-rendering (issue #135). Returns a page's rendered read
|
||||
* HTML — plugin fallbacks, expanded page embeds, resolved media — for a
|
||||
* signed-in viewer with read access. The transclusion node view fetches this to
|
||||
* show an embedded page's content inline in the authenticated read view, which
|
||||
* must also work for pages that are not public and so are out of reach of the
|
||||
* `/public` endpoints. NOT `@Public`: the auth guard requires a session and the
|
||||
* service enforces read permission (a non-readable page 404s, no leak).
|
||||
*/
|
||||
@Controller('read')
|
||||
export class ReadContentController {
|
||||
constructor(private readonly publicPages: PublicService) {}
|
||||
|
||||
// Session required (explicit access rule, issue #52); per-page read
|
||||
// permission is enforced in the service (resolve → canAccessPage → 404).
|
||||
@Get(':pondSlug/:pageSlug')
|
||||
@AuthenticatedOnly()
|
||||
async content(
|
||||
@Param('pondSlug') pondSlug: string,
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Req() request: AuthedRequest,
|
||||
): Promise<PublicPageContent> {
|
||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,11 @@ import { contextForUser } from './helpers';
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
/**
|
||||
* Comments UI (issue #92): the full two-user lifecycle in the panel,
|
||||
* resolve/unresolve with the collapsed section, the permission variant
|
||||
* (composer hidden with a hint), and the localStorage unread badge.
|
||||
* The pack provisions its own page and grant, so it is repeatable.
|
||||
* Comments UI (issues #92, #133): the discussion is a fixed inline section in
|
||||
* the read view (no toggle), between the backlinks and the local graph. The
|
||||
* pack drives the full two-user lifecycle, resolve/unresolve with the collapsed
|
||||
* section, and the permission variant (composer hidden with a hint). It
|
||||
* provisions its own page and grant, so it is repeatable.
|
||||
*/
|
||||
|
||||
let pondId: string;
|
||||
@ -52,23 +53,21 @@ test.afterAll(async ({ browser }) => {
|
||||
});
|
||||
|
||||
test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => {
|
||||
// The reader starts the thread.
|
||||
// The reader starts the thread — the section is inline in the read view.
|
||||
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
|
||||
const readerPage = await reader.newPage();
|
||||
await readerPage.goto(pageUrl);
|
||||
await readerPage.locator('.editor-shell__comments-toggle').click();
|
||||
const readerPanel = readerPage.locator('.comments-panel');
|
||||
const readerPanel = readerPage.locator('.comments-section');
|
||||
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
|
||||
await readerPanel.locator('.comments-composer button[type="submit"]').click();
|
||||
await expect(readerPanel.locator('.comment__body strong')).toHaveText('final');
|
||||
|
||||
// The owner sees the unread badge, replies, and resolves the thread.
|
||||
// The owner opens the page and sees the thread inline, replies, resolves.
|
||||
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||
const ownerPage = await owner.newPage();
|
||||
await ownerPage.goto(pageUrl);
|
||||
await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible();
|
||||
await ownerPage.locator('.editor-shell__comments-toggle').click();
|
||||
const ownerPanel = ownerPage.locator('.comments-panel');
|
||||
const ownerPanel = ownerPage.locator('.comments-section');
|
||||
await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final');
|
||||
await ownerPanel
|
||||
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
|
||||
.click();
|
||||
@ -83,7 +82,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
|
||||
.locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i })
|
||||
.first()
|
||||
.click();
|
||||
const resolvedSection = ownerPanel.locator('.comments-panel__resolved');
|
||||
const resolvedSection = ownerPanel.locator('.comments-section__resolved');
|
||||
await expect(resolvedSection).toBeVisible();
|
||||
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
|
||||
// Collapsed: the thread body is hidden until the section is opened.
|
||||
@ -93,7 +92,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
|
||||
|
||||
// Unresolve restores it to the open list.
|
||||
await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click();
|
||||
await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0);
|
||||
await expect(ownerPanel.locator('.comments-section__resolved')).toHaveCount(0);
|
||||
await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible();
|
||||
|
||||
// Author edits and deletes own reply.
|
||||
@ -108,9 +107,8 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
|
||||
|
||||
// Cleanup: the reader deletes their own (now reply-free) root.
|
||||
await readerPage.reload();
|
||||
await readerPage.locator('.editor-shell__comments-toggle').click();
|
||||
await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
|
||||
await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible();
|
||||
await expect(readerPanel.locator('.comments-section__empty')).toBeVisible();
|
||||
|
||||
await reader.close();
|
||||
await owner.close();
|
||||
@ -127,9 +125,8 @@ test('the composer hides with a hint when the policy bars readers', async ({ bro
|
||||
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
|
||||
const page = await reader.newPage();
|
||||
await page.goto(pageUrl);
|
||||
await page.locator('.editor-shell__comments-toggle').click();
|
||||
const panel = page.locator('.comments-panel');
|
||||
await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible();
|
||||
const panel = page.locator('.comments-section');
|
||||
await expect(panel.locator('.comments-section__policy-hint')).toBeVisible();
|
||||
await expect(panel.locator('.comments-composer')).toHaveCount(0);
|
||||
await reader.close();
|
||||
|
||||
|
||||
@ -1,35 +1,36 @@
|
||||
import type { CommentThreadView, CommentView } from '@dorfteich/shared';
|
||||
import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiDelete, apiPatch, apiPost } from '../lib/api';
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
|
||||
import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments';
|
||||
|
||||
/**
|
||||
* The page's discussion panel (issue #92): threaded comments with a
|
||||
* Markdown composer, edit/delete for authors, resolve with a collapsed
|
||||
* resolved section, and the permission-aware composer (hidden with a hint
|
||||
* when the pond's policy bars the viewer).
|
||||
* The page's discussion (issue #92), rendered inline in the read view between
|
||||
* the backlinks and the local graph (issue #133): threaded comments with a
|
||||
* Markdown composer, edit/delete for authors, and resolve with a collapsed
|
||||
* resolved section. The composer is hidden with a hint when the pond's policy
|
||||
* bars the viewer. Replaces the former slide-in panel.
|
||||
*/
|
||||
export function CommentsPanel({
|
||||
export function CommentsSection({
|
||||
pageId,
|
||||
mayComment,
|
||||
onClose,
|
||||
}: {
|
||||
pageId: string;
|
||||
/** Resolved by the caller from the pond's commentPolicy + collab mode. */
|
||||
/** Resolved by the caller from the pond's commentPolicy + write access. */
|
||||
mayComment: boolean;
|
||||
onClose: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('comments');
|
||||
const comments = useComments(pageId);
|
||||
const refresh = useInvalidateComments(pageId);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
|
||||
// Opening the panel is "visiting" the discussion: the unread badge resets.
|
||||
// Seeing the inline section is "visiting" the discussion: the unread notion
|
||||
// (used by comment notifications) resets while it is on screen.
|
||||
useEffect(() => {
|
||||
markCommentsSeen(pageId);
|
||||
return () => markCommentsSeen(pageId);
|
||||
@ -45,22 +46,15 @@ export function CommentsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const view = comments.data;
|
||||
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
|
||||
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
|
||||
|
||||
return (
|
||||
<section className="comments-panel" aria-label={t('title')}>
|
||||
<div className="comments-panel__header">
|
||||
<section id="comments" className="comments-section" aria-label={t('title')}>
|
||||
<div className="comments-section__header">
|
||||
<h2>
|
||||
{t('title')}
|
||||
{view && view.openCount > 0 && (
|
||||
<span className="comments-panel__count">{view.openCount}</span>
|
||||
{comments.data && comments.data.openCount > 0 && (
|
||||
<span className="comments-section__count">{comments.data.openCount}</span>
|
||||
)}
|
||||
</h2>
|
||||
<button type="button" className="button" onClick={onClose}>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
<FormError error={error} />
|
||||
|
||||
@ -71,14 +65,82 @@ export function CommentsPanel({
|
||||
onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))}
|
||||
/>
|
||||
) : (
|
||||
<p className="comments-panel__policy-hint">{t('composer.editorsOnly')}</p>
|
||||
<p className="comments-section__policy-hint">{t('composer.editorsOnly')}</p>
|
||||
)}
|
||||
|
||||
<CommentThreads
|
||||
view={comments.data}
|
||||
pageId={pageId}
|
||||
mayComment={mayComment}
|
||||
run={run}
|
||||
readOnly={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only comments for the anonymous public view (issue #133): fetched from
|
||||
* the public endpoint (no auth, no composer, no action controls). The section
|
||||
* is omitted entirely when the page has no comments, so public pages stay clean.
|
||||
*/
|
||||
export function PublicComments({
|
||||
pondSlug,
|
||||
pageSlug,
|
||||
}: {
|
||||
pondSlug: string;
|
||||
pageSlug: string;
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useTranslation('comments');
|
||||
const query = useQuery({
|
||||
queryKey: ['public-comments', pondSlug, pageSlug],
|
||||
queryFn: () => apiGet<PageCommentsView>(`/public/${pondSlug}/${pageSlug}/comments`),
|
||||
enabled: Boolean(pondSlug && pageSlug),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const view = query.data;
|
||||
if (!view || view.threads.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="comments-section comments-section--readonly" aria-label={t('title')}>
|
||||
<div className="comments-section__header">
|
||||
<h2>
|
||||
{t('title')}
|
||||
{view.openCount > 0 && <span className="comments-section__count">{view.openCount}</span>}
|
||||
</h2>
|
||||
</div>
|
||||
<CommentThreads view={view} pageId="" mayComment={false} run={noop} readOnly />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const noop = async (): Promise<void> => {};
|
||||
|
||||
function CommentThreads({
|
||||
view,
|
||||
pageId,
|
||||
mayComment,
|
||||
run,
|
||||
readOnly,
|
||||
}: {
|
||||
view: PageCommentsView | undefined;
|
||||
pageId: string;
|
||||
mayComment: boolean;
|
||||
run: (action: () => Promise<unknown>) => Promise<void>;
|
||||
readOnly: boolean;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('comments');
|
||||
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
|
||||
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
|
||||
|
||||
return (
|
||||
<>
|
||||
{view && open.length === 0 && resolved.length === 0 && (
|
||||
<p className="comments-panel__empty">{t('empty')}</p>
|
||||
<p className="comments-section__empty">{t('empty')}</p>
|
||||
)}
|
||||
|
||||
<ul className="comments-panel__threads">
|
||||
<ul className="comments-section__threads">
|
||||
{open.map((thread) => (
|
||||
<Thread
|
||||
key={thread.root.id}
|
||||
@ -86,14 +148,15 @@ export function CommentsPanel({
|
||||
pageId={pageId}
|
||||
mayComment={mayComment}
|
||||
run={run}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<details className="comments-panel__resolved">
|
||||
<details className="comments-section__resolved">
|
||||
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
|
||||
<ul className="comments-panel__threads">
|
||||
<ul className="comments-section__threads">
|
||||
{resolved.map((thread) => (
|
||||
<Thread
|
||||
key={thread.root.id}
|
||||
@ -101,12 +164,13 @@ export function CommentsPanel({
|
||||
pageId={pageId}
|
||||
mayComment={mayComment}
|
||||
run={run}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -115,26 +179,42 @@ function Thread({
|
||||
pageId,
|
||||
mayComment,
|
||||
run,
|
||||
readOnly,
|
||||
}: {
|
||||
thread: CommentThreadView;
|
||||
pageId: string;
|
||||
mayComment: boolean;
|
||||
run: (action: () => Promise<unknown>) => Promise<void>;
|
||||
readOnly: boolean;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('comments');
|
||||
const [replying, setReplying] = useState(false);
|
||||
|
||||
return (
|
||||
<li className={`comments-thread${thread.resolved ? ' comments-thread--resolved' : ''}`}>
|
||||
<CommentItem comment={thread.root} run={run} isRoot resolved={thread.resolved} />
|
||||
<CommentItem
|
||||
comment={thread.root}
|
||||
run={run}
|
||||
isRoot
|
||||
resolved={thread.resolved}
|
||||
mayComment={mayComment}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<ul className="comments-thread__replies">
|
||||
{thread.replies.map((reply) => (
|
||||
<li key={reply.id}>
|
||||
<CommentItem comment={reply} run={run} isRoot={false} resolved={thread.resolved} />
|
||||
<CommentItem
|
||||
comment={reply}
|
||||
run={run}
|
||||
isRoot={false}
|
||||
resolved={thread.resolved}
|
||||
mayComment={mayComment}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{mayComment && !thread.resolved && (
|
||||
{!readOnly && mayComment && !thread.resolved && (
|
||||
<div className="comments-thread__actions">
|
||||
{replying ? (
|
||||
<Composer
|
||||
@ -169,11 +249,15 @@ function CommentItem({
|
||||
run,
|
||||
isRoot,
|
||||
resolved,
|
||||
mayComment,
|
||||
readOnly,
|
||||
}: {
|
||||
comment: CommentView;
|
||||
run: (action: () => Promise<unknown>) => Promise<void>;
|
||||
isRoot: boolean;
|
||||
resolved: boolean;
|
||||
mayComment: boolean;
|
||||
readOnly: boolean;
|
||||
}): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('comments');
|
||||
const { user } = useAuth();
|
||||
@ -205,40 +289,47 @@ function CommentItem({
|
||||
// Server-sanitized render (shared pipeline, issue #91) — safe by contract.
|
||||
<div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} />
|
||||
)}
|
||||
<footer className="comment__actions">
|
||||
{own && !editing && (
|
||||
<>
|
||||
<button type="button" className="comments-link-button" onClick={() => setEditing(true)}>
|
||||
{t('edit.open')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isRoot &&
|
||||
(resolved ? (
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
|
||||
>
|
||||
{t('unresolve')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
|
||||
>
|
||||
{t('resolve')}
|
||||
</button>
|
||||
))}
|
||||
</footer>
|
||||
{!readOnly && (
|
||||
<footer className="comment__actions">
|
||||
{own && !editing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{t('edit.open')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isRoot &&
|
||||
mayComment &&
|
||||
(resolved ? (
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
|
||||
>
|
||||
{t('unresolve')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="comments-link-button"
|
||||
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
|
||||
>
|
||||
{t('resolve')}
|
||||
</button>
|
||||
))}
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useWikilinks } from './wikilink-context';
|
||||
|
||||
/** An open `[[` context: the query typed so far and where its `[[` began. */
|
||||
/** An open `[[` context: the query typed so far and where its `[[` began.
|
||||
* `embed` is true when it was opened as `![[` — a page embed (issue #135). */
|
||||
interface QueryState {
|
||||
query: string;
|
||||
from: number;
|
||||
embed: boolean;
|
||||
coords: { left: number; bottom: number };
|
||||
}
|
||||
|
||||
@ -16,18 +18,21 @@ interface QueryState {
|
||||
type Suggestion =
|
||||
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
|
||||
|
||||
/** Detects a `[[query` immediately before a collapsed cursor (issue #46). */
|
||||
function detectQuery(editor: Editor): { query: string; from: number } | null {
|
||||
/** Detects a `[[query` (link) or `![[query` (embed, #135) immediately before a
|
||||
* collapsed cursor (issue #46). The optional leading `!` opens an embed. */
|
||||
function detectQuery(editor: Editor): { query: string; from: number; embed: boolean } | null {
|
||||
const { selection } = editor.state;
|
||||
if (!selection.empty) return null;
|
||||
const $from = selection.$from;
|
||||
if (!$from.parent.isTextblock) return null;
|
||||
const start = Math.max(0, $from.parentOffset - 200);
|
||||
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
|
||||
const match = /\[\[([^[\]\n]*)$/.exec(before);
|
||||
const match = /(!?)\[\[([^[\]\n]*)$/.exec(before);
|
||||
if (!match) return null;
|
||||
const query = match[1] ?? '';
|
||||
return { query, from: selection.from - query.length - 2 };
|
||||
const embed = match[1] === '!';
|
||||
const query = match[2] ?? '';
|
||||
// `[[` is 2 chars; an embed's leading `!` is one more to swallow.
|
||||
return { query, from: selection.from - query.length - 2 - (embed ? 1 : 0), embed };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -70,14 +75,28 @@ export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX.
|
||||
function choose(item: Suggestion | undefined): void {
|
||||
const current = live.current.state;
|
||||
if (!item || !current) return;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt({ from: current.from, to: editor.state.selection.from }, [
|
||||
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
|
||||
{ type: 'text', text: ' ' },
|
||||
])
|
||||
.run();
|
||||
const range = { from: current.from, to: editor.state.selection.from };
|
||||
if (current.embed) {
|
||||
// A page embed is a block node (#135) — replace the typed `![[query` with
|
||||
// the transclusion block; ProseMirror lifts it out of the paragraph.
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, {
|
||||
type: 'transclusion',
|
||||
attrs: { targetSlug: item.slug, displayText: null },
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
|
||||
{ type: 'text', text: ' ' },
|
||||
])
|
||||
.run();
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
||||
import { PluginBlock } from './nodes/plugin-block';
|
||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||
import { TaskItem } from './nodes/task-item';
|
||||
import { Transclusion } from './nodes/transclusion';
|
||||
import { Wikilink } from './nodes/wikilink';
|
||||
import {
|
||||
Blockquote,
|
||||
@ -43,6 +44,7 @@ export const documentExtensions: AnyExtension[] = [
|
||||
Image,
|
||||
PluginBlock,
|
||||
Wikilink,
|
||||
Transclusion,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
|
||||
88
apps/web/src/editor/nodes/transclusion.tsx
Normal file
88
apps/web/src/editor/nodes/transclusion.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Node } from '@tiptap/core';
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import type { NodeViewProps } from '@tiptap/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { apiGet } from '../../lib/api';
|
||||
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||
import { useWikilinks } from '../wikilink-context';
|
||||
|
||||
interface RenderedContent {
|
||||
title: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `![[page embed]]` (issue #135). In edit mode — and while loading or
|
||||
* when the target is missing/unreadable — it shows a compact placeholder card
|
||||
* (title + open link) so writing stays fast. In read mode it fetches the
|
||||
* target's server-rendered HTML (`/read/:pond/:slug`, permission-checked, with
|
||||
* nested embeds already expanded) and shows it inline.
|
||||
*/
|
||||
function TransclusionView({ node, editor }: NodeViewProps): React.JSX.Element {
|
||||
const { t } = useTranslation('editor');
|
||||
const { resolve, pondSlug } = useWikilinks();
|
||||
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
const display = node.attrs.displayText as string | null;
|
||||
const { title, exists } = resolve(slug);
|
||||
const label = display ?? title ?? slug;
|
||||
const editable = editor.isEditable;
|
||||
|
||||
const content = useQuery({
|
||||
queryKey: ['rendered', pondSlug, slug],
|
||||
queryFn: () => apiGet<RenderedContent>(`/read/${pondSlug}/${slug}`),
|
||||
enabled: !editable && exists,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (editable || !exists || content.isError) {
|
||||
return (
|
||||
<NodeViewWrapper className="dt-transclusion-card" contentEditable={false}>
|
||||
<span className="dt-transclusion-card__icon" aria-hidden>
|
||||
⧉
|
||||
</span>
|
||||
<span className="dt-transclusion-card__label">
|
||||
{t('transclusion.embedded', { title: label })}
|
||||
</span>
|
||||
<Link className="dt-transclusion-card__open" to={`/p/${pondSlug}/${slug}`}>
|
||||
{t('transclusion.open')}
|
||||
</Link>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="dt-embed" contentEditable={false}>
|
||||
<div className="dt-embed__title">
|
||||
<Link className="wikilink" to={`/p/${pondSlug}/${slug}`}>
|
||||
{content.data?.title ?? label}
|
||||
</Link>
|
||||
</div>
|
||||
{content.data ? (
|
||||
// Server-sanitized read HTML (shared docToHtml pipeline) — safe by
|
||||
// contract, same as the public view.
|
||||
<div className="dt-embed__body" dangerouslySetInnerHTML={{ __html: content.data.html }} />
|
||||
) : (
|
||||
<div className="dt-embed__body" aria-busy="true" />
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const transclusionSpec = nodeSpec('transclusion');
|
||||
export const Transclusion = Node.create({
|
||||
name: 'transclusion',
|
||||
group: transclusionSpec.group,
|
||||
atom: transclusionSpec.atom,
|
||||
addAttributes() {
|
||||
return attributesFromSpec(transclusionSpec);
|
||||
},
|
||||
parseHTML: () => transclusionSpec.parseDOM,
|
||||
renderHTML: ({ node }) => transclusionSpec.toDOM!(node),
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(TransclusionView);
|
||||
},
|
||||
});
|
||||
22
apps/web/src/lib/word-count.ts
Normal file
22
apps/web/src/lib/word-count.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Approximate word count from plain text (issue #134). Splits on whitespace
|
||||
* and keeps only tokens that contain a letter or digit, so markdown/HTML
|
||||
* punctuation (`##`, `-`, `|`, `>`) never inflates the count.
|
||||
*/
|
||||
export function countWords(text: string): number {
|
||||
if (!text) return 0;
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML to its visible text using the browser's parser (issue #134). Used
|
||||
* by the public read view to derive a word count from the server-rendered HTML
|
||||
* without loading the editor bundle.
|
||||
*/
|
||||
export function htmlToText(html: string): string {
|
||||
if (!html) return '';
|
||||
return new DOMParser().parseFromString(html, 'text/html').body.textContent ?? '';
|
||||
}
|
||||
@ -7,7 +7,6 @@ import {
|
||||
Ellipsis,
|
||||
FolderInput,
|
||||
History,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Save,
|
||||
@ -35,9 +34,6 @@ interface PageActionsProps {
|
||||
pondSlug: string;
|
||||
mode: 'view' | 'edit';
|
||||
onToggleMode: () => void;
|
||||
unread: number;
|
||||
showComments: boolean;
|
||||
onToggleComments: () => void;
|
||||
showAttachments: boolean;
|
||||
onToggleAttachments: () => void;
|
||||
hasTools: boolean;
|
||||
@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
|
||||
</IconButton>
|
||||
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
|
||||
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
|
||||
<IconButton
|
||||
className="editor-shell__comments-toggle"
|
||||
label={
|
||||
props.unread > 0
|
||||
? `${t('comments:toggle')} — ${t('comments:unread', { count: props.unread })}`
|
||||
: t('comments:toggle')
|
||||
}
|
||||
active={props.showComments}
|
||||
aria-expanded={props.showComments}
|
||||
onClick={props.onToggleComments}
|
||||
>
|
||||
<MessageSquare aria-hidden />
|
||||
{props.unread > 0 && <span className="comments-unread-badge">{props.unread}</span>}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="editor-shell__attachments-toggle"
|
||||
label={t('files:title')}
|
||||
|
||||
@ -11,8 +11,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { CommentsPanel } from '../comments/CommentsPanel';
|
||||
import { unreadCount, useComments } from '../comments/use-comments';
|
||||
import { CommentsSection } from '../comments/CommentsSection';
|
||||
import { FormError } from '../components/forms';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||
@ -32,9 +31,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
||||
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
||||
import { usePageActionsSlot } from '../layout/page-actions';
|
||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||
import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||
import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api';
|
||||
import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard';
|
||||
import { countWords } from '../lib/word-count';
|
||||
import { PageActions } from './PageActions';
|
||||
import { PageStatusBar } from './PageStatusBar';
|
||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
||||
import { PluginBlockContext } from '../editor/plugin-block-context';
|
||||
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
||||
@ -126,22 +127,20 @@ function PageEditor({
|
||||
page,
|
||||
mode,
|
||||
pondSlug,
|
||||
commentPolicy,
|
||||
showAttachments,
|
||||
showComments,
|
||||
showPageTools,
|
||||
onCloseAttachments,
|
||||
onCloseComments,
|
||||
onWriteAccess,
|
||||
}: {
|
||||
page: ResolvedPage;
|
||||
mode: Mode;
|
||||
pondSlug: string;
|
||||
commentPolicy: 'readers' | 'editors';
|
||||
showAttachments: boolean;
|
||||
showComments: boolean;
|
||||
showPageTools: boolean;
|
||||
onCloseAttachments: () => void;
|
||||
onCloseComments: () => void;
|
||||
/** Reports write access up so the outer page can render the inline
|
||||
* comments composer (issue #133); the collab mode lives with the provider. */
|
||||
onWriteAccess: (canWrite: boolean) => void;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('editor');
|
||||
const { user } = useAuth();
|
||||
@ -168,10 +167,12 @@ function PageEditor({
|
||||
|
||||
const collab = useCollabProvider(ydoc, page.id);
|
||||
const readOnly = collab.mode === 'ro';
|
||||
// `readers` = everyone who can see the page; `editors` = the collab token
|
||||
// explicitly granted rw (while it is still null, stay conservative — the
|
||||
// composer appears once the mode resolves).
|
||||
const mayComment = commentPolicy === 'readers' || collab.mode === 'rw';
|
||||
// The collab token's rw grant is the authoritative write signal; report it up
|
||||
// so the outer page can decide whether to show the inline comment composer
|
||||
// (issue #133). `readers`-policy ponds let anyone comment regardless.
|
||||
useEffect(() => {
|
||||
onWriteAccess(collab.mode === 'rw');
|
||||
}, [collab.mode, onWriteAccess]);
|
||||
// A revoked page can no longer be edited (issue #39); the content stays
|
||||
// visible for export via the dialog below.
|
||||
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
|
||||
@ -274,9 +275,6 @@ function PageEditor({
|
||||
onClose={onCloseAttachments}
|
||||
/>
|
||||
)}
|
||||
{showComments && (
|
||||
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
|
||||
)}
|
||||
{/* The connection status renders as an icon in the content footer
|
||||
(left half); the localized text stays for screen readers and as
|
||||
the hover tooltip. */}
|
||||
@ -338,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
const [showAttachments, setShowAttachments] = useState(false);
|
||||
// Deep link from a comment notification (issue #94): ?comments=1 opens
|
||||
// the panel immediately.
|
||||
const [showComments, setShowComments] = useState(
|
||||
() => new URLSearchParams(window.location.search).get('comments') === '1',
|
||||
);
|
||||
const [showPageTools, setShowPageTools] = useState(false);
|
||||
// Write access reported up by the editor's collab provider, so the outer page
|
||||
// can decide whether to show the inline comment composer (issue #133).
|
||||
const [canWrite, setCanWrite] = useState(false);
|
||||
const actionsSlot = usePageActionsSlot();
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useToast();
|
||||
@ -360,6 +356,16 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
enabled: Boolean(pond.data),
|
||||
});
|
||||
|
||||
// Word count for the read-mode status line (#134). Derived from the same
|
||||
// Markdown export the history panel uses (shared query key), fetched only in
|
||||
// reading mode so the editor session pays nothing.
|
||||
const pageMarkdown = useQuery({
|
||||
queryKey: ['page-markdown', page.data?.id],
|
||||
queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`),
|
||||
enabled: mode === 'view' && Boolean(page.data?.id),
|
||||
});
|
||||
const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]);
|
||||
|
||||
// Remember this page's metadata while online so it can be opened offline (#38).
|
||||
useEffect(() => {
|
||||
if (pond.data && page.data) {
|
||||
@ -448,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [pageId, user, mode, queryClient, t, showToast]);
|
||||
|
||||
// TopBar action data (issue #101): the comments badge and the plugin
|
||||
// page-tools visibility live next to the icons, not inside the editor.
|
||||
const comments = useComments(resolved?.id);
|
||||
const unread = showComments || !resolved ? 0 : unreadCount(comments.data, resolved.id, user?.id);
|
||||
// TopBar action data (issue #101): the plugin page-tools visibility lives
|
||||
// next to the icons, not inside the editor.
|
||||
const pagePlugins = usePondPlugins(resolved?.pondId);
|
||||
|
||||
// Comments are shown inline in the read view (issue #133). `readers`-policy
|
||||
// ponds let any reader comment; `editors` require the collab rw grant the
|
||||
// editor reports up via onWriteAccess.
|
||||
const mayComment = (pond.data?.settings.commentPolicy ?? 'readers') === 'readers' || canWrite;
|
||||
|
||||
// Deep link from a comment notification (issue #94): ?comments=1 scrolls to
|
||||
// the inline discussion once it has mounted in read mode.
|
||||
useEffect(() => {
|
||||
if (mode !== 'view') return;
|
||||
if (new URLSearchParams(window.location.search).get('comments') !== '1') return;
|
||||
const timer = window.setTimeout(() => {
|
||||
document.getElementById('comments')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, resolved?.id]);
|
||||
|
||||
async function saveTitle(): Promise<void> {
|
||||
if (!resolved || title === resolved.title) return;
|
||||
await apiPatch(`/pages/${resolved.id}`, { title });
|
||||
@ -494,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
pondSlug={pondSlug}
|
||||
mode={mode}
|
||||
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
|
||||
unread={unread}
|
||||
showComments={showComments}
|
||||
onToggleComments={() => setShowComments((open) => !open)}
|
||||
showAttachments={showAttachments}
|
||||
onToggleAttachments={() => setShowAttachments((open) => !open)}
|
||||
hasTools={hasPageTools(pagePlugins.data)}
|
||||
@ -521,17 +538,20 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
onBlur={() => void saveTitle()}
|
||||
/>
|
||||
</div>
|
||||
{/* Status line between the header and the article (#134): last update,
|
||||
word count, reading time — reading mode only. */}
|
||||
{mode === 'view' && page.data && (
|
||||
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
|
||||
)}
|
||||
<div className="editor-page__body">
|
||||
<PageEditor
|
||||
page={resolved}
|
||||
mode={mode}
|
||||
pondSlug={pondSlug}
|
||||
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
|
||||
showAttachments={showAttachments}
|
||||
showComments={showComments}
|
||||
showPageTools={showPageTools}
|
||||
onCloseAttachments={() => setShowAttachments(false)}
|
||||
onCloseComments={() => setShowComments(false)}
|
||||
onWriteAccess={setCanWrite}
|
||||
/>
|
||||
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||
{(showLabels || showHistory) && (
|
||||
@ -551,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
{/* "Linked from" appears below the content in read mode (issue #48);
|
||||
the local neighborhood graph joins it there (issue #113). */}
|
||||
the inline discussion (issue #133) and the local neighborhood graph
|
||||
(issue #113) follow it, in that order. */}
|
||||
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
||||
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
|
||||
{mode === 'view' && (
|
||||
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
|
||||
)}
|
||||
|
||||
44
apps/web/src/pages/PageStatusBar.tsx
Normal file
44
apps/web/src/pages/PageStatusBar.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Average silent reading speed; reading time = ceil(words / WPM). */
|
||||
const WORDS_PER_MINUTE = 200;
|
||||
|
||||
/**
|
||||
* A slim status line shown between the page header and the article body
|
||||
* (issue #134): last update, word count, and estimated reading time. Rendered
|
||||
* both in the authenticated read view and the anonymous public view, so it
|
||||
* takes already-derived values as props rather than reaching into the editor.
|
||||
*/
|
||||
export function PageStatusBar({
|
||||
updatedAt,
|
||||
wordCount,
|
||||
}: {
|
||||
updatedAt: string;
|
||||
wordCount: number;
|
||||
}): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
|
||||
const updated = useMemo(() => {
|
||||
const date = new Date(updatedAt);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date);
|
||||
}, [updatedAt, i18n.language]);
|
||||
|
||||
const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));
|
||||
|
||||
return (
|
||||
<div className="page-statusbar" aria-label={t('statusbar.label')}>
|
||||
{updated && (
|
||||
<span className="page-statusbar__item">{t('statusbar.updated', { date: updated })}</span>
|
||||
)}
|
||||
<span className="page-statusbar__item">{t('statusbar.words', { count: wordCount })}</span>
|
||||
{wordCount > 0 && (
|
||||
<span className="page-statusbar__item">{t('statusbar.readingTime', { minutes })}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { PublicComments } from '../comments/CommentsSection';
|
||||
import { ApiError, apiGet } from '../lib/api';
|
||||
import { countWords, htmlToText } from '../lib/word-count';
|
||||
import { NotFoundPage } from './NotFoundPage';
|
||||
import { PageStatusBar } from './PageStatusBar';
|
||||
|
||||
interface PublicPageContent {
|
||||
pondName: string;
|
||||
@ -31,6 +35,14 @@ export function PublicPageView(): React.JSX.Element {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Word count for the status line (#134), derived from the server-rendered
|
||||
// HTML — no editor bundle needed. Kept before the early returns so the hook
|
||||
// order stays stable.
|
||||
const wordCount = useMemo(
|
||||
() => countWords(htmlToText(query.data?.html ?? '')),
|
||||
[query.data?.html],
|
||||
);
|
||||
|
||||
if (query.error instanceof ApiError && query.error.status === 404) return <NotFoundPage />;
|
||||
if (query.isLoading || !query.data) return <div aria-busy="true" />;
|
||||
|
||||
@ -40,9 +52,12 @@ export function PublicPageView(): React.JSX.Element {
|
||||
<p className="public-page__badge">{t('readOnlyBadge')}</p>
|
||||
<p className="public-page__pond">{page.pondName}</p>
|
||||
<h1 className="public-page__title">{page.title}</h1>
|
||||
<PageStatusBar updatedAt={page.updatedAt} wordCount={wordCount} />
|
||||
{/* The HTML comes from the server's content cache (issue #24), derived
|
||||
from the sanitized editor schema — safe to render. */}
|
||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
|
||||
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@ -522,15 +522,6 @@ button {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.icon-button .comments-unread-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
margin-left: 0;
|
||||
line-height: 1.1rem;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
.page-actions__more {
|
||||
position: relative;
|
||||
}
|
||||
@ -979,6 +970,29 @@ button {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Status line between page header and article (#134). Middot-separated,
|
||||
muted; shared by the authenticated read view and the public view. */
|
||||
.page-statusbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: calc(-1 * var(--space-2));
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.page-statusbar__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-statusbar__item:not(:first-child)::before {
|
||||
content: '·';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
/* The export entries flow inline with the other overflow-menu items. */
|
||||
.editor-page__export {
|
||||
display: contents;
|
||||
@ -1581,17 +1595,44 @@ button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-content ul[data-type='task_list'] {
|
||||
/* Task lists (checkbox lists). Styled globally via the unique
|
||||
`data-type='task_list'` attribute — produced only by docToHtml and the
|
||||
editor nodeview — so checkbox and text line up in the editor
|
||||
(.editor-content) AND every read-mode container that injects docToHtml
|
||||
output (.public-page__body, .comment__body, .legal-page__body, .home-page,
|
||||
.history-panel__preview). Previously these rules were scoped to
|
||||
.editor-content and never reached the read views. Issue #137. */
|
||||
ul[data-type='task_list'] {
|
||||
list-style: none;
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
|
||||
.editor-content ul[data-type='task_list'] li {
|
||||
ul[data-type='task_list'] li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* The checkbox is the top flex child; nudge it onto the first text line and
|
||||
drop the leading/trailing margins of the item's paragraph so the text meets
|
||||
the checkbox instead of dropping a line. Covers both DOM shapes: read-mode
|
||||
`li > input` + `li > p`, and editor `li > label > input` + `li > div > p`. */
|
||||
ul[data-type='task_list'] li > input[type='checkbox'],
|
||||
ul[data-type='task_list'] li > label {
|
||||
flex: none;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
ul[data-type='task_list'] li > p:first-child,
|
||||
ul[data-type='task_list'] li > div > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
ul[data-type='task_list'] li > p:last-child,
|
||||
ul[data-type='task_list'] li > div > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-content table {
|
||||
border-collapse: collapse;
|
||||
margin: var(--space-4) 0;
|
||||
@ -2342,6 +2383,58 @@ button {
|
||||
border-bottom: 1px dashed currentColor;
|
||||
}
|
||||
|
||||
/* Page embed / transclusion (issue #135). Placeholder card shown in the editor
|
||||
and while loading; the expanded embed wraps the target's rendered content. */
|
||||
.dt-transclusion-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.dt-transclusion-card__icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.dt-transclusion-card__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dt-transclusion-card__open {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dt-embed {
|
||||
margin: var(--space-3) 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.dt-embed__title {
|
||||
margin-bottom: var(--space-2);
|
||||
font-weight: var(--font-weight-heading);
|
||||
}
|
||||
|
||||
.dt-embed__body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dt-embed__body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wikilink-suggest {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@ -3296,8 +3389,8 @@ button {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* Comments panel (issue #92) */
|
||||
.comments-panel {
|
||||
/* Inline comments section, read view (issues #92, #133) */
|
||||
.comments-section {
|
||||
border: 1px solid var(--color-border, #cbd5e1);
|
||||
border-radius: 8px;
|
||||
padding: var(--space-3);
|
||||
@ -3305,19 +3398,19 @@ button {
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.comments-panel__header {
|
||||
.comments-section__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.comments-panel__header h2 {
|
||||
.comments-section__header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.comments-panel__count {
|
||||
.comments-section__count {
|
||||
margin-left: var(--space-2);
|
||||
font-size: 0.8125rem;
|
||||
background: var(--color-surface-muted, #e2e8f0);
|
||||
@ -3325,12 +3418,12 @@ button {
|
||||
padding: 0.05rem 0.5rem;
|
||||
}
|
||||
|
||||
.comments-panel__policy-hint,
|
||||
.comments-panel__empty {
|
||||
.comments-section__policy-hint,
|
||||
.comments-section__empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.comments-panel__threads {
|
||||
.comments-section__threads {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@ -3339,16 +3432,16 @@ button {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.comments-panel__resolved {
|
||||
.comments-section__resolved {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.comments-panel__resolved > summary {
|
||||
.comments-section__resolved > summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.comments-panel__resolved > .comments-panel__threads {
|
||||
.comments-section__resolved > .comments-section__threads {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@ -3421,15 +3514,6 @@ button {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.comments-unread-badge {
|
||||
margin-left: var(--space-1);
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-primary, #2f6f4f);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
}
|
||||
|
||||
.comment-policy__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
2
packages/plugins/excalidraw/.gitignore
vendored
Normal file
2
packages/plugins/excalidraw/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
vendor/
|
||||
dist/
|
||||
115
packages/plugins/excalidraw/build.mjs
Normal file
115
packages/plugins/excalidraw/build.mjs
Normal file
@ -0,0 +1,115 @@
|
||||
// Builds the installable Excalidraw plugin. Unlike draw.io (which vendors a
|
||||
// standalone webapp) Excalidraw is an npm React component: esbuild bundles the
|
||||
// controller + React + Excalidraw into plugin.js, and its font/worker assets
|
||||
// are copied from node_modules into `excalidraw-assets/` so the runtime loads
|
||||
// them from the plugin's own asset path (EXCALIDRAW_ASSET_PATH) — nothing ever
|
||||
// talks to excalidraw.com, and the sandbox CSP pins every request to self.
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// --- 1. Locate Excalidraw's prebuilt font/worker assets --------------------
|
||||
// The package's `exports` map hides ./package.json, so resolve the main entry
|
||||
// and walk up to the package root instead.
|
||||
function packageRootOf(specifier) {
|
||||
let dir = dirname(require.resolve(specifier));
|
||||
while (dir !== dirname(dir)) {
|
||||
const pj = join(dir, 'package.json');
|
||||
if (existsSync(pj)) {
|
||||
try {
|
||||
if (JSON.parse(readFileSync(pj, 'utf8')).name === specifier) return dir;
|
||||
} catch {
|
||||
// keep walking up
|
||||
}
|
||||
}
|
||||
dir = dirname(dir);
|
||||
}
|
||||
throw new Error(`package root not found for ${specifier}`);
|
||||
}
|
||||
const excalidrawPkg = packageRootOf('@excalidraw/excalidraw');
|
||||
const prodDir = join(excalidrawPkg, 'dist', 'prod');
|
||||
if (!existsSync(prodDir)) {
|
||||
throw new Error(`Excalidraw prod build not found at ${prodDir}. Check the dist layout.`);
|
||||
}
|
||||
// Runtime assets loaded relative to EXCALIDRAW_ASSET_PATH (set to the plugin's
|
||||
// asset base in plugin.tsx): fonts render the sketch, locales translate the
|
||||
// editor UI, data holds font metadata. Copied to the ZIP root so the sandbox
|
||||
// CSP (self only) can serve them.
|
||||
const ASSET_SUBDIRS = ['fonts', 'locales', 'data'];
|
||||
|
||||
/** Recursively collect files below `dir` (absolute), keyed by path relative to
|
||||
* it, each prefixed with `prefix`. */
|
||||
function collect(dir, prefix) {
|
||||
const files = {};
|
||||
for (const name of readdirSync(dir)) {
|
||||
const abs = join(dir, name);
|
||||
const rel = `${prefix}${name}`;
|
||||
if (statSync(abs).isDirectory()) Object.assign(files, collect(abs, `${rel}/`));
|
||||
else files[rel] = readFileSync(abs);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
const assetFiles = {};
|
||||
for (const sub of ASSET_SUBDIRS) {
|
||||
const dir = join(prodDir, sub);
|
||||
if (existsSync(dir)) Object.assign(assetFiles, collect(dir, `${sub}/`));
|
||||
}
|
||||
|
||||
// --- 2. Bundle the plugin controller (React + Excalidraw) ------------------
|
||||
mkdirSync(join(root, 'dist'), { recursive: true });
|
||||
await build({
|
||||
entryPoints: [join(root, 'src/plugin.tsx')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
// Excalidraw's `exports` gate index.js / index.css behind `production` /
|
||||
// `development` conditions (no `default`); select the production build.
|
||||
conditions: ['production'],
|
||||
outfile: join(root, 'dist/plugin.js'),
|
||||
minify: true,
|
||||
jsx: 'automatic',
|
||||
loader: { '.css': 'text', '.woff2': 'dataurl', '.ttf': 'dataurl', '.svg': 'text' },
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"',
|
||||
'process.env.IS_PREACT': '"false"',
|
||||
},
|
||||
});
|
||||
|
||||
// --- 3. Pack the ZIP --------------------------------------------------------
|
||||
const files = {
|
||||
'manifest.json': readFileSync(join(root, 'manifest.json')),
|
||||
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
|
||||
};
|
||||
for (const name of readdirSync(join(root, 'i18n'))) {
|
||||
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
|
||||
}
|
||||
// Asset paths already carry their subdir prefix (fonts/…, locales/…, data/…)
|
||||
// and sit at the ZIP root so they resolve under EXCALIDRAW_ASSET_PATH.
|
||||
Object.assign(files, assetFiles);
|
||||
|
||||
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
|
||||
rmSync(target, { force: true });
|
||||
writeFileSync(target, zipSync(files, { level: 6 }));
|
||||
const unpacked = Object.values(files).reduce((sum, bytes) => sum + bytes.length, 0);
|
||||
console.log(
|
||||
`wrote ${relative(process.cwd(), target)} ` +
|
||||
`(zip ${(statSync(target).size / 1024 / 1024).toFixed(1)} MiB, ` +
|
||||
`unpacked ${(unpacked / 1024 / 1024).toFixed(1)} MiB, ` +
|
||||
`${Object.keys(files).length} files)`,
|
||||
);
|
||||
10
packages/plugins/excalidraw/i18n/de.json
Normal file
10
packages/plugins/excalidraw/i18n/de.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"empty": "Noch keine Skizze.",
|
||||
"editButton": "Skizze im Vollbild bearbeiten",
|
||||
"createButton": "Skizze erstellen",
|
||||
"loading": "Excalidraw wird geladen …",
|
||||
"save": "Speichern & Beenden",
|
||||
"cancel": "Abbrechen",
|
||||
"saved": "Gespeichert — „Fertig“ schließt den Bearbeiten-Modus.",
|
||||
"renderHint": "Zum Bearbeiten in den Bearbeiten-Modus der Seite wechseln."
|
||||
}
|
||||
10
packages/plugins/excalidraw/i18n/en.json
Normal file
10
packages/plugins/excalidraw/i18n/en.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"empty": "No sketch yet.",
|
||||
"editButton": "Edit sketch in fullscreen",
|
||||
"createButton": "Create sketch",
|
||||
"loading": "Loading Excalidraw …",
|
||||
"save": "Save & Exit",
|
||||
"cancel": "Cancel",
|
||||
"saved": "Saved — \"Done\" closes edit mode.",
|
||||
"renderHint": "Switch the page to edit mode to change the sketch."
|
||||
}
|
||||
19
packages/plugins/excalidraw/manifest.json
Normal file
19
packages/plugins/excalidraw/manifest.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "excalidraw",
|
||||
"name": "Excalidraw Whiteboard",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1",
|
||||
"kind": "code",
|
||||
"extensionPoints": [
|
||||
{
|
||||
"type": "block",
|
||||
"id": "diagram",
|
||||
"title": { "de": "Excalidraw-Skizze", "en": "Excalidraw sketch" }
|
||||
}
|
||||
],
|
||||
"permissions": ["blockData", "ui"],
|
||||
"fallback": { "type": "text", "value": "[Excalidraw]" },
|
||||
"license": "MIT",
|
||||
"homepage": "https://excalidraw.com",
|
||||
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
|
||||
}
|
||||
25
packages/plugins/excalidraw/package.json
Normal file
25
packages/plugins/excalidraw/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dorfteich/plugin-excalidraw",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Reference block plugin: Excalidraw hand-drawn whiteboard sketches — fullscreen editing with the bundled Excalidraw editor, inline SVG rendering",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dorfteich/plugin-sdk": "workspace:*",
|
||||
"@excalidraw/excalidraw": "0.18.1",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"fflate": "^0.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
4
packages/plugins/excalidraw/src/globals.d.ts
vendored
Normal file
4
packages/plugins/excalidraw/src/globals.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
// The Excalidraw stylesheet is bundled as a string via esbuild's `.css` → text
|
||||
// loader (see build.mjs) and injected at runtime; declare the module so the
|
||||
// import type-checks.
|
||||
declare module '@excalidraw/excalidraw/index.css';
|
||||
268
packages/plugins/excalidraw/src/plugin.tsx
Normal file
268
packages/plugins/excalidraw/src/plugin.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk';
|
||||
import { Excalidraw, exportToSvg, serializeAsJSON } from '@excalidraw/excalidraw';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
/** The subset of Excalidraw's imperative API this plugin uses — kept local so
|
||||
* the deep types-path export (which moves between versions) is not imported. */
|
||||
interface ExcalidrawApi {
|
||||
getSceneElements: () => readonly unknown[];
|
||||
getAppState: () => Record<string, unknown>;
|
||||
getFiles: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Bundled as a string (esbuild `.css` → text loader) and injected once, since
|
||||
// the sandbox frame document loads only `plugin.js` and the CSP forbids remote
|
||||
// stylesheets.
|
||||
import excalidrawCss from '@excalidraw/excalidraw/index.css';
|
||||
|
||||
import de from '../i18n/de.json';
|
||||
import en from '../i18n/en.json';
|
||||
|
||||
/**
|
||||
* Excalidraw reference plugin: hand-drawn whiteboard sketches edited in the
|
||||
* REAL Excalidraw editor, which the package bundles (npm, esbuild) together
|
||||
* with its font assets — nothing is ever loaded from excalidraw.com; the
|
||||
* sandbox CSP pins every request to the plugin's own version-pinned asset path
|
||||
* (`EXCALIDRAW_ASSET_PATH`, set below).
|
||||
*
|
||||
* Block data: `{ scene, svg }` — `scene` is the Excalidraw scene as a JSON
|
||||
* string (document of record), `svg` the rendered snapshot as raw markup, so
|
||||
* render mode, office/PDF exports (fallback renderer) and the public read view
|
||||
* can all show the sketch without running any editor code.
|
||||
*/
|
||||
const STRINGS: Record<string, Record<string, string>> = { de, en };
|
||||
|
||||
function labelFor(locale: string, key: string): string {
|
||||
const base = locale.split('-')[0] ?? locale;
|
||||
return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key;
|
||||
}
|
||||
|
||||
interface SketchData {
|
||||
/** Excalidraw scene as a serialized JSON string (serializeAsJSON). */
|
||||
scene?: string;
|
||||
/** Rendered snapshot as raw SVG markup. */
|
||||
svg?: string;
|
||||
}
|
||||
|
||||
function dataOf(context: RenderContext): SketchData {
|
||||
return context.data && typeof context.data === 'object' ? (context.data as SketchData) : {};
|
||||
}
|
||||
|
||||
// Fonts, locales and font metadata load from the plugin's own asset path (the
|
||||
// directory this module was served from), which the sandbox CSP allows.
|
||||
// Excalidraw resolves them relative to EXCALIDRAW_ASSET_PATH (e.g. `fonts/…`);
|
||||
// build.mjs copies dist/prod/{fonts,locales,data} to the ZIP root.
|
||||
(window as unknown as { EXCALIDRAW_ASSET_PATH: string }).EXCALIDRAW_ASSET_PATH = new URL(
|
||||
'.',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
let cssInjected = false;
|
||||
function ensureCss(): void {
|
||||
if (cssInjected) return;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = excalidrawCss;
|
||||
document.head.appendChild(style);
|
||||
cssInjected = true;
|
||||
}
|
||||
|
||||
const { host } = createPlugin({
|
||||
transport: windowTransport({
|
||||
target: { postMessage: (message) => window.parent.postMessage(message, '*') },
|
||||
source: window,
|
||||
}),
|
||||
onRender: (context) => renderMode(context),
|
||||
onEdit: (context) => editMode(context),
|
||||
onDestroy: () => closeEditor(),
|
||||
});
|
||||
|
||||
function resize(): void {
|
||||
void host.ui.resize(Math.max(64, document.body.scrollHeight + 16));
|
||||
}
|
||||
|
||||
/** The stored snapshot as an inline drawing, or the empty-state hint. */
|
||||
function snapshotElement(data: SketchData, locale: string): HTMLElement {
|
||||
if (data.svg && data.svg.trim() !== '') {
|
||||
const holder = document.createElement('div');
|
||||
holder.className = 'dt-excalidraw__snapshot';
|
||||
holder.innerHTML = data.svg;
|
||||
const svg = holder.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.style.maxWidth = '100%';
|
||||
svg.style.height = 'auto';
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
const hint = document.createElement('p');
|
||||
hint.textContent = labelFor(locale, 'empty');
|
||||
hint.style.color = '#6b7280';
|
||||
return hint;
|
||||
}
|
||||
|
||||
function renderMode(context: RenderContext): void {
|
||||
closeEditor();
|
||||
const data = dataOf(context);
|
||||
document.body.textContent = '';
|
||||
document.body.className = 'dt-excalidraw dt-excalidraw--render';
|
||||
document.body.appendChild(snapshotElement(data, context.locale));
|
||||
resize();
|
||||
}
|
||||
|
||||
function editMode(context: RenderContext): void {
|
||||
closeEditor();
|
||||
const data = dataOf(context);
|
||||
document.body.textContent = '';
|
||||
document.body.className = 'dt-excalidraw dt-excalidraw--edit';
|
||||
|
||||
document.body.appendChild(snapshotElement(data, context.locale));
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = labelFor(context.locale, data.svg ? 'editButton' : 'createButton');
|
||||
button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;';
|
||||
button.addEventListener('click', () => void openEditor(context));
|
||||
document.body.appendChild(button);
|
||||
resize();
|
||||
|
||||
// A block that has no sketch yet goes straight into the editor.
|
||||
if (!data.svg && !data.scene) void openEditor(context);
|
||||
}
|
||||
|
||||
/** The active fullscreen editing session, if any. */
|
||||
let session: { container: HTMLDivElement; root: Root } | null = null;
|
||||
|
||||
function closeEditor(): void {
|
||||
if (!session) return;
|
||||
const { container, root } = session;
|
||||
session = null;
|
||||
root.unmount();
|
||||
container.remove();
|
||||
void host.ui.exitFullscreen();
|
||||
}
|
||||
|
||||
function parseScene(scene: string | undefined): {
|
||||
elements: readonly unknown[];
|
||||
appState: Record<string, unknown>;
|
||||
files: Record<string, unknown>;
|
||||
} {
|
||||
if (!scene) return { elements: [], appState: {}, files: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(scene) as {
|
||||
elements?: unknown[];
|
||||
appState?: Record<string, unknown>;
|
||||
files?: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
elements: parsed.elements ?? [],
|
||||
appState: parsed.appState ?? {},
|
||||
files: parsed.files ?? {},
|
||||
};
|
||||
} catch {
|
||||
return { elements: [], appState: {}, files: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditor(context: RenderContext): Promise<void> {
|
||||
if (session) return;
|
||||
ensureCss();
|
||||
const data = dataOf(context);
|
||||
const initial = parseScene(data.scene);
|
||||
|
||||
await host.ui.enterFullscreen();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'dt-excalidraw__editor';
|
||||
container.style.cssText =
|
||||
'position:fixed;inset:0;background:#fff;display:flex;flex-direction:column;';
|
||||
document.body.appendChild(container);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.style.cssText =
|
||||
'display:flex;gap:8px;justify-content:flex-end;padding:8px;border-bottom:1px solid #e5e7eb;background:#fff;z-index:5;';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.textContent = labelFor(context.locale, 'cancel');
|
||||
cancel.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;';
|
||||
const save = document.createElement('button');
|
||||
save.type = 'button';
|
||||
save.textContent = labelFor(context.locale, 'save');
|
||||
save.style.cssText = 'padding:6px 12px;cursor:pointer;font:inherit;font-weight:600;';
|
||||
bar.append(cancel, save);
|
||||
container.appendChild(bar);
|
||||
|
||||
const canvas = document.createElement('div');
|
||||
canvas.style.cssText = 'flex:1;min-height:0;position:relative;';
|
||||
container.appendChild(canvas);
|
||||
|
||||
let api: ExcalidrawApi | null = null;
|
||||
const root = createRoot(canvas);
|
||||
session = { container, root };
|
||||
|
||||
root.render(
|
||||
createElement(Excalidraw, {
|
||||
initialData: {
|
||||
elements: initial.elements,
|
||||
appState: initial.appState,
|
||||
files: initial.files,
|
||||
},
|
||||
excalidrawAPI: (instance: ExcalidrawApi) => {
|
||||
api = instance;
|
||||
},
|
||||
} as Parameters<typeof Excalidraw>[0]),
|
||||
);
|
||||
|
||||
cancel.addEventListener('click', () => {
|
||||
closeEditor();
|
||||
finishEdit(dataOf(context), context.locale);
|
||||
});
|
||||
|
||||
save.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
if (!api) return;
|
||||
const elements = api.getSceneElements();
|
||||
const appState = api.getAppState();
|
||||
const files = api.getFiles();
|
||||
const scene = serializeAsJSON(
|
||||
elements as Parameters<typeof serializeAsJSON>[0],
|
||||
appState as Parameters<typeof serializeAsJSON>[1],
|
||||
files as Parameters<typeof serializeAsJSON>[2],
|
||||
'local',
|
||||
);
|
||||
const svgEl = await exportToSvg({
|
||||
elements,
|
||||
appState: { ...appState, exportBackground: true, exportWithDarkMode: false },
|
||||
files,
|
||||
} as Parameters<typeof exportToSvg>[0]);
|
||||
const svg = new XMLSerializer().serializeToString(svgEl);
|
||||
await host.blockData.setData({ scene, svg } satisfies SketchData);
|
||||
closeEditor();
|
||||
finishEdit({ scene, svg }, context.locale);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** Redraws the inline edit surface after the fullscreen editor closed. */
|
||||
function finishEdit(data: SketchData, locale: string): void {
|
||||
document.body.textContent = '';
|
||||
document.body.className = 'dt-excalidraw dt-excalidraw--edit';
|
||||
document.body.appendChild(snapshotElement(data, locale));
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = labelFor(locale, data.svg ? 'editButton' : 'createButton');
|
||||
button.style.cssText = 'display:block;margin:8px 0;padding:6px 12px;cursor:pointer;font:inherit;';
|
||||
button.addEventListener(
|
||||
'click',
|
||||
() => void openEditor({ extensionPointId: 'diagram', locale, data }),
|
||||
);
|
||||
document.body.appendChild(button);
|
||||
|
||||
if (data.svg) {
|
||||
const note = document.createElement('p');
|
||||
note.textContent = labelFor(locale, 'saved');
|
||||
note.style.cssText = 'color:#6b7280;font-size:13px;';
|
||||
document.body.appendChild(note);
|
||||
}
|
||||
resize();
|
||||
}
|
||||
12
packages/plugins/excalidraw/tsconfig.json
Normal file
12
packages/plugins/excalidraw/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src", "*.ts"]
|
||||
}
|
||||
@ -1,4 +1,11 @@
|
||||
{
|
||||
"statusbar": {
|
||||
"label": "Seiteninformationen",
|
||||
"updated": "Aktualisiert {{date}}",
|
||||
"words_one": "{{count}} Wort",
|
||||
"words_other": "{{count}} Wörter",
|
||||
"readingTime": "ca. {{minutes}} Min. Lesezeit"
|
||||
},
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"expand": "Seitenleiste einblenden",
|
||||
|
||||
@ -173,6 +173,10 @@
|
||||
"autocompleteLabel": "Auf eine Seite verlinken",
|
||||
"createHint": "Seite „{{title}}“ anlegen"
|
||||
},
|
||||
"transclusion": {
|
||||
"embedded": "Eingebettet: {{title}}",
|
||||
"open": "Öffnen"
|
||||
},
|
||||
"notFound": {
|
||||
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
||||
"create": "Seite „{{slug}}“ anlegen"
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
{
|
||||
"statusbar": {
|
||||
"label": "Page information",
|
||||
"updated": "Updated {{date}}",
|
||||
"words_one": "{{count}} word",
|
||||
"words_other": "{{count}} words",
|
||||
"readingTime": "~{{minutes}} min read"
|
||||
},
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"expand": "Show sidebar",
|
||||
|
||||
@ -173,6 +173,10 @@
|
||||
"autocompleteLabel": "Link to a page",
|
||||
"createHint": "Create page “{{title}}”"
|
||||
},
|
||||
"transclusion": {
|
||||
"embedded": "Embedded: {{title}}",
|
||||
"open": "Open"
|
||||
},
|
||||
"notFound": {
|
||||
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
||||
"create": "Create the page “{{slug}}”"
|
||||
|
||||
@ -132,6 +132,15 @@ function renderBlock(node: Node): string {
|
||||
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
|
||||
);
|
||||
}
|
||||
case 'transclusion': {
|
||||
// A page embed (#135). The static HTML is a placeholder carrying the
|
||||
// target slug; the read view / public renderer expands it server-side to
|
||||
// the target page's rendered HTML (permission-checked, recursion limited).
|
||||
// Left un-expanded (here) it degrades to a labelled block.
|
||||
const slug = escapeHtml(node.attrs.targetSlug as string);
|
||||
const display = node.attrs.displayText ? escapeHtml(node.attrs.displayText as string) : slug;
|
||||
return `<div class="dt-transclusion" data-transclusion="${slug}">${display}</div>`;
|
||||
}
|
||||
case 'code_block':
|
||||
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
|
||||
case 'horizontal_rule':
|
||||
|
||||
@ -192,6 +192,35 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page (#135). */
|
||||
const TRANSCLUSION_LINE = /^!\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/;
|
||||
|
||||
/**
|
||||
* Block rule for page embeds (issue #135). A line consisting solely of
|
||||
* `![[slug]]` becomes a `transclusion` block node; anything else (including
|
||||
* `![[x]]` mid-paragraph) is left untouched. Registered before `paragraph` so
|
||||
* the lone-embed line is not swallowed as ordinary text.
|
||||
*/
|
||||
function transclusionRule(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
_endLine: number,
|
||||
silent: boolean,
|
||||
): boolean {
|
||||
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
|
||||
const max = state.eMarks[startLine]!;
|
||||
const match = TRANSCLUSION_LINE.exec(state.src.slice(start, max));
|
||||
if (!match) return false;
|
||||
if (silent) return true;
|
||||
const token = state.push('transclusion', '', 0);
|
||||
token.attrSet('target', match[1]!.trim());
|
||||
const display = match[2]?.trim();
|
||||
if (display) token.attrSet('display', display);
|
||||
token.map = [startLine, startLine + 1];
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */
|
||||
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
|
||||
const SECTION_CLOSE = /^:::+\s*$/;
|
||||
@ -248,6 +277,9 @@ function createTokenizer(): MarkdownIt {
|
||||
const md = new MarkdownIt('default', { html: false });
|
||||
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
||||
// as plain text (issue #135).
|
||||
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
||||
// Run before `fence` so `:::` is not read as a code fence.
|
||||
md.block.ruler.before('fence', 'section', sectionRule);
|
||||
const rawParse = md.parse.bind(md);
|
||||
@ -312,6 +344,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
||||
displayText: tok.attrGet('display') || null,
|
||||
}),
|
||||
},
|
||||
transclusion: {
|
||||
node: 'transclusion',
|
||||
getAttrs: (tok) => ({
|
||||
targetSlug: tok.attrGet('target') ?? '',
|
||||
displayText: tok.attrGet('display') || null,
|
||||
}),
|
||||
},
|
||||
em: { mark: 'italic' },
|
||||
strong: { mark: 'bold' },
|
||||
s: { mark: 'strikethrough' },
|
||||
@ -435,6 +474,12 @@ const markdownSerializer = new MarkdownSerializer(
|
||||
const display = node.attrs.displayText as string | null;
|
||||
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
||||
},
|
||||
transclusion(state, node) {
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
const display = node.attrs.displayText as string | null;
|
||||
state.write(display ? `![[${slug}|${display}]]` : `![[${slug}]]`);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
hard_break(state, node, parent, index) {
|
||||
for (let i = index + 1; i < parent.childCount; i += 1) {
|
||||
if (parent.child(i).type !== node.type) {
|
||||
|
||||
@ -7,8 +7,9 @@ import { tableNodes } from 'prosemirror-tables';
|
||||
* validation (ADR 0008) all import this schema instead of defining their
|
||||
* own, so "valid document" means the same thing everywhere.
|
||||
*
|
||||
* Node names `wikilink` and `plugin_block` are reserved for these features
|
||||
* (wikilinks #46, plugin-defined block types #76) — do not repurpose them.
|
||||
* Node names `wikilink`, `plugin_block`, and `transclusion` are reserved for
|
||||
* these features (wikilinks #46, plugin-defined block types #76, page embeds
|
||||
* #135) — do not repurpose them.
|
||||
*/
|
||||
export const editorSchema = new Schema({
|
||||
nodes: {
|
||||
@ -255,6 +256,40 @@ export const editorSchema = new Schema({
|
||||
},
|
||||
},
|
||||
|
||||
// Obsidian-style page embed `![[slug]]` (issue #135). A block atom that
|
||||
// references another page by slug; the read view / public renderer expands
|
||||
// it to the target page's rendered HTML (permission-checked, recursion
|
||||
// limited), while the editor shows a placeholder card. Like `wikilink` it
|
||||
// stores slug + optional display text only — live resolution happens where
|
||||
// the pond's pages are known.
|
||||
transclusion: {
|
||||
group: 'block',
|
||||
atom: true,
|
||||
attrs: {
|
||||
targetSlug: { validate: 'string' },
|
||||
displayText: { default: null },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-transclusion]',
|
||||
getAttrs: (dom) => ({
|
||||
targetSlug: dom.getAttribute('data-transclusion'),
|
||||
displayText: dom.getAttribute('data-display') || null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
const display = node.attrs.displayText as string | null;
|
||||
const attrs: Record<string, string> = {
|
||||
'data-transclusion': slug,
|
||||
class: 'dt-transclusion',
|
||||
};
|
||||
if (display) attrs['data-display'] = display;
|
||||
return ['div', attrs, display ?? slug];
|
||||
},
|
||||
},
|
||||
|
||||
...tableNodes({ tableGroup: 'block', cellContent: 'block+', cellAttributes: {} }),
|
||||
},
|
||||
|
||||
|
||||
50
packages/shared/src/editor-schema/transclusion.test.ts
Normal file
50
packages/shared/src/editor-schema/transclusion.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Node } from 'prosemirror-model';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { docToHtml } from './html';
|
||||
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||
import { extractWikilinkSlugs } from './wikilinks';
|
||||
|
||||
function firstTransclusion(doc: Node): Node | null {
|
||||
let found: Node | null = null;
|
||||
doc.descendants((node) => {
|
||||
if (!found && node.type.name === 'transclusion') found = node;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('page embed / transclusion (issue #135)', () => {
|
||||
it('parses a lone ![[slug]] line to a transclusion block and round-trips', () => {
|
||||
const doc = markdownToDoc('Intro\n\n![[rennrad]]\n\nOutro');
|
||||
const node = firstTransclusion(doc);
|
||||
expect(node).not.toBeNull();
|
||||
expect(node!.attrs.targetSlug).toBe('rennrad');
|
||||
expect(node!.attrs.displayText).toBeNull();
|
||||
expect(docToMarkdown(doc)).toContain('![[rennrad]]');
|
||||
});
|
||||
|
||||
it('supports optional display text and round-trips it', () => {
|
||||
const doc = markdownToDoc('![[rennrad|Mein Rad]]');
|
||||
const node = firstTransclusion(doc);
|
||||
expect(node!.attrs.targetSlug).toBe('rennrad');
|
||||
expect(node!.attrs.displayText).toBe('Mein Rad');
|
||||
expect(docToMarkdown(doc).trim()).toBe('![[rennrad|Mein Rad]]');
|
||||
});
|
||||
|
||||
it('renders a placeholder div carrying the slug', () => {
|
||||
const doc = markdownToDoc('![[rennrad]]');
|
||||
const html = docToHtml(doc);
|
||||
expect(html).toContain('class="dt-transclusion"');
|
||||
expect(html).toContain('data-transclusion="rennrad"');
|
||||
});
|
||||
|
||||
it('registers the embed target as an outgoing link (backlinks/graph)', () => {
|
||||
const doc = markdownToDoc('![[rennrad]]\n\n[[dota]]');
|
||||
expect(extractWikilinkSlugs(doc)).toEqual(['rennrad', 'dota']);
|
||||
});
|
||||
|
||||
it('only embeds a lone line — mid-paragraph ![[x]] is not a transclusion', () => {
|
||||
const doc = markdownToDoc('see ![[rennrad]] inline');
|
||||
expect(firstTransclusion(doc)).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -1,16 +1,18 @@
|
||||
import { Node } from 'prosemirror-model';
|
||||
|
||||
/**
|
||||
* The distinct target slugs of every `[[wikilink]]` in a document (issue #47).
|
||||
* Used server-side to maintain the `page_links` index on every content change,
|
||||
* so backlinks and phantom (missing-target) links can be queried. Order of
|
||||
* first appearance is preserved; duplicates are collapsed.
|
||||
* The distinct target slugs of every `[[wikilink]]` and every `![[embed]]`
|
||||
* (transclusion, issue #135) in a document (issue #47). Used server-side to
|
||||
* maintain the `page_links` index on every content change, so backlinks and
|
||||
* phantom (missing-target) links can be queried — an embed is semantically a
|
||||
* reference, so it registers as a link too. Order of first appearance is
|
||||
* preserved; duplicates are collapsed.
|
||||
*/
|
||||
export function extractWikilinkSlugs(doc: Node): string[] {
|
||||
const slugs: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === 'wikilink') {
|
||||
if (node.type.name === 'wikilink' || node.type.name === 'transclusion') {
|
||||
const slug = node.attrs.targetSlug as string;
|
||||
if (slug && !seen.has(slug)) {
|
||||
seen.add(slug);
|
||||
|
||||
1249
pnpm-lock.yaml
generated
1249
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -12,3 +12,11 @@ allowBuilds:
|
||||
argon2: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
# Excalidraw 0.18.1 (excalidraw plugin, issue #136) transitively pulls
|
||||
# @floating-ui/react-dom@2.1.9, which requires @floating-ui/dom@^1.8.0 — a
|
||||
# version not published on the registry (latest 1.7.6), breaking `pnpm install`
|
||||
# repo-wide. Pin react-dom to 2.1.2, which needs @floating-ui/dom@^1.0.0. Only
|
||||
# the excalidraw plugin pulls @floating-ui at all; drop this once upstream
|
||||
# @floating-ui/dom@1.8.0 is published.
|
||||
overrides:
|
||||
'@floating-ui/react-dom': '2.1.2'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user