diff --git a/frontend/src/lib/actions/preserveFocus.ts b/frontend/src/lib/actions/preserveFocus.ts
new file mode 100644
index 0000000..50e5999
--- /dev/null
+++ b/frontend/src/lib/actions/preserveFocus.ts
@@ -0,0 +1,27 @@
+/**
+ * Svelte action that prevents an element from stealing focus from the
+ * currently-focused text editor when tapped/clicked.
+ *
+ * Used on workspace tool buttons (quick-tools-rail, tabs, save/publish)
+ * so interacting with the workspace does not blur the inline editor.
+ *
+ * Note: we deliberately do NOT preventDefault on touchstart, because doing
+ * so suppresses the subsequent `click` event on mobile browsers. mousedown
+ * (which fires as a compatibility event after touchend on touch devices)
+ * is sufficient to block the focus change while still letting click fire.
+ */
+export function preserveFocus(node: HTMLElement) {
+ function onDown(e: Event) {
+ e.preventDefault();
+ }
+
+ node.addEventListener('pointerdown', onDown, { passive: false });
+ node.addEventListener('mousedown', onDown, { passive: false });
+
+ return {
+ destroy() {
+ node.removeEventListener('pointerdown', onDown);
+ node.removeEventListener('mousedown', onDown);
+ }
+ };
+}
diff --git a/frontend/src/lib/components/CodeEditor.svelte b/frontend/src/lib/components/CodeEditor.svelte
index cf68b41..487b37f 100644
--- a/frontend/src/lib/components/CodeEditor.svelte
+++ b/frontend/src/lib/components/CodeEditor.svelte
@@ -8,10 +8,21 @@
readonly?: boolean;
noPaste?: boolean;
storageKey?: string;
+ initialCursorPos?: number;
+ initialLine?: number;
onchange?: (value: string) => void;
}
- let { code = '', language = 'c', readonly = false, noPaste = false, storageKey, onchange }: Props = $props();
+ let {
+ code = '',
+ language = 'c',
+ readonly = false,
+ noPaste = false,
+ storageKey,
+ initialCursorPos,
+ initialLine,
+ onchange
+ }: Props = $props();
let container: HTMLDivElement;
let view: any;
@@ -205,6 +216,12 @@
}),
parent: container,
});
+
+ if (initialCursorPos !== undefined) {
+ setCursorPos(initialCursorPos);
+ } else if (initialLine !== undefined) {
+ scrollToLine(initialLine);
+ }
}
function setupNoPasteListeners() {
@@ -317,6 +334,40 @@
export function getCode(): string {
return view?.state.doc.toString() ?? code;
}
+
+ /** Set cursor to an exact character position and scroll it into view. */
+ export function setCursorPos(pos: number) {
+ if (!view || !CM) return;
+ const docLen = view.state.doc.length;
+ const safePos = Math.max(0, Math.min(pos, docLen));
+ view.dispatch({
+ selection: { anchor: safePos, head: safePos },
+ effects: CM.EditorView.scrollIntoView(safePos, { y: 'center' }),
+ });
+ view.focus();
+ }
+
+ /** Scroll editor to a specific 1-indexed line and position cursor there. */
+ export function scrollToLine(lineNumber: number) {
+ if (!view || !CM) return;
+ try {
+ const totalLines = view.state.doc.lines;
+ const targetLine = Math.max(1, Math.min(lineNumber, totalLines));
+ const line = view.state.doc.line(targetLine);
+ view.dispatch({
+ selection: { anchor: line.from, head: line.from },
+ effects: CM.EditorView.scrollIntoView(line.from, { y: 'center' }),
+ });
+ view.focus();
+ } catch (e) {
+ // line out of range — ignore
+ }
+ }
+
+ /** Focus the editor */
+ export function focus() {
+ view?.focus();
+ }
{#if !ready}
diff --git a/frontend/src/lib/components/ContentInlineEditor.svelte b/frontend/src/lib/components/ContentInlineEditor.svelte
new file mode 100644
index 0000000..71c1b32
--- /dev/null
+++ b/frontend/src/lib/components/ContentInlineEditor.svelte
@@ -0,0 +1,227 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/LessonContentView.svelte b/frontend/src/lib/components/LessonContentView.svelte
index b32d4ef..c8f3206 100644
--- a/frontend/src/lib/components/LessonContentView.svelte
+++ b/frontend/src/lib/components/LessonContentView.svelte
@@ -6,20 +6,116 @@
import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds';
import { autoRenderMath } from '$lib/actions/renderMath';
+ export interface ClickTextDetail {
+ word: string;
+ snippet: string;
+ blockText: string;
+ blockIndex: number;
+ totalBlocks: number;
+ event: MouseEvent;
+ }
+
interface Props {
lessonHtml: string;
exerciseHtml?: string;
quizData?: unknown[];
slides?: string[];
activeTabs?: string[];
+ clickable?: boolean;
+ onclicktext?: (detail: ClickTextDetail) => void;
}
- let { lessonHtml, exerciseHtml = '', quizData = [], slides = [], activeTabs = [] }: Props = $props();
+ let {
+ lessonHtml,
+ exerciseHtml = '',
+ quizData = [],
+ slides = [],
+ activeTabs = [],
+ clickable = false,
+ onclicktext
+ }: Props = $props();
let contentEl = $state
(null);
// Plain variable (not $state) to avoid infinite reactivity loops when mounting inside $effect
let slideComponent: ReturnType | null = null;
+ function handleContentClick(event: MouseEvent) {
+ if (!clickable || !onclicktext) return;
+
+ // Don't trigger when clicking buttons, links, controls, or interactive embeds
+ const targetEl = event.target as HTMLElement | null;
+ if (targetEl?.closest('button, a, input, select, textarea, .try-btn, iframe, [data-interactive]')) {
+ return;
+ }
+
+ let textNode: Node | null = null;
+ let offset = 0;
+
+ const doc = document as any;
+ if (doc.caretPositionFromPoint) {
+ const pos = doc.caretPositionFromPoint(event.clientX, event.clientY);
+ if (pos) {
+ textNode = pos.offsetNode;
+ offset = pos.offset;
+ }
+ } else if (doc.caretRangeFromPoint) {
+ const range = doc.caretRangeFromPoint(event.clientX, event.clientY);
+ if (range) {
+ textNode = range.startContainer;
+ offset = range.startOffset;
+ }
+ }
+
+ let word = '';
+ let snippet = '';
+ if (textNode) {
+ const fullText = textNode.textContent || '';
+ let start = offset;
+ while (start > 0 && /\S/.test(fullText[start - 1])) {
+ start--;
+ }
+ let end = offset;
+ while (end < fullText.length && /\S/.test(fullText[end])) {
+ end++;
+ }
+ word = fullText.slice(start, end).trim();
+ const snippetStart = Math.max(0, start - 25);
+ const snippetEnd = Math.min(fullText.length, end + 25);
+ snippet = fullText.slice(snippetStart, snippetEnd).trim();
+ }
+
+ let blockEl: Element | null = null;
+ let curr: Node | null = textNode || targetEl;
+ while (curr && curr !== contentEl) {
+ if (curr.nodeType === Node.ELEMENT_NODE) {
+ const tag = (curr as Element).tagName.toLowerCase();
+ if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'pre', 'tr', 'td', 'th', 'summary'].includes(tag)) {
+ blockEl = curr as Element;
+ break;
+ }
+ }
+ curr = curr.parentNode;
+ }
+
+ const blockText = blockEl ? (blockEl.textContent || '').trim() : '';
+ let blockIndex = -1;
+ let totalBlocks = 0;
+ if (blockEl && contentEl) {
+ const allBlocks = contentEl.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, tr');
+ totalBlocks = allBlocks.length;
+ blockIndex = Array.from(allBlocks).indexOf(blockEl);
+ }
+
+ onclicktext({
+ word,
+ snippet,
+ blockText,
+ blockIndex,
+ totalBlocks,
+ event
+ });
+ }
+
$effect(() => {
if (contentEl && lessonHtml) {
const currentSlides = slides;
@@ -72,7 +168,13 @@
});
-
+
+
{@html lessonHtml}
@@ -83,4 +185,28 @@
text-align: left;
max-width: 70ch;
}
+ .lesson-content-view.clickable {
+ cursor: text;
+ }
+ /* Match /lesson .prose typography (lesson.css) */
+ .lesson-content-view p {
+ margin-bottom: 1.5rem;
+ }
+ .lesson-content-view h2 {
+ font-size: 1.5rem;
+ margin-top: 3rem;
+ margin-bottom: 1.5rem;
+ }
+ .lesson-content-view h3 {
+ font-size: 1.25rem;
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+ }
+ .lesson-content-view pre {
+ background: var(--color-bg-secondary);
+ padding: 0.75rem;
+ border-radius: var(--radius);
+ overflow-x: auto;
+ position: relative;
+ }
diff --git a/frontend/src/lib/services/contentEditor.svelte.ts b/frontend/src/lib/services/contentEditor.svelte.ts
index 47ebdb0..56b32ef 100644
--- a/frontend/src/lib/services/contentEditor.svelte.ts
+++ b/frontend/src/lib/services/contentEditor.svelte.ts
@@ -22,7 +22,7 @@ export class ContentEditorManager {
previewLoading = $state(false);
// Mobile mode (replicated from LessonManager pattern)
- mobileMode = $state<'hidden' | 'h30' | 'h50' | 'h70' | 'full'>('hidden');
+ mobileMode = $state<'hidden' | 'peek' | 'h30' | 'h50' | 'h70' | 'full'>('hidden');
isMobile = $state(false);
// Tab state — 'editor' | 'preview' | 'exercise' | 'quiz' | dynamic tabs from active_tabs ('c', 'python', 'circuit', 'velxio', 'flowchart')
diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte
index b28340a..cded6bd 100644
--- a/frontend/src/routes/teacher/content/+page.svelte
+++ b/frontend/src/routes/teacher/content/+page.svelte
@@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import { authIsTeacher } from '$stores/auth';
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
+ import { preserveFocus } from '$actions/preserveFocus';
import CodeEditor from '$components/CodeEditor.svelte';
import CircuitEditor from '$components/CircuitEditor.svelte';
import TeacherVelxioEditor from '$components/TeacherVelxioEditor.svelte';
import ContentFileTree from '$components/ContentFileTree.svelte';
- import LessonContentView from '$components/LessonContentView.svelte';
+ import LessonContentView, { type ClickTextDetail } from '$components/LessonContentView.svelte';
+ import ContentInlineEditor from '$components/ContentInlineEditor.svelte';
import QuizPreviewReadonly from '$components/QuizPreviewReadonly.svelte';
import { ContentEditorManager } from '$services/contentEditor.svelte';
import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset, exportBundle, extractBundle, importBundle } from '$services/contentEditor';
@@ -16,24 +18,45 @@
const mgr = new ContentEditorManager();
const float = createFloatingPanel();
- let editorRef = $state
(null);
+ let inlineEditorRef = $state(null);
let circuitEditorRef = $state(null);
let velxioEditorRef = $state(null);
- // Mobile: show tree or editor
- let mobileShowTree = $state(true);
+ // Content view mode: 'preview' (default rendered view) or 'edit' (inline editor)
+ let contentViewMode = $state<'preview' | 'edit'>('preview');
+ let editCursorPos = $state(undefined);
+ let editLineNumber = $state(undefined);
- // Load trees on mount
- onMount(async () => {
- await loadTrees();
+ // Mobile: show tree or editor
+
+ // Load trees on mount & setup global shortcut
+ onMount(() => {
+ loadTrees();
+
+ const handleGlobalKey = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') {
+ if (mgr.activePath && !isAssetFile) {
+ e.preventDefault();
+ contentViewMode = contentViewMode === 'preview' ? 'edit' : 'preview';
+ }
+ }
+ };
+ window.addEventListener('keydown', handleGlobalKey);
+ return () => {
+ window.removeEventListener('keydown', handleGlobalKey);
+ };
+ });
+
+ // Reset content view mode to preview when active path changes
+ $effect(() => {
+ if (mgr.activePath) {
+ contentViewMode = 'preview';
+ editCursorPos = undefined;
+ editLineNumber = undefined;
+ }
});
// When mobile & no file selected, default to tree view
- $effect(() => {
- if (mgr.isMobile && !mgr.activePath) {
- mobileShowTree = true;
- }
- });
// Mobile behavior for floating panel
$effect(() => {
@@ -90,16 +113,16 @@
// Tab label map for chrome tabs
let allTabs = $derived([
- { id: 'editor', label: 'Editor' },
+ { id: 'features', label: 'Fitur & Aksi' },
...dynamicTabs,
{ id: 'exercise', label: 'Exercise', show: hasExercise },
{ id: 'quiz', label: 'Quiz', show: hasQuiz },
].filter((t) => t.show !== false));
- // Auto-switch to editor tab when switching files
+ // Auto-switch to features tab when switching files
$effect(() => {
if (mgr.activePath) {
- mgr.activeTab = 'editor';
+ mgr.activeTab = 'features';
}
});
@@ -107,7 +130,7 @@
$effect(() => {
const validIds = allTabs.map((t) => t.id);
if (!validIds.includes(mgr.activeTab)) {
- mgr.activeTab = 'editor';
+ mgr.activeTab = 'features';
}
});
@@ -145,31 +168,73 @@
mgr.previewSlides = [];
mgr.previewActiveTabs = [];
mgr.previewError = null;
- mgr.activeTab = 'editor';
+ mgr.activeTab = 'features';
} else {
mgr.loadDraft(path);
}
if (mgr.isMobile) {
- mobileShowTree = false;
- mgr.mobileMode = 'h50';
+ mgr.mobileMode = mgr.treeRoot === 'assets' ? 'h50' : 'peek';
}
}
- function handleMobileBackToTree() {
- if (mgr.dirty && !window.confirm('Ada perubahan yang belum disimpan. Kembali ke tree?')) {
- return;
- }
- mobileShowTree = true;
- }
-
function handleEditorChange(value: string) {
mgr.onBodyChange(value);
}
- function handleEditorMount() {
- if (editorRef && mgr.body) {
- editorRef.setCode(mgr.body);
+ function handleContentTextClick(detail: ClickTextDetail) {
+ if (!mgr.activePath || isAssetFile) return;
+
+ let targetPos: number | undefined = undefined;
+ let targetLine: number | undefined = undefined;
+
+ const bodyText = mgr.body;
+
+ // 1. Try finding context snippet in bodyText
+ if (detail.snippet && detail.snippet.length >= 8) {
+ const idx = bodyText.indexOf(detail.snippet);
+ if (idx !== -1) {
+ if (detail.word) {
+ const wordRel = detail.snippet.indexOf(detail.word);
+ targetPos = wordRel !== -1 ? idx + wordRel : idx;
+ } else {
+ targetPos = idx;
+ }
+ }
}
+
+ // 2. If snippet wasn't found (e.g. markdown formatting), try the clicked word
+ if (targetPos === undefined && detail.word && detail.word.length >= 3) {
+ const idx = bodyText.indexOf(detail.word);
+ if (idx !== -1) {
+ targetPos = idx;
+ }
+ }
+
+ // 3. If word not found, try block text preview (first 25 chars)
+ if (targetPos === undefined && detail.blockText) {
+ const lead = detail.blockText.slice(0, 25).trim();
+ if (lead.length >= 6) {
+ const idx = bodyText.indexOf(lead);
+ if (idx !== -1) {
+ targetPos = idx;
+ }
+ }
+ }
+
+ // 4. Calculate line number from targetPos or estimate from block ratio
+ if (targetPos !== undefined) {
+ targetLine = bodyText.slice(0, targetPos).split('\n').length;
+ } else if (detail.blockIndex >= 0 && detail.totalBlocks > 0) {
+ const lines = bodyText.split('\n');
+ const estimatedLine = Math.max(1, Math.round((detail.blockIndex / detail.totalBlocks) * lines.length));
+ targetLine = estimatedLine;
+ } else {
+ targetLine = 1;
+ }
+
+ editCursorPos = targetPos;
+ editLineNumber = targetLine;
+ contentViewMode = 'edit';
}
function syncVelxioToMarkdown(): boolean {
@@ -203,8 +268,8 @@
if (currentBody !== mgr.body) {
mgr.onBodyChange(currentBody);
- if (editorRef) {
- editorRef.setCode(currentBody);
+ if (inlineEditorRef) {
+ inlineEditorRef.setCode(currentBody);
}
}
}
@@ -237,19 +302,17 @@
}
}
- // ── Feature template sidebar ──────────────────────────────────────
- // Insert a feature section template at the editor cursor. Works in all
- // layout modes (desktop docked, floating, mobile). Shown in a hide-able
- // right sidebar grouped into "Konten" and "Penilaian".
+ // Step the mobile workspace height up/down through the available modes
+ const MOBILE_STEPS = ['hidden', 'peek', 'h30', 'h50', 'h70', 'full'] as const;
+ function stepMobileMode(dir: 1 | -1) {
+ const idx = MOBILE_STEPS.indexOf(mgr.mobileMode as (typeof MOBILE_STEPS)[number]);
+ const next = MOBILE_STEPS[Math.min(MOBILE_STEPS.length - 1, Math.max(0, idx + dir))];
+ mgr.mobileMode = next as typeof mgr.mobileMode;
+ }
+
+ // ── Feature templates ──────────────────────────────────────
type FeatureTemplate = { id: string; label: string; group: 'layout' | 'set_soal' | 'inline'; snippet: string };
- let featureSidebarOpen = $state(true);
-
- // Desktop: panel fitur terbuka by default. Mobile: default tersembunyi (drawer).
- $effect(() => {
- if (mgr.isMobile) featureSidebarOpen = false;
- });
-
const FEATURE_TEMPLATES: FeatureTemplate[] = [
// ── Layout ──
{
@@ -461,11 +524,38 @@
}
];
+ function getTemplateDesc(id: string): string {
+ switch (id) {
+ case 'info': return 'Tujuan pembelajaran & prasyarat materi';
+ case 'quiz': return 'Pilihan ganda interaktif & pembahasan';
+ case 'exercise': return 'Blok instruksi tantangan latihan mandiri';
+ case 'slide': return 'Presentasi carousel slide atau embed Canva';
+ case 'set-c': return 'Bundle C: initial, expected, solution, key';
+ case 'set-python': return 'Bundle Python dengan verifikasi output';
+ case 'set-circuit': return 'Rangkaian analog & evaluasi tegangan node';
+ case 'set-arduino': return 'Simulasi Arduino Uno & evaluasi wiring';
+ case 'expected-wiring': return 'Evaluasi sambungan pin hardware Arduino';
+ case 'set-flowchart': return 'Diagram alur algoritma dengan verifikasi';
+ case 'circuit-inline': return 'Embed simulasi rangkaian analog inline';
+ case 'flowchart-inline': return 'Embed canvas diagram alur interaktif inline';
+ case 'embed': return 'Embed iframe responsif (Canva, video, dll)';
+ default: return 'Sisipkan template ke materi';
+ }
+ }
+
function insertTemplate(tpl: FeatureTemplate) {
if (!mgr.activePath || isAssetFile) return;
- editorRef?.insertAtCursor(tpl.snippet);
- // Ensure editor tab is visible so the user sees the insertion
- mgr.activeTab = 'editor';
+
+ if (contentViewMode === 'edit' && inlineEditorRef) {
+ inlineEditorRef.insertAtCursor(tpl.snippet);
+ } else {
+ // Mode preview: tambahkan template ke akhir body dan alihkan ke mode edit
+ const current = mgr.body.trimEnd();
+ const newBody = current ? `${current}\n\n${tpl.snippet.trim()}\n` : `${tpl.snippet.trim()}\n`;
+ mgr.onBodyChange(newBody);
+ contentViewMode = 'edit';
+ }
+ showToast(`Template "${tpl.label}" disisipkan`);
}
@@ -490,9 +580,6 @@
if (res.success) {
await loadTrees();
await mgr.loadDraft(path);
- if (mgr.isMobile) {
- mobileShowTree = false;
- }
} else {
alert(res.message || 'Gagal membuat file');
}
@@ -545,7 +632,6 @@
mgr.draftId = null;
mgr.previewSlides = [];
mgr.previewActiveTabs = [];
- if (mgr.isMobile) mobileShowTree = true;
}
} else if (res.needs_force) {
if (window.confirm(`Folder "${name}" tidak kosong. Hapus semua isi?`)) {
@@ -556,7 +642,6 @@
mgr.activePath = null;
mgr.body = '';
mgr.draftId = null;
- if (mgr.isMobile) mobileShowTree = true;
}
} else {
alert(forceRes.message || 'Gagal menghapus');
@@ -726,31 +811,55 @@
class:single-col={float.floating || mgr.isMobile}
class:has-floating={float.floating && !mgr.isMobile}
>
-
+
e.preventDefault()}
- oncopy={(e) => e.preventDefault()}
- oncut={(e) => e.preventDefault()}
- oncontextmenu={(e) => e.preventDefault()}
+ class:in-edit-mode={contentViewMode === 'edit'}
+ onselectstart={(e) => { if (contentViewMode !== 'edit') e.preventDefault(); }}
+ oncopy={(e) => { if (contentViewMode !== 'edit') e.preventDefault(); }}
+ oncut={(e) => { if (contentViewMode !== 'edit') e.preventDefault(); }}
+ oncontextmenu={(e) => { if (contentViewMode !== 'edit') e.preventDefault(); }}
>
- {#if mgr.previewLoading}
+ {#if mgr.previewLoading && contentViewMode !== 'edit'}
Memuat preview...
- {:else if mgr.previewError}
+ {:else if mgr.previewError && contentViewMode !== 'edit'}
{mgr.previewError}
{:else if isImageFile && mgr.activePath}
/assets/{mgr.activePath}
+ {:else if contentViewMode === 'edit' && mgr.activePath && !isAssetFile}
+
contentViewMode = 'preview'}
+ />
{:else if mgr.previewHtml}
+
+ 👆 Klik teks untuk edit langsung
+
+
{#if mgr.previewQuizData.length > 0}
@@ -774,6 +883,7 @@
class:floating-hidden={float.floating && float.minimized && !mgr.isMobile}
class:mobile-sheet={mgr.isMobile}
class:mobile-hidden={mgr.isMobile && mgr.mobileMode === 'hidden'}
+ class:mobile-peek={mgr.isMobile && mgr.mobileMode === 'peek'}
class:mobile-h30={mgr.isMobile && mgr.mobileMode === 'h30'}
class:mobile-h50={mgr.isMobile && mgr.mobileMode === 'h50'}
class:mobile-h70={mgr.isMobile && mgr.mobileMode === 'h70'}
@@ -782,22 +892,29 @@
>
{#snippet saveActions()}
-
-
- {#if importProgress}
- Impor {importProgress.done}/{importProgress.total}
- {/if}
{#if mgr.activePath && !isAssetFile}
-
-
{:else if mgr.isMobile}
-
+
-