Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
132 lines
3.6 KiB
TypeScript
132 lines
3.6 KiB
TypeScript
'use client'
|
|
|
|
import { Node, mergeAttributes } from '@tiptap/core'
|
|
import {
|
|
ReactNodeViewRenderer,
|
|
NodeViewWrapper,
|
|
type NodeViewProps,
|
|
} from '@tiptap/react'
|
|
import type { Editor } from '@tiptap/core'
|
|
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
|
|
import {
|
|
validateInteractiveDemo,
|
|
type InteractiveDemoV1,
|
|
} from '@/lib/interactive-demo'
|
|
import attnresFixture from '@/lib/interactive-demo/fixtures/attnres.demo.json'
|
|
import { AlertCircle } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useMemo } from 'react'
|
|
|
|
function InteractiveDemoView(props: NodeViewProps) {
|
|
const { t } = useLanguage()
|
|
const raw = props.node.attrs.demoJson as string
|
|
|
|
const parsed = useMemo(() => {
|
|
try {
|
|
const data = JSON.parse(raw || '{}')
|
|
return validateInteractiveDemo(data)
|
|
} catch {
|
|
return {
|
|
ok: false as const,
|
|
issues: [{ code: 'invalid_json', path: '', message: 'Invalid JSON' }],
|
|
}
|
|
}
|
|
}, [raw])
|
|
|
|
if (!parsed.ok) {
|
|
return (
|
|
<NodeViewWrapper
|
|
className="interactive-demo-block my-4"
|
|
data-drag-handle
|
|
contentEditable={false}
|
|
>
|
|
<div className="rounded-xl border border-dashed border-destructive/40 bg-destructive/5 p-4 flex gap-3 text-sm">
|
|
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
|
|
<div>
|
|
<p className="font-medium">
|
|
{t('interactiveDemo.invalid') || 'Interactive demo invalide'}
|
|
</p>
|
|
<ul className="mt-1 text-muted-foreground list-disc list-inside">
|
|
{parsed.issues.slice(0, 5).map((iss, i) => (
|
|
<li key={i}>
|
|
{iss.path ? `${iss.path}: ` : ''}
|
|
{iss.message}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</NodeViewWrapper>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<NodeViewWrapper
|
|
className="interactive-demo-block my-4"
|
|
data-drag-handle
|
|
contentEditable={false}
|
|
>
|
|
<InteractiveDemoPlayer demo={parsed.demo} mode="interactive" />
|
|
</NodeViewWrapper>
|
|
)
|
|
}
|
|
|
|
export const InteractiveDemoExtension = Node.create({
|
|
name: 'interactiveDemo',
|
|
group: 'block',
|
|
atom: true,
|
|
draggable: true,
|
|
selectable: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
demoJson: {
|
|
default: '{}',
|
|
parseHTML: (el) => el.getAttribute('data-demo-json') || '{}',
|
|
renderHTML: (attrs) => ({
|
|
'data-demo-json': attrs.demoJson || '{}',
|
|
}),
|
|
},
|
|
}
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{ tag: 'div[data-interactive-demo]' }]
|
|
},
|
|
|
|
renderHTML({ HTMLAttributes }) {
|
|
return [
|
|
'div',
|
|
mergeAttributes(HTMLAttributes, { 'data-interactive-demo': 'true' }),
|
|
]
|
|
},
|
|
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(InteractiveDemoView)
|
|
},
|
|
})
|
|
|
|
export function insertInteractiveDemoAtSelection(
|
|
editor: Editor,
|
|
demo?: InteractiveDemoV1
|
|
): boolean {
|
|
const type = editor.schema.nodes.interactiveDemo
|
|
if (!type) return false
|
|
|
|
const payload = demo ?? (attnresFixture as InteractiveDemoV1)
|
|
const check = validateInteractiveDemo(payload)
|
|
// Prefer validated shape; if already-server-validated payload fails client Zod
|
|
// (HMR drift), still insert so the block appears — NodeView shows issues if needed.
|
|
const attrs = {
|
|
demoJson: JSON.stringify(check.ok ? check.demo : payload),
|
|
}
|
|
const { empty, $from } = editor.state.selection
|
|
const pos = empty ? $from.pos : editor.state.selection.from
|
|
|
|
return editor
|
|
.chain()
|
|
.focus()
|
|
.insertContentAt(pos, { type: 'interactiveDemo', attrs })
|
|
.run()
|
|
}
|