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
|
# Font catalog WOFF2 + generated stylesheet — fetched at build time
|
||||||
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
|
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
|
||||||
apps/web/public/fonts/
|
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.
|
# as pandoc reads the exported document back — Prettier would break them.
|
||||||
fixtures/export/*.expected.md
|
fixtures/export/*.expected.md
|
||||||
packages/plugins/*/vendor/
|
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 },
|
{ from: a, to: secret },
|
||||||
].sort((x, y) => x.to.localeCompare(y.to)),
|
].sort((x, y) => x.to.localeCompare(y.to)),
|
||||||
);
|
);
|
||||||
expect(graph.phantoms.map((p) => p.targetSlug).sort()).toEqual([
|
// Sort BOTH sides: phantom order is not guaranteed, and the two slugs share
|
||||||
`ghost-${suffix}`,
|
// the random suffix so their sort order flips on ~1/5 of suffixes (the
|
||||||
`ghost-secret-${suffix}`,
|
// 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 () => {
|
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 { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||||
|
import type { PageCommentsView } from '@dorfteich/shared';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { AuthedRequest, Public } from '../auth/auth.guard';
|
import { AuthedRequest, Public } from '../auth/auth.guard';
|
||||||
@ -24,6 +25,16 @@ export class PublicController {
|
|||||||
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
|
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')
|
@Get(':pondSlug/:pageSlug')
|
||||||
@Public()
|
@Public()
|
||||||
async html(
|
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);
|
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 } });
|
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
|
||||||
// The API route invalidates on its own mutations; this test deletes
|
// The API route invalidates on its own mutations; this test deletes
|
||||||
// directly, so drop the cached pond context to mirror that (issue #39).
|
// directly, so drop the cached pond context to mirror that (issue #39).
|
||||||
app.get(PondPermissionCache).invalidate(pondId);
|
app.get(PondPermissionCache).invalidate(pondId);
|
||||||
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404);
|
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}/content`).expect(404);
|
||||||
|
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CommentsModule } from '../comments/comments.module';
|
||||||
import { PluginsModule } from '../plugins/plugins.module';
|
import { PluginsModule } from '../plugins/plugins.module';
|
||||||
|
|
||||||
import { PublicController } from './public.controller';
|
import { PublicController } from './public.controller';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
|
import { ReadContentController } from './read-content.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public read access (issue #56): anonymous-reachable page endpoints on top of
|
* 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.
|
* marks `GET /media/:fileId` public too.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PluginsModule],
|
imports: [PluginsModule, CommentsModule],
|
||||||
controllers: [PublicController],
|
controllers: [PublicController, ReadContentController],
|
||||||
providers: [PublicService],
|
providers: [PublicService],
|
||||||
})
|
})
|
||||||
export class PublicModule {}
|
export class PublicModule {}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { PageCommentsView } from '@dorfteich/shared';
|
||||||
import { Pond, User } from '@prisma/client';
|
import { Pond, User } from '@prisma/client';
|
||||||
|
|
||||||
|
import { CommentsService } from '../comments/comments.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@ -38,6 +40,7 @@ export class PublicService {
|
|||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
private readonly fallbacks: PluginFallbackRenderer,
|
private readonly fallbacks: PluginFallbackRenderer,
|
||||||
private readonly settings: InstanceSettingsService,
|
private readonly settings: InstanceSettingsService,
|
||||||
|
private readonly commentsService: CommentsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async resolve(
|
private async resolve(
|
||||||
@ -58,25 +61,98 @@ export class PublicService {
|
|||||||
return { pond, page };
|
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> {
|
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
|
||||||
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
|
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
|
||||||
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
|
||||||
// Plugin blocks render their static form (#79), and the pond's active
|
// The pond's active section-style CSS travels inline — the read view loads
|
||||||
// section-style CSS travels inline — the public view loads no plugin
|
// no plugin runtime, and the CSS passed the install gate's scoping rules.
|
||||||
// runtime, and the CSS passed the install gate's scoping rules.
|
|
||||||
const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? '');
|
|
||||||
const styleTag = await this.fallbacks.sectionStyleTag(pond.id);
|
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 {
|
return {
|
||||||
pondName: pond.name,
|
pondName: pond.name,
|
||||||
pondSlug: pond.slug,
|
pondSlug: pond.slug,
|
||||||
title: page.title,
|
title: page.title,
|
||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
html: styleTag + resolveMediaUrls(withFallbacks),
|
html: styleTag + resolveMediaUrls(body),
|
||||||
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
|
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. */
|
/** A complete, self-contained HTML document for crawlers / PDF export. */
|
||||||
async html(
|
async html(
|
||||||
user: User | null,
|
user: User | null,
|
||||||
@ -112,3 +188,35 @@ function resolveMediaUrls(html: string): string {
|
|||||||
'src="/api/v1/media/$1" data-file-id="$1"',
|
'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';
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comments UI (issue #92): the full two-user lifecycle in the panel,
|
* Comments UI (issues #92, #133): the discussion is a fixed inline section in
|
||||||
* resolve/unresolve with the collapsed section, the permission variant
|
* the read view (no toggle), between the backlinks and the local graph. The
|
||||||
* (composer hidden with a hint), and the localStorage unread badge.
|
* pack drives the full two-user lifecycle, resolve/unresolve with the collapsed
|
||||||
* The pack provisions its own page and grant, so it is repeatable.
|
* section, and the permission variant (composer hidden with a hint). It
|
||||||
|
* provisions its own page and grant, so it is repeatable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let pondId: string;
|
let pondId: string;
|
||||||
@ -52,23 +53,21 @@ test.afterAll(async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('two users run the full comment lifecycle with resolve/unresolve', 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 reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
|
||||||
const readerPage = await reader.newPage();
|
const readerPage = await reader.newPage();
|
||||||
await readerPage.goto(pageUrl);
|
await readerPage.goto(pageUrl);
|
||||||
await readerPage.locator('.editor-shell__comments-toggle').click();
|
const readerPanel = readerPage.locator('.comments-section');
|
||||||
const readerPanel = readerPage.locator('.comments-panel');
|
|
||||||
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
|
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
|
||||||
await readerPanel.locator('.comments-composer button[type="submit"]').click();
|
await readerPanel.locator('.comments-composer button[type="submit"]').click();
|
||||||
await expect(readerPanel.locator('.comment__body strong')).toHaveText('final');
|
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 owner = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
const ownerPage = await owner.newPage();
|
const ownerPage = await owner.newPage();
|
||||||
await ownerPage.goto(pageUrl);
|
await ownerPage.goto(pageUrl);
|
||||||
await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible();
|
const ownerPanel = ownerPage.locator('.comments-section');
|
||||||
await ownerPage.locator('.editor-shell__comments-toggle').click();
|
await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final');
|
||||||
const ownerPanel = ownerPage.locator('.comments-panel');
|
|
||||||
await ownerPanel
|
await ownerPanel
|
||||||
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
|
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
|
||||||
.click();
|
.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 })
|
.locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
const resolvedSection = ownerPanel.locator('.comments-panel__resolved');
|
const resolvedSection = ownerPanel.locator('.comments-section__resolved');
|
||||||
await expect(resolvedSection).toBeVisible();
|
await expect(resolvedSection).toBeVisible();
|
||||||
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
|
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
|
||||||
// Collapsed: the thread body is hidden until the section is opened.
|
// 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.
|
// Unresolve restores it to the open list.
|
||||||
await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click();
|
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();
|
await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible();
|
||||||
|
|
||||||
// Author edits and deletes own reply.
|
// 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.
|
// Cleanup: the reader deletes their own (now reply-free) root.
|
||||||
await readerPage.reload();
|
await readerPage.reload();
|
||||||
await readerPage.locator('.editor-shell__comments-toggle').click();
|
|
||||||
await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).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 reader.close();
|
||||||
await owner.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 reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
|
||||||
const page = await reader.newPage();
|
const page = await reader.newPage();
|
||||||
await page.goto(pageUrl);
|
await page.goto(pageUrl);
|
||||||
await page.locator('.editor-shell__comments-toggle').click();
|
const panel = page.locator('.comments-section');
|
||||||
const panel = page.locator('.comments-panel');
|
await expect(panel.locator('.comments-section__policy-hint')).toBeVisible();
|
||||||
await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible();
|
|
||||||
await expect(panel.locator('.comments-composer')).toHaveCount(0);
|
await expect(panel.locator('.comments-composer')).toHaveCount(0);
|
||||||
await reader.close();
|
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 { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { FormError } from '../components/forms';
|
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';
|
import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page's discussion panel (issue #92): threaded comments with a
|
* The page's discussion (issue #92), rendered inline in the read view between
|
||||||
* Markdown composer, edit/delete for authors, resolve with a collapsed
|
* the backlinks and the local graph (issue #133): threaded comments with a
|
||||||
* resolved section, and the permission-aware composer (hidden with a hint
|
* Markdown composer, edit/delete for authors, and resolve with a collapsed
|
||||||
* when the pond's policy bars the viewer).
|
* 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,
|
pageId,
|
||||||
mayComment,
|
mayComment,
|
||||||
onClose,
|
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
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;
|
mayComment: boolean;
|
||||||
onClose: () => void;
|
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('comments');
|
const { t } = useTranslation('comments');
|
||||||
const comments = useComments(pageId);
|
const comments = useComments(pageId);
|
||||||
const refresh = useInvalidateComments(pageId);
|
const refresh = useInvalidateComments(pageId);
|
||||||
const [error, setError] = useState<unknown>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
markCommentsSeen(pageId);
|
markCommentsSeen(pageId);
|
||||||
return () => 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 (
|
return (
|
||||||
<section className="comments-panel" aria-label={t('title')}>
|
<section id="comments" className="comments-section" aria-label={t('title')}>
|
||||||
<div className="comments-panel__header">
|
<div className="comments-section__header">
|
||||||
<h2>
|
<h2>
|
||||||
{t('title')}
|
{t('title')}
|
||||||
{view && view.openCount > 0 && (
|
{comments.data && comments.data.openCount > 0 && (
|
||||||
<span className="comments-panel__count">{view.openCount}</span>
|
<span className="comments-section__count">{comments.data.openCount}</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<button type="button" className="button" onClick={onClose}>
|
|
||||||
{t('close')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
|
|
||||||
@ -71,14 +65,82 @@ export function CommentsPanel({
|
|||||||
onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))}
|
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 && (
|
{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) => (
|
{open.map((thread) => (
|
||||||
<Thread
|
<Thread
|
||||||
key={thread.root.id}
|
key={thread.root.id}
|
||||||
@ -86,14 +148,15 @@ export function CommentsPanel({
|
|||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
mayComment={mayComment}
|
mayComment={mayComment}
|
||||||
run={run}
|
run={run}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{resolved.length > 0 && (
|
{resolved.length > 0 && (
|
||||||
<details className="comments-panel__resolved">
|
<details className="comments-section__resolved">
|
||||||
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
|
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
|
||||||
<ul className="comments-panel__threads">
|
<ul className="comments-section__threads">
|
||||||
{resolved.map((thread) => (
|
{resolved.map((thread) => (
|
||||||
<Thread
|
<Thread
|
||||||
key={thread.root.id}
|
key={thread.root.id}
|
||||||
@ -101,12 +164,13 @@ export function CommentsPanel({
|
|||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
mayComment={mayComment}
|
mayComment={mayComment}
|
||||||
run={run}
|
run={run}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,26 +179,42 @@ function Thread({
|
|||||||
pageId,
|
pageId,
|
||||||
mayComment,
|
mayComment,
|
||||||
run,
|
run,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
thread: CommentThreadView;
|
thread: CommentThreadView;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
mayComment: boolean;
|
mayComment: boolean;
|
||||||
run: (action: () => Promise<unknown>) => Promise<void>;
|
run: (action: () => Promise<unknown>) => Promise<void>;
|
||||||
|
readOnly: boolean;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('comments');
|
const { t } = useTranslation('comments');
|
||||||
const [replying, setReplying] = useState(false);
|
const [replying, setReplying] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`comments-thread${thread.resolved ? ' comments-thread--resolved' : ''}`}>
|
<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">
|
<ul className="comments-thread__replies">
|
||||||
{thread.replies.map((reply) => (
|
{thread.replies.map((reply) => (
|
||||||
<li key={reply.id}>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{mayComment && !thread.resolved && (
|
{!readOnly && mayComment && !thread.resolved && (
|
||||||
<div className="comments-thread__actions">
|
<div className="comments-thread__actions">
|
||||||
{replying ? (
|
{replying ? (
|
||||||
<Composer
|
<Composer
|
||||||
@ -169,11 +249,15 @@ function CommentItem({
|
|||||||
run,
|
run,
|
||||||
isRoot,
|
isRoot,
|
||||||
resolved,
|
resolved,
|
||||||
|
mayComment,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
comment: CommentView;
|
comment: CommentView;
|
||||||
run: (action: () => Promise<unknown>) => Promise<void>;
|
run: (action: () => Promise<unknown>) => Promise<void>;
|
||||||
isRoot: boolean;
|
isRoot: boolean;
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
|
mayComment: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t, i18n } = useTranslation('comments');
|
const { t, i18n } = useTranslation('comments');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -205,40 +289,47 @@ function CommentItem({
|
|||||||
// Server-sanitized render (shared pipeline, issue #91) — safe by contract.
|
// Server-sanitized render (shared pipeline, issue #91) — safe by contract.
|
||||||
<div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} />
|
<div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} />
|
||||||
)}
|
)}
|
||||||
<footer className="comment__actions">
|
{!readOnly && (
|
||||||
{own && !editing && (
|
<footer className="comment__actions">
|
||||||
<>
|
{own && !editing && (
|
||||||
<button type="button" className="comments-link-button" onClick={() => setEditing(true)}>
|
<>
|
||||||
{t('edit.open')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
className="comments-link-button"
|
||||||
type="button"
|
onClick={() => setEditing(true)}
|
||||||
className="comments-link-button"
|
>
|
||||||
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
|
{t('edit.open')}
|
||||||
>
|
</button>
|
||||||
{t('delete')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</>
|
className="comments-link-button"
|
||||||
)}
|
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
|
||||||
{isRoot &&
|
>
|
||||||
(resolved ? (
|
{t('delete')}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</>
|
||||||
className="comments-link-button"
|
)}
|
||||||
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
|
{isRoot &&
|
||||||
>
|
mayComment &&
|
||||||
{t('unresolve')}
|
(resolved ? (
|
||||||
</button>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<button
|
className="comments-link-button"
|
||||||
type="button"
|
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
|
||||||
className="comments-link-button"
|
>
|
||||||
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
|
{t('unresolve')}
|
||||||
>
|
</button>
|
||||||
{t('resolve')}
|
) : (
|
||||||
</button>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</footer>
|
className="comments-link-button"
|
||||||
|
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
|
||||||
|
>
|
||||||
|
{t('resolve')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useWikilinks } from './wikilink-context';
|
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 {
|
interface QueryState {
|
||||||
query: string;
|
query: string;
|
||||||
from: number;
|
from: number;
|
||||||
|
embed: boolean;
|
||||||
coords: { left: number; bottom: number };
|
coords: { left: number; bottom: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,18 +18,21 @@ interface QueryState {
|
|||||||
type Suggestion =
|
type Suggestion =
|
||||||
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
|
{ kind: 'page'; slug: string; label: string } | { kind: 'create'; slug: string; label: string };
|
||||||
|
|
||||||
/** Detects a `[[query` immediately before a collapsed cursor (issue #46). */
|
/** Detects a `[[query` (link) or `![[query` (embed, #135) immediately before a
|
||||||
function detectQuery(editor: Editor): { query: string; from: number } | null {
|
* 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;
|
const { selection } = editor.state;
|
||||||
if (!selection.empty) return null;
|
if (!selection.empty) return null;
|
||||||
const $from = selection.$from;
|
const $from = selection.$from;
|
||||||
if (!$from.parent.isTextblock) return null;
|
if (!$from.parent.isTextblock) return null;
|
||||||
const start = Math.max(0, $from.parentOffset - 200);
|
const start = Math.max(0, $from.parentOffset - 200);
|
||||||
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
|
const before = $from.parent.textBetween(start, $from.parentOffset, undefined, '');
|
||||||
const match = /\[\[([^[\]\n]*)$/.exec(before);
|
const match = /(!?)\[\[([^[\]\n]*)$/.exec(before);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const query = match[1] ?? '';
|
const embed = match[1] === '!';
|
||||||
return { query, from: selection.from - query.length - 2 };
|
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 {
|
function choose(item: Suggestion | undefined): void {
|
||||||
const current = live.current.state;
|
const current = live.current.state;
|
||||||
if (!item || !current) return;
|
if (!item || !current) return;
|
||||||
editor
|
const range = { from: current.from, to: editor.state.selection.from };
|
||||||
.chain()
|
if (current.embed) {
|
||||||
.focus()
|
// A page embed is a block node (#135) — replace the typed `![[query` with
|
||||||
.insertContentAt({ from: current.from, to: editor.state.selection.from }, [
|
// the transclusion block; ProseMirror lifts it out of the paragraph.
|
||||||
{ type: 'wikilink', attrs: { targetSlug: item.slug, displayText: null } },
|
editor
|
||||||
{ type: 'text', text: ' ' },
|
.chain()
|
||||||
])
|
.focus()
|
||||||
.run();
|
.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();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
|||||||
import { PluginBlock } from './nodes/plugin-block';
|
import { PluginBlock } from './nodes/plugin-block';
|
||||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
|
import { Transclusion } from './nodes/transclusion';
|
||||||
import { Wikilink } from './nodes/wikilink';
|
import { Wikilink } from './nodes/wikilink';
|
||||||
import {
|
import {
|
||||||
Blockquote,
|
Blockquote,
|
||||||
@ -43,6 +44,7 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
Image,
|
Image,
|
||||||
PluginBlock,
|
PluginBlock,
|
||||||
Wikilink,
|
Wikilink,
|
||||||
|
Transclusion,
|
||||||
Table,
|
Table,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
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,
|
Ellipsis,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
History,
|
History,
|
||||||
MessageSquare,
|
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Pencil,
|
Pencil,
|
||||||
Save,
|
Save,
|
||||||
@ -35,9 +34,6 @@ interface PageActionsProps {
|
|||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
mode: 'view' | 'edit';
|
mode: 'view' | 'edit';
|
||||||
onToggleMode: () => void;
|
onToggleMode: () => void;
|
||||||
unread: number;
|
|
||||||
showComments: boolean;
|
|
||||||
onToggleComments: () => void;
|
|
||||||
showAttachments: boolean;
|
showAttachments: boolean;
|
||||||
onToggleAttachments: () => void;
|
onToggleAttachments: () => void;
|
||||||
hasTools: boolean;
|
hasTools: boolean;
|
||||||
@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
|
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
|
||||||
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
|
<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
|
<IconButton
|
||||||
className="editor-shell__attachments-toggle"
|
className="editor-shell__attachments-toggle"
|
||||||
label={t('files:title')}
|
label={t('files:title')}
|
||||||
|
|||||||
@ -11,8 +11,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { CommentsPanel } from '../comments/CommentsPanel';
|
import { CommentsSection } from '../comments/CommentsSection';
|
||||||
import { unreadCount, useComments } from '../comments/use-comments';
|
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
|
||||||
@ -32,9 +31,11 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
|||||||
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
||||||
import { usePageActionsSlot } from '../layout/page-actions';
|
import { usePageActionsSlot } from '../layout/page-actions';
|
||||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
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 { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard';
|
||||||
|
import { countWords } from '../lib/word-count';
|
||||||
import { PageActions } from './PageActions';
|
import { PageActions } from './PageActions';
|
||||||
|
import { PageStatusBar } from './PageStatusBar';
|
||||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
import { recallPage, rememberPage } from '../offline/page-cache';
|
||||||
import { PluginBlockContext } from '../editor/plugin-block-context';
|
import { PluginBlockContext } from '../editor/plugin-block-context';
|
||||||
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
||||||
@ -126,22 +127,20 @@ function PageEditor({
|
|||||||
page,
|
page,
|
||||||
mode,
|
mode,
|
||||||
pondSlug,
|
pondSlug,
|
||||||
commentPolicy,
|
|
||||||
showAttachments,
|
showAttachments,
|
||||||
showComments,
|
|
||||||
showPageTools,
|
showPageTools,
|
||||||
onCloseAttachments,
|
onCloseAttachments,
|
||||||
onCloseComments,
|
onWriteAccess,
|
||||||
}: {
|
}: {
|
||||||
page: ResolvedPage;
|
page: ResolvedPage;
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
commentPolicy: 'readers' | 'editors';
|
|
||||||
showAttachments: boolean;
|
showAttachments: boolean;
|
||||||
showComments: boolean;
|
|
||||||
showPageTools: boolean;
|
showPageTools: boolean;
|
||||||
onCloseAttachments: () => void;
|
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 {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -168,10 +167,12 @@ function PageEditor({
|
|||||||
|
|
||||||
const collab = useCollabProvider(ydoc, page.id);
|
const collab = useCollabProvider(ydoc, page.id);
|
||||||
const readOnly = collab.mode === 'ro';
|
const readOnly = collab.mode === 'ro';
|
||||||
// `readers` = everyone who can see the page; `editors` = the collab token
|
// The collab token's rw grant is the authoritative write signal; report it up
|
||||||
// explicitly granted rw (while it is still null, stay conservative — the
|
// so the outer page can decide whether to show the inline comment composer
|
||||||
// composer appears once the mode resolves).
|
// (issue #133). `readers`-policy ponds let anyone comment regardless.
|
||||||
const mayComment = commentPolicy === 'readers' || collab.mode === 'rw';
|
useEffect(() => {
|
||||||
|
onWriteAccess(collab.mode === 'rw');
|
||||||
|
}, [collab.mode, onWriteAccess]);
|
||||||
// A revoked page can no longer be edited (issue #39); the content stays
|
// A revoked page can no longer be edited (issue #39); the content stays
|
||||||
// visible for export via the dialog below.
|
// visible for export via the dialog below.
|
||||||
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
|
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
|
||||||
@ -274,9 +275,6 @@ function PageEditor({
|
|||||||
onClose={onCloseAttachments}
|
onClose={onCloseAttachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showComments && (
|
|
||||||
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
|
|
||||||
)}
|
|
||||||
{/* The connection status renders as an icon in the content footer
|
{/* The connection status renders as an icon in the content footer
|
||||||
(left half); the localized text stays for screen readers and as
|
(left half); the localized text stays for screen readers and as
|
||||||
the hover tooltip. */}
|
the hover tooltip. */}
|
||||||
@ -338,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
const [showLabels, setShowLabels] = useState(false);
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
const [showAttachments, setShowAttachments] = 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);
|
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 actionsSlot = usePageActionsSlot();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const showToast = useToast();
|
const showToast = useToast();
|
||||||
@ -360,6 +356,16 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
enabled: Boolean(pond.data),
|
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).
|
// Remember this page's metadata while online so it can be opened offline (#38).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pond.data && page.data) {
|
if (pond.data && page.data) {
|
||||||
@ -448,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [pageId, user, mode, queryClient, t, showToast]);
|
}, [pageId, user, mode, queryClient, t, showToast]);
|
||||||
|
|
||||||
// TopBar action data (issue #101): the comments badge and the plugin
|
// TopBar action data (issue #101): the plugin page-tools visibility lives
|
||||||
// page-tools visibility live next to the icons, not inside the editor.
|
// 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);
|
|
||||||
const pagePlugins = usePondPlugins(resolved?.pondId);
|
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> {
|
async function saveTitle(): Promise<void> {
|
||||||
if (!resolved || title === resolved.title) return;
|
if (!resolved || title === resolved.title) return;
|
||||||
await apiPatch(`/pages/${resolved.id}`, { title });
|
await apiPatch(`/pages/${resolved.id}`, { title });
|
||||||
@ -494,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
|
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
|
||||||
unread={unread}
|
|
||||||
showComments={showComments}
|
|
||||||
onToggleComments={() => setShowComments((open) => !open)}
|
|
||||||
showAttachments={showAttachments}
|
showAttachments={showAttachments}
|
||||||
onToggleAttachments={() => setShowAttachments((open) => !open)}
|
onToggleAttachments={() => setShowAttachments((open) => !open)}
|
||||||
hasTools={hasPageTools(pagePlugins.data)}
|
hasTools={hasPageTools(pagePlugins.data)}
|
||||||
@ -521,17 +538,20 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
onBlur={() => void saveTitle()}
|
onBlur={() => void saveTitle()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="editor-page__body">
|
||||||
<PageEditor
|
<PageEditor
|
||||||
page={resolved}
|
page={resolved}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
|
|
||||||
showAttachments={showAttachments}
|
showAttachments={showAttachments}
|
||||||
showComments={showComments}
|
|
||||||
showPageTools={showPageTools}
|
showPageTools={showPageTools}
|
||||||
onCloseAttachments={() => setShowAttachments(false)}
|
onCloseAttachments={() => setShowAttachments(false)}
|
||||||
onCloseComments={() => setShowComments(false)}
|
onWriteAccess={setCanWrite}
|
||||||
/>
|
/>
|
||||||
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
{/* Side panels stack vertically in one column (M10 follow-up). */}
|
||||||
{(showLabels || showHistory) && (
|
{(showLabels || showHistory) && (
|
||||||
@ -551,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* "Linked from" appears below the content in read mode (issue #48);
|
{/* "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' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
|
||||||
|
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
|
||||||
{mode === 'view' && (
|
{mode === 'view' && (
|
||||||
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
|
<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 { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { PublicComments } from '../comments/CommentsSection';
|
||||||
import { ApiError, apiGet } from '../lib/api';
|
import { ApiError, apiGet } from '../lib/api';
|
||||||
|
import { countWords, htmlToText } from '../lib/word-count';
|
||||||
import { NotFoundPage } from './NotFoundPage';
|
import { NotFoundPage } from './NotFoundPage';
|
||||||
|
import { PageStatusBar } from './PageStatusBar';
|
||||||
|
|
||||||
interface PublicPageContent {
|
interface PublicPageContent {
|
||||||
pondName: string;
|
pondName: string;
|
||||||
@ -31,6 +35,14 @@ export function PublicPageView(): React.JSX.Element {
|
|||||||
retry: false,
|
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.error instanceof ApiError && query.error.status === 404) return <NotFoundPage />;
|
||||||
if (query.isLoading || !query.data) return <div aria-busy="true" />;
|
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__badge">{t('readOnlyBadge')}</p>
|
||||||
<p className="public-page__pond">{page.pondName}</p>
|
<p className="public-page__pond">{page.pondName}</p>
|
||||||
<h1 className="public-page__title">{page.title}</h1>
|
<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
|
{/* The HTML comes from the server's content cache (issue #24), derived
|
||||||
from the sanitized editor schema — safe to render. */}
|
from the sanitized editor schema — safe to render. */}
|
||||||
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
|
||||||
|
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
|
||||||
|
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -522,15 +522,6 @@ button {
|
|||||||
gap: var(--space-1);
|
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 {
|
.page-actions__more {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -979,6 +970,29 @@ button {
|
|||||||
outline-offset: 2px;
|
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. */
|
/* The export entries flow inline with the other overflow-menu items. */
|
||||||
.editor-page__export {
|
.editor-page__export {
|
||||||
display: contents;
|
display: contents;
|
||||||
@ -1581,17 +1595,44 @@ button {
|
|||||||
padding: 0;
|
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;
|
list-style: none;
|
||||||
padding-left: var(--space-2);
|
padding-left: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-content ul[data-type='task_list'] li {
|
ul[data-type='task_list'] li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-2);
|
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 {
|
.editor-content table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: var(--space-4) 0;
|
margin: var(--space-4) 0;
|
||||||
@ -2342,6 +2383,58 @@ button {
|
|||||||
border-bottom: 1px dashed currentColor;
|
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 {
|
.wikilink-suggest {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -3296,8 +3389,8 @@ button {
|
|||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Comments panel (issue #92) */
|
/* Inline comments section, read view (issues #92, #133) */
|
||||||
.comments-panel {
|
.comments-section {
|
||||||
border: 1px solid var(--color-border, #cbd5e1);
|
border: 1px solid var(--color-border, #cbd5e1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
@ -3305,19 +3398,19 @@ button {
|
|||||||
background: var(--color-surface, #fff);
|
background: var(--color-surface, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__header {
|
.comments-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__header h2 {
|
.comments-section__header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__count {
|
.comments-section__count {
|
||||||
margin-left: var(--space-2);
|
margin-left: var(--space-2);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
background: var(--color-surface-muted, #e2e8f0);
|
background: var(--color-surface-muted, #e2e8f0);
|
||||||
@ -3325,12 +3418,12 @@ button {
|
|||||||
padding: 0.05rem 0.5rem;
|
padding: 0.05rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__policy-hint,
|
.comments-section__policy-hint,
|
||||||
.comments-panel__empty {
|
.comments-section__empty {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__threads {
|
.comments-section__threads {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -3339,16 +3432,16 @@ button {
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__resolved {
|
.comments-section__resolved {
|
||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__resolved > summary {
|
.comments-section__resolved > summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments-panel__resolved > .comments-panel__threads {
|
.comments-section__resolved > .comments-section__threads {
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3421,15 +3514,6 @@ button {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.comment-policy__label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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": {
|
"layout": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"expand": "Seitenleiste einblenden",
|
"expand": "Seitenleiste einblenden",
|
||||||
|
|||||||
@ -173,6 +173,10 @@
|
|||||||
"autocompleteLabel": "Auf eine Seite verlinken",
|
"autocompleteLabel": "Auf eine Seite verlinken",
|
||||||
"createHint": "Seite „{{title}}“ anlegen"
|
"createHint": "Seite „{{title}}“ anlegen"
|
||||||
},
|
},
|
||||||
|
"transclusion": {
|
||||||
|
"embedded": "Eingebettet: {{title}}",
|
||||||
|
"open": "Öffnen"
|
||||||
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
"hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.",
|
||||||
"create": "Seite „{{slug}}“ anlegen"
|
"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": {
|
"layout": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"expand": "Show sidebar",
|
"expand": "Show sidebar",
|
||||||
|
|||||||
@ -173,6 +173,10 @@
|
|||||||
"autocompleteLabel": "Link to a page",
|
"autocompleteLabel": "Link to a page",
|
||||||
"createHint": "Create page “{{title}}”"
|
"createHint": "Create page “{{title}}”"
|
||||||
},
|
},
|
||||||
|
"transclusion": {
|
||||||
|
"embedded": "Embedded: {{title}}",
|
||||||
|
"open": "Open"
|
||||||
|
},
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
"hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.",
|
||||||
"create": "Create the page “{{slug}}”"
|
"create": "Create the page “{{slug}}”"
|
||||||
|
|||||||
@ -132,6 +132,15 @@ function renderBlock(node: Node): string {
|
|||||||
` data-plugin-data="${data}">[${pluginId}/${blockType}]</div>`
|
` 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':
|
case 'code_block':
|
||||||
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
|
return `<pre><code>${escapeHtml(node.textContent)}</code></pre>`;
|
||||||
case 'horizontal_rule':
|
case 'horizontal_rule':
|
||||||
|
|||||||
@ -192,6 +192,35 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
|||||||
return true;
|
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"}`. */
|
/** Opening fence of a section-style container: `::: {data-section-style="p/s"}`. */
|
||||||
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
|
const SECTION_OPEN = /^:::+\s*\{\s*data-section-style="([^"/]+)\/([^"]+)"\s*\}\s*$/;
|
||||||
const SECTION_CLOSE = /^:::+\s*$/;
|
const SECTION_CLOSE = /^:::+\s*$/;
|
||||||
@ -248,6 +277,9 @@ function createTokenizer(): MarkdownIt {
|
|||||||
const md = new MarkdownIt('default', { html: false });
|
const md = new MarkdownIt('default', { html: false });
|
||||||
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
// Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links.
|
||||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
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.
|
// Run before `fence` so `:::` is not read as a code fence.
|
||||||
md.block.ruler.before('fence', 'section', sectionRule);
|
md.block.ruler.before('fence', 'section', sectionRule);
|
||||||
const rawParse = md.parse.bind(md);
|
const rawParse = md.parse.bind(md);
|
||||||
@ -312,6 +344,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
displayText: tok.attrGet('display') || null,
|
displayText: tok.attrGet('display') || null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
transclusion: {
|
||||||
|
node: 'transclusion',
|
||||||
|
getAttrs: (tok) => ({
|
||||||
|
targetSlug: tok.attrGet('target') ?? '',
|
||||||
|
displayText: tok.attrGet('display') || null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
em: { mark: 'italic' },
|
em: { mark: 'italic' },
|
||||||
strong: { mark: 'bold' },
|
strong: { mark: 'bold' },
|
||||||
s: { mark: 'strikethrough' },
|
s: { mark: 'strikethrough' },
|
||||||
@ -435,6 +474,12 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
const display = node.attrs.displayText as string | null;
|
const display = node.attrs.displayText as string | null;
|
||||||
state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`);
|
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) {
|
hard_break(state, node, parent, index) {
|
||||||
for (let i = index + 1; i < parent.childCount; i += 1) {
|
for (let i = index + 1; i < parent.childCount; i += 1) {
|
||||||
if (parent.child(i).type !== node.type) {
|
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
|
* validation (ADR 0008) all import this schema instead of defining their
|
||||||
* own, so "valid document" means the same thing everywhere.
|
* own, so "valid document" means the same thing everywhere.
|
||||||
*
|
*
|
||||||
* Node names `wikilink` and `plugin_block` are reserved for these features
|
* Node names `wikilink`, `plugin_block`, and `transclusion` are reserved for
|
||||||
* (wikilinks #46, plugin-defined block types #76) — do not repurpose them.
|
* these features (wikilinks #46, plugin-defined block types #76, page embeds
|
||||||
|
* #135) — do not repurpose them.
|
||||||
*/
|
*/
|
||||||
export const editorSchema = new Schema({
|
export const editorSchema = new Schema({
|
||||||
nodes: {
|
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: {} }),
|
...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';
|
import { Node } from 'prosemirror-model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The distinct target slugs of every `[[wikilink]]` in a document (issue #47).
|
* The distinct target slugs of every `[[wikilink]]` and every `![[embed]]`
|
||||||
* Used server-side to maintain the `page_links` index on every content change,
|
* (transclusion, issue #135) in a document (issue #47). Used server-side to
|
||||||
* so backlinks and phantom (missing-target) links can be queried. Order of
|
* maintain the `page_links` index on every content change, so backlinks and
|
||||||
* first appearance is preserved; duplicates are collapsed.
|
* 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[] {
|
export function extractWikilinkSlugs(doc: Node): string[] {
|
||||||
const slugs: string[] = [];
|
const slugs: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
doc.descendants((node) => {
|
doc.descendants((node) => {
|
||||||
if (node.type.name === 'wikilink') {
|
if (node.type.name === 'wikilink' || node.type.name === 'transclusion') {
|
||||||
const slug = node.attrs.targetSlug as string;
|
const slug = node.attrs.targetSlug as string;
|
||||||
if (slug && !seen.has(slug)) {
|
if (slug && !seen.has(slug)) {
|
||||||
seen.add(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
|
argon2: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: 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