feat(flowchart): support interactive embedding in markdown with readonly mode and frontend evaluation
This commit is contained in:
parent
433c095394
commit
c103bc02ad
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
Loading…
Reference in New Issue