feat(flowchart): support interactive embedding in markdown with readonly mode and frontend evaluation

This commit is contained in:
a2nr 2026-05-02 19:45:04 +07:00
parent 433c095394
commit c103bc02ad
5 changed files with 124 additions and 1 deletions

View File

@ -18,6 +18,9 @@
if (params.get('iframe') === 'true') {
fcState.isIframeMode = true;
}
if (params.get('readonly') === 'true') {
fcState.isReadonly = true;
}
// Listen to messages from parent window
window.addEventListener('message', (event) => {
@ -72,9 +75,14 @@
<main>
<Icons />
{#if !fcState.isReadonly}
<Topbar />
<Toolbar />
{/if}
<Canvas />
{#if !fcState.isReadonly}
<Properties />
<div id="zoom-controls">
@ -89,6 +97,7 @@
<svg><use href="#icon-fit"/></svg>
</button>
</div>
{/if}
</main>
<style>

View File

@ -34,6 +34,7 @@ export class FlowchartState {
panY = $state<number>(0);
isIframeMode = $state<boolean>(false);
isReadonly = $state<boolean>(false);
initialData = $state<any>(null);
isDragging = $state<boolean>(false);

View File

@ -0,0 +1,71 @@
/**
* Find `.flowchart-embed` divs (generated by backend from ```flowchart fences)
* and replace them with live Flowchart iframes. Uses IntersectionObserver
* for lazy loading.
*/
function loadFlowchartEmbed(div: HTMLElement) {
const width = div.dataset.width || '100%';
const height = div.dataset.height || '400px';
const dataEl = div.querySelector('.flowchart-data');
const flowchartData = dataEl?.textContent?.trim() || '';
const wrapper = document.createElement('div');
wrapper.className = 'flowchart-embed-wrapper';
wrapper.style.width = width;
wrapper.style.height = height;
wrapper.style.position = 'relative';
const iframe = document.createElement('iframe');
// Using cb to avoid caching issues during development
iframe.src = `/flowchart/?iframe=true&readonly=true&cb=${Date.now()}`;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.title = 'Flowchart Editor';
const loadData = () => {
if (!flowchartData) return;
try {
iframe.contentWindow?.postMessage({
type: 'FLOWCHART_LOAD',
payload: JSON.stringify({
initialData: flowchartData
})
}, '*');
} catch (e) {
console.error('Flowchart embed load error:', e);
}
const loadingEl = div.querySelector('.flowchart-embed-loading');
if (loadingEl) loadingEl.remove();
};
window.addEventListener('message', (event) => {
if (event.source === iframe.contentWindow && event.data?.type === 'FLOWCHART_READY') {
loadData();
}
});
wrapper.appendChild(iframe);
div.appendChild(wrapper);
div.dataset.rendered = 'true';
}
export function renderFlowchartEmbeds(container: HTMLElement) {
const embeds = container.querySelectorAll('.flowchart-embed:not([data-rendered])');
if (embeds.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadFlowchartEmbed(entry.target as HTMLElement);
observer.unobserve(entry.target);
}
});
},
{ rootMargin: '200px' }
);
embeds.forEach((el) => observer.observe(el));
}

View File

@ -26,6 +26,7 @@
import { highlightAllCode } from '$actions/highlightCode';
import { setupTryButtons } from '$actions/setupTryButtons';
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds';
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
import { tick, untrack } from 'svelte';
import type { LessonContent } from '$types/lesson';
@ -295,12 +296,14 @@
setupTryButtons(contentEl, handleTryCode);
highlightAllCode(contentEl);
renderCircuitEmbeds(contentEl);
renderFlowchartEmbeds(contentEl);
autoRenderMath(contentEl);
}
if (tabsEl) {
setupTryButtons(tabsEl, handleTryCode);
highlightAllCode(tabsEl);
renderCircuitEmbeds(tabsEl);
renderFlowchartEmbeds(tabsEl);
autoRenderMath(tabsEl);
}
});

View File

@ -241,6 +241,42 @@ def _process_circuit_embeds(text):
return pattern.sub(_replacer, text)
def _process_flowchart_embeds(text):
"""Replace ```flowchart[,width][,height] code fences with embeddable HTML divs.
Supported formats:
```flowchart -> width=100%, height=400px
```flowchart,500px -> width=100%, height=500px
```flowchart,80%,500px -> width=80%, height=500px
"""
pattern = re.compile(
r'```flowchart(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n(.*?)```',
re.DOTALL,
)
def _replacer(match):
param1 = match.group(1)
param2 = match.group(2)
# One param = height only; two params = width, height
if param1 and param2:
width, height = param1, param2
elif param1:
width, height = '100%', param1
else:
width, height = '100%', '400px'
data = html_module.escape(match.group(3).strip())
return (
f'<div class="flowchart-embed" '
f'data-width="{html_module.escape(width)}" '
f'data-height="{html_module.escape(height)}">'
f'<pre class="flowchart-data" style="display:none">{data}</pre>'
f'<div class="flowchart-embed-loading">Memuat flowchart...</div>'
f'</div>'
)
return pattern.sub(_replacer, text)
def _extract_section(content, start_marker, end_marker):
"""Extract text between markers and return (extracted, remaining_content)."""
if start_marker not in content or end_marker not in content:
@ -379,12 +415,15 @@ def render_markdown_content(file_path):
lesson_content = parts[0] if parts else lesson_content
exercise_content = parts[1] if len(parts) > 1 else ""
# Convert ```circuit fences to embed divs before markdown rendering
# Convert ```circuit and ```flowchart fences to embed divs before markdown rendering
lesson_content = _process_circuit_embeds(lesson_content)
lesson_content = _process_flowchart_embeds(lesson_content)
if exercise_content:
exercise_content = _process_circuit_embeds(exercise_content)
exercise_content = _process_flowchart_embeds(exercise_content)
if lesson_info:
lesson_info = _process_circuit_embeds(lesson_info)
lesson_info = _process_flowchart_embeds(lesson_info)
lesson_html = md.markdown(lesson_content, extensions=MD_EXTENSIONS)
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""