Epic 6: Stories 6-2 (Markdown roundtrip) + 6-3 (Brainstorm PPTX + Canvas)
Story 6-2 — Markdown roundtrip export/import: - lib/editor/markdown-export.ts: tiptapHTMLToMarkdown, markdownToHTML, looksLikeMarkdown - lib/editor/markdown-paste-extension.ts: TipTap extension paste Markdown → blocs - note-editor-toolbar.tsx: export .md + import .md (file picker) - rich-text-editor.tsx: intégration MarkdownPasteExtension - 40 tests unitaires markdown-export.test.ts Story 6-3 — Brainstorm PPTX + Canvas: - lib/brainstorm/export-pptx.ts: génération PPTX 5 slides (pptxgenjs) - app/api/brainstorm/[sessionId]/export-pptx/route.ts: route POST protégée - brainstorm-page.tsx: bouton PPTX, auto-select session, fix emoji, fix router.replace - wave-canvas.tsx: fitTrigger recentrage, légende bas-droite Onboarding activation wizard (Story 6-1): - components/onboarding/: wizard multi-étapes, hints éditeur - app/api/onboarding/: route PATCH onboarding - prisma/migrations: champs onboarding user Locales: 15 langues mises à jour (brainstorm, markdown, onboarding keys) Sprint: 6-1 done, 6-2 review, 6-3 review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
322
memento-note/lib/brainstorm/export-pptx.ts
Normal file
322
memento-note/lib/brainstorm/export-pptx.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type PptxGenJSModule from 'pptxgenjs'
|
||||
|
||||
let _PptxGenJS: (new () => PptxGenJSModule) | null = null
|
||||
async function getPptxGenClass(): Promise<new () => PptxGenJSModule> {
|
||||
if (!_PptxGenJS) {
|
||||
const mod = await import('pptxgenjs')
|
||||
_PptxGenJS = (mod.default ?? mod) as unknown as new () => PptxGenJSModule
|
||||
}
|
||||
return _PptxGenJS
|
||||
}
|
||||
|
||||
// ── Theme — cohérent avec l'identité visuelle Momento ───────────────────────
|
||||
const T = {
|
||||
bg: 'F2F0E9',
|
||||
primary: '1C1C1C',
|
||||
accent: 'A47148',
|
||||
secondary: 'D4A373',
|
||||
muted: '9A8C87',
|
||||
wave1: 'fb923c',
|
||||
wave2: '60a5fa',
|
||||
wave3: 'a78bfa',
|
||||
green: '10b981',
|
||||
}
|
||||
|
||||
const WAVE_LABELS: Record<number, string> = {
|
||||
1: '🔄 Variations',
|
||||
2: '🔗 Analogies',
|
||||
3: '💥 Disruptions',
|
||||
}
|
||||
|
||||
const WAVE_COLORS: Record<number, string> = {
|
||||
1: T.wave1,
|
||||
2: T.wave2,
|
||||
3: T.wave3,
|
||||
}
|
||||
|
||||
// ── Types (minimal subset) ──────────────────────────────────────────────────
|
||||
|
||||
interface IdeaLike {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
waveNumber: number
|
||||
status: string
|
||||
isStarred: boolean
|
||||
convertedToNoteId: string | null
|
||||
}
|
||||
|
||||
interface SessionLike {
|
||||
seedIdea: string
|
||||
createdAt: Date
|
||||
ideas: IdeaLike[]
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
||||
}
|
||||
|
||||
// Add a consistent slide background
|
||||
function addBg(slide: any) {
|
||||
slide.background = { color: T.bg }
|
||||
}
|
||||
|
||||
// Accent bar at the top of a slide
|
||||
function addTopBar(slide: any, color: string = T.accent) {
|
||||
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: 0.12, fill: { color } })
|
||||
}
|
||||
|
||||
// ── Slide builders ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildCoverSlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number }) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
|
||||
// Left accent panel
|
||||
slide.addShape('rect', { x: 0, y: 0, w: 3.6, h: '100%', fill: { color: T.primary } })
|
||||
|
||||
// Brand label on accent panel
|
||||
slide.addText('MOMENTO', {
|
||||
x: 0.3, y: 0.4, w: 3.0, h: 0.4,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.accent, bold: true,
|
||||
charSpacing: 4, align: 'left',
|
||||
})
|
||||
|
||||
// Seed idea (big) on right
|
||||
slide.addText(truncate(session.seedIdea, 80), {
|
||||
x: 4.0, y: 1.2, w: 5.6, h: 2.4,
|
||||
fontSize: 26, fontFace: 'Georgia', color: T.primary,
|
||||
bold: false, align: 'left', valign: 'middle', wrap: true,
|
||||
})
|
||||
|
||||
// Date
|
||||
slide.addText(session.createdAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }), {
|
||||
x: 4.0, y: 4.0, w: 5.6, h: 0.4,
|
||||
fontSize: 10, fontFace: 'Arial', color: T.muted, align: 'left',
|
||||
})
|
||||
|
||||
// Stats row
|
||||
const statItems = [
|
||||
{ label: 'idées', value: String(stats.total) },
|
||||
{ label: 'converties', value: String(stats.converted) },
|
||||
{ label: 'favorites', value: String(stats.starred) },
|
||||
]
|
||||
statItems.forEach((s, i) => {
|
||||
const x = 4.0 + i * 2.0
|
||||
slide.addText(s.value, { x, y: 4.8, w: 1.8, h: 0.55, fontSize: 22, fontFace: 'Georgia', color: T.accent, bold: true, align: 'left' })
|
||||
slide.addText(s.label, { x, y: 5.35, w: 1.8, h: 0.3, fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1 })
|
||||
})
|
||||
|
||||
// Vertical label "BRAINSTORM" on left panel
|
||||
slide.addText('BRAINSTORM', {
|
||||
x: 0.1, y: 2.0, w: 3.2, h: 0.5,
|
||||
fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true,
|
||||
charSpacing: 6, align: 'center',
|
||||
rotate: 270,
|
||||
})
|
||||
}
|
||||
|
||||
function buildWaveSlide(pres: PptxGenJSModule, wave: number, ideas: IdeaLike[]) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
addTopBar(slide, WAVE_COLORS[wave] || T.accent)
|
||||
|
||||
// Wave label
|
||||
slide.addText(WAVE_LABELS[wave] || `Wave ${wave}`, {
|
||||
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
|
||||
fontSize: 14, fontFace: 'Arial', color: WAVE_COLORS[wave] || T.accent, bold: true, charSpacing: 2,
|
||||
})
|
||||
|
||||
// Count badge
|
||||
slide.addShape('roundRect', { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fill: { color: WAVE_COLORS[wave] || T.accent }, rectRadius: 0.1 })
|
||||
slide.addText(String(ideas.length), { x: 9.0, y: 0.28, w: 0.6, h: 0.4, fontSize: 11, fontFace: 'Arial', color: 'FFFFFF', bold: true, align: 'center', valign: 'middle' })
|
||||
|
||||
const maxPerSlide = 6
|
||||
const shown = ideas.slice(0, maxPerSlide)
|
||||
|
||||
const colW = 4.5
|
||||
const rowH = 1.3
|
||||
const startY = 1.0
|
||||
|
||||
shown.forEach((idea, i) => {
|
||||
const col = i % 2
|
||||
const row = Math.floor(i / 2)
|
||||
const x = 0.4 + col * (colW + 0.3)
|
||||
const y = startY + row * (rowH + 0.15)
|
||||
|
||||
// Card background
|
||||
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: 'E8E6E0', width: 0.75 }, rectRadius: 0.08 })
|
||||
|
||||
// Star / converted badge
|
||||
if (idea.isStarred || idea.convertedToNoteId) {
|
||||
const badge = idea.convertedToNoteId ? '✓' : '⭐'
|
||||
const badgeColor = idea.convertedToNoteId ? T.green : T.wave1
|
||||
slide.addText(badge, { x: x + colW - 0.45, y: y + 0.08, w: 0.35, h: 0.35, fontSize: 10, fontFace: 'Arial', color: badgeColor, align: 'center' })
|
||||
}
|
||||
|
||||
// Title
|
||||
slide.addText(truncate(idea.title, 55), {
|
||||
x: x + 0.18, y: y + 0.12, w: colW - 0.55, h: 0.4,
|
||||
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
|
||||
})
|
||||
|
||||
// Description
|
||||
slide.addText(truncate(idea.description, 120), {
|
||||
x: x + 0.18, y: y + 0.52, w: colW - 0.36, h: 0.65,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
|
||||
})
|
||||
})
|
||||
|
||||
if (ideas.length > maxPerSlide) {
|
||||
slide.addText(`+ ${ideas.length - maxPerSlide} autres idées`, {
|
||||
x: 0.4, y: 5.5, w: 9.2, h: 0.3,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.muted, align: 'center', italic: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopIdeasSlide(pres: PptxGenJSModule, starred: IdeaLike[], converted: IdeaLike[]) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
addTopBar(slide, T.accent)
|
||||
|
||||
slide.addText('Top Idées', {
|
||||
x: 0.5, y: 0.25, w: 9.0, h: 0.5,
|
||||
fontSize: 14, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 2,
|
||||
})
|
||||
|
||||
const all = [
|
||||
...starred.map(i => ({ ...i, badge: '⭐', badgeColor: T.wave1 })),
|
||||
...converted.filter(i => !i.isStarred).map(i => ({ ...i, badge: '✓', badgeColor: T.green })),
|
||||
].slice(0, 6)
|
||||
|
||||
if (all.length === 0) {
|
||||
slide.addText('Aucune idée favorite ou convertie.', {
|
||||
x: 0.5, y: 3.0, w: 9.0, h: 0.5,
|
||||
fontSize: 12, fontFace: 'Georgia', color: T.muted, align: 'center', italic: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const colW = 4.5
|
||||
const rowH = 1.25
|
||||
|
||||
all.forEach((idea, i) => {
|
||||
const col = i % 2
|
||||
const row = Math.floor(i / 2)
|
||||
const x = 0.4 + col * (colW + 0.3)
|
||||
const y = 1.1 + row * (rowH + 0.15)
|
||||
|
||||
const waveColor = WAVE_COLORS[idea.waveNumber] || T.muted
|
||||
|
||||
slide.addShape('roundRect', { x, y, w: colW, h: rowH, fill: { color: 'FFFFFF' }, line: { color: waveColor, width: 1.5 }, rectRadius: 0.08 })
|
||||
|
||||
slide.addText(idea.badge, { x: x + 0.1, y: y + 0.1, w: 0.4, h: 0.4, fontSize: 14, fontFace: 'Arial', color: idea.badgeColor, align: 'center' })
|
||||
|
||||
slide.addText(truncate(idea.title, 55), {
|
||||
x: x + 0.55, y: y + 0.1, w: colW - 0.7, h: 0.4,
|
||||
fontSize: 11, fontFace: 'Arial', color: T.primary, bold: true, wrap: true,
|
||||
})
|
||||
|
||||
slide.addText(truncate(idea.description, 110), {
|
||||
x: x + 0.18, y: y + 0.55, w: colW - 0.36, h: 0.6,
|
||||
fontSize: 9, fontFace: 'Arial', color: T.muted, wrap: true, valign: 'top',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildSummarySlide(pres: PptxGenJSModule, session: SessionLike, stats: { total: number; converted: number; starred: number; dismissed: number }) {
|
||||
const slide = pres.addSlide()
|
||||
addBg(slide)
|
||||
|
||||
// Dark left panel
|
||||
slide.addShape('rect', { x: 0, y: 0, w: '100%', h: '100%', fill: { color: T.primary } })
|
||||
|
||||
slide.addText('BILAN DE SESSION', {
|
||||
x: 0.8, y: 0.8, w: 8.4, h: 0.6,
|
||||
fontSize: 10, fontFace: 'Arial', color: T.accent, bold: true, charSpacing: 5, align: 'left',
|
||||
})
|
||||
|
||||
slide.addText(truncate(session.seedIdea, 70), {
|
||||
x: 0.8, y: 1.55, w: 8.4, h: 1.0,
|
||||
fontSize: 20, fontFace: 'Georgia', color: 'FFFFFF', align: 'left', wrap: true,
|
||||
})
|
||||
|
||||
// Divider
|
||||
slide.addShape('rect', { x: 0.8, y: 2.8, w: 8.4, h: 0.02, fill: { color: T.accent } })
|
||||
|
||||
// Stats grid
|
||||
const statCols = [
|
||||
{ label: 'IDÉES GÉNÉRÉES', value: String(stats.total), color: T.wave2 },
|
||||
{ label: 'CONVERTIES EN NOTES', value: String(stats.converted), color: T.green },
|
||||
{ label: 'FAVORITES', value: String(stats.starred), color: T.wave1 },
|
||||
{ label: 'REJETÉES', value: String(stats.dismissed), color: T.muted },
|
||||
]
|
||||
|
||||
statCols.forEach((s, i) => {
|
||||
const x = 0.8 + i * 2.3
|
||||
slide.addText(s.value, { x, y: 3.2, w: 2.2, h: 0.9, fontSize: 36, fontFace: 'Georgia', color: s.color, bold: true, align: 'left' })
|
||||
slide.addText(s.label, { x, y: 4.1, w: 2.2, h: 0.5, fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'left', charSpacing: 1.5, wrap: true })
|
||||
})
|
||||
|
||||
slide.addText('Généré par Momento', {
|
||||
x: 0.8, y: 5.5, w: 8.4, h: 0.3,
|
||||
fontSize: 8, fontFace: 'Arial', color: T.muted, align: 'right', italic: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main export function ─────────────────────────────────────────────────────
|
||||
|
||||
export async function generateBrainstormPptx(session: SessionLike): Promise<{ buffer: Buffer; filename: string }> {
|
||||
const PptxGenJS = await getPptxGenClass()
|
||||
const pres = new PptxGenJS()
|
||||
|
||||
pres.layout = 'LAYOUT_WIDE'
|
||||
pres.author = 'Momento'
|
||||
pres.subject = `Brainstorm: ${session.seedIdea}`
|
||||
|
||||
const activeIdeas = session.ideas.filter(i => i.status !== 'dismissed')
|
||||
const dismissedCount = session.ideas.filter(i => i.status === 'dismissed').length
|
||||
const converted = activeIdeas.filter(i => i.convertedToNoteId !== null)
|
||||
const starred = activeIdeas.filter(i => i.isStarred)
|
||||
|
||||
const stats = {
|
||||
total: activeIdeas.length,
|
||||
converted: converted.length,
|
||||
starred: starred.length,
|
||||
dismissed: dismissedCount,
|
||||
}
|
||||
|
||||
// Slide 1 — Cover
|
||||
buildCoverSlide(pres, session, stats)
|
||||
|
||||
// Slides 2-4 — One per active wave
|
||||
for (const wave of [1, 2, 3]) {
|
||||
const waveIdeas = activeIdeas.filter(i => i.waveNumber === wave)
|
||||
if (waveIdeas.length === 0) continue
|
||||
buildWaveSlide(pres, wave, waveIdeas)
|
||||
}
|
||||
|
||||
// Slide N — Top ideas (starred + converted)
|
||||
if (starred.length > 0 || converted.length > 0) {
|
||||
buildTopIdeasSlide(pres, starred, converted)
|
||||
}
|
||||
|
||||
// Last slide — Summary
|
||||
buildSummarySlide(pres, session, stats)
|
||||
|
||||
const buffer = (await pres.write({ outputType: 'nodebuffer' })) as unknown as Buffer
|
||||
const filename = `brainstorm-${slugify(session.seedIdea)}.pptx`
|
||||
|
||||
return { buffer, filename }
|
||||
}
|
||||
209
memento-note/lib/editor/markdown-export.ts
Normal file
209
memento-note/lib/editor/markdown-export.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* markdown-export.ts
|
||||
* Utilities for TipTap HTML ↔ Markdown conversion.
|
||||
*
|
||||
* Uses:
|
||||
* - turndown (+ turndown-plugin-gfm) : HTML → Markdown
|
||||
* - marked : Markdown → HTML
|
||||
*/
|
||||
|
||||
import TurndownService from 'turndown'
|
||||
import { tables, taskListItems, strikethrough } from 'turndown-plugin-gfm'
|
||||
import { marked } from 'marked'
|
||||
|
||||
// ── Markdown heuristic detection ────────────────────────────────────────────
|
||||
|
||||
const MARKDOWN_PATTERNS = [
|
||||
/^#{1,6}\s/m, // headings
|
||||
/^\s*[-*+]\s/m, // unordered list
|
||||
/^\s*\d+\.\s/m, // ordered list
|
||||
/^\s*>\s/m, // blockquote
|
||||
/^```/m, // code fence
|
||||
/`[^`]+`/, // inline code
|
||||
/\*\*[^*]+\*\*/, // bold
|
||||
/\*[^*]+\*/, // italic
|
||||
/^[|].+[|]/m, // table
|
||||
/\[.+\]\(.+\)/, // link
|
||||
/!\[.+\]\(.+\)/, // image
|
||||
/~~[^~]+~~/, // strikethrough
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns true if the given plain text looks like it contains Markdown syntax.
|
||||
* Used by the paste handler to decide whether to convert before inserting.
|
||||
*/
|
||||
export function looksLikeMarkdown(text: string): boolean {
|
||||
if (!text || text.trim().length < 3) return false
|
||||
return MARKDOWN_PATTERNS.some((re) => re.test(text))
|
||||
}
|
||||
|
||||
// ── Turndown service factory ─────────────────────────────────────────────────
|
||||
|
||||
function createTurndownService(): TurndownService {
|
||||
const td = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
hr: '---',
|
||||
bulletListMarker: '-',
|
||||
codeBlockStyle: 'fenced',
|
||||
fence: '```',
|
||||
emDelimiter: '_',
|
||||
strongDelimiter: '**',
|
||||
linkStyle: 'inlined',
|
||||
linkReferenceStyle: 'full',
|
||||
})
|
||||
|
||||
// GFM plugins: tables + task lists + strikethrough
|
||||
td.use([tables, taskListItems, strikethrough])
|
||||
|
||||
// Custom rule: liveBlock → HTML comment
|
||||
td.addRule('liveBlock', {
|
||||
filter(node) {
|
||||
return (
|
||||
node.nodeName === 'DIV' &&
|
||||
(node as HTMLElement).hasAttribute('data-live-block')
|
||||
)
|
||||
},
|
||||
replacement(_content, node) {
|
||||
const el = node as HTMLElement
|
||||
const sourceNoteId = el.getAttribute('sourcenoteId') || el.getAttribute('sourcenoteId') || el.getAttribute('sourcenoteid') || ''
|
||||
const blockId = el.getAttribute('blockId') || el.getAttribute('blockid') || ''
|
||||
return `\n\n<!-- live-block: ${sourceNoteId}#${blockId} -->\n\n`
|
||||
},
|
||||
})
|
||||
|
||||
// Custom rule: structuredViewBlock → HTML comment
|
||||
td.addRule('structuredViewBlock', {
|
||||
filter(node) {
|
||||
return (
|
||||
node.nodeName === 'DIV' &&
|
||||
(node as HTMLElement).hasAttribute('data-structured-view-block')
|
||||
)
|
||||
},
|
||||
replacement(_content, node) {
|
||||
const el = node as HTMLElement
|
||||
const attrs: Record<string, string> = {}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name !== 'data-structured-view-block') {
|
||||
attrs[attr.name] = attr.value
|
||||
}
|
||||
}
|
||||
return `\n\n<!-- structured-view: ${JSON.stringify(attrs)} -->\n\n`
|
||||
},
|
||||
})
|
||||
|
||||
return td
|
||||
}
|
||||
|
||||
// Singleton (lazy-init) — safe for server + client usage
|
||||
let _tdService: TurndownService | null = null
|
||||
function getTurndownService(): TurndownService {
|
||||
if (!_tdService) _tdService = createTurndownService()
|
||||
return _tdService
|
||||
}
|
||||
|
||||
// ── Custom node pre-processor ─────────────────────────────────────────────
|
||||
|
||||
// Sentinel prefix — alphanumeric only to avoid Markdown escaping by turndown
|
||||
const SENTINEL_PREFIX = 'MOMENTOBLOCKSENTINEL'
|
||||
|
||||
interface BlockPlaceholder {
|
||||
key: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-process HTML before passing to turndown:
|
||||
* - Replace empty custom node divs (liveBlock, structuredViewBlock) with text
|
||||
* placeholders so they survive turndown processing (turndown drops blank nodes
|
||||
* and strips HTML comments).
|
||||
* - Return the modified HTML and a map of placeholder → HTML comment.
|
||||
*/
|
||||
function preprocessCustomNodes(html: string): { html: string; placeholders: BlockPlaceholder[] } {
|
||||
const placeholders: BlockPlaceholder[] = []
|
||||
|
||||
// liveBlock: <div data-live-block="true" sourceNoteId="..." blockId="..."></div>
|
||||
let result = html.replace(
|
||||
/<div([^>]*?data-live-block[^>]*?)>\s*<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
const snId = (attrs.match(/sourcenoteid="([^"]*)"/i) || attrs.match(/sourcenoteid='([^']*)'/i) || [])[1] || ''
|
||||
const bId = (attrs.match(/blockid="([^"]*)"/i) || attrs.match(/blockid='([^']*)'/i) || [])[1] || ''
|
||||
const key = `${SENTINEL_PREFIX}LIVEBLOCK${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- live-block: ${snId}#${bId} -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
// structuredViewBlock: <div data-structured-view-block="true" ...></div>
|
||||
result = result.replace(
|
||||
/<div([^>]*?data-structured-view-block[^>]*?)>\s*<\/div>/gi,
|
||||
(_match, attrs) => {
|
||||
const attrMap: Record<string, string> = {}
|
||||
const attrRe = /(data-[a-z-]+)="([^"]*)"/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = attrRe.exec(attrs)) !== null) {
|
||||
if (m[1] !== 'data-structured-view-block') attrMap[m[1]] = m[2]
|
||||
}
|
||||
const key = `${SENTINEL_PREFIX}SVBLOCK${placeholders.length}`
|
||||
placeholders.push({ key, comment: `<!-- structured-view: ${JSON.stringify(attrMap)} -->` })
|
||||
return `<p>${key}</p>`
|
||||
}
|
||||
)
|
||||
|
||||
return { html: result, placeholders }
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process the markdown output: replace sentinel placeholders with HTML comments.
|
||||
*/
|
||||
function postprocessPlaceholders(md: string, placeholders: BlockPlaceholder[]): string {
|
||||
let result = md
|
||||
for (const { key, comment } of placeholders) {
|
||||
result = result.replace(key, `\n\n${comment}\n\n`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── HTML → Markdown ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a TipTap-generated HTML string to GitHub-Flavored Markdown.
|
||||
* Custom nodes (liveBlock, structuredViewBlock) are serialised as HTML comments.
|
||||
*/
|
||||
export function tiptapHTMLToMarkdown(html: string): string {
|
||||
if (!html || html.trim() === '') return ''
|
||||
const { html: preprocessed, placeholders } = preprocessCustomNodes(html)
|
||||
const td = getTurndownService()
|
||||
const md = td.turndown(preprocessed).trim()
|
||||
return postprocessPlaceholders(md, placeholders).trim()
|
||||
}
|
||||
|
||||
// ── Markdown → HTML ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a Markdown string to HTML suitable for injection into TipTap via
|
||||
* `editor.commands.setContent(html)`.
|
||||
*
|
||||
* Uses marked with GFM enabled (tables, task lists, line breaks).
|
||||
*/
|
||||
export function markdownToHTML(markdown: string): string {
|
||||
if (!markdown || markdown.trim() === '') return ''
|
||||
|
||||
// marked v18+ uses synchronous parse by default when no async tokens
|
||||
const html = marked.parse(markdown, {
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
}) as string
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// ── Title extraction from Markdown ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the first H1 title from a Markdown string.
|
||||
* Returns null if no H1 is found.
|
||||
*/
|
||||
export function extractMarkdownTitle(markdown: string): string | null {
|
||||
const match = markdown.match(/^#\s+(.+)/m)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
50
memento-note/lib/editor/markdown-paste-extension.ts
Normal file
50
memento-note/lib/editor/markdown-paste-extension.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* markdown-paste-extension.ts
|
||||
*
|
||||
* TipTap extension that intercepts paste events. If the pasted plain text
|
||||
* looks like Markdown, it converts it to HTML and inserts it into the editor
|
||||
* as structured TipTap nodes — instead of inserting it as raw text.
|
||||
*/
|
||||
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { looksLikeMarkdown, markdownToHTML } from './markdown-export'
|
||||
|
||||
const MARKDOWN_PASTE_KEY = new PluginKey('markdownPaste')
|
||||
|
||||
export const MarkdownPasteExtension = Extension.create({
|
||||
name: 'markdownPaste',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: MARKDOWN_PASTE_KEY,
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (!text || !looksLikeMarkdown(text)) return false
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const html = markdownToHTML(text)
|
||||
// Schedule after current event loop to avoid transaction conflicts
|
||||
setTimeout(() => {
|
||||
editor.commands.insertContent(html, {
|
||||
parseOptions: { preserveWhitespace: 'full' },
|
||||
})
|
||||
}, 0)
|
||||
} catch {
|
||||
// Fallback: let TipTap handle the paste normally
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user