feat(content-editor): dockable/floating workspace, mobile view modes, asset preview

- Floating/dockable editor panel with drag & resize handles
- Mobile view modes: hidden → 50% → 100% toggle cycle
- Mobile tree-first: show file tree when no file selected, back button to return
- Asset file preview with image display and copy-path button
- Touch support for drag and resize on mobile
- Fix missing closing div in asset preview section
This commit is contained in:
a2nr 2026-08-21 19:03:53 +07:00
parent 084eb71a71
commit b75eb06346
2 changed files with 456 additions and 65 deletions

View File

@ -31,13 +31,14 @@ export function createFloatingPanel() {
return '';
});
function getPanel(e: MouseEvent): HTMLElement | null {
return (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement | null;
function getPanelFromTarget(target: HTMLElement | null): HTMLElement | null {
return target?.closest('.editor-area') as HTMLElement | null;
}
function onDragStart(e: MouseEvent) {
if (!floating) return;
const panel = getPanel(e);
e.preventDefault();
const panel = getPanelFromTarget(e.currentTarget as HTMLElement);
if (!panel) return;
dragging = true;
const rect = panel.getBoundingClientRect();
@ -61,10 +62,38 @@ export function createFloatingPanel() {
window.addEventListener('mouseup', onUp);
}
function onTouchDragStart(e: TouchEvent) {
if (!floating) return;
const panel = getPanelFromTarget(e.currentTarget as HTMLElement);
if (!panel) return;
dragging = true;
const rect = panel.getBoundingClientRect();
const touch = e.touches[0];
dragOffset = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
if (!size) {
size = { width: rect.width, height: rect.height };
}
const onMove = (ev: TouchEvent) => {
if (!dragging) return;
const t = ev.touches[0];
const newLeft = Math.max(0, Math.min(window.innerWidth - 100, t.clientX - dragOffset.x));
const newTop = Math.max(0, Math.min(window.innerHeight - 48, t.clientY - dragOffset.y));
pos = { top: newTop, left: newLeft };
};
const onUp = () => {
dragging = false;
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onUp);
};
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onUp);
}
function onResizeStart(e: MouseEvent) {
if (!floating) return;
e.preventDefault();
const panel = (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement;
const panel = getPanelFromTarget(e.currentTarget as HTMLElement);
if (!panel) return;
const rect = panel.getBoundingClientRect();
const startX = e.clientX;
@ -93,6 +122,39 @@ export function createFloatingPanel() {
window.addEventListener('mouseup', onUp);
}
function onTouchResizeStart(e: TouchEvent) {
if (!floating) return;
const panel = getPanelFromTarget(e.currentTarget as HTMLElement);
if (!panel) return;
const rect = panel.getBoundingClientRect();
const touch = e.touches[0];
const startX = touch.clientX;
const startY = touch.clientY;
const startW = rect.width;
const startH = rect.height;
const startLeft = rect.left;
const startTop = rect.top;
const onMove = (ev: TouchEvent) => {
const t = ev.touches[0];
const dx = startX - t.clientX;
const dy = startY - t.clientY;
const newW = Math.max(320, startW + dx);
const newH = Math.max(200, startH + dy);
size = { width: newW, height: newH };
pos = {
left: startLeft - (newW - startW),
top: startTop - (newH - startH),
};
};
const onUp = () => {
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onUp);
};
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('touchend', onUp);
}
function toggle() {
floating = !floating;
minimized = false;
@ -118,6 +180,8 @@ export function createFloatingPanel() {
minimize,
restore,
onDragStart,
onTouchDragStart,
onResizeStart,
onTouchResizeStart,
};
}

View File

@ -15,11 +15,21 @@
let editorRef = $state<any>(null);
// Mobile: show tree or editor
let mobileShowTree = $state(true);
// Load trees on mount
onMount(async () => {
await loadTrees();
});
// When mobile & no file selected, default to tree view
$effect(() => {
if (mgr.isMobile && !mgr.activePath) {
mobileShowTree = true;
}
});
// Mobile behavior for floating panel
$effect(() => {
if (mgr.isMobile) {
@ -49,7 +59,31 @@
if (mgr.dirty && !window.confirm('Ada perubahan yang belum disimpan. Lanjut buka file lain?')) {
return;
}
mgr.loadDraft(path);
if (mgr.treeRoot === 'assets') {
// Asset file: load for preview only, don't go through draft system
mgr.activePath = path;
mgr.body = '';
mgr.draftId = null;
mgr.dirty = false;
// Clear preview since assets don't have markdown
mgr.previewHtml = '';
mgr.previewExerciseHtml = '';
mgr.previewQuizData = [];
mgr.previewError = null;
} else {
mgr.loadDraft(path);
}
if (mgr.isMobile) {
mobileShowTree = false;
mgr.mobileMode = 'h50';
}
}
function handleMobileBackToTree() {
if (mgr.dirty && !window.confirm('Ada perubahan yang belum disimpan. Kembali ke tree?')) {
return;
}
mobileShowTree = true;
}
function handleEditorChange(value: string) {
@ -83,6 +117,9 @@
if (res.success) {
await loadTrees();
await mgr.loadDraft(path);
if (mgr.isMobile) {
mobileShowTree = false;
}
} else {
alert(res.message || 'Gagal membuat file');
}
@ -115,6 +152,7 @@
mgr.activePath = null;
mgr.body = '';
mgr.draftId = null;
if (mgr.isMobile) mobileShowTree = true;
}
} else if (res.needs_force) {
if (window.confirm(`Folder "${name}" tidak kosong. Hapus semua isi?`)) {
@ -125,6 +163,7 @@
mgr.activePath = null;
mgr.body = '';
mgr.draftId = null;
if (mgr.isMobile) mobileShowTree = true;
}
} else {
alert(forceRes.message || 'Gagal menghapus');
@ -157,16 +196,20 @@
input.click();
}
function handleCopyAssetPath(path: string) {
function handleCopyAssetPath(e?: MouseEvent) {
const path = mgr.activePath;
if (!path) return;
navigator.clipboard.writeText(`/assets/${path}`);
const toast = document.createElement('div');
toast.textContent = 'Path disalin';
toast.textContent = 'Path disalin ke clipboard';
toast.style.cssText = 'position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:6px 16px;border-radius:8px;font-size:0.8rem;z-index:10000;';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 1500);
}
let activeFileName = $derived(mgr.activePath?.split('/').pop() ?? '');
let isAssetFile = $derived(mgr.treeRoot === 'assets' && mgr.activePath !== null && !mgr.activePath.endsWith('/'));
let isImageFile = $derived(isAssetFile && /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(mgr.activePath ?? ''));
</script>
<svelte:head>
@ -179,8 +222,11 @@
<p>Halaman ini hanya untuk guru.</p>
</div>
{:else}
<div class="content-editor-layout" class:single-col={float.floating || mgr.isMobile}>
<!-- Preview Panel -->
<div class="content-editor-layout"
class:single-col={float.floating || mgr.isMobile}
class:has-floating={float.floating && !mgr.isMobile}
>
<!-- Preview Panel (always behind editor) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="preview-panel"
class:full-width={float.floating || mgr.isMobile}
@ -193,6 +239,11 @@
<div class="preview-loading">Memuat preview...</div>
{:else if mgr.previewError}
<div class="preview-error">{mgr.previewError}</div>
{:else if isImageFile && mgr.activePath}
<div class="preview-asset">
<img src="/assets/{mgr.activePath}" alt={activeFileName} style="max-width:100%;border-radius:var(--radius);" loading="lazy" />
<p class="preview-asset-meta">/assets/{mgr.activePath}</p>
</div>
{:else if mgr.previewHtml}
<LessonContentView
lessonHtml={mgr.previewHtml}
@ -229,10 +280,49 @@
class:mobile-full={mgr.isMobile && mgr.mobileMode === 'full'}
style={float.style}
>
<!-- Drag Handle Bar (floating mode) --> {#if float.floating && !mgr.isMobile}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="drag-handle" onmousedown={float.onDragStart} ontouchstart={float.onTouchDragStart}>
<span class="drag-handle-grip">⣿</span>
<span class="drag-handle-title">Workspace</span>
<div class="drag-handle-actions">
<button class="drag-btn" title="Kembali ke dock (inline)" onclick={float.toggle}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
</button>
<button class="drag-btn" title="Minimize" onclick={float.minimize}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
{/if}
<!-- Editor Header -->
<div class="editor-header">
<div class="editor-header" class:draggable-header={float.floating && !mgr.isMobile}
onmousedown={float.floating && !mgr.isMobile ? float.onDragStart : undefined}
>
<!-- Mobile: back to tree button -->
{#if mgr.isMobile && !mobileShowTree}
<button class="btn-icon mobile-back-btn" onclick={handleMobileBackToTree} title="Kembali ke tree">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
{/if}
<span class="editor-filename">{activeFileName || 'Editor'}</span>
<div class="editor-actions">
<!-- Desktop: Dock/Float toggle -->
{#if !mgr.isMobile}
{#if float.floating}
<button class="btn btn-sm btn-secondary" onclick={float.toggle} title="Dock ke layout">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</button>
{:else}
<button class="btn btn-sm btn-secondary" onclick={float.toggle} title="Float (detach)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2"/><polyline points="9 2 9 9 2 9"/></svg>
</button>
{/if}
{/if}
{#if !isAssetFile}
<button
class="btn btn-sm btn-secondary"
onclick={() => mgr.handleSave()}
@ -247,10 +337,18 @@
>
{mgr.publishing ? 'Mempublikasikan...' : '🚀 Publish'}
</button>
{/if}
{#if mgr.isMobile}
<div class="mobile-mode-toggle">
<button class="btn btn-sm btn-secondary" onclick={() => mgr.mobileMode = mgr.mobileMode === 'hidden' ? 'h50' : 'hidden'}>
{mgr.mobileMode === 'hidden' ? '▲' : '▼'}
<button
class="btn btn-sm btn-secondary"
onclick={() => {
if (mgr.mobileMode === 'hidden') mgr.mobileMode = 'h50';
else if (mgr.mobileMode === 'h50') mgr.mobileMode = 'full';
else mgr.mobileMode = 'hidden';
}}
>
{mgr.mobileMode === 'hidden' ? '▲' : mgr.mobileMode === 'full' ? '▼' : '⬜'}
</button>
</div>
{/if}
@ -259,63 +357,122 @@
<!-- Editor Body -->
<div class="editor-body">
<!-- File Tree -->
<div class="tree-panel">
<div class="tree-header">
<div class="tree-tabs">
<button
class="tree-tab"
class:active={mgr.treeRoot === 'content'}
onclick={() => mgr.treeRoot = 'content'}
>Materi</button>
<button
class="tree-tab"
class:active={mgr.treeRoot === 'assets'}
onclick={() => mgr.treeRoot = 'assets'}
>Assets</button>
{#if mgr.isMobile && mobileShowTree}
<!-- MOBILE: Show full-width tree -->
<div class="tree-panel mobile-tree-full">
<div class="tree-header">
<div class="tree-tabs">
<button
class="tree-tab"
class:active={mgr.treeRoot === 'content'}
onclick={() => mgr.treeRoot = 'content'}
>Materi</button>
<button
class="tree-tab"
class:active={mgr.treeRoot === 'assets'}
onclick={() => mgr.treeRoot = 'assets'}
>Assets</button>
</div>
<div class="tree-actions-header">
{#if mgr.treeRoot === 'content'}
<button class="action-btn-sm" title="Buat file baru" onclick={() => handleCreateFile('')}>📄+</button>
{/if}
<button class="action-btn-sm" title="Buat folder baru" onclick={() => handleCreateFolder('')}>📁+</button>
{#if mgr.treeRoot === 'assets'}
<button class="action-btn-sm" title="Upload gambar" onclick={handleUpload}>⬆️</button>
{/if}
</div>
</div>
<div class="tree-actions-header">
{#if mgr.treeRoot === 'content'}
<button class="action-btn-sm" title="Buat file baru" onclick={() => handleCreateFile('')}>📄+</button>
{/if}
<button class="action-btn-sm" title="Buat folder baru" onclick={() => handleCreateFolder('')}>📁+</button>
{#if mgr.treeRoot === 'assets'}
<button class="action-btn-sm" title="Upload gambar" onclick={handleUpload}>⬆️</button>
<div class="tree-content">
{#if mgr.treeLoading}
<div class="tree-loading">Memuat...</div>
{:else}
<ContentFileTree
nodes={mgr.treeRoot === 'content' ? mgr.contentTree : mgr.assetsTree}
activePath={mgr.activePath}
root={mgr.treeRoot}
onselect={handleSelect}
oncreate={mgr.treeRoot === 'content' ? (type, parentPath) => type === 'file' ? handleCreateFile(parentPath) : handleCreateFolder(parentPath) : undefined}
onrename={handleRename}
ondelete={handleDelete}
/>
{/if}
</div>
</div>
<div class="tree-content">
{#if mgr.treeLoading}
<div class="tree-loading">Memuat...</div>
{:else}
<!-- DESKTOP or MOBILE: Tree + Code side by side -->
<!-- File Tree -->
<div class="tree-panel" class:mobile-tree-hidden={mgr.isMobile}>
<div class="tree-header">
<div class="tree-tabs">
<button
class="tree-tab"
class:active={mgr.treeRoot === 'content'}
onclick={() => mgr.treeRoot = 'content'}
>Materi</button>
<button
class="tree-tab"
class:active={mgr.treeRoot === 'assets'}
onclick={() => mgr.treeRoot = 'assets'}
>Assets</button>
</div>
<div class="tree-actions-header">
{#if mgr.treeRoot === 'content'}
<button class="action-btn-sm" title="Buat file baru" onclick={() => handleCreateFile('')}>📄+</button>
{/if}
<button class="action-btn-sm" title="Buat folder baru" onclick={() => handleCreateFolder('')}>📁+</button>
{#if mgr.treeRoot === 'assets'}
<button class="action-btn-sm" title="Upload gambar" onclick={handleUpload}>⬆️</button>
{/if}
</div>
</div>
<div class="tree-content">
{#if mgr.treeLoading}
<div class="tree-loading">Memuat...</div>
{:else}
<ContentFileTree
nodes={mgr.treeRoot === 'content' ? mgr.contentTree : mgr.assetsTree}
activePath={mgr.activePath}
root={mgr.treeRoot}
onselect={handleSelect}
oncreate={mgr.treeRoot === 'content' ? (type, parentPath) => type === 'file' ? handleCreateFile(parentPath) : handleCreateFolder(parentPath) : undefined}
onrename={handleRename}
ondelete={handleDelete}
/>
{/if}
</div>
</div>
<!-- Code Editor / Asset Preview -->
<div class="code-panel">
{#if mgr.activePath}
{#if isImageFile}
<div class="asset-preview">
<div class="asset-preview-image">
<img src="/assets/{mgr.activePath}" alt={activeFileName} loading="lazy" />
</div>
<div class="asset-preview-info">
<span class="asset-name">{activeFileName}</span>
<span class="asset-path">/assets/{mgr.activePath}</span> <div class="asset-actions">
<button class="btn btn-sm btn-secondary" onclick={handleCopyAssetPath}>📋 Salin Path</button>
</div>
</div>
</div>
{:else}
<CodeEditor
bind:this={editorRef}
code={mgr.body}
language="markdown"
onchange={handleEditorChange}
/>
{/if}
{:else}
<ContentFileTree
nodes={mgr.treeRoot === 'content' ? mgr.contentTree : mgr.assetsTree}
activePath={mgr.activePath}
root={mgr.treeRoot}
onselect={handleSelect}
oncreate={mgr.treeRoot === 'content' ? (type, parentPath) => type === 'file' ? handleCreateFile(parentPath) : handleCreateFolder(parentPath) : undefined}
onrename={handleRename}
ondelete={handleDelete}
/>
<div class="code-empty">
<p>Pilih file dari tree untuk mulai mengedit.</p>
</div>
{/if}
</div>
</div>
<!-- Code Editor -->
<div class="code-panel">
{#if mgr.activePath}
<CodeEditor
bind:this={editorRef}
code={mgr.body}
language="markdown"
onchange={handleEditorChange}
/>
{:else}
<div class="code-empty">
<p>Pilih file dari tree untuk mulai mengedit.</p>
</div>
{/if}
</div>
{/if}
</div>
<!-- Status bar -->
@ -324,6 +481,11 @@
{mgr.lastMessage.text}
</div>
{/if}
<!-- Resize handle (floating mode) --> {#if float.floating && !mgr.isMobile && !float.minimized}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={float.onResizeStart} ontouchstart={float.onTouchResizeStart}></div>
{/if}
</div>
</div>
{/if}
@ -342,6 +504,10 @@
grid-template-columns: 1fr;
}
.content-editor-layout.has-floating .preview-panel {
grid-column: 1 / -1;
}
.access-denied {
text-align: center;
padding: 4rem 2rem;
@ -367,6 +533,18 @@
min-height: 200px;
color: var(--color-text-muted);
}
.preview-asset {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem;
}
.preview-asset-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
word-break: break-all;
}
.preview-error {
padding: 1rem;
background: #fff5f5;
@ -389,6 +567,61 @@
height: 70vh;
}
/* Drag Handle */
.drag-handle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 4px 10px;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
cursor: grab;
user-select: none;
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.drag-handle-grip {
color: var(--color-text-muted);
font-size: 0.7rem;
line-height: 1;
letter-spacing: -1px;
}
.drag-handle-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
flex: 1;
}
.drag-handle-actions {
display: flex;
gap: 2px;
}
.drag-btn {
background: none;
border: 1px solid transparent;
cursor: pointer;
padding: 3px 5px;
border-radius: 4px;
color: var(--color-text-muted);
display: flex;
align-items: center;
transition: all 0.15s;
}
.drag-btn:hover {
background: var(--color-border);
color: var(--color-text);
border-color: var(--color-border);
}
.draggable-header {
cursor: grab;
}
.draggable-header:active {
cursor: grabbing;
}
.editor-header {
display: flex;
align-items: center;
@ -396,6 +629,7 @@
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
flex-shrink: 0;
}
.editor-filename {
font-weight: 600;
@ -411,6 +645,21 @@
align-items: center;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--color-text);
display: flex;
align-items: center;
transition: background 0.15s;
}
.btn-icon:hover {
background: var(--color-border);
}
.editor-body {
flex: 1;
display: flex;
@ -503,6 +752,52 @@
color: var(--color-text-muted);
}
/* Asset Preview */
.asset-preview {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.asset-preview-image {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
overflow: auto;
background: repeating-conic-gradient(var(--color-border) 0% 25%, transparent 0% 50%) 50% / 16px 16px;
}
.asset-preview-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.asset-preview-info {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.5rem 1rem;
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.asset-name {
font-weight: 600;
font-size: 0.85rem;
}
.asset-path {
font-size: 0.75rem;
color: var(--color-text-muted);
word-break: break-all;
}
.asset-actions {
display: flex;
gap: 0.5rem;
margin-top: 4px;
}
/* Status Bar */
.status-bar {
padding: 0.4rem 1rem;
@ -510,6 +805,7 @@
font-weight: 500;
text-align: center;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.status-bar.success {
background: #ebfbee;
@ -520,6 +816,29 @@
color: #c92a2a;
}
/* Resize Handle (floating mode) */
.resize-handle {
position: absolute;
bottom: 0;
left: 0;
width: 18px;
height: 18px;
cursor: nwse-resize;
z-index: 10;
}
.resize-handle::before {
content: '';
position: absolute;
bottom: 3px;
left: 3px;
width: 10px;
height: 10px;
border-right: 2px solid var(--color-text-muted);
border-bottom: 2px solid var(--color-text-muted);
border-radius: 0 0 3px 0;
opacity: 0.5;
}
/* Mobile modes (reuse lesson.css patterns) */
.editor-area.mobile-sheet {
position: fixed;
@ -544,6 +863,17 @@
.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;
flex: 1;
}
.tree-panel.mobile-tree-hidden {
display: none;
}
/* Floating mode */
.editor-area.floating {
position: fixed;
@ -613,8 +943,5 @@
grid-template-columns: 1fr;
padding: 0;
}
.tree-panel {
display: none;
}
}
</style>