feat: page interactive, démos Play/Step et simulateur Carnot
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>
This commit is contained in:
131
memento-note/components/tiptap-interactive-demo-extension.tsx
Normal file
131
memento-note/components/tiptap-interactive-demo-extension.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'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()
|
||||
}
|
||||
Reference in New Issue
Block a user