dorfteich/apps/web/src/editor/nodes/transclusion.tsx
Claude Fable 5 9bd25f6ce3 #146: Transparente Einbettung $[[Seite]] ohne Rahmen und Titel
Neues bare-Attr am transclusion-Node; $-Präfix in Markdown-Regel,
Serializer und Autocomplete; HTML-Placeholder trägt
data-transclusion-bare, Server-Expansion und NodeView lassen bei bare
Rahmen und Titel weg. Gleiche Tiefen-/Zyklen-/Permission-Regeln,
zählt weiter als Link. Unit- und DB-Tests ergänzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:34:15 +02:00

104 lines
3.5 KiB
TypeScript

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 bare = node.attrs.bare as boolean;
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(bare ? 'transclusion.embeddedBare' : 'transclusion.embedded', { title: label })}
</span>
<Link className="dt-transclusion-card__open" to={`/p/${pondSlug}/${slug}`}>
{t('transclusion.open')}
</Link>
</NodeViewWrapper>
);
}
if (bare) {
// `$[[…]]` (#146): the embed reads as part of the host page — no frame,
// no title, just the target's rendered content.
return (
<NodeViewWrapper className="dt-embed-bare" contentEditable={false}>
{content.data ? (
<div dangerouslySetInnerHTML={{ __html: content.data.html }} />
) : (
<div aria-busy="true" />
)}
</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);
},
});