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') {
|
if (params.get('iframe') === 'true') {
|
||||||
fcState.isIframeMode = true;
|
fcState.isIframeMode = true;
|
||||||
}
|
}
|
||||||
|
if (params.get('readonly') === 'true') {
|
||||||
|
fcState.isReadonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Listen to messages from parent window
|
// Listen to messages from parent window
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
|
|
@ -72,9 +75,14 @@
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<Icons />
|
<Icons />
|
||||||
|
{#if !fcState.isReadonly}
|
||||||
<Topbar />
|
<Topbar />
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Canvas />
|
<Canvas />
|
||||||
|
|
||||||
|
{#if !fcState.isReadonly}
|
||||||
<Properties />
|
<Properties />
|
||||||
|
|
||||||
<div id="zoom-controls">
|
<div id="zoom-controls">
|
||||||
|
|
@ -89,6 +97,7 @@
|
||||||
<svg><use href="#icon-fit"/></svg>
|
<svg><use href="#icon-fit"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export class FlowchartState {
|
||||||
panY = $state<number>(0);
|
panY = $state<number>(0);
|
||||||
|
|
||||||
isIframeMode = $state<boolean>(false);
|
isIframeMode = $state<boolean>(false);
|
||||||
|
isReadonly = $state<boolean>(false);
|
||||||
initialData = $state<any>(null);
|
initialData = $state<any>(null);
|
||||||
|
|
||||||
isDragging = $state<boolean>(false);
|
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 { highlightAllCode } from '$actions/highlightCode';
|
||||||
import { setupTryButtons } from '$actions/setupTryButtons';
|
import { setupTryButtons } from '$actions/setupTryButtons';
|
||||||
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
|
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
|
||||||
|
import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds';
|
||||||
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
|
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
|
||||||
import { tick, untrack } from 'svelte';
|
import { tick, untrack } from 'svelte';
|
||||||
import type { LessonContent } from '$types/lesson';
|
import type { LessonContent } from '$types/lesson';
|
||||||
|
|
@ -295,12 +296,14 @@
|
||||||
setupTryButtons(contentEl, handleTryCode);
|
setupTryButtons(contentEl, handleTryCode);
|
||||||
highlightAllCode(contentEl);
|
highlightAllCode(contentEl);
|
||||||
renderCircuitEmbeds(contentEl);
|
renderCircuitEmbeds(contentEl);
|
||||||
|
renderFlowchartEmbeds(contentEl);
|
||||||
autoRenderMath(contentEl);
|
autoRenderMath(contentEl);
|
||||||
}
|
}
|
||||||
if (tabsEl) {
|
if (tabsEl) {
|
||||||
setupTryButtons(tabsEl, handleTryCode);
|
setupTryButtons(tabsEl, handleTryCode);
|
||||||
highlightAllCode(tabsEl);
|
highlightAllCode(tabsEl);
|
||||||
renderCircuitEmbeds(tabsEl);
|
renderCircuitEmbeds(tabsEl);
|
||||||
|
renderFlowchartEmbeds(tabsEl);
|
||||||
autoRenderMath(tabsEl);
|
autoRenderMath(tabsEl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,42 @@ def _process_circuit_embeds(text):
|
||||||
return pattern.sub(_replacer, 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):
|
def _extract_section(content, start_marker, end_marker):
|
||||||
"""Extract text between markers and return (extracted, remaining_content)."""
|
"""Extract text between markers and return (extracted, remaining_content)."""
|
||||||
if start_marker not in content or end_marker not in 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
|
lesson_content = parts[0] if parts else lesson_content
|
||||||
exercise_content = parts[1] if len(parts) > 1 else ""
|
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_circuit_embeds(lesson_content)
|
||||||
|
lesson_content = _process_flowchart_embeds(lesson_content)
|
||||||
if exercise_content:
|
if exercise_content:
|
||||||
exercise_content = _process_circuit_embeds(exercise_content)
|
exercise_content = _process_circuit_embeds(exercise_content)
|
||||||
|
exercise_content = _process_flowchart_embeds(exercise_content)
|
||||||
if lesson_info:
|
if lesson_info:
|
||||||
lesson_info = _process_circuit_embeds(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)
|
lesson_html = md.markdown(lesson_content, extensions=MD_EXTENSIONS)
|
||||||
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
|
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue