Improve teacher content editor mobile workspace

This commit is contained in:
a2nr 2026-10-05 15:18:39 +07:00
parent 3c2546c818
commit 8cf85c1e4c
6 changed files with 1087 additions and 247 deletions

View File

@ -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);
}
};
}

View File

@ -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();
}
</script>
<div class="editor-wrapper" class:no-paste={noPaste} bind:this={container}>
{#if !ready}

View File

@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from 'svelte';
import CodeEditor from '$components/CodeEditor.svelte';
interface Props {
code?: string;
fileName?: string;
initialCursorPos?: number;
initialLine?: number;
onchange: (value: string) => void;
onclose: () => void;
}
let {
code = '',
fileName = '',
initialCursorPos,
initialLine,
onchange,
onclose
}: Props = $props();
let containerEl: HTMLDivElement;
let editorRef = $state<any>(null);
onMount(() => {
// Handle Escape key to return to preview
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onclose();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') {
e.preventDefault();
e.stopPropagation();
onclose();
}
};
// Handle click outside editor container to return to preview.
// Clicks inside the workspace (editor-area) or on focus-preserving
// controls must NOT close the editor — otherwise picking a tool
// from the workspace would drop focus / exit edit mode.
const handleDocumentClick = (e: MouseEvent) => {
if (!containerEl) return;
const target = e.target as Node | null;
if (!target) return;
const el = target instanceof Element ? target : target.parentElement;
if (el && (el.closest('.editor-area') || el.closest('[data-preserve-editor-focus]'))) return;
if (!containerEl.contains(target)) {
onclose();
}
};
window.addEventListener('keydown', handleKeyDown, true);
// Delay attaching click-outside listener to avoid triggering on the click that opened the editor
const timer = setTimeout(() => {
window.addEventListener('click', handleDocumentClick, true);
}, 100);
return () => {
clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('click', handleDocumentClick, true);
};
});
export function focus() {
editorRef?.focus();
}
export function scrollToLine(line: number) {
editorRef?.scrollToLine(line);
}
export function setCursorPos(pos: number) {
editorRef?.setCursorPos(pos);
}
export function insertAtCursor(text: string) {
editorRef?.insertAtCursor(text);
}
export function setCode(newCode: string) {
editorRef?.setCode(newCode);
}
</script>
<div class="inline-editor-container" bind:this={containerEl}>
<div class="inline-editor-header">
<div class="header-left">
<span class="mode-badge">✏️ Mode Edit</span>
{#if fileName}
<span class="file-title">{fileName}</span>
{/if}
<span class="help-text">Tekan <kbd>Esc</kbd> atau klik di luar untuk preview</span>
</div>
<div class="header-right">
<button type="button" class="btn-preview-mode" onclick={onclose} title="Kembali ke preview (Esc)">
👁️ Kembali ke Preview
</button>
</div>
</div>
<div class="inline-editor-body">
<CodeEditor
bind:this={editorRef}
{code}
language="markdown"
{initialCursorPos}
{initialLine}
{onchange}
/>
</div>
</div>
<style>
.inline-editor-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 500px;
background: var(--color-bg);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--color-primary, #339af0);
box-shadow: 0 0 0 1px var(--color-primary, #339af0);
}
.inline-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
overflow: hidden;
}
.mode-badge {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-primary, #339af0);
background: color-mix(in srgb, var(--color-primary, #339af0) 15%, transparent);
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
.file-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.help-text {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.help-text kbd {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 1px 4px;
font-size: 0.7rem;
font-family: inherit;
}
.btn-preview-mode {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text);
border-radius: 4px;
padding: 0.25rem 0.6rem;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-preview-mode:hover {
background: var(--color-border);
color: var(--color-primary, #339af0);
}
.inline-editor-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.inline-editor-body :global(.editor-wrapper) {
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
}
.inline-editor-body :global(.cm-editor) {
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
}
.inline-editor-body :global(.cm-scroller) {
height: 100%;
}
</style>

View File

@ -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<HTMLElement | null>(null);
// Plain variable (not $state) to avoid infinite reactivity loops when mounting inside $effect
let slideComponent: ReturnType<typeof mount> | 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 @@
});
</script>
<div class="lesson-content-view prose" bind:this={contentEl}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="lesson-content-view prose"
class:clickable
bind:this={contentEl}
onclick={handleContentClick}
>
{@html lessonHtml}
</div>
@ -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;
}
</style>

View File

@ -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')

File diff suppressed because it is too large Load Diff