From 8cf85c1e4cd60e1bb0161a8ef9512bb6f23fcc15 Mon Sep 17 00:00:00 2001 From: a2nr Date: Mon, 5 Oct 2026 15:18:39 +0700 Subject: [PATCH] Improve teacher content editor mobile workspace --- frontend/src/lib/actions/preserveFocus.ts | 27 + frontend/src/lib/components/CodeEditor.svelte | 53 +- .../lib/components/ContentInlineEditor.svelte | 227 +++++ .../lib/components/LessonContentView.svelte | 130 ++- .../src/lib/services/contentEditor.svelte.ts | 2 +- .../src/routes/teacher/content/+page.svelte | 895 +++++++++++++----- 6 files changed, 1087 insertions(+), 247 deletions(-) create mode 100644 frontend/src/lib/actions/preserveFocus.ts create mode 100644 frontend/src/lib/components/ContentInlineEditor.svelte 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 @@ + + +
+
+
+ ✏️ Mode Edit + {#if fileName} + {fileName} + {/if} + Tekan Esc atau klik di luar untuk preview +
+
+ +
+
+
+ +
+
+ + 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}
{activeFileName}

/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} - - - {/if} {/snippet} + + {#snippet treeActions()} + {#if mgr.treeRoot === 'content'} + + + {:else} + + + {/if} + + + {/snippet} + {#if float.floating && !mgr.isMobile} @@ -805,7 +922,7 @@ { e.stopPropagation(); float.onResizeStart(e); }} ontouchstart={float.onTouchResizeStart}>◳
{#each allTabs as tab} - + {/each}
@@ -815,37 +932,22 @@
{:else if mgr.isMobile} - + -
{ /* future swipe support */ }}> - {#if !mobileShowTree && mgr.activePath} - - {/if} -
- {#if !mobileShowTree} - {#each allTabs as tab} - - {/each} - {:else} - File Tree - {/if} -
+
+ {mgr.activePath ? activeFileName : 'File Tree'}
- {@render saveActions()} - - + +
{:else}
- {#if mgr.activePath && !isAssetFile} - - {/if}
{#if mgr.activePath} {#each allTabs as tab} - + {/each} {/if}
@@ -858,10 +960,67 @@
{/if} + + {#if mgr.isMobile && mgr.activePath && !isAssetFile} +
+ +
+ + + +
+ + +
+ + + +
+ 📐 Layout + {#each FEATURE_TEMPLATES.filter(t => t.group === 'layout') as tpl} + + {/each} +
+ + + + +
+ 🎯 Set Soal + {#each FEATURE_TEMPLATES.filter(t => t.group === 'set_soal') as tpl} + + {/each} +
+ + + + +
+ ⚡ Inline + {#each FEATURE_TEMPLATES.filter(t => t.group === 'inline') as tpl} + + {/each} +
+
+
+ {/if} +
- {#if mgr.isMobile && mobileShowTree} + {#if mgr.isMobile}
@@ -869,14 +1028,8 @@
-
- {#if mgr.treeRoot === 'content'} - - {/if} - - {#if mgr.treeRoot === 'assets'} - - {/if} +
+ {@render treeActions()}
@@ -896,22 +1049,16 @@
{:else} - + -
+
-
- {#if mgr.treeRoot === 'content'} - - {/if} - - {#if mgr.treeRoot === 'assets'} - - {/if} +
+ {@render treeActions()}
@@ -933,37 +1080,97 @@
- -
- {#if mgr.activePath} - {#if isImageFile} -
-
- {activeFileName} -
-
- {activeFileName} - /assets/{mgr.activePath} -
- + +
+
+ {#if mgr.activePath} + {#if isImageFile} + +
+
+ 🖼️ Pratinjau Asset +
+
+
+ {activeFileName} +
+
+ {activeFileName} + /assets/{mgr.activePath} +
+ +
+
-
+ {:else} + +
+
+ 📄 {activeFileName} + + {mgr.dirty ? '● Perubahan Belum Disimpan' : '✓ Tersimpan'} + +
+
+ + + +
+
+ + +
+

📐 Template Layout & Konten

+
+ {#each FEATURE_TEMPLATES.filter(t => t.group === 'layout') as tpl} + + {/each} +
+
+ + +
+

🎯 Set Soal & Penilaian

+
+ {#each FEATURE_TEMPLATES.filter(t => t.group === 'set_soal') as tpl} + + {/each} +
+
+ + +
+

⚡ Komponen Interaktif Inline

+
+ {#each FEATURE_TEMPLATES.filter(t => t.group === 'inline') as tpl} + + {/each} +
+
+ {/if} {:else} -
- + +
+

👈 Pilih materi di daftar berkas untuk melihat template & aksi dokumen aktif.

{/if} - {:else} -
-

Pilih file dari tree untuk mulai mengedit.

-
- {/if} +
@@ -1043,36 +1250,6 @@ {/if}
- - {#if featureSidebarOpen && mgr.activePath && !isAssetFile} - - {/if}
@@ -1127,6 +1304,48 @@ .preview-panel.full-width { max-height: none; } + .preview-panel.in-edit-mode { + padding: 0.5rem; + display: flex; + flex-direction: column; + } + .preview-panel.in-edit-mode :global(.inline-editor-container) { + flex: 1; + min-height: 75vh; + } + .preview-edit-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.35rem 0.6rem; + background: var(--color-bg-secondary); + border: 1px dashed var(--color-border); + border-radius: var(--radius); + font-size: 0.78rem; + color: var(--color-text-muted); + } + .preview-edit-hint { + display: flex; + align-items: center; + gap: 4px; + } + .btn-quick-edit { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 0.2rem 0.55rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-primary, #339af0); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + } + .btn-quick-edit:hover { + background: var(--color-border); + } .preview-loading, .preview-empty { display: flex; align-items: center; @@ -1187,6 +1406,7 @@ display: flex; gap: 0.25rem; align-self: center; + margin-left: auto; } .panel-btn { background: none; @@ -1216,85 +1436,181 @@ min-height: 0; overflow: hidden; } - .feature-btn { - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 4px; - padding: 0.35rem 0.6rem; - cursor: pointer; - font-size: 0.78rem; - color: var(--color-text); - line-height: 1; - white-space: nowrap; - width: 100%; - text-align: left; - } - .feature-btn:hover:not(:disabled) { background: var(--color-border); } - .feature-btn:disabled { opacity: 0.45; cursor: not-allowed; } - - /* Right feature sidebar */ - .feature-sidebar { - width: 20%; - min-width: 150px; - max-width: 300px; - flex-shrink: 0; - background: var(--color-bg-secondary); - border-left: 1px solid var(--color-border); + /* ── Toolbox (Fitur & Aksi) ── */ + .toolbox-container { + flex: 1; + overflow-y: auto; + padding: 1rem; display: flex; flex-direction: column; - min-height: 0; + gap: 1rem; } - .feature-sidebar-header { + .toolbox-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.75rem; + } + .card-header { display: flex; align-items: center; justify-content: space-between; - padding: 6px 8px; - font-size: 0.78rem; - font-weight: 600; - color: var(--color-text-muted); - border-bottom: 1px solid var(--color-border); - flex-shrink: 0; + gap: 0.5rem; + margin-bottom: 0.5rem; } - .feature-sidebar-body { - padding: 8px; + .card-title { + font-size: 0.85rem; + font-weight: 700; + color: var(--color-text); + } + .btn-row { display: flex; - flex-direction: column; - gap: 0.35rem; - overflow-y: auto; + flex-wrap: wrap; + gap: 0.5rem; } - .feature-group-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.04em; + .btn-action { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0.45rem 0.75rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + } + .btn-action:hover:not(:disabled) { + background: var(--color-border); + border-color: var(--color-text-muted); + } + .btn-action:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .btn-primary { + background: var(--color-primary, #339af0); + border-color: var(--color-primary, #339af0); + color: #fff; + font-weight: 600; + } + .btn-primary:hover:not(:disabled) { + background: #228be6; + border-color: #228be6; + } + .btn-success { + background: #2b8a3e; + border-color: #2b8a3e; + color: #fff; + font-weight: 600; + } + .btn-success:hover:not(:disabled) { + background: #237032; + border-color: #237032; + } + .btn-secondary { + background: var(--color-bg); + border: 1px dashed var(--color-border); color: var(--color-text-muted); - margin-top: 0.5rem; - margin-bottom: 0.1rem; } - .feature-group-label:first-child { margin-top: 0; } - - /* Toolbar Fitur toggle (icon, highlighted when sidebar open) */ - .toolbar-toggle { - font-size: 0.95rem; - line-height: 1; - padding: 0.2rem 0.5rem; - } - .toolbar-toggle.active { + .btn-secondary:hover:not(:disabled) { background: var(--color-border); color: var(--color-text); } + .active-file-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; + } + .file-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text); + word-break: break-all; + } + .file-status-badge { + font-size: 0.72rem; + padding: 0.2rem 0.5rem; + border-radius: 4px; + background: #ebfbee; + color: #2b8a3e; + white-space: nowrap; + } + .file-status-badge.dirty { + background: #fff3bf; + color: #e67700; + } + .toolbox-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .section-title { + font-size: 0.78rem; + font-weight: 700; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + } + .template-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.5rem; + } + .template-card-btn { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + padding: 0.6rem 0.75rem; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + text-align: left; + transition: all 0.15s; + } + .template-card-btn:hover { + background: var(--color-border); + border-color: var(--color-text-muted); + } + .tpl-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text); + } + .tpl-desc { + font-size: 0.7rem; + color: var(--color-text-muted); + line-height: 1.3; + } + .toolbox-zero-state { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + color: var(--color-text-muted); + text-align: center; + font-size: 0.85rem; + } - /* Mobile: same 20% inline sidebar as desktop (no drawer overlay) */ @media (max-width: 768px) { - .feature-sidebar.mobile-drawer { - position: static; - top: auto; - right: auto; - bottom: auto; - width: 30%; - min-width: 70px; - max-width: 200px; - z-index: auto; - box-shadow: none; + .toolbox-container { + padding: 0.75rem; + gap: 0.75rem; + } + .template-grid { + grid-template-columns: repeat(2, 1fr); + } + .btn-row { + flex-direction: column; + } + .btn-action, .btn-primary, .btn-success, .btn-secondary { + width: 100%; + text-align: center; } } @@ -1358,7 +1674,7 @@ /* Tab Panels */ .tab-panels { - flex: 1; + flex: 0 0 35%; display: flex; flex-direction: column; overflow: hidden; @@ -1413,13 +1729,17 @@ /* Tree Panel */ .tree-panel { - width: 220px; - min-width: 180px; + flex: 0 0 65%; + min-width: 0; border-right: 1px solid var(--color-border); display: flex; flex-direction: column; overflow: hidden; } + .tree-panel.mobile-tree-full { + flex: 0 0 100%; + border-right: none; + } .tree-header { display: flex; align-items: center; @@ -1453,21 +1773,35 @@ color: white; border-color: var(--color-primary, #339af0); } - .tree-actions-header { + .tree-actions { display: flex; gap: 2px; } - .action-btn-sm { - background: none; - border: none; - cursor: pointer; - padding: 2px 4px; - font-size: 0.75rem; + .tree-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: var(--color-bg); + border: 1px solid var(--color-border); border-radius: 4px; - transition: background 0.15s; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; } - .action-btn-sm:hover { - background: var(--color-border, #ddd); + .tree-action-btn:hover:not(:disabled) { + background: var(--color-border); + border-color: var(--color-text-muted); + } + .tree-action-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .tree-actions-header { + display: flex; + gap: 2px; } .tree-content { flex: 1; @@ -1643,17 +1977,92 @@ transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.2s ease; } .editor-area.mobile-hidden { height: 52px; } + .editor-area.mobile-peek { height: 84px; } .editor-area.mobile-h30 { height: 30vh; } .editor-area.mobile-h50 { height: 50vh; } .editor-area.mobile-h70 { height: 70vh; } .editor-area.mobile-full { height: 100dvh; border-radius: 0; max-height: none; } - /* Mobile tree full-width */ - .tree-panel.mobile-tree-full { - width: 100%; - min-width: 0; - border-right: none; + /* ── Quick tools rail ── */ + .quick-tools-rail { + display: flex; + align-items: stretch; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; + } + .qt-pinned { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.6rem; + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + flex-shrink: 0; + } + .qt-scroll { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; flex: 1; + min-width: 0; + } + .qt-scroll::-webkit-scrollbar { display: none; } + + .qt-group { + display: flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; + } + .qt-label { + font-size: 0.68rem; + font-weight: 700; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + } + .qt-divider { + width: 1px; + height: 22px; + background: var(--color-border); + flex-shrink: 0; + } + .qt-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.4rem 0.7rem; + min-height: 36px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; + } + .qt-chip:hover:not(:disabled) { + background: var(--color-border); + border-color: var(--color-text-muted); + } + .qt-chip:disabled { + opacity: 0.45; + cursor: not-allowed; + } + .qt-action-edit { + color: var(--color-primary, #339af0); + font-weight: 600; } .tree-panel.mobile-tree-hidden { display: none;