diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 43a0fb45e..41a08304b 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -213,6 +213,56 @@ function registerCoreCommands() { return true; }, }); + addCommand({ + name: "newPane", + description: "Create new editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("new-pane"); + return true; + }, + }); + addCommand({ + name: "moveTabToNewPane", + description: "Move current tab to new pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("move-tab-to-new-pane"); + return true; + }, + }); + addCommand({ + name: "closePane", + description: "Close active editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("close-pane"); + return true; + }, + }); + addCommand({ + name: "focusNextPane", + description: "Focus next editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("focus-next-pane"); + return true; + }, + }); + addCommand({ + name: "focusPreviousPane", + description: "Focus previous editor pane", + readOnly: true, + requiresView: false, + run() { + acode.exec("focus-previous-pane"); + return true; + }, + }); addCommand({ name: "closeAllTabs", description: "Close all tabs", diff --git a/src/cm/editorUtils.ts b/src/cm/editorUtils.ts index ff750f061..a8f2569e6 100644 --- a/src/cm/editorUtils.ts +++ b/src/cm/editorUtils.ts @@ -22,6 +22,24 @@ export interface ScrollPosition { scrollLeft: number; } +const docTextCache = new WeakMap(); + +/** + * CodeMirror Text documents are immutable, so the full string can be cached + * per document object and reused across compatibility/plugin reads. + */ +export function getDocText( + doc: { toString(): string } | string | null | undefined, +): string { + if (!doc) return ""; + if (typeof doc !== "object" && typeof doc !== "function") return String(doc); + const cached = docTextCache.get(doc); + if (cached !== undefined) return cached; + const text = doc.toString(); + docTextCache.set(doc, text); + return text; +} + /** * Get all folded ranges from CodeMirror editor state */ diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index 53bf8e7cf..79bc31cc6 100644 --- a/src/cm/lsp/references.ts +++ b/src/cm/lsp/references.ts @@ -5,6 +5,7 @@ import { openReferencesTab, showReferencesPanel, } from "components/referencesPanel"; +import { getDocText } from "cm/editorUtils"; import settings from "lib/settings"; interface Position { @@ -61,7 +62,7 @@ async function fetchLineText(uri: string, line: number): Promise { } } if (typeof doc.toString === "function") { - const content = doc.toString(); + const content = getDocText(doc as { toString(): string }); const lines = content.split("\n"); if (lines[line] !== undefined) { return lines[line]; diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js index fc8b5015c..7a862d8dc 100644 --- a/src/components/sidebar/index.js +++ b/src/components/sidebar/index.js @@ -644,7 +644,12 @@ function create($container, $toggler) { root.style.width = `calc(100% - ${width}px)`; clearTimeout(setWidthTimeout); setWidthTimeout = setTimeout(() => { - editorManager?.editor?.resize(true); + const editor = editorManager?.editor; + if (typeof editor?.resize === "function") { + editor.resize(true); + } else { + editor?.requestMeasure?.(); + } }, 300); } diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index a0c2699a9..ef39f4ac7 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -1,5 +1,6 @@ import config from "lib/config"; import settings from "lib/settings"; +import { animate } from "motion"; const opts = { passive: false }; @@ -18,6 +19,9 @@ let $tab = null; * @type {HTMLDivElement} */ let $parent = null; +let $originParent = null; +let draggedFile = null; +let allowPaneTransfer = true; let MAX_SCROLL = 0; let MIN_SCROLL = 0; @@ -72,14 +76,15 @@ let prevScrollLeft = 0; */ let initialNextSibling = null; let didReorder = false; +let dragSessionId = 0; const MIN_SCROLL_SPEED = 2; const MAX_SCROLL_SPEED = 14; -const REORDER_DURATION = 280; -const RELEASE_DURATION = 250; -const SPRING_EASING = "cubic-bezier(0.2, 1.2, 0.4, 1)"; +const REORDER_DURATION = 0.28; +const RELEASE_DURATION = 0.25; +const SPRING_EASING = [0.2, 1, 0.4, 1]; -/** @type {WeakMap} */ +/** @type {WeakMap} */ const reorderAnimations = new WeakMap(); /** @@ -98,11 +103,16 @@ export default function startDrag(e) { navigator.vibrate(config.VIBRATION_TIME); } - $tab = e.target; + $tab = e.currentTarget || e.target.closest?.(".tile") || e.target; $parent = $tab.parentElement; + $originParent = $parent; + draggedFile = editorManager.files.find((file) => file.tab === $tab) || null; + allowPaneTransfer = + !!draggedFile && !(draggedFile.isPanePlaceholder && !draggedFile.isUnsaved); $tabClone = $tab.cloneNode(true); initialNextSibling = $tab.nextElementSibling; didReorder = false; + dragSessionId += 1; const rect = $tab.getBoundingClientRect(); const parentRect = $parent.getBoundingClientRect(); @@ -137,6 +147,7 @@ export default function startDrag(e) { $tabClone.style.width = `${rect.width}px`; $tabClone.style.transform = `translate3d(${rect.x}px, ${rect.y}px, 0)`; $tab.style.opacity = "0.35"; + $tab.dataset.editorTabDragging = "true"; app.append($tabClone); $tab.click(); @@ -196,30 +207,25 @@ function releaseDrag(e) { /**@type {HTMLDivElement} target tab */ const $target = document.elementFromPoint(clientX, clientY); - const shouldCommitReorder = $parent.contains($target); + const isPathDropTarget = isFilePathDropTarget($target); + const $dropParent = isPathDropTarget + ? null + : getDropTabList(clientX, clientY); + if ($dropParent && $dropParent !== $parent) { + moveDragToParent($dropParent); + } + const shouldCommitReorder = + !!$dropParent && ($parent.contains($target) || $dropParent === $parent); if (shouldCommitReorder) { updateDragPreview(clientX, clientY); - if (didReorder) { + if ($parent !== $originParent) { + commitPaneTransfer(); + } else if (didReorder) { updateFileList($parent); } - } else if ( - $target && - ($target.tagName === "INPUT" || - $target.tagName === "TEXTAREA" || - $target.isContentEditable || - $target.closest(".cm-editor")) - ) { - // If released on an input area or CodeMirror editor - const filePath = editorManager.activeFile.uri; - if (filePath) { - if ($target.closest(".cm-editor")) { - const view = editorManager.editor; - view.dispatch(view.state.replaceSelection(filePath)); - } else { - $target.value += filePath; - } - } + } else if (isPathDropTarget) { + insertDraggedFilePath($target, clientX, clientY); } const shouldSettleClone = shouldCommitReorder || didReorder; @@ -232,6 +238,7 @@ function releaseDrag(e) { } function finishDrag(shouldSettleClone) { + const dragState = getCurrentDragState(); cancelAnimationFrame(animationFrame); document.removeEventListener("mousemove", onDrag, opts); @@ -244,29 +251,63 @@ function finishDrag(shouldSettleClone) { $parent.removeEventListener("scroll", preventDefaultScroll); if (shouldSettleClone) { - const rect = $tab.getBoundingClientRect(); - const anim = $tabClone.animate( - [{ transform: `translate3d(${rect.left}px, ${rect.top}px, 0)` }], + const rect = dragState.tab.getBoundingClientRect(); + let cleaned = false; + let cleanupTimeout = null; + const safeCleanup = () => { + if (cleaned) return; + cleaned = true; + if (cleanupTimeout) clearTimeout(cleanupTimeout); + cleanupDrag(dragState); + }; + animate( + dragState.tabClone, + { transform: `translate3d(${rect.left}px, ${rect.top}px, 0)` }, { duration: document.body.classList.contains("no-animation") ? 0 : RELEASE_DURATION, - easing: SPRING_EASING, - fill: "forwards", + ease: SPRING_EASING, }, - ); - anim.onfinish = cleanupDrag; - anim.oncancel = cleanupDrag; + ) + .then(safeCleanup) + .catch(safeCleanup); + cleanupTimeout = setTimeout(safeCleanup, 500); return; } - cleanupDrag(); + cleanupDrag(dragState); +} + +function getCurrentDragState() { + return { + id: dragSessionId, + tab: $tab, + tabClone: $tabClone, + }; } -function cleanupDrag() { - $tab.style.opacity = ""; - $tabClone.remove(); +function cleanupDrag(state = getCurrentDragState()) { + const isCurrentDrag = + state.id === dragSessionId && + state.tabClone && + state.tabClone === $tabClone; + const isSameTabInNewDrag = !isCurrentDrag && state.tab && state.tab === $tab; + + if (state.tab && !isSameTabInNewDrag) { + state.tab.style.opacity = ""; + delete state.tab.dataset.editorTabDragging; + } + + state.tabClone?.remove(); + + if (!isCurrentDrag) return; + + editorManager.syncOpenFileList?.(); $tabClone = null; + $originParent = null; + draggedFile = null; + allowPaneTransfer = true; initialNextSibling = null; didReorder = false; } @@ -277,16 +318,19 @@ function preventDefaultScroll() { function updateDragPreview(clientX, clientY) { const $target = document.elementFromPoint(clientX, clientY); - if ( - !$parent.contains($target) || - $target === $tab || - $tab.contains($target) - ) { + const $dropParent = getDropTabList(clientX, clientY); + if ($dropParent && $dropParent !== $parent) { + moveDragToParent($dropParent); + } + + if (!$target || $target === $tab || $tab.contains($target)) { return; } const $targetTab = $target.closest(".tile"); - if (!$targetTab) return; + if (!$targetTab || !$parent.contains($targetTab)) { + return; + } const rect = $targetTab.getBoundingClientRect(); const midX = rect.left + rect.width / 2; @@ -302,11 +346,49 @@ function updateDragPreview(clientX, clientY) { } function restoreInitialTabPosition() { + if ($parent !== $originParent) { + moveDragToParent($originParent, initialNextSibling); + return; + } + reorderTab( initialNextSibling?.parentElement === $parent ? initialNextSibling : null, ); } +function moveDragToParent($nextParent, $insertBefore = null) { + if (!$nextParent || $nextParent === $parent) return; + + const previousParents = [$parent, $nextParent].filter(Boolean); + const previousRects = captureVisualPositionsForParents(previousParents); + $parent.removeEventListener("scroll", preventDefaultScroll, opts); + + if ($insertBefore?.parentElement === $nextParent) { + $nextParent.insertBefore($tab, $insertBefore); + } else { + $nextParent.appendChild($tab); + } + + previousParents.forEach(($list) => animateTabReorder($list, previousRects)); + $parent = $nextParent; + updateParentMetrics(); + prevScrollLeft = $parent.scrollLeft; + $parent.addEventListener("scroll", preventDefaultScroll, opts); + didReorder = true; +} + +function commitPaneTransfer() { + const targetPane = $parent?.__editorPane; + if (!targetPane || !draggedFile || !allowPaneTransfer) return; + + const index = [...$parent.children].indexOf($tab); + editorManager.moveFileToPane?.(draggedFile, targetPane, { + activate: true, + index, + }); + editorManager.updatePaneFileOrderFromTabs?.($parent, { draggedFile }); +} + function reorderTab($insertBefore) { const previousRects = captureVisualPositions($parent); @@ -335,10 +417,222 @@ function captureVisualPositions($parent) { ); } +function captureVisualPositionsForParents(parents) { + const rects = new Map(); + parents.forEach(($list) => { + if (!$list) return; + for (const $child of $list.children) { + rects.set($child, $child.getBoundingClientRect()); + } + }); + return rects; +} + +function updateParentMetrics() { + const parentRect = $parent.getBoundingClientRect(); + parentLeft = parentRect.left; + parentRight = parentRect.right; + MAX_SCROLL = $parent.scrollWidth - parentRect.width; + MIN_SCROLL = 0; +} + +function getDropTabList(clientX, clientY) { + const $target = document.elementFromPoint(clientX, clientY); + const $tabList = + $target?.closest?.(".open-file-list") || + $target?.closest?.(".file-list > ul"); + if (isValidDropTabList($tabList)) return $tabList; + + if (isFilePathDropTarget($target)) return null; + + const pane = $target?.closest?.(".editor-pane")?.__editorPane; + if (pane?.tabList !== $parent && isValidDropTabList(pane?.tabList)) { + return pane.tabList; + } + + return null; +} + +function isFilePathDropTarget($target) { + return !!( + $target && + ($target.tagName === "INPUT" || + $target.tagName === "TEXTAREA" || + $target.isContentEditable || + $target.closest(".cm-editor")) + ); +} + +function insertDraggedFilePath($target, clientX, clientY) { + const filePath = draggedFile?.uri || editorManager.activeFile?.uri; + if (!filePath) return; + + if ($target.closest(".cm-editor")) { + const view = + $target.closest(".editor-pane")?.__editorPane?.editor || + editorManager.editor; + view.dispatch(view.state.replaceSelection(filePath)); + } else if ($target.isContentEditable) { + insertTextIntoContentEditable($target, filePath, clientX, clientY); + } else { + insertTextIntoInput($target, filePath); + } +} + +function insertTextIntoContentEditable($target, text, clientX, clientY) { + const $editable = getContentEditableRoot($target); + const range = getContentEditableRange($editable, clientX, clientY); + const selection = document.getSelection(); + + if (!range || !selection) { + $editable.append(document.createTextNode(text)); + $editable.dispatchEvent(createInputEvent(text)); + return; + } + + focusWithoutScrolling($editable); + selection.removeAllRanges(); + selection.addRange(range); + + if ( + typeof document.execCommand === "function" && + document.queryCommandSupported?.("insertText") + ) { + const inserted = document.execCommand("insertText", false, text); + if (inserted) return; + } + + range.deleteContents(); + const textNode = document.createTextNode(text); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + $editable.dispatchEvent(createInputEvent(text)); +} + +function getContentEditableRoot($target) { + let $editable = $target; + while ($editable.parentElement?.isContentEditable) { + $editable = $editable.parentElement; + } + + return $editable; +} + +function focusWithoutScrolling($target) { + try { + $target.focus({ preventScroll: true }); + } catch { + $target.focus(); + } +} + +function getContentEditableRange($target, clientX, clientY) { + const range = getCaretRangeFromPoint(clientX, clientY); + if (range && isRangeInsideElement(range, $target)) return range; + + const selection = document.getSelection(); + if (selection?.rangeCount) { + const selectionRange = selection.getRangeAt(0); + if (isRangeInsideElement(selectionRange, $target)) { + return selectionRange.cloneRange(); + } + } + + const fallbackRange = document.createRange(); + fallbackRange.selectNodeContents($target); + fallbackRange.collapse(false); + return fallbackRange; +} + +function getCaretRangeFromPoint(clientX, clientY) { + const caretRange = document.caretRangeFromPoint?.(clientX, clientY); + if (caretRange) return caretRange; + + const caretPosition = document.caretPositionFromPoint?.(clientX, clientY); + if (!caretPosition) return null; + + const range = document.createRange(); + range.setStart(caretPosition.offsetNode, caretPosition.offset); + range.collapse(true); + return range; +} + +function isRangeInsideElement(range, $element) { + const { commonAncestorContainer } = range; + return ( + commonAncestorContainer === $element || + $element.contains(commonAncestorContainer) + ); +} + +function insertTextIntoInput($target, text) { + const value = $target.value || ""; + const start = getInputSelectionOffset( + $target, + "selectionStart", + value.length, + ); + const end = getInputSelectionOffset($target, "selectionEnd", start); + + if (typeof $target.setRangeText === "function") { + try { + $target.setRangeText(text, start, end, "end"); + } catch { + $target.value = `${value}${text}`; + } + } else { + const nextValue = `${value.slice(0, start)}${text}${value.slice(end)}`; + $target.value = nextValue; + setInputSelectionOffset($target, start + text.length); + } + + $target.dispatchEvent(createInputEvent(text)); +} + +function getInputSelectionOffset($target, key, fallback) { + try { + return typeof $target[key] === "number" ? $target[key] : fallback; + } catch { + return fallback; + } +} + +function setInputSelectionOffset($target, offset) { + try { + $target.selectionStart = $target.selectionEnd = offset; + } catch { + return; + } +} + +function createInputEvent(text) { + if (typeof InputEvent === "function") { + return new InputEvent("input", { + bubbles: true, + cancelable: false, + data: text, + inputType: "insertText", + }); + } + + return new Event("input", { bubbles: true, cancelable: false }); +} + +function isValidDropTabList($tabList) { + if (!$tabList) return false; + if ($tabList === $originParent) return true; + if (!$tabList.__editorPane) return false; + if (!allowPaneTransfer) return false; + return $tabList.getClientRects().length > 0; +} + /** * Animates the visual change after the DOM order is updated using FLIP. - * Uses WAAPI directly for reliable mid-animation compositing and to - * properly respect the app's no-animation setting. + * Uses Motion for reliable mid-animation compositing and to respect the + * app's no-animation setting. * @param {HTMLElement} $parent * @param {Map} previousRects */ @@ -348,8 +642,9 @@ function animateTabReorder($parent, previousRects) { const oldAnim = reorderAnimations.get($child); if (oldAnim) { - oldAnim.cancel(); + oldAnim.cancel?.(); reorderAnimations.delete($child); + $child.style.transform = ""; } const previousRect = previousRects.get($child); @@ -361,32 +656,31 @@ function animateTabReorder($parent, previousRects) { if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) continue; - const anim = $child.animate( - [ - { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, - { transform: "translate3d(0, 0, 0)" }, - ], + const anim = animate( + $child, + { + transform: [ + `translate3d(${deltaX}px, ${deltaY}px, 0)`, + "translate3d(0, 0, 0)", + ], + }, { duration: document.body.classList.contains("no-animation") ? 0 : REORDER_DURATION, - easing: SPRING_EASING, - fill: "none", - composite: "replace", + ease: SPRING_EASING, }, ); reorderAnimations.set($child, anim); - anim.onfinish = () => { - if (reorderAnimations.get($child) === anim) { - reorderAnimations.delete($child); - } - }; - anim.oncancel = () => { + const cleanup = () => { if (reorderAnimations.get($child) === anim) { + $child.style.transform = ""; reorderAnimations.delete($child); } }; + + anim.then(cleanup).catch(cleanup); } } @@ -394,13 +688,13 @@ function animateTabReorder($parent, previousRects) { * Scrolls the container using animation frame */ function scrollContainer() { - return animate(); + return step(); - function animate() { + function step() { const scroll = getScroll(); if (!scroll) return; prevScrollLeft = $parent.scrollLeft += scroll; - animationFrame = requestAnimationFrame(animate); + animationFrame = requestAnimationFrame(step); } } @@ -432,6 +726,11 @@ function getClientPos(e) { * @param {HTMLElement} $parent */ function updateFileList($parent) { + if (typeof editorManager.updatePaneFileOrderFromTabs === "function") { + if (editorManager.updatePaneFileOrderFromTabs($parent, { draggedFile })) + return; + } + const pinnedCount = editorManager.files.filter((file) => file.pinned).length; const children = [...$parent.children]; const newFileList = []; @@ -446,19 +745,19 @@ function updateFileList($parent) { editorManager.files = newFileList; - const draggedFile = newFileList.find((file) => file.tab === $tab); - if (draggedFile) { - const draggedIndex = newFileList.indexOf(draggedFile); + const localDraggedFile = newFileList.find((file) => file.tab === $tab); + if (localDraggedFile) { + const draggedIndex = newFileList.indexOf(localDraggedFile); let nextPinnedState; - if (!draggedFile.pinned && draggedIndex < pinnedCount) { + if (!localDraggedFile.pinned && draggedIndex < pinnedCount) { nextPinnedState = true; - } else if (draggedFile.pinned && draggedIndex >= pinnedCount) { + } else if (localDraggedFile.pinned && draggedIndex >= pinnedCount) { nextPinnedState = false; } if (nextPinnedState !== undefined) { - draggedFile.setPinnedState(nextPinnedState, { reorder: false }); + localDraggedFile.setPinnedState(nextPinnedState, { reorder: false }); if (typeof editorManager.normalizePinnedTabOrder === "function") { editorManager.normalizePinnedTabOrder(editorManager.files); } diff --git a/src/lib/commands.js b/src/lib/commands.js index 2b2a1cc80..c704008b0 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -59,8 +59,8 @@ export function canSaveFile(file = editorManager.activeFile) { } function getTabsRelativeToFile(side, referenceFile) { - const { files } = editorManager; const file = resolveReferenceFile(referenceFile); + const files = editorManager.getPaneFiles?.(file) || editorManager.files; const activeIndex = files.indexOf(file); if (activeIndex === -1) return []; @@ -163,7 +163,46 @@ export default { }); }, "close-current-tab"() { - editorManager.activeFile.remove(); + editorManager.activeFile?.remove(); + }, + "new-pane"() { + return editorManager.createPane?.(); + }, + "split-pane"() { + return editorManager.splitPane?.(); + }, + "split-pane-right"() { + return editorManager.splitPaneRight?.(); + }, + "split-pane-down"() { + return editorManager.splitPaneDown?.(); + }, + "close-pane"() { + return editorManager.closeActivePane?.(); + }, + "focus-next-pane"() { + return editorManager.focusNextPane?.(); + }, + "focus-previous-pane"() { + return editorManager.focusPreviousPane?.(); + }, + "focus-pane-left"() { + return editorManager.focusPaneByDirection?.("left"); + }, + "focus-pane-right"() { + return editorManager.focusPaneByDirection?.("right"); + }, + "focus-pane-up"() { + return editorManager.focusPaneByDirection?.("up"); + }, + "focus-pane-down"() { + return editorManager.focusPaneByDirection?.("down"); + }, + "move-tab-to-new-pane"() { + return editorManager.moveActiveFileToNewPane?.(); + }, + "move-tab-to-new-pane-down"() { + return editorManager.moveActiveFileToNewPane?.("vertical"); }, "toggle-pin-tab"(referenceFile) { resolveReferenceFile(referenceFile)?.togglePinned?.(); @@ -199,7 +238,9 @@ export default { navigator.app.exitApp(); }, "edit-with"() { - editorManager.activeFile.editWith(); + const { activeFile } = editorManager; + if (!activeFile?.uri) return; + activeFile.editWith?.(); }, async "find-file"() { const { default: findFile } = await import( @@ -246,13 +287,18 @@ export default { }); }, "next-file"() { - const len = editorManager.files.length; - let fileIndex = editorManager.files.indexOf(editorManager.activeFile); + const files = + editorManager.getPaneFiles?.(editorManager.activeFile) || + editorManager.files; + const len = files.length; + let fileIndex = files.indexOf(editorManager.activeFile); + + if (!len || fileIndex === -1) return; if (fileIndex === len - 1) fileIndex = 0; else ++fileIndex; - editorManager.files[fileIndex].makeActive(); + files[fileIndex].makeActive(); }, async open(page) { switch (page) { @@ -300,7 +346,9 @@ export default { editorManager.editor.contentDOM.blur(); }, "open-with"() { - editorManager.activeFile.openWith(); + const { activeFile } = editorManager; + if (!activeFile?.uri) return; + activeFile.openWith?.(); }, async "open-file"() { editorManager.editor.contentDOM.blur(); @@ -317,13 +365,18 @@ export default { .catch(FileBrowser.openFolderError); }, "prev-file"() { - const len = editorManager.files.length; - let fileIndex = editorManager.files.indexOf(editorManager.activeFile); + const files = + editorManager.getPaneFiles?.(editorManager.activeFile) || + editorManager.files; + const len = files.length; + let fileIndex = files.indexOf(editorManager.activeFile); + + if (!len || fileIndex === -1) return; if (fileIndex === 0) fileIndex = len - 1; else --fileIndex; - editorManager.files[fileIndex].makeActive(); + files[fileIndex].makeActive(); }, "read-only"() { const file = editorManager.activeFile; @@ -359,12 +412,13 @@ export default { browser.open(url); }, run() { - editorManager.activeFile[ + const { activeFile } = editorManager; + activeFile?.[ appSettings.value.useCurrentFileForPreview ? "runFile" : "run" ]?.(); }, "run-file"() { - editorManager.activeFile.runFile?.(); + editorManager.activeFile?.runFile?.(); }, async save(showToast) { try { @@ -394,7 +448,9 @@ export default { saveState(); }, share() { - editorManager.activeFile.share(); + const { activeFile } = editorManager; + if (!activeFile?.uri) return; + activeFile.share?.(); }, async "pin-file-shortcut"() { const file = editorManager.activeFile; @@ -588,6 +644,7 @@ export default { } }, async eol() { + if (editorManager.activeFile?.type !== "editor") return; const eol = await select(strings["new line mode"], ["unix", "windows"], { default: editorManager.activeFile.eol, }); diff --git a/src/lib/editorFile.js b/src/lib/editorFile.js index 3ffa01558..170ccd75b 100644 --- a/src/lib/editorFile.js +++ b/src/lib/editorFile.js @@ -3,6 +3,7 @@ import fsOperation from "fileSystem"; import { EditorState } from "@codemirror/state"; import { clearSelection, + getDocText, restoreFolds, restoreSelection, setScrollPosition, @@ -28,6 +29,44 @@ import run from "./run"; import saveFile from "./saveFile"; import appSettings from "./settings"; +function syncQuickToolsVisibility(file) { + const { $toggler } = quickTools; + const hideForFile = !!file?.hideQuickTools; + + clearTimeout($toggler._hideTimeout); + if (hideForFile || !appSettings.value.floatingButton) { + $toggler.classList.add("hide"); + $toggler._hideTimeout = setTimeout(() => { + $toggler.remove(); + $toggler._hideTimeout = null; + }, 300); + } else { + $toggler._hideTimeout = null; + $toggler.classList.remove("hide"); + if (!$toggler.isConnected) { + root.appendOuter($toggler); + } + } + + if (hideForFile) { + actions("set-height", { height: 0, save: false }); + return; + } + + const quickToolsHeight = + appSettings.value.quickTools !== undefined + ? appSettings.value.quickTools + : 1; + actions("set-height", { height: quickToolsHeight, save: false }); +} + +function isTouchDevice() { + return ( + typeof navigator !== "undefined" && + Number(navigator.maxTouchPoints || 0) > 0 + ); +} + /** * Creates a Proxy around an EditorState that provides Ace-compatible methods. * @param {EditorState} state - The raw CodeMirror EditorState @@ -82,7 +121,7 @@ function createSessionProxy(state, file) { // Ace-compatible method: getValue() if (prop === "getValue") { - return () => target.doc.toString(); + return () => getDocText(target.doc); } // Ace-compatible method: setValue(text) @@ -269,6 +308,9 @@ function maybeRecommendLanguageModeExtension(file, modeInfo) { * @property {number} [savedMtime] file mtime last saved or loaded from disk * @property {number} [diskMtime] latest known file mtime on disk * @property {boolean} [hasDiskConflict] whether editor and disk both changed + * @property {string} [paneId] target editor pane id + * @property {object} [pane] target editor pane + * @property {boolean} [isPanePlaceholder] temporary empty tab for an empty pane */ export default class EditorFile { @@ -437,6 +479,7 @@ export default class EditorFile { savedMtime = null; diskMtime = null; hasDiskConflict = false; + isPanePlaceholder = false; /** * @@ -448,6 +491,8 @@ export default class EditorFile { let doesExists = null; this.hideQuickTools = options?.hideQuickTools || false; + this.paneId = options?.paneId || options?.pane?.id || null; + this.isPanePlaceholder = !!options?.isPanePlaceholder; // if options are passed if (options) { @@ -812,7 +857,7 @@ export default class EditorFile { set eol(value) { if (this.type !== "editor") return; if (this.eol === value) return; - let text = this.session.doc.toString(); + let text = getDocText(this.session.doc); if (value === "windows") { text = text.replace(/\n(? { @@ -1200,7 +1255,14 @@ export default class EditorFile { * @param {boolean} force if true, will prompt to save the file */ async remove(force = false, options = {}) { - const { ignorePinned = false, silentPinned = false } = options || {}; + const { + ignorePinned = false, + silentPinned = false, + suppressPanePlaceholder = false, + } = options || {}; + const isUnsaved = this.refreshUnsavedState(); + const suppressFallback = + suppressPanePlaceholder && this.isPanePlaceholder && !isUnsaved; if (this.id === config.DEFAULT_FILE_SESSION && !editorManager.files.length) return false; @@ -1213,7 +1275,7 @@ export default class EditorFile { } return false; } - if (!force && this.isUnsaved) { + if (!force && isUnsaved) { const confirmation = await confirm( strings.warning.toUpperCase(), strings["unsaved file"], @@ -1223,20 +1285,52 @@ export default class EditorFile { this.#destroy(); - editorManager.files = editorManager.files.filter( - (file) => file.id !== this.id, - ); - const { files, activeFile } = editorManager; + const removal = editorManager.removeFileFromPane?.(this); + if (!removal) { + editorManager.files = editorManager.files.filter( + (file) => file.id !== this.id, + ); + } + const { activeFile } = editorManager; const wasActive = activeFile?.id === this.id; if (wasActive) { editorManager.activeFile = null; } + const paneClosed = + !suppressFallback && + this.isPanePlaceholder && + !isUnsaved && + removal?.pane && + !removal.nextFile && + editorManager.closeEmptyPane?.(removal.pane); + const { files } = editorManager; if (!files.length) { Sidebar.hide(); editorManager.activeFile = null; - new EditorFile(); - } else if (wasActive) { - files[files.length - 1].makeActive(); + if (!suppressFallback) new EditorFile(); + } else if ( + removal?.wasPaneActive && + removal.nextFile && + !suppressFallback + ) { + removal.nextFile.makeActive(); + } else if ( + removal?.wasPaneActive && + removal.pane && + !removal.nextFile && + !paneClosed && + !suppressFallback + ) { + new EditorFile(config.DEFAULT_FILE_NAME, { + paneId: removal.pane.id, + text: "", + isUnsaved: false, + isPanePlaceholder: true, + }); + } else if (wasActive && !suppressFallback) { + ( + editorManager.activePane?.activeFile || files[files.length - 1] + ).makeActive(); } editorManager.onupdate("remove-file"); editorManager.emit("remove-file", this); @@ -1262,15 +1356,26 @@ export default class EditorFile { } setReadOnly(value) { + const readOnly = !!value; + this.readOnly = readOnly; + this.#editable = !readOnly; + try { - const { editor, readOnlyCompartment } = editorManager; - if (!editor) return; - if (!readOnlyCompartment) return; - editor.dispatch({ - effects: readOnlyCompartment.reconfigure( - EditorState.readOnly.of(!!value), - ), - }); + const { readOnlyCompartment } = editorManager; + if (readOnlyCompartment) { + const pane = editorManager.getFilePane?.(this); + const targetEditor = + pane?.activeFile?.id === this.id + ? pane.editor + : editorManager.activeFile?.id === this.id + ? editorManager.editor + : null; + targetEditor?.dispatch({ + effects: readOnlyCompartment.reconfigure( + EditorState.readOnly.of(readOnly), + ), + }); + } } catch (error) { console.warn( `Failed to update read-only state for ${this.filename || this.uri}`, @@ -1278,9 +1383,6 @@ export default class EditorFile { ); } - // Sync internal flags and header - this.readOnly = !!value; - this.#editable = !this.readOnly; if (editorManager.activeFile?.id === this.id) { editorManager.header.subText = this.#getTitle(); } @@ -1328,25 +1430,37 @@ export default class EditorFile { * Makes this file active */ makeActive() { - const { activeFile, editor, switchFile } = editorManager; - - if (activeFile) { - if (activeFile.id === this.id) return; - activeFile.focusedBefore = activeFile.focused; - activeFile.removeActive(); + const pane = editorManager.getFilePane?.(this) || editorManager.activePane; + const wasActivePane = editorManager.activePane?.id === pane?.id; + const { activeFile, switchFile } = editorManager; + const paneActiveFile = pane?.activeFile; + const activeEditor = editorManager.editor; + const editorHadDomFocus = + activeEditor?.contentDOM === document.activeElement || + activeEditor?.contentDOM?.contains(document.activeElement); + const inactiveFiles = [paneActiveFile, !wasActivePane ? activeFile : null]; + const blurredFileIds = new Set(); + + for (const file of inactiveFiles) { + if (!file || file.id === this.id || blurredFileIds.has(file.id)) continue; + file.focusedBefore = file.focused; + file.removeActive(); + blurredFileIds.add(file.id); + } - // Hide previous content if it exists - if (activeFile.type !== "editor" && activeFile.content) { - activeFile.content.style.display = "none"; - } + if (activeFile?.id === this.id && wasActivePane) { + syncQuickToolsVisibility(this); + return; } - switchFile(this.id); + switchFile(this.id, pane); + + const { editor } = editorManager; // Show/hide appropriate content if (this.type === "editor") { editorManager.container.style.display = "block"; - if (this.focused) { + if (this.focused && editorHadDomFocus && !isTouchDevice()) { editor.focus(); } else { editor.contentDOM.blur(); @@ -1361,7 +1475,9 @@ export default class EditorFile { editorManager.container.style.display = "none"; if (this.content) { this.content.style.display = "block"; - if (!this.content.parentElement) { + if ( + this.content.parentElement !== editorManager.container.parentElement + ) { editorManager.container.parentElement.appendChild(this.content); } } @@ -1378,32 +1494,7 @@ export default class EditorFile { this.#loadText(); } - // Handle quicktools visibility based on hideQuickTools property - if (this.hideQuickTools) { - const { $toggler } = quickTools; - clearTimeout($toggler._hideTimeout); - $toggler.classList.add("hide"); - $toggler._hideTimeout = setTimeout(() => { - $toggler.remove(); - $toggler._hideTimeout = null; - }, 300); - actions("set-height", { height: 0, save: false }); - } else { - const { $toggler } = quickTools; - if (appSettings.value.floatingButton) { - clearTimeout($toggler._hideTimeout); - $toggler._hideTimeout = null; - $toggler.classList.remove("hide"); - if (!$toggler.isConnected) { - root.appendOuter($toggler); - } - } - const quickToolsHeight = - appSettings.value.quickTools !== undefined - ? appSettings.value.quickTools - : 1; - actions("set-height", { height: quickToolsHeight, save: false }); - } + syncQuickToolsVisibility(this); editorManager.header.subText = this.#getTitle(); @@ -1442,11 +1533,27 @@ export default class EditorFile { this.makeActive(); if (this.id !== config.DEFAULT_FILE_SESSION) { + const pane = editorManager.getFilePane?.(this); const defaultFile = editorManager.getFile( config.DEFAULT_FILE_SESSION, "id", ); - defaultFile?.remove(); + if (defaultFile && editorManager.getFilePane?.(defaultFile) === pane) { + defaultFile.remove(); + } + + editorManager + .getPaneFiles?.(this) + ?.filter( + (file) => + file !== this && + file.isPanePlaceholder && + !file.isUnsaved && + editorManager.getFilePane?.(file) === pane, + ) + .forEach((file) => { + file.remove(true, { ignorePinned: true }); + }); } // Show/hide editor based on content type @@ -1457,7 +1564,11 @@ export default class EditorFile { editorManager.container.style.display = "none"; if (this.#content) { this.#content.style.display = "block"; - editorManager.container.parentElement.appendChild(this.#content); + if ( + this.#content.parentElement !== editorManager.container.parentElement + ) { + editorManager.container.parentElement.appendChild(this.#content); + } } } } @@ -1656,7 +1767,9 @@ export default class EditorFile { this.loading = false; const { activeFile, emit } = editorManager; - if (activeFile?.id === this.id) { + const pane = editorManager.getFilePane?.(this); + const isActiveInPane = pane?.activeFile?.id === this.id; + if (isActiveInPane || activeFile?.id === this.id) { this.setReadOnly(editable === false); emit("file-loaded", this); } diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 19ddd22f6..d80d64c1d 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -67,6 +67,7 @@ import { serverCompletionSource } from "@codemirror/lsp-client"; import colorView from "cm/colorView"; import { getAllFolds, + getDocText, restoreFolds, restoreSelection, setScrollPosition, @@ -87,6 +88,8 @@ import quickTools from "components/quickTools"; import ScrollBar from "components/scrollbar"; import SideButton, { sideButtonContainer } from "components/sideButton"; import keyboardHandler, { keydownState } from "handlers/keyboard"; +import { animate } from "motion"; +import config from "./config"; import EditorFile from "./editorFile"; import openFile from "./openFile"; import { addedFolder } from "./openFolder"; @@ -123,10 +126,14 @@ async function EditorManager($header, $body) { let scrollRestoreFrame = 0; let scrollRestoreNestedFrame = 0; let scrollRestoreTimeout = 0; - - // Debounce timers for CodeMirror change handling - let checkTimeout = null; - let autosaveTimeout = null; + const MIN_PANE_WIDTH = 360; + const MIN_PANE_HEIGHT = 220; + const MIN_RESIZED_PANE_WIDTH = 280; + const MIN_RESIZED_PANE_HEIGHT = 180; + const PANE_SPLIT_HORIZONTAL = "horizontal"; + const PANE_SPLIT_VERTICAL = "vertical"; + + const docSyncTimers = new WeakMap(); let touchSelectionController = null; let touchSelectionSyncRaf = 0; let nativeContextMenuDisabled = null; @@ -140,6 +147,26 @@ async function EditorManager($header, $body) { console.warn(message, error); } + function getDocSyncTimers(file) { + let timers = docSyncTimers.get(file); + if (!timers) { + timers = { + checkTimeout: null, + autosaveTimeout: null, + }; + docSyncTimers.set(file, timers); + } + return timers; + } + + function clearDocSyncTimers(file) { + const timers = docSyncTimers.get(file); + if (!timers) return; + if (timers.checkTimeout) clearTimeout(timers.checkTimeout); + if (timers.autosaveTimeout) clearTimeout(timers.autosaveTimeout); + docSyncTimers.delete(file); + } + function isCoarsePointerDevice() { if (typeof window !== "undefined") { try { @@ -187,12 +214,36 @@ async function EditorManager($header, $body) { events[event].forEach((fn) => fn(...args)); }, }; - const $container =
; - // Ensure the container participates well in flex layouts and can constrain the editor - $container.style.flex = "1 1 auto"; - $container.style.minHeight = "0"; // allow child scroller to size correctly - $container.style.height = "100%"; - $container.style.width = "100%"; + let manager; + let paneIdCounter = 0; + let activePane = null; + let editor = null; + const panes = []; + const $paneRoot =
; + const $globalOpenFileList = ( +
    + ); + const globalOpenFileListNative = { + append: $globalOpenFileList.append.bind($globalOpenFileList), + appendChild: $globalOpenFileList.appendChild.bind($globalOpenFileList), + insertBefore: $globalOpenFileList.insertBefore.bind($globalOpenFileList), + prepend: $globalOpenFileList.prepend.bind($globalOpenFileList), + replaceChildren: + $globalOpenFileList.replaceChildren.bind($globalOpenFileList), + }; + let globalOpenFileListMirrorFiles = null; + let globalOpenFileListMirrorActiveFileId = ""; + const globalOpenFileListMirrorTabs = new Map(); + const globalOpenFileListMirrorTabsById = new Map(); + const globalOpenFileListMirrorTabSignatures = new Map(); + const globalOpenFileListMirrorDirtyFiles = new Set(); + const $paneAwareOpenFileList = + createPaneAwareOpenFileListProxy($globalOpenFileList); + let $container = createEditorContainer(); + let paneLayoutRoot = null; + const primaryPane = createPaneShell($container); + paneLayoutRoot = createPaneNode(primaryPane); + $paneRoot.append(paneLayoutRoot.element); const problemButton = SideButton({ text: strings.problems, icon: "warningreport_problem", @@ -203,6 +254,475 @@ async function EditorManager($header, $body) { }, }); + function createEditorContainer() { + const $el =
    ; + // Ensure the container participates well in flex layouts and can constrain the editor. + $el.style.flex = "1 1 auto"; + $el.style.minHeight = "0"; + $el.style.height = "100%"; + $el.style.width = "100%"; + return $el; + } + + function createPaneAwareOpenFileListProxy(target) { + return new Proxy(target, { + get(target, prop) { + if (prop === "children" || prop === "childNodes") { + return toDomCollection(getOpenFileListChildren()); + } + if (prop === "childElementCount") + return getOpenFileListChildren().length; + if (prop === "firstChild" || prop === "firstElementChild") { + return getOpenFileListChildren()[0] || null; + } + if (prop === "lastChild" || prop === "lastElementChild") { + const children = getOpenFileListChildren(); + return children[children.length - 1] || null; + } + if (prop === "append" || prop === "appendChild" || prop === "prepend") { + return (...args) => mutateVisibleOpenFileList(prop, args); + } + if (prop === "insertBefore") { + return (node, child) => + mutateVisibleOpenFileList("insertBefore", [node, child]); + } + if (prop === "contains") { + return (node) => + getOpenFileListChildren().some( + (child) => child === node || child.contains(node), + ) || target.contains(node); + } + if (prop === "querySelector") { + return (selector) => queryOpenFileList(selector)[0] || null; + } + if (prop === "querySelectorAll") { + return (selector) => toDomCollection(queryOpenFileList(selector)); + } + if (prop === "getElementsByClassName") { + return (className) => + toDomCollection( + collectFromOpenFileListChildren((child) => [ + ...(child.classList.contains(className) ? [child] : []), + ...child.getElementsByClassName(className), + ]), + ); + } + if (prop === "getElementsByTagName") { + return (tagName) => { + const selector = tagName === "*" ? "*" : tagName; + return toDomCollection(queryOpenFileList(selector)); + }; + } + if (prop === "getClientRects" || prop === "getBoundingClientRect") { + return (...args) => { + const targetList = getOpenFileListMutationTarget(); + return targetList[prop](...args); + }; + } + if (typeof prop === "string" && /^\d+$/.test(prop)) { + return getOpenFileListChildren()[Number(prop)]; + } + + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + } + + function createPaneShell( + editorContainer = createEditorContainer(), + registerPane = true, + ) { + const pane = { + id: `pane-${++paneIdCounter}`, + files: [], + activeFile: null, + editor: null, + cleanupEditorListeners: null, + editorContainer, + touchSelectionController: null, + element:
    , + tabList:
      , + content:
      , + layoutNode: null, + }; + + pane.element.dataset.paneId = pane.id; + pane.tabList.dataset.paneId = pane.id; + pane.element.__editorPane = pane; + pane.tabList.__editorPane = pane; + pane.content.__editorPane = pane; + pane.editorContainer.__editorPane = pane; + pane.content.append(pane.editorContainer); + pane.element.append(pane.tabList, pane.content); + pane.element.addEventListener( + "pointerdown", + () => { + activatePane(pane, { focusEditor: false }); + }, + true, + ); + if (registerPane) panes.push(pane); + return pane; + } + + function createPaneNode(pane) { + const node = { + type: "pane", + pane, + parent: null, + element: pane.element, + }; + pane.layoutNode = node; + return node; + } + + function createSplitNode(direction) { + const node = { + type: "split", + direction: normalizePaneDirection(direction), + children: [], + parent: null, + element:
      , + }; + node.element.dataset.direction = node.direction; + return node; + } + + function normalizePaneDirection(direction) { + return direction === PANE_SPLIT_VERTICAL || + direction === "down" || + direction === "below" + ? PANE_SPLIT_VERTICAL + : PANE_SPLIT_HORIZONTAL; + } + + function renderPaneLayout(node = paneLayoutRoot) { + if (!node || node.type !== "split") return; + + node.element.dataset.direction = node.direction; + node.element.replaceChildren(); + node.children.forEach((child, index) => { + if (index > 0) { + node.element.append(createPaneSplitHandle(node, index)); + } + node.element.append(child.element); + renderPaneLayout(child); + }); + } + + function createPaneSplitHandle(splitNode, childIndex) { + const $handle =
      ; + $handle.dataset.direction = splitNode.direction; + $handle.addEventListener("pointerdown", (event) => { + startPaneResize(event, splitNode, childIndex, $handle); + }); + return $handle; + } + + function replacePaneLayoutNode(oldNode, nextNode) { + const parent = oldNode?.parent || null; + if (parent) { + const index = parent.children.indexOf(oldNode); + if (index >= 0) { + parent.children[index] = nextNode; + nextNode.parent = parent; + oldNode.parent = null; + renderPaneLayout(parent); + } + return; + } + + paneLayoutRoot = nextNode; + nextNode.parent = null; + $paneRoot.replaceChildren(nextNode.element); + renderPaneLayout(nextNode); + } + + function insertPaneIntoLayout(sourcePane, pane, direction) { + const sourceNode = sourcePane?.layoutNode || paneLayoutRoot; + const paneNode = createPaneNode(pane); + const splitDirection = normalizePaneDirection(direction); + + if (!sourceNode) { + paneLayoutRoot = paneNode; + $paneRoot.replaceChildren(paneNode.element); + return paneNode; + } + + if ( + sourceNode.parent && + sourceNode.parent.type === "split" && + sourceNode.parent.direction === splitDirection + ) { + const parent = sourceNode.parent; + const index = parent.children.indexOf(sourceNode); + parent.children.splice(index + 1, 0, paneNode); + paneNode.parent = parent; + renderPaneLayout(parent); + return paneNode; + } + + const splitNode = createSplitNode(splitDirection); + const oldParent = sourceNode.parent; + const previousFlex = sourceNode.element.style.flex; + splitNode.children = [sourceNode, paneNode]; + splitNode.element.style.flex = previousFlex; + sourceNode.element.style.flex = ""; + paneNode.element.style.flex = ""; + sourceNode.parent = splitNode; + paneNode.parent = splitNode; + + if (oldParent) { + const index = oldParent.children.indexOf(sourceNode); + if (index >= 0) { + oldParent.children[index] = splitNode; + splitNode.parent = oldParent; + renderPaneLayout(oldParent); + } + } else { + paneLayoutRoot = splitNode; + splitNode.parent = null; + $paneRoot.replaceChildren(splitNode.element); + renderPaneLayout(splitNode); + } + return paneNode; + } + + function removePaneFromLayout(pane) { + const node = pane?.layoutNode; + if (!node) { + pane?.element?.remove(); + return; + } + + const parent = node.parent; + if (!parent) { + paneLayoutRoot = null; + node.element.remove(); + pane.layoutNode = null; + return; + } + + const index = parent.children.indexOf(node); + if (index >= 0) { + parent.children.splice(index, 1); + } + node.parent = null; + pane.layoutNode = null; + + if (parent.children.length === 1) { + const onlyChild = parent.children[0]; + onlyChild.element.style.flex = parent.parent + ? parent.element.style.flex || "1 1 0" + : "1 1 0"; + replacePaneLayoutNode(parent, onlyChild); + parent.children = []; + parent.element.remove(); + return; + } + + renderPaneLayout(parent); + } + + function getOrderedPanes(node = paneLayoutRoot) { + if (!node) return panes.slice(); + if (node.type === "pane") return node.pane ? [node.pane] : []; + return node.children.flatMap((child) => getOrderedPanes(child)); + } + + function getVisiblePaneRect(pane) { + const element = pane?.element; + if (!element?.isConnected || element.getClientRects().length === 0) { + return null; + } + + const rect = element.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return null; + return rect; + } + + function updatePaneLayoutState() { + $paneRoot.classList.toggle("multi-pane", panes.length > 1); + renderPaneLayout(); + updateActivePaneLayoutPath(activePane); + } + + function animatePaneEntry(pane) { + if (!pane?.element || document.body.classList.contains("no-animation")) { + return; + } + + pane.element.style.opacity = "0"; + pane.element.style.transform = "scale(0.985)"; + const element = pane.element; + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + element.style.opacity = ""; + element.style.transform = ""; + }; + animate( + element, + { opacity: 1, transform: "scale(1)" }, + { type: "spring", stiffness: 360, damping: 32 }, + ) + .then(cleanup) + .catch(cleanup); + } + + function startPaneResize(event, splitNode, childIndex, handle) { + const previousNode = splitNode.children[childIndex - 1]; + const nextNode = splitNode.children[childIndex]; + if (!previousNode || !nextNode) return; + + event.preventDefault(); + event.stopPropagation(); + + const isVertical = splitNode.direction === PANE_SPLIT_VERTICAL; + const axis = isVertical ? "y" : "x"; + const start = isVertical ? event.clientY : event.clientX; + const previousRect = previousNode.element.getBoundingClientRect(); + const nextRect = nextNode.element.getBoundingClientRect(); + const previousSize = isVertical ? previousRect.height : previousRect.width; + const nextSize = isVertical ? nextRect.height : nextRect.width; + const totalSize = previousSize + nextSize; + const minSize = Math.min( + isVertical ? MIN_RESIZED_PANE_HEIGHT : MIN_RESIZED_PANE_WIDTH, + totalSize / 2, + ); + let pendingDelta = 0; + let resizeFrame = 0; + + document.body.classList.add("resizing-editor-pane"); + document.body.dataset.editorPaneResizeAxis = axis; + handle.setPointerCapture?.(event.pointerId); + + const resize = (moveEvent) => { + pendingDelta = + (isVertical ? moveEvent.clientY : moveEvent.clientX) - start; + if (resizeFrame) return; + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + const nextPreviousSize = Math.max( + minSize, + Math.min(totalSize - minSize, previousSize + pendingDelta), + ); + const nextCurrentSize = totalSize - nextPreviousSize; + previousNode.element.style.flex = `1 1 ${nextPreviousSize}px`; + nextNode.element.style.flex = `1 1 ${nextCurrentSize}px`; + }); + }; + + const refreshEditors = () => { + getOrderedPanes().forEach((pane) => { + pane.editor?.requestMeasure?.(); + }); + updateActivePaneScrollbars(); + }; + + const stop = () => { + if (resizeFrame) { + cancelAnimationFrame(resizeFrame); + resizeFrame = 0; + } + document.removeEventListener("pointermove", resize); + document.removeEventListener("pointerup", stop); + document.removeEventListener("pointercancel", stop); + document.body.classList.remove("resizing-editor-pane"); + delete document.body.dataset.editorPaneResizeAxis; + handle.releasePointerCapture?.(event.pointerId); + requestAnimationFrame(refreshEditors); + }; + + document.addEventListener("pointermove", resize, { passive: true }); + document.addEventListener("pointerup", stop); + document.addEventListener("pointercancel", stop); + } + + function getActivePane() { + return activePane || panes[0] || null; + } + + function setActivePane(pane, options = {}) { + if (!pane || activePane === pane) return pane; + + activePane?.element.classList.remove("active"); + activePane = pane; + editor = pane.editor || editor; + $container = pane.editorContainer || $container; + touchSelectionController = pane.touchSelectionController || null; + pane.element.classList.add("active"); + updateActivePaneLayoutPath(pane); + + if (manager) { + manager.activeFile = pane.activeFile || null; + updateHeaderForFile(manager.activeFile); + if (isPaneTabLayout()) syncGlobalOpenFileListMirror(); + updateActivePaneScrollbars(); + toggleProblemButton(); + if (options.emitSwitch !== false && manager.activeFile) { + manager.onupdate("switch-file"); + events.emit("switch-file", manager.activeFile); + } + if (options.configureLsp !== false) { + if (manager.activeFile?.type === "editor") { + void configureLspForFile(manager.activeFile); + } else { + detachActiveLsp(); + } + } + } + + return pane; + } + + function activatePane(pane, options = {}) { + if (!pane) return null; + if (activePane === pane) { + if (options.focusEditor !== false) pane.editor?.focus?.(); + return pane; + } + + const fileToActivate = pane.activeFile || null; + if (fileToActivate) { + fileToActivate.makeActive(); + return pane; + } + + setActivePane(pane, { emitSwitch: false }); + if (options.focusEditor !== false) pane.editor?.focus?.(); + return pane; + } + + function updateActivePaneLayoutPath(pane) { + $paneRoot + .querySelectorAll(".editor-pane-split.active-path") + .forEach(($split) => $split.classList.remove("active-path")); + + let node = pane?.layoutNode?.parent || null; + while (node) { + node.element?.classList.add("active-path"); + node = node.parent; + } + } + + function updateHeaderForFile(file) { + if (!$header) return; + $header.text = file?.filename || ""; + $header.subText = file?.headerSubtitle || ""; + } + + function updateActivePaneScrollbars() { + $hScrollbar?.hideImmediately?.(); + $vScrollbar?.hideImmediately?.(); + setVScrollValue(); + if (!appSettings.value.textWrap) { + setHScrollValue(); + } + } + const pointerCursorVisibilityExtension = EditorView.updateListener.of( (update) => { if (!update.transactions.length) return; @@ -248,6 +768,39 @@ async function EditorManager($header, $body) { isMac: false, }); }; + + function registerSoftKeyboardCursorReveal() { + const shouldRevealCursor = () => { + const view = editor; + if (!view || manager?.activeFile?.type !== "editor") return false; + const activeElement = document.activeElement; + return ( + view.contentDOM === activeElement || + view.contentDOM.contains(activeElement) + ); + }; + + keyboardHandler.on("keyboardShowStart", () => { + requestAnimationFrame(() => { + if (!shouldRevealCursor()) return; + if (isCursorRevealSuppressed()) return; + scrollCursorIntoView({ behavior: "instant" }); + }); + }); + keyboardHandler.on("keyboardShow", () => { + if (!shouldRevealCursor()) return; + if (isCursorRevealSuppressed()) return; + scrollCursorIntoView(); + }); + keyboardHandler.on("keyboardHide", () => { + requestAnimationFrame(() => { + if (!shouldRevealCursor()) return; + if (isCursorRevealSuppressed()) return; + scrollCursorIntoView({ behavior: "instant" }); + }); + }); + } + const shiftClickSelectionExtension = EditorView.domEventHandlers({ mousedown(event, view) { if (!event.shiftKey || isShiftClickSelectionEnabled()) return false; @@ -740,26 +1293,40 @@ async function EditorManager($header, $body) { ]; } - function applyOptions(keys) { + function applyOptions(keys, targetEditor = null) { const filter = keys ? new Set(keys) : null; - for (const spec of cmOptionSpecs) { - if (filter && !spec.keys.some((k) => filter.has(k))) continue; - const built = spec.build(); - const effects = []; - if (spec.compartments.length === 1) { - effects.push(spec.compartments[0].reconfigure(built)); - } else { - const arr = Array.isArray(built) ? built : [built]; - for (let i = 0; i < spec.compartments.length; i++) { - const comp = spec.compartments[i]; - const ext = arr[i] ?? []; - effects.push(comp.reconfigure(ext)); + const targetEditors = targetEditor + ? [targetEditor] + : panes.map((pane) => pane.editor).filter(Boolean); + + for (const target of targetEditors) { + for (const spec of cmOptionSpecs) { + if (filter && !spec.keys.some((k) => filter.has(k))) continue; + const built = spec.build(); + const effects = []; + if (spec.compartments.length === 1) { + effects.push(spec.compartments[0].reconfigure(built)); + } else { + const arr = Array.isArray(built) ? built : [built]; + for (let i = 0; i < spec.compartments.length; i++) { + const comp = spec.compartments[i]; + const ext = arr[i] ?? []; + effects.push(comp.reconfigure(ext)); + } } + target.dispatch({ effects }); } - editor.dispatch({ effects }); } } + function setThemeForEditors(themeId) { + panes.forEach((pane) => { + if (pane.editor) { + applyThemeToEditor(pane.editor, themeId); + } + }); + } + function buildLspMetadata(file) { if (!file || file.type !== "editor") return null; const uri = getFileLspUri(file); @@ -991,34 +1558,43 @@ async function EditorManager($header, $body) { } // Create minimal CodeMirror editor - - const editorState = EditorState.create({ - doc: "", - extensions: createMainEditorExtensions({ - // Emmet needs highest precedence so place before default keymaps - emmetExtensions: createEmmetExtensionSet({ - syntax: EmmetKnownSyntax.html, + function createEmptyEditorState() { + return EditorState.create({ + doc: "", + extensions: createMainEditorExtensions({ + // Emmet needs highest precedence so place before default keymaps + emmetExtensions: createEmmetExtensionSet({ + syntax: EmmetKnownSyntax.html, + }), + baseExtensions: createConfiguredBaseExtensions(), + commandKeymapExtension: getCommandKeymapExtension(), + themeExtension: themeCompartment.of(getConfiguredThemeExtension()), + pointerCursorVisibilityExtension, + shiftClickSelectionExtension, + multiCursorSelectionExtension, + touchSelectionUpdateExtension, + quickToolsModifierInputExtension: quickToolsModifierInput(), + searchExtension: search(), + // Ensure read-only can be toggled later via compartment + readOnlyExtension: readOnlyCompartment.of( + EditorState.readOnly.of(false), + ), + // Editor options driven by settings via compartments + optionExtensions: getBaseExtensionsFromOptions(), }), - baseExtensions: createConfiguredBaseExtensions(), - commandKeymapExtension: getCommandKeymapExtension(), - themeExtension: themeCompartment.of(getConfiguredThemeExtension()), - pointerCursorVisibilityExtension, - shiftClickSelectionExtension, - multiCursorSelectionExtension, - touchSelectionUpdateExtension, - quickToolsModifierInputExtension: quickToolsModifierInput(), - searchExtension: search(), - // Ensure read-only can be toggled later via compartment - readOnlyExtension: readOnlyCompartment.of(EditorState.readOnly.of(false)), - // Editor options driven by settings via compartments - optionExtensions: getBaseExtensionsFromOptions(), - }), - }); + }); + } + + const editorState = createEmptyEditorState(); - const editor = new EditorView({ + editor = new EditorView({ state: editorState, parent: $container, }); + editor.__editorPane = primaryPane; + primaryPane.editor = editor; + activePane = primaryPane; + primaryPane.element.classList.add("active"); await applyKeyBindings(editor); @@ -1041,6 +1617,7 @@ async function EditorManager($header, $body) { }; Object.defineProperty(editor.commands, "commands", { + configurable: true, get() { const map = {}; getRegisteredCommands().forEach((cmd) => { @@ -1053,8 +1630,9 @@ async function EditorManager($header, $body) { // Provide editor.session for Ace API compatibility // Returns the active file's session (Proxy with Ace-like methods) Object.defineProperty(editor, "session", { + configurable: true, get() { - return manager.activeFile?.session ?? null; + return editor.__editorPane?.activeFile?.session ?? null; }, }); @@ -1064,6 +1642,7 @@ async function EditorManager($header, $body) { isShiftSelectionActive, isMultiCursorSelectionActive: isQuickToolsMultiCursorSelectionActive, }); + primaryPane.touchSelectionController = touchSelectionController; // Provide minimal Ace-like API compatibility used by plugins /** @@ -1090,15 +1669,19 @@ async function EditorManager($header, $body) { }; // Set CodeMirror theme by id registered in our registry - editor.setTheme = function (themeId) { + function applyThemeToEditor(targetEditor, themeId) { try { const id = String(themeId || ""); const ext = getThemeExtensions(id, [oneDark]); - editor.dispatch({ effects: themeCompartment.reconfigure(ext) }); + targetEditor.dispatch({ effects: themeCompartment.reconfigure(ext) }); return true; } catch (_) { return false; } + } + + editor.setTheme = function (themeId) { + return applyThemeToEditor(editor, themeId); }; /** @@ -1281,7 +1864,7 @@ async function EditorManager($header, $body) { */ editor.getValue = function () { try { - return editor.state.doc.toString(); + return getDocText(editor.state.doc); } catch (_) { return ""; } @@ -1336,27 +1919,638 @@ async function EditorManager($header, $body) { }, }; - /** - * Get selected text or text under cursor (CodeMirror implementation) - * @returns {string} Selected text - */ - editor.getCopyText = function () { - try { - const { from, to } = editor.state.selection.main; - if (from === to) return ""; // No selection - return editor.state.doc.sliceString(from, to); - } catch (_) { - return ""; + /** + * Get selected text or text under cursor (CodeMirror implementation) + * @returns {string} Selected text + */ + editor.getCopyText = function () { + try { + const { from, to } = editor.state.selection.main; + if (from === to) return ""; // No selection + return editor.state.doc.sliceString(from, to); + } catch (_) { + return ""; + } + }; + + editor.setSelection = function (value) { + touchSelectionController?.setSelection(!!value); + }; + + editor.setMenu = function (value) { + touchSelectionController?.setMenu(!!value); + }; + + function getEditorCompatibilityPane(targetEditor) { + return targetEditor?.__editorPane || getActivePane(); + } + + function refreshPaneCommandKeymaps() { + panes.forEach((pane) => { + if (pane.editor) refreshCommandKeymap(pane.editor); + }); + } + + function createEditorCommands() { + const commands = { + addCommand(descriptor) { + const command = registerExternalCommand(descriptor); + refreshPaneCommandKeymaps(); + return command; + }, + removeCommand(name) { + if (!name) return; + removeExternalCommand(name); + refreshPaneCommandKeymaps(); + }, + }; + + Object.defineProperty(commands, "commands", { + configurable: true, + get() { + const map = {}; + getRegisteredCommands().forEach((cmd) => { + map[cmd.name] = cmd; + }); + return map; + }, + }); + + return commands; + } + + function createEditorCompatibilityDescriptors(targetEditor) { + const getState = () => targetEditor.state; + const getDoc = () => getState().doc; + const getSelection = () => getState().selection.main; + const getTouchSelectionController = () => + getEditorCompatibilityPane(targetEditor)?.touchSelectionController || + touchSelectionController; + + return { + execCommand: { + configurable: true, + writable: true, + value(commandName, args) { + if (!commandName) return false; + return executeCommand(String(commandName), targetEditor, args); + }, + }, + commands: { + configurable: true, + writable: true, + value: createEditorCommands(), + }, + session: { + configurable: true, + get() { + return ( + getEditorCompatibilityPane(targetEditor)?.activeFile?.session ?? + null + ); + }, + }, + insert: { + configurable: true, + writable: true, + value(text) { + try { + const { from, to } = getSelection(); + const insertText = String(text ?? ""); + targetEditor.dispatch({ + changes: { from, to, insert: insertText }, + selection: { + anchor: from + insertText.length, + head: from + insertText.length, + }, + }); + return true; + } catch (_) { + return false; + } + }, + }, + setTheme: { + configurable: true, + writable: true, + value(themeId) { + return applyThemeToEditor(targetEditor, themeId); + }, + }, + gotoLine: { + configurable: true, + writable: true, + value(line, column = 0, animate = false) { + try { + const state = getState(); + const { doc } = state; + + let targetLine; + let targetColumn = column; + + if (typeof line === "string") { + const match = /^([+-])?(\d+)?(:\d+)?(%)?$/.exec(line.trim()); + if (!match) { + console.warn("Invalid gotoLine format:", line); + return false; + } + + const currentLine = doc.lineAt(state.selection.main.head); + const [, sign, lineNum, colonColumn, percent] = match; + + if (colonColumn) { + targetColumn = Math.max(0, +colonColumn.slice(1) - 1); + } + + const parsedLine = lineNum ? +lineNum : currentLine.number; + + if (lineNum && percent) { + let percentage = parsedLine / 100; + if (sign) { + percentage = + percentage * (sign === "-" ? -1 : 1) + + currentLine.number / doc.lines; + } + targetLine = Math.round(doc.lines * percentage); + } else if (lineNum && sign) { + targetLine = + parsedLine * (sign === "-" ? -1 : 1) + currentLine.number; + } else if (lineNum) { + targetLine = parsedLine; + } else { + targetLine = currentLine.number; + } + } else { + targetLine = line; + } + + const lineNum = Math.max(1, Math.min(targetLine, doc.lines)); + const docLine = doc.line(lineNum); + const col = Math.max(0, Math.min(targetColumn, docLine.length)); + const pos = docLine.from + col; + + targetEditor.dispatch({ + selection: { anchor: pos, head: pos }, + effects: EditorView.scrollIntoView(pos, { y: "center" }), + }); + targetEditor.focus(); + return true; + } catch (error) { + console.error("Error in gotoLine:", error); + return false; + } + }, + }, + getCursorPosition: { + configurable: true, + writable: true, + value() { + try { + const head = getSelection().head; + const cursor = getDoc().lineAt(head); + return { row: cursor.number, column: head - cursor.from }; + } catch (_) { + return { row: 1, column: 0 }; + } + }, + }, + getSelectionRange: { + configurable: true, + writable: true, + value() { + try { + const { from, to } = getSelection(); + const doc = getDoc(); + const fromLine = doc.lineAt(from); + const toLine = doc.lineAt(to); + return { + start: { + row: Math.max(0, fromLine.number - 1), + column: from - fromLine.from, + }, + end: { + row: Math.max(0, toLine.number - 1), + column: to - toLine.from, + }, + }; + } catch (_) { + return { start: { row: 0, column: 0 }, end: { row: 0, column: 0 } }; + } + }, + }, + scrollToRow: { + configurable: true, + writable: true, + value(row) { + try { + const scroller = targetEditor.scrollDOM; + if (!scroller) return false; + + if (row === Number.POSITIVE_INFINITY) { + clearScrollbarScrollLock(); + scroller.scrollTop = Math.max( + scroller.scrollHeight - scroller.clientHeight, + 0, + ); + return true; + } + + const parsedRow = Number(row); + if (!Number.isFinite(parsedRow)) return false; + const aceRow = Math.max(0, Math.floor(parsedRow)); + const lineNum = Math.min(getDoc().lines, aceRow + 1); + const line = getDoc().line(lineNum); + targetEditor.dispatch({ + effects: EditorView.scrollIntoView(line.from, { y: "start" }), + }); + return true; + } catch (_) { + return false; + } + }, + }, + moveCursorToPosition: { + configurable: true, + writable: true, + value(pos) { + try { + const lineNum = Math.max(1, pos.row || 1); + const col = Math.max(0, pos.column || 0); + targetEditor.gotoLine(lineNum, col); + } catch (_) { + // ignore + } + }, + }, + getValue: { + configurable: true, + writable: true, + value() { + try { + return getDocText(getDoc()); + } catch (_) { + return ""; + } + }, + }, + selection: { + configurable: true, + writable: true, + value: { + get anchor() { + try { + return getSelection().anchor; + } catch (_) { + return 0; + } + }, + getRange() { + try { + const { from, to } = getSelection(); + const doc = getDoc(); + const fromLine = doc.lineAt(from); + const toLine = doc.lineAt(to); + return { + start: { + row: fromLine.number, + column: from - fromLine.from, + }, + end: { + row: toLine.number, + column: to - toLine.from, + }, + }; + } catch (_) { + return { + start: { row: 1, column: 0 }, + end: { row: 1, column: 0 }, + }; + } + }, + getCursor() { + return targetEditor.getCursorPosition(); + }, + }, + }, + getCopyText: { + configurable: true, + writable: true, + value() { + try { + const { from, to } = getSelection(); + if (from === to) return ""; + return getDoc().sliceString(from, to); + } catch (_) { + return ""; + } + }, + }, + setSelection: { + configurable: true, + writable: true, + value(value) { + getTouchSelectionController()?.setSelection(!!value); + }, + }, + setMenu: { + configurable: true, + writable: true, + value(value) { + getTouchSelectionController()?.setMenu(!!value); + }, + }, + }; + } + + function applyEditorCompatibility(targetEditor) { + Object.defineProperties( + targetEditor, + createEditorCompatibilityDescriptors(targetEditor), + ); + } + + applyEditorCompatibility(editor); + + function canCreatePane( + direction = PANE_SPLIT_HORIZONTAL, + sourcePane = getActivePane(), + ) { + const normalizedDirection = normalizePaneDirection(direction); + const rect = sourcePane?.element?.getBoundingClientRect?.() || + $paneRoot.getBoundingClientRect?.() || { + width: $body.clientWidth || 0, + height: $body.clientHeight || 0, + }; + if (normalizedDirection === PANE_SPLIT_VERTICAL) { + if (!rect.height) return true; + return rect.height / 2 >= MIN_PANE_HEIGHT; + } + if (!rect.width) return true; + return rect.width / 2 >= MIN_PANE_WIDTH; + } + + function createUntitledPaneFile(pane) { + const existingPlaceholder = pane?.files?.find( + (file) => file.isPanePlaceholder && !file.isUnsaved, + ); + if (existingPlaceholder) { + if (!pane.activeFile) existingPlaceholder.makeActive(); + return existingPlaceholder; + } + + return new EditorFile(config.DEFAULT_FILE_NAME, { + paneId: pane.id, + text: "", + isUnsaved: false, + isPanePlaceholder: true, + }); + } + + function removePanePlaceholders(pane, exceptFile = null) { + const placeholders = [...(pane?.files || [])].filter( + (file) => + file !== exceptFile && file.isPanePlaceholder && !file.isUnsaved, + ); + placeholders.forEach((file) => { + file.remove(true, { + ignorePinned: true, + suppressPanePlaceholder: true, + }); + }); + } + + async function createPaneEditor(pane) { + const paneEditor = new EditorView({ + state: createEmptyEditorState(), + parent: pane.editorContainer, + }); + pane.editor = paneEditor; + paneEditor.__editorPane = pane; + applyEditorCompatibility(paneEditor); + await applyKeyBindings(paneEditor); + pane.touchSelectionController = createTouchSelectionMenu(paneEditor, { + container: pane.editorContainer, + getActiveFile: () => pane.activeFile || null, + isShiftSelectionActive, + }); + await setupEditor(pane); + return paneEditor; + } + + async function createPane(options = {}) { + const direction = normalizePaneDirection(options.direction); + const sourcePane = options.sourcePane || getActivePane() || primaryPane; + if (!canCreatePane(direction, sourcePane)) { + window.toast?.( + strings["not enough space"] || + "Not enough space to create another editor pane.", + ); + return null; + } + + const pane = createPaneShell(undefined, false); + try { + await createPaneEditor(pane); + } catch (error) { + pane.touchSelectionController?.destroy?.(); + pane.touchSelectionController = null; + pane.editor?.destroy?.(); + pane.editor = null; + warnRecoverable( + "Failed to create split editor pane.", + error, + `create-pane-editor-${pane.id}`, + ); + window.toast?.(strings.error || "Error"); + return null; + } + panes.push(pane); + insertPaneIntoLayout(sourcePane, pane, direction); + updatePaneLayoutState(); + animatePaneEntry(pane); + pane.editor?.requestMeasure?.(); + syncOpenFileList(); + + if (options.moveFile) { + moveFileToPane(options.moveFile, pane, { activate: true }); + } else if (options.createUntitled !== false) { + createUntitledPaneFile(pane); + } else if (options.activate !== false) { + setActivePane(pane); + } + + return pane; + } + + function splitPane(direction = PANE_SPLIT_HORIZONTAL) { + return createPane({ direction }); + } + + function splitPaneRight() { + return splitPane(PANE_SPLIT_HORIZONTAL); + } + + function splitPaneDown() { + return splitPane(PANE_SPLIT_VERTICAL); + } + + async function moveActiveFileToNewPane(direction = PANE_SPLIT_HORIZONTAL) { + const file = manager.activeFile; + if (!file) return null; + return createPane({ moveFile: file, direction }); + } + + function closePane(pane = getActivePane()) { + if (!pane || panes.length <= 1) return false; + const preferredFile = pane.activeFile; + const wasActivePane = activePane === pane; + + const orderedPanes = getOrderedPanes(); + const paneIndex = orderedPanes.indexOf(pane); + const targetPane = + orderedPanes[paneIndex - 1] || + orderedPanes[paneIndex + 1] || + orderedPanes[0] || + null; + if (!targetPane) return false; + + for (const file of [...pane.files]) { + if (file.isPanePlaceholder && !file.isUnsaved) { + file.remove(true, { + ignorePinned: true, + suppressPanePlaceholder: true, + }); + continue; + } + + moveFileToPane(file, targetPane, { + activate: false, + createSourcePlaceholder: false, + activateSourceFallback: false, + }); + } + + pane.touchSelectionController?.destroy?.(); + pane.touchSelectionController = null; + pane.cleanupEditorListeners?.(); + pane.cleanupEditorListeners = null; + pane.editor?.destroy?.(); + pane.editor = null; + removePaneFromLayout(pane); + const storedPaneIndex = panes.indexOf(pane); + if (storedPaneIndex >= 0) panes.splice(storedPaneIndex, 1); + updatePaneLayoutState(); + rebuildFileListFromPanes(); + const fileToActivate = targetPane.files.includes(preferredFile) + ? preferredFile + : targetPane.activeFile; + if (wasActivePane || activePane === pane) { + if (fileToActivate) { + fileToActivate.makeActive(); + } else { + activatePane(targetPane, { focusEditor: false }); + } + } else { + updateActivePaneLayoutPath(activePane); + } + syncOpenFileList(); + return true; + } + + function closeActivePane() { + return closePane(getActivePane()); + } + + function closeEmptyPane(pane) { + if (!pane || pane.files.length || panes.length <= 1) return false; + return closePane(pane); + } + + function focusPaneByOffset(offset) { + if (panes.length <= 1) return false; + const orderedPanes = getOrderedPanes(); + const index = Math.max(0, orderedPanes.indexOf(getActivePane())); + const nextPane = + orderedPanes[ + (index + offset + orderedPanes.length) % orderedPanes.length + ]; + if (!nextPane) return false; + activatePane(nextPane, { focusEditor: false }); + return true; + } + + function focusNextPane() { + return focusPaneByOffset(1); + } + + function focusPreviousPane() { + return focusPaneByOffset(-1); + } + + function focusPaneByDirection(direction) { + const active = getActivePane(); + if (!active || panes.length <= 1) return false; + + const orderedPanes = getOrderedPanes(); + const visiblePanes = orderedPanes + .map((pane) => ({ pane, rect: getVisiblePaneRect(pane) })) + .filter((entry) => entry.rect); + const activeEntry = visiblePanes.find((entry) => entry.pane === active); + + if (!activeEntry || visiblePanes.length <= 1) { + if (direction === "left" || direction === "up") { + return focusPaneByOffset(-1); + } + if (direction === "right" || direction === "down") { + return focusPaneByOffset(1); + } + return false; } - }; - editor.setSelection = function (value) { - touchSelectionController?.setSelection(!!value); - }; + const activeRect = activeEntry.rect; + const activeCenterX = activeRect.left + activeRect.width / 2; + const activeCenterY = activeRect.top + activeRect.height / 2; + let bestPane = null; + let bestScore = Number.POSITIVE_INFINITY; + + for (const { pane, rect } of visiblePanes) { + if (pane === active) continue; + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + let axisDistance = 0; + let crossDistance = 0; + + if (direction === "left") { + if (centerX >= activeCenterX) continue; + axisDistance = activeRect.left - rect.right; + crossDistance = Math.abs(centerY - activeCenterY); + } else if (direction === "right") { + if (centerX <= activeCenterX) continue; + axisDistance = rect.left - activeRect.right; + crossDistance = Math.abs(centerY - activeCenterY); + } else if (direction === "up") { + if (centerY >= activeCenterY) continue; + axisDistance = activeRect.top - rect.bottom; + crossDistance = Math.abs(centerX - activeCenterX); + } else if (direction === "down") { + if (centerY <= activeCenterY) continue; + axisDistance = rect.top - activeRect.bottom; + crossDistance = Math.abs(centerX - activeCenterX); + } else { + return false; + } - editor.setMenu = function (value) { - touchSelectionController?.setMenu(!!value); - }; + const score = Math.max(0, axisDistance) * 1000 + crossDistance; + if (score < bestScore) { + bestScore = score; + bestPane = pane; + } + } + + if (!bestPane) return false; + activatePane(bestPane, { focusEditor: false }); + return true; + } function getEditorExtensionSignature(file) { return JSON.stringify({ @@ -1458,12 +2652,23 @@ async function EditorManager($header, $body) { markLanguageReady(file, languageSignature, false); result .then((ext) => { + const pane = getFilePane(file); + const isGlobalActive = manager.activeFile?.id === fileId; + const isPaneActive = pane?.activeFile?.id === fileId; if ( - manager.activeFile?.id !== fileId || + (!isGlobalActive && !isPaneActive) || file.__cmLanguageSignature !== languageSignature ) { return; } + + if (isPaneActive && pane !== getActivePane()) { + withPaneEditorContext(pane, () => { + dispatchLanguageExtension(file, languageSignature, ext, warnKey); + }); + return; + } + dispatchLanguageExtension(file, languageSignature, ext, warnKey); }) .catch(() => { @@ -1525,10 +2730,51 @@ async function EditorManager($header, $body) { touchSelectionController?.onSessionChanged(); } + function withPaneEditorContext(pane, callback) { + if (!pane?.editor) return callback(); + + const previousEditor = editor; + const previousContainer = $container; + const previousTouchSelectionController = touchSelectionController; + + editor = pane.editor; + $container = pane.editorContainer || $container; + touchSelectionController = pane.touchSelectionController || null; + + try { + return callback(); + } finally { + editor = previousEditor; + $container = previousContainer; + touchSelectionController = previousTouchSelectionController; + } + } + + function applyFileToPaneEditor(file, pane, options = {}) { + if (!file || file.type !== "editor") return false; + if (!pane?.editor || pane.activeFile?.id !== file.id) return false; + const isCurrentPane = pane === getActivePane(); + const paneOptions = { + ...options, + restoreScroll: options.restoreScroll ?? isCurrentPane, + scheduleLsp: options.scheduleLsp ?? isCurrentPane, + }; + if (isCurrentPane) { + applyFileToEditor(file, paneOptions); + } else { + withPaneEditorContext(pane, () => applyFileToEditor(file, paneOptions)); + } + return true; + } + // Helper: apply a file's content and language to the editor view function applyFileToEditor(file, options = {}) { if (!file || file.type !== "editor") return; - const { forceRecreate = false } = options; + const { + forceRecreate = false, + restoreScroll = true, + scheduleLsp = true, + } = options; const extensionSignature = getEditorExtensionSignature(file); const languageSignature = getFileLanguageSignature( file, @@ -1556,8 +2802,8 @@ async function EditorManager($header, $body) { } } - restoreFileScrollPosition(file); - scheduleLspForFile(file); + if (restoreScroll) restoreFileScrollPosition(file); + if (scheduleLsp) scheduleLspForFile(file); return; } @@ -1653,9 +2899,8 @@ async function EditorManager($header, $body) { ); } - restoreFileScrollPosition(file); - - scheduleLspForFile(file); + if (restoreScroll) restoreFileScrollPosition(file); + if (scheduleLsp) scheduleLspForFile(file); } function restoreFileScrollPosition(file) { @@ -1774,31 +3019,67 @@ async function EditorManager($header, $body) { parent: $body, placement: "bottom", }); - const manager = { + manager = { files: [], onupdate: () => {}, activeFile: null, isCodeMirror: true, addFile, - editor, readOnlyCompartment, getFile, + getFilePane, + getPaneFiles, + getPaneTabList, + setActivePane, reapplyActiveFile, switchFile, + createPane, + splitPane, + splitPaneRight, + splitPaneDown, + closeActivePane, + closeEmptyPane, + focusNextPane, + focusPreviousPane, + focusPaneByDirection, + moveActiveFileToNewPane, + moveFileToPane, + removeFileFromPane, moveFileByPinnedState, normalizePinnedTabOrder, + updatePaneFileOrderFromTabs, syncOpenFileList, hasUnsavedFiles, getEditorHeight, getEditorWidth, header: $header, - container: $container, getLspMetadata: buildLspMetadata, + get editor() { + return getActivePane()?.editor || editor; + }, + get activePane() { + return getActivePane(); + }, + get panes() { + return panes.slice(); + }, + get activePaneTabList() { + return getPaneTabList(); + }, + get container() { + return getActivePane()?.editorContainer || $container; + }, get isScrolling() { return isScrolling; }, get openFileList() { - if (!$openFileList) initFileTabContainer(); + if (isPaneTabLayout()) { + syncGlobalOpenFileListMirror(); + return $paneAwareOpenFileList; + } + if (!$openFileList || $openFileList === $globalOpenFileList) { + initFileTabContainer(); + } return $openFileList; }, get TIMEOUT_VALUE() { @@ -1943,9 +3224,10 @@ async function EditorManager($header, $body) { }); applyLspSettings(); - $body.append($container); + $body.append($paneRoot); initModes(); // Initialize CodeMirror modes - await setupEditor(); + registerSoftKeyboardCursorReveal(); + await setupEditor(primaryPane); // Initialize theme from settings or fallback try { @@ -2106,7 +3388,7 @@ async function EditorManager($header, $body) { appSettings.on("update:editorTheme", function () { const desiredTheme = appSettings?.value?.editorTheme || "one_dark"; - editor.setTheme(desiredTheme); + setThemeForEditors(desiredTheme); applyOptions(["rainbowBrackets"]); }); @@ -2167,7 +3449,8 @@ async function EditorManager($header, $body) { // Keep file.session and cache in sync on every edit function getDocSyncListener() { return EditorView.updateListener.of((update) => { - const file = manager.activeFile; + const pane = update.view.__editorPane || getActivePane(); + const file = pane?.activeFile || manager.activeFile; if (!file || file.type !== "editor") return; if (update.docChanged) { @@ -2187,10 +3470,13 @@ async function EditorManager($header, $body) { file.markEdited(); // Debounced change handling (unsaved flag, cache, autosave) - if (checkTimeout) clearTimeout(checkTimeout); - if (autosaveTimeout) clearTimeout(autosaveTimeout); + const timers = getDocSyncTimers(file); + if (timers.checkTimeout) clearTimeout(timers.checkTimeout); + if (timers.autosaveTimeout) clearTimeout(timers.autosaveTimeout); - checkTimeout = setTimeout(async () => { + timers.checkTimeout = setTimeout(async () => { + timers.checkTimeout = null; + file.refreshUnsavedState?.(); try { file.scheduleCacheWrite(); } catch (error) { @@ -2208,8 +3494,15 @@ async function EditorManager($header, $body) { const { autosave } = appSettings.value; if (file.uri && file.isUnsaved && autosave) { - autosaveTimeout = setTimeout(() => { - acode.exec("save", false); + timers.autosaveTimeout = setTimeout(() => { + timers.autosaveTimeout = null; + file.save()?.catch?.((error) => { + warnRecoverable( + `Failed to autosave ${file.filename || file.uri}`, + error, + `autosave-${file.id}`, + ); + }); }, autosave); } @@ -2220,12 +3513,20 @@ async function EditorManager($header, $body) { // Register critical listeners manager.on(["file-loaded"], (file) => { - if (!file) return; - if (manager.activeFile?.id === file.id && file.type === "editor") { + if (!file || file.type !== "editor") return; + const pane = getFilePane(file); + if (pane?.activeFile?.id === file.id) { + applyFileToPaneEditor(file, pane); + } else if (manager.activeFile?.id === file.id) { applyFileToEditor(file); } }); + manager.on( + ["file-content-changed", "rename-file", "save-file", "update:pin-tab"], + markGlobalOpenFileListMirrorDirty, + ); + manager.on(["update:read-only"], () => { const file = manager.activeFile; if (file?.type !== "editor") return; @@ -2247,6 +3548,7 @@ async function EditorManager($header, $body) { }); manager.on(["remove-file"], (file) => { + clearDocSyncTimers(file); detachLspForFile(file); toggleProblemButton(); }); @@ -2259,19 +3561,6 @@ async function EditorManager($header, $body) { } }); - // Attach doc-sync listener to the current editor instance - try { - editor.dispatch({ - effects: StateEffect.appendConfig.of(getDocSyncListener()), - }); - } catch (error) { - warnRecoverable( - "Failed to attach document sync listener to editor.", - error, - "doc-sync-listener", - ); - } - return manager; /** @@ -2280,10 +3569,10 @@ async function EditorManager($header, $body) { */ function addFile(file) { if (manager.files.includes(file)) return; - const insertAt = file.pinned - ? getPinnedInsertIndex() - : manager.files.length; - manager.files.splice(insertAt, 0, file); + const pane = getPaneById(file.paneId) || getActivePane() || primaryPane; + insertFileIntoPane(file, pane); + if (!pane.activeFile) pane.activeFile = file; + rebuildFileListFromPanes(); syncOpenFileList(); if (!manager.activeFile) { $header.text = file.name; @@ -2291,32 +3580,276 @@ async function EditorManager($header, $body) { toggleProblemButton(); } - function getPinnedInsertIndex(skipFile = null) { - return manager.files.reduce((count, file) => { + function getPinnedInsertIndex(files, skipFile = null) { + return files.reduce((count, file) => { if (file === skipFile) return count; return count + (file.pinned ? 1 : 0); }, 0); } + function insertFileIntoPane(file, pane, index = null) { + if (!file || !pane) return; + const oldPane = getFilePane(file); + if (oldPane) { + oldPane.files = oldPane.files.filter((paneFile) => paneFile !== file); + } + file.paneId = pane.id; + const insertAt = + Number.isInteger(index) && index >= 0 + ? Math.min(index, pane.files.length) + : file.pinned + ? getPinnedInsertIndex(pane.files) + : pane.files.length; + pane.files.splice(insertAt, 0, file); + } + + function rebuildFileListFromPanes() { + manager.files = getOrderedPanes().flatMap((pane) => pane.files); + return manager.files; + } + + function toDomCollection(nodes) { + const collection = [...nodes].filter(Boolean); + collection.item = (index) => collection[index] || null; + return collection; + } + + function getOpenFileListChildren() { + if (!isPaneTabLayout()) { + const list = $openFileList?.$ul || $openFileList; + return list ? [...list.children] : []; + } + + return getOrderedPanes().flatMap((pane) => [...pane.tabList.children]); + } + + function collectFromOpenFileListChildren(collector) { + const result = []; + getOpenFileListChildren().forEach((child) => { + result.push(...collector(child)); + }); + return result; + } + + function queryOpenFileList(selector) { + return collectFromOpenFileListChildren((child) => [ + ...(child.matches?.(selector) ? [child] : []), + ...child.querySelectorAll(selector), + ]); + } + + function getOpenFileListMutationTarget(referenceNode = null) { + if (!isPaneTabLayout()) { + if (!$openFileList || $openFileList === $globalOpenFileList) { + initFileTabContainer(); + } + return $openFileList?.$ul || $openFileList || $globalOpenFileList; + } + + if (referenceNode?.parentElement?.classList?.contains("editor-pane-tabs")) { + return referenceNode.parentElement; + } + + return getPaneTabList() || getActivePane()?.tabList || $globalOpenFileList; + } + + function mutateVisibleOpenFileList(method, args) { + const target = getOpenFileListMutationTarget( + method === "insertBefore" ? args[1] : null, + ); + if (!target || target === $globalOpenFileList) { + return globalOpenFileListNative[method]?.(...args); + } + + if (method === "insertBefore") { + const [node, referenceNode] = args; + return target.insertBefore( + node, + referenceNode?.parentElement === target ? referenceNode : null, + ); + } + + return target[method](...args); + } + + function syncGlobalOpenFileListMirror() { + const shouldRebuild = + globalOpenFileListMirrorFiles !== manager.files || + $globalOpenFileList.childElementCount !== manager.files.length; + + if (shouldRebuild) { + rebuildGlobalOpenFileListMirror(); + return; + } + + if (!globalOpenFileListMirrorDirtyFiles.size) { + syncGlobalOpenFileListMirrorActiveState(); + return; + } + + const dirtyFiles = [...globalOpenFileListMirrorDirtyFiles]; + const fileIndexes = new Map( + manager.files.map((file, index) => [file, index]), + ); + globalOpenFileListMirrorDirtyFiles.clear(); + + dirtyFiles.forEach((file) => { + const index = fileIndexes.get(file); + if (index === undefined) return; + const signature = getGlobalOpenFileListMirrorTabSignature(file); + if (globalOpenFileListMirrorTabSignatures.get(file) === signature) { + return; + } + + const nextTab = createGlobalOpenFileListMirrorTab(file); + const currentTab = + globalOpenFileListMirrorTabs.get(file) || + $globalOpenFileList.children[index]; + if (currentTab?.parentElement === $globalOpenFileList) { + globalOpenFileListNative.insertBefore(nextTab, currentTab); + currentTab.remove(); + } else { + globalOpenFileListNative.insertBefore( + nextTab, + $globalOpenFileList.children[index] || null, + ); + } + cacheGlobalOpenFileListMirrorTab(file, nextTab, signature); + }); + + syncGlobalOpenFileListMirrorActiveState(); + } + + function rebuildGlobalOpenFileListMirror() { + globalOpenFileListMirrorTabs.clear(); + globalOpenFileListMirrorTabsById.clear(); + globalOpenFileListMirrorTabSignatures.clear(); + globalOpenFileListMirrorDirtyFiles.clear(); + + globalOpenFileListNative.replaceChildren( + ...manager.files.map((file) => { + const tab = createGlobalOpenFileListMirrorTab(file); + cacheGlobalOpenFileListMirrorTab( + file, + tab, + getGlobalOpenFileListMirrorTabSignature(file), + ); + return tab; + }), + ); + + globalOpenFileListMirrorFiles = manager.files; + globalOpenFileListMirrorActiveFileId = manager.activeFile?.id || ""; + } + + function createGlobalOpenFileListMirrorTab(file) { + const tab = file.tab.cloneNode(true); + tab.dataset.fileId = file.id; + tab.dataset.paneId = file.paneId || ""; + tab.classList.toggle("active", manager.activeFile?.id === file.id); + return tab; + } + + function cacheGlobalOpenFileListMirrorTab(file, tab, signature) { + globalOpenFileListMirrorTabs.set(file, tab); + globalOpenFileListMirrorTabsById.set(file.id, tab); + globalOpenFileListMirrorTabSignatures.set(file, signature); + } + + function markGlobalOpenFileListMirrorDirty(file) { + if (file) globalOpenFileListMirrorDirtyFiles.add(file); + } + + function syncGlobalOpenFileListMirrorActiveState() { + const activeFileId = manager.activeFile?.id || ""; + if (globalOpenFileListMirrorActiveFileId === activeFileId) return; + + globalOpenFileListMirrorTabsById + .get(globalOpenFileListMirrorActiveFileId) + ?.classList.remove("active"); + globalOpenFileListMirrorTabsById.get(activeFileId)?.classList.add("active"); + globalOpenFileListMirrorActiveFileId = activeFileId; + } + + function getGlobalOpenFileListMirrorTabSignature(file) { + const tab = file.tab; + const classNames = [...tab.classList] + .filter((className) => className !== "active") + .join(" "); + return `${classNames}\n${tab.innerHTML}`; + } + + function isDraggingFileTab(file) { + return file?.tab?.dataset.editorTabDragging === "true"; + } + function syncOpenFileList() { - const $list = manager.openFileList; + if (isPaneTabLayout()) { + $paneRoot.classList.remove("hide-pane-tabs"); + panes.forEach((pane) => { + const preserveCurrentTabOrder = !!pane.tabList.querySelector( + '[data-editor-tab-dragging="true"]', + ); + pane.files.forEach((file) => { + file.tab.classList.toggle("active", pane.activeFile?.id === file.id); + if (isDraggingFileTab(file) || preserveCurrentTabOrder) return; + pane.tabList.append(file.tab); + }); + pane.element.classList.toggle("empty", pane.files.length === 0); + }); + syncGlobalOpenFileListMirror(); + return; + } + + $paneRoot.classList.add("hide-pane-tabs"); + if (!$openFileList || $openFileList === $globalOpenFileList) { + initFileTabContainer(); + } + const $list = $openFileList; manager.files.forEach((file) => { $list.append(file.tab); }); } function moveFileByPinnedState(file) { - if (!manager.files.includes(file)) return; + const pane = getFilePane(file); + if (!pane) return; + pane.files = normalizePinnedFiles(pane.files); + rebuildFileListFromPanes(); + syncOpenFileList(); if (manager.activeFile?.id === file.id) { file.tab.scrollIntoView(); } } function normalizePinnedTabOrder(nextFiles = manager.files) { + const pane = + nextFiles.length && + nextFiles.every((file) => getFilePane(file) === getFilePane(nextFiles[0])) + ? getFilePane(nextFiles[0]) + : null; + + if (pane) { + pane.files = normalizePinnedFiles(nextFiles); + rebuildFileListFromPanes(); + syncOpenFileList(); + return pane.files; + } + + panes.forEach((pane) => { + pane.files = normalizePinnedFiles(pane.files); + }); + rebuildFileListFromPanes(); + syncOpenFileList(); + + return manager.files; + } + + function normalizePinnedFiles(files) { const pinnedFiles = []; const regularFiles = []; - nextFiles.forEach((file) => { + files.forEach((file) => { if (file.pinned) { pinnedFiles.push(file); return; @@ -2324,17 +3857,169 @@ async function EditorManager($header, $body) { regularFiles.push(file); }); - manager.files = [...pinnedFiles, ...regularFiles]; + return [...pinnedFiles, ...regularFiles]; + } + + function getPaneById(id) { + if (!id) return null; + return panes.find((pane) => pane.id === id) || null; + } + + function getFilePane(fileOrId) { + const id = typeof fileOrId === "string" ? fileOrId : fileOrId?.id || null; + if (!id) return null; + return ( + panes.find((pane) => pane.files.some((file) => file.id === id)) || null + ); + } + + function getPaneFiles(fileOrPane = getActivePane()) { + const pane = fileOrPane?.files ? fileOrPane : getFilePane(fileOrPane); + return pane?.files || manager.files; + } + + function getPaneTabList(fileOrPane = getActivePane()) { + const pane = fileOrPane?.tabList + ? fileOrPane + : typeof fileOrPane === "string" + ? getPaneById(fileOrPane) || getFilePane(fileOrPane) + : getFilePane(fileOrPane); + return pane?.tabList || null; + } + + function getPaneFallbackFile(pane) { + if (!pane?.files?.length) return null; + for (let i = pane.files.length - 1; i >= 0; i--) { + const file = pane.files[i]; + if (!isDraggingFileTab(file)) return file; + } + return null; + } + + function moveFileToPane(file, targetPane, options = {}) { + if (!file || !targetPane) return false; + const { + activate = true, + index = null, + createSourcePlaceholder = true, + activateSourceFallback = true, + } = options; + const sourcePane = getFilePane(file); + if (sourcePane === targetPane) { + if (activate) file.makeActive(); + return true; + } + + if (sourcePane?.activeFile?.id === file.id && file.type === "editor") { + const sourceEditor = sourcePane.editor; + file.session = getRawEditorState(sourceEditor?.state); + file.lastScrollTop = sourceEditor?.scrollDOM?.scrollTop || 0; + file.lastScrollLeft = sourceEditor?.scrollDOM?.scrollLeft || 0; + } + + insertFileIntoPane(file, targetPane, index); + if (!file.isPanePlaceholder || file.isUnsaved) { + removePanePlaceholders(targetPane, file); + } + if (!targetPane.activeFile && !activate) { + targetPane.activeFile = file; + } + rebuildFileListFromPanes(); syncOpenFileList(); - return manager.files; + if (sourcePane?.activeFile?.id === file.id) { + const nextSourceFile = getPaneFallbackFile(sourcePane); + sourcePane.activeFile = null; + file.tab?.classList.remove("active"); + if (nextSourceFile && activateSourceFallback) { + nextSourceFile.makeActive(); + } else if (createSourcePlaceholder) { + sourcePane.editor?.setState(createEmptyEditorState()); + sourcePane.editorContainer.style.display = "block"; + createUntitledPaneFile(sourcePane); + } + syncOpenFileList(); + } + + if (activate) { + file.makeActive(); + } + return true; + } + + function removeFileFromPane(file) { + const pane = getFilePane(file); + if (!pane) return null; + const wasPaneActive = pane.activeFile?.id === file.id; + pane.files = pane.files.filter((paneFile) => paneFile !== file); + let nextFile = pane.activeFile; + if (wasPaneActive) { + nextFile = pane.files[pane.files.length - 1] || null; + pane.activeFile = null; + if (!nextFile) { + pane.editor?.setState(createEmptyEditorState()); + pane.editorContainer.style.display = "block"; + } + } + rebuildFileListFromPanes(); + syncOpenFileList(); + return { pane, wasPaneActive, nextFile }; + } + + function updatePaneFileOrderFromTabs($tabList, options = {}) { + const pane = $tabList?.__editorPane; + if (!pane) return false; + + const nextFiles = [...$tabList.children] + .map(($tab) => pane.files.find((file) => file.tab === $tab)) + .filter(Boolean); + if (!nextFiles.length) return false; + + pane.files = nextFiles; + const pinnedCount = pane.files.filter((file) => file.pinned).length; + const draggedFile = pane.files.includes(options.draggedFile) + ? options.draggedFile + : pane.files.find( + (file) => file.tab?.dataset.editorTabDragging === "true", + ); + if (draggedFile) { + const draggedIndex = pane.files.indexOf(draggedFile); + let nextPinnedState; + + if (!draggedFile.pinned && draggedIndex < pinnedCount) { + nextPinnedState = true; + } else if (draggedFile.pinned && draggedIndex >= pinnedCount) { + nextPinnedState = false; + } + + if (nextPinnedState !== undefined) { + draggedFile.setPinnedState(nextPinnedState, { reorder: false }); + pane.files = normalizePinnedFiles(pane.files); + } + } + + rebuildFileListFromPanes(); + syncOpenFileList(); + return true; + } + + function isPaneTabLayout() { + const { openFileListPos } = appSettings.value; + // Sidebar mode keeps the global sidebar list, so pane-local tab bars stay hidden. + return ( + openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER || + openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM + ); } /** * Sets up the editor with various configurations and event listeners. * @returns {Promise} A promise that resolves once the editor is set up. */ - async function setupEditor() { + async function setupEditor(pane = getActivePane()) { + const editor = pane?.editor; + const touchSelectionController = pane?.touchSelectionController; + if (!pane || !editor) return; const settings = appSettings.value; const { leftMargin, textWrap, colorPreview, fontSize, lineHeight } = appSettings.value; @@ -2343,13 +4028,15 @@ async function EditorManager($header, $body) { const scrollMarginRight = textWrap ? 0 : leftMargin; const scrollMarginBottom = 0; - let checkTimeout = null; - let autosaveTimeout; let scrollTimeout; let scrollSyncRaf = 0; const scroller = editor.scrollDOM; + let pendingKeyboardHideBlur = null; + pane.cleanupEditorListeners?.(); + pane.cleanupEditorListeners = null; function syncScrollUi() { + if (pane !== activePane) return; scrollSyncRaf = 0; editor.requestMeasure({ read: () => readScrollMetrics(), @@ -2358,6 +4045,7 @@ async function EditorManager($header, $body) { } function handleEditorScroll() { + if (pane !== activePane) return; if (!scroller) return; if (restoreScrollbarScrollLock()) return; if (!isScrolling) { @@ -2389,23 +4077,6 @@ async function EditorManager($header, $body) { }); syncScrollUi(); - keyboardHandler.on("keyboardShowStart", () => { - requestAnimationFrame(() => { - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView({ behavior: "instant" }); - }); - }); - keyboardHandler.on("keyboardShow", () => { - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView(); - }); - keyboardHandler.on("keyboardHide", () => { - requestAnimationFrame(() => { - if (isCursorRevealSuppressed()) return; - scrollCursorIntoView({ behavior: "instant" }); - }); - }); - // Attach native DOM event listeners directly to the editor's contentDOM const contentDOM = editor.contentDOM; const isFocused = @@ -2413,22 +4084,23 @@ async function EditorManager($header, $body) { contentDOM.contains(document.activeElement); setNativeContextMenuDisabled(isFocused); - contentDOM.addEventListener("focus", (_event) => { + function handleContentFocus(_event) { + setActivePane(pane); setNativeContextMenuDisabled(true); - const { activeFile } = manager; + const activeFile = pane.activeFile; if (activeFile) { activeFile.focused = true; } touchSelectionController?.onStateChanged(); - }); + } - contentDOM.addEventListener("blur", async (_event) => { + async function handleContentBlur(_event) { setNativeContextMenuDisabled(false); touchSelectionController?.setMenu(false); const { hardKeyboardHidden, keyboardHeight } = await getSystemConfiguration(); const blur = () => { - const { activeFile } = manager; + const activeFile = pane.activeFile; if (activeFile) { activeFile.focused = false; activeFile.focusedBefore = false; @@ -2445,16 +4117,46 @@ async function EditorManager($header, $body) { // soft keyboard - wait for keyboard to hide const onKeyboardHide = () => { keyboardHandler.off("keyboardHide", onKeyboardHide); + if (pendingKeyboardHideBlur === onKeyboardHide) { + pendingKeyboardHideBlur = null; + } blur(); }; + if (pendingKeyboardHideBlur) { + keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); + } + pendingKeyboardHideBlur = onKeyboardHide; keyboardHandler.on("keyboardHide", onKeyboardHide); - }); + } - contentDOM.addEventListener("keydown", (event) => { + function handleContentKeydown(event) { if (event.key === "Escape") { keydownState.esc = { value: true, target: contentDOM }; } - }); + } + + contentDOM.addEventListener("focus", handleContentFocus); + contentDOM.addEventListener("blur", handleContentBlur); + contentDOM.addEventListener("keydown", handleContentKeydown); + + pane.cleanupEditorListeners = () => { + scroller?.removeEventListener("scroll", handleEditorScroll); + scroller?.removeEventListener("pointerdown", clearScrollbarScrollLock); + scroller?.removeEventListener("touchstart", clearScrollbarScrollLock); + scroller?.removeEventListener("wheel", clearScrollbarScrollLock); + contentDOM.removeEventListener("focus", handleContentFocus); + contentDOM.removeEventListener("blur", handleContentBlur); + contentDOM.removeEventListener("keydown", handleContentKeydown); + clearTimeout(scrollTimeout); + if (scrollSyncRaf) { + cancelAnimationFrame(scrollSyncRaf); + scrollSyncRaf = 0; + } + if (pendingKeyboardHideBlur) { + keyboardHandler.off("keyboardHide", pendingKeyboardHideBlur); + pendingKeyboardHideBlur = null; + } + }; updateMargin(true); updateSideButtonContainer(); @@ -2866,8 +4568,9 @@ async function EditorManager($header, $body) { function getDiagnosticStateForFile(file) { if (!file || file.type !== "editor") return null; - if (manager.activeFile?.id === file.id && editor?.state) { - return editor.state; + const pane = getFilePane(file); + if (pane?.activeFile?.id === file.id && pane.editor?.state) { + return pane.editor.state; } return file.session || null; } @@ -2912,26 +4615,38 @@ async function EditorManager($header, $body) { * Switches the active file in the editor. * @param {string} id - The ID of the file to switch to. */ - function switchFile(id) { - const { id: activeFileId } = manager.activeFile || {}; - if (activeFileId === id) return; - + function switchFile(id, targetPane = null) { + const pane = targetPane || getFilePane(id) || getActivePane(); + if (!pane) return; + const paneActiveFile = pane.activeFile; const file = manager.getFile(id); if (!file) return; + if (paneActiveFile?.id === id && activePane === pane) { + if (manager.activeFile?.id !== id) { + manager.activeFile = file; + file.tab?.classList.add("active"); + updateHeaderForFile(file); + if (isPaneTabLayout()) syncGlobalOpenFileListMirror(); + manager.onupdate("switch-file"); + events.emit("switch-file", file); + toggleProblemButton(); + } + return; + } - manager.activeFile?.tab.classList.remove("active"); + setActivePane(pane, { emitSwitch: false }); // Hide previous content if it was non-editor - if (manager.activeFile?.type !== "editor" && manager.activeFile?.content) { - manager.activeFile.content.style.display = "none"; + if (paneActiveFile?.type !== "editor" && paneActiveFile?.content) { + paneActiveFile.content.style.display = "none"; } // Persist the previous editor's state before switching away - const prev = manager.activeFile; + const prev = paneActiveFile; if (prev?.type === "editor") { - prev.session = getRawEditorState(editor.state); - prev.lastScrollTop = editor.scrollDOM?.scrollTop || 0; - prev.lastScrollLeft = editor.scrollDOM?.scrollLeft || 0; + prev.session = getRawEditorState(pane.editor.state); + prev.lastScrollTop = pane.editor.scrollDOM?.scrollTop || 0; + prev.lastScrollLeft = pane.editor.scrollDOM?.scrollLeft || 0; window.setTimeout(() => { prev.flushCacheWrite?.().catch((error) => { warnRecoverable( @@ -2943,21 +4658,23 @@ async function EditorManager($header, $body) { }, 1000); } + paneActiveFile?.tab.classList.remove("active"); + pane.activeFile = file; manager.activeFile = file; file.tab.classList.add("active"); file.tab.scrollIntoView(); - $header.text = file.filename; - $header.subText = file.headerSubtitle || ""; + updateHeaderForFile(file); + if (isPaneTabLayout()) syncGlobalOpenFileListMirror(); if (file.type === "editor") { - touchSelectionController?.setEnabled(true); + pane.touchSelectionController?.setEnabled(true); if (!file.loaded && !file.loading) { showLoadingEditor(file); } else { // Apply active file content and language to CodeMirror applyFileToEditor(file); } - $container.style.display = "block"; + pane.editorContainer.style.display = "block"; $hScrollbar.hideImmediately(); $vScrollbar.hideImmediately(); @@ -2967,12 +4684,12 @@ async function EditorManager($header, $body) { setHScrollValue(); } } else { - touchSelectionController?.setEnabled(false); - $container.style.display = "none"; + pane.touchSelectionController?.setEnabled(false); + pane.editorContainer.style.display = "none"; if (file.content) { file.content.style.display = "block"; - if (!file.content.parentElement) { - $container.parentElement.appendChild(file.content); + if (file.content.parentElement !== pane.content) { + pane.content.appendChild(file.content); } } } @@ -2994,7 +4711,11 @@ async function EditorManager($header, $body) { function initFileTabContainer() { let $list; - if ($openFileList) { + if ( + $openFileList && + $openFileList !== $globalOpenFileList && + !$openFileList.classList.contains("editor-pane-tabs") + ) { if ($openFileList.classList.contains("collapsible")) { $list = Array.from($openFileList.$ul.children); } else { @@ -3005,30 +4726,14 @@ async function EditorManager($header, $body) { // show open file list in header const { openFileListPos } = appSettings.value; - if ( - openFileListPos === appSettings.OPEN_FILE_LIST_POS_HEADER || - openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM - ) { - if (!$openFileList?.classList.contains("open-file-list")) { - $openFileList =
        ; - } - if ($list) $openFileList.append(...$list); - - if (openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM) { - $container.parentElement.insertAdjacentElement( - "afterend", - $openFileList, - ); - } else { - $header.insertAdjacentElement("afterend", $openFileList); - } - - root.classList.add("top-bar"); - - const oldAppend = $openFileList.append; - $openFileList.append = (...args) => { - oldAppend.apply($openFileList, args); - }; + if (isPaneTabLayout()) { + $openFileList = $globalOpenFileList; + $paneRoot.dataset.tabsPosition = + openFileListPos === appSettings.OPEN_FILE_LIST_POS_BOTTOM + ? "bottom" + : "top"; + root.classList.remove("top-bar"); + syncOpenFileList(); } else { $openFileList = list(strings["active files"]); $openFileList.classList.add("file-list"); @@ -3043,6 +4748,7 @@ async function EditorManager($header, $body) { const files = sidebarApps.get("files"); files.insertBefore($openFileList, files.firstElementChild); root.classList.remove("top-bar"); + syncOpenFileList(); } root.setAttribute("open-file-list-pos", openFileListPos); @@ -3054,7 +4760,9 @@ async function EditorManager($header, $body) { * @returns {number} The number of unsaved files. */ function hasUnsavedFiles() { - const unsavedFiles = manager.files.filter((file) => file.isUnsaved); + const unsavedFiles = manager.files.filter( + (file) => file.refreshUnsavedState?.() ?? file.isUnsaved, + ); return unsavedFiles.length; } diff --git a/src/lib/fileList.js b/src/lib/fileList.js index b57b062fe..39f4807de 100644 --- a/src/lib/fileList.js +++ b/src/lib/fileList.js @@ -21,7 +21,7 @@ const events = { }; export function initFileList() { - if (editorManager?.activeFile.loading) { + if (editorManager?.activeFile?.loading) { editorManager.activeFile.on("loadend", initFileList); return; } diff --git a/src/lib/keyBindings.js b/src/lib/keyBindings.js index c80edd32f..1f9a00feb 100644 --- a/src/lib/keyBindings.js +++ b/src/lib/keyBindings.js @@ -122,6 +122,76 @@ const APP_BINDING_CONFIG = [ action: "prev-file", readOnly: true, }, + { + name: "splitPaneRight", + description: "Split editor pane right", + key: "Ctrl-\\", + action: "split-pane-right", + readOnly: true, + }, + { + name: "splitPaneDown", + description: "Split editor pane down", + key: "Ctrl-Shift-\\", + action: "split-pane-down", + readOnly: true, + }, + { + name: "closePane", + description: "Close active editor pane", + key: "Ctrl-Alt-W", + action: "close-pane", + readOnly: true, + }, + { + name: "focusNextPane", + description: "Focus next editor pane", + key: null, + action: "focus-next-pane", + readOnly: true, + }, + { + name: "focusPreviousPane", + description: "Focus previous editor pane", + key: null, + action: "focus-previous-pane", + readOnly: true, + }, + { + name: "focusPaneLeft", + description: "Focus editor pane to the left", + key: "Ctrl-Alt-Left", + action: "focus-pane-left", + readOnly: true, + }, + { + name: "focusPaneRight", + description: "Focus editor pane to the right", + key: "Ctrl-Alt-Right", + action: "focus-pane-right", + readOnly: true, + }, + { + name: "focusPaneUp", + description: "Focus editor pane above", + key: "Ctrl-Alt-Up", + action: "focus-pane-up", + readOnly: true, + }, + { + name: "focusPaneDown", + description: "Focus editor pane below", + key: "Ctrl-Alt-Down", + action: "focus-pane-down", + readOnly: true, + }, + { + name: "moveTabToNewPane", + description: "Move current tab to new pane", + key: "Ctrl-Alt-\\", + action: "move-tab-to-new-pane", + readOnly: true, + }, { name: "showSettingsMenu", description: "Show settings menu", diff --git a/src/lib/openFile.js b/src/lib/openFile.js index c803a55c2..bbb5e5812 100644 --- a/src/lib/openFile.js +++ b/src/lib/openFile.js @@ -21,6 +21,7 @@ import appSettings from "./settings"; * @property {string} encoding * @property {string} mode * @property {string} uri + * @property {string} paneId */ /** @@ -36,7 +37,7 @@ export default async function openFile(file, options = {}) { /**@type {EditorFile} */ const existingFile = editorManager.getFile(uri, "uri"); - const { cursorPos, render, onsave, text, mode, encoding } = options; + const { cursorPos, render, onsave, text, mode, encoding, paneId } = options; if (existingFile) { // If file is already opened and new text is provided @@ -44,7 +45,16 @@ export default async function openFile(file, options = {}) { text != null ? Text.of(String(text).split("\n")) : null; // If file is already opened - existingFile.makeActive(); + const targetPane = paneId + ? editorManager.panes?.find((pane) => pane.id === paneId) + : null; + if (targetPane) { + editorManager.moveFileToPane?.(existingFile, targetPane, { + activate: true, + }); + } else { + existingFile.makeActive(); + } const { editor } = editorManager; @@ -109,6 +119,7 @@ export default async function openFile(file, options = {}) { SAFMode: mode, savedMtime: helpers.getStatMtime(fileInfo), diskMtime: helpers.getStatMtime(fileInfo), + paneId, }); }; @@ -181,6 +192,7 @@ export default async function openFile(file, options = {}) { content: videoContainer, render: true, hideQuickTools: true, + paneId, }); return; } @@ -349,6 +361,7 @@ export default async function openFile(file, options = {}) { content: imageContainer, render: true, hideQuickTools: true, + paneId, }); return; } @@ -377,6 +390,7 @@ export default async function openFile(file, options = {}) { content: audioPlayer.container, render: true, hideQuickTools: true, + paneId, }); audioTab.onclose = () => { audioPlayer.cleanup(); diff --git a/src/lib/prettierFormatter.js b/src/lib/prettierFormatter.js index fb1a3b86c..7200e5646 100644 --- a/src/lib/prettierFormatter.js +++ b/src/lib/prettierFormatter.js @@ -1,5 +1,6 @@ import fsOperation from "fileSystem"; import { parse } from "acorn"; +import { getDocText } from "cm/editorUtils"; import toast from "components/toast"; import appSettings from "lib/settings"; import prettierPluginBabel from "prettier/plugins/babel"; @@ -83,7 +84,7 @@ export async function formatActiveFileWithPrettier() { } const doc = editor.state.doc; - const source = doc.toString(); + const source = getDocText(doc); const filepath = file.uri || file.filename || ""; try { const config = await resolvePrettierConfig(file); diff --git a/src/lib/run.js b/src/lib/run.js index d903c9c18..7978c4b6a 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -1,4 +1,5 @@ import fsOperation from "fileSystem"; +import { getDocText } from "cm/editorUtils"; import tutorial from "components/tutorial"; import alert from "dialogs/alert"; import dialog from "dialogs/dialog"; @@ -205,7 +206,7 @@ async function run( break; case EXECUTING_SCRIPT: { - const text = activeFile?.session?.doc?.toString() || ""; + const text = getDocText(activeFile?.session?.doc); sendText(text, reqId, "application/javascript"); break; } @@ -244,7 +245,7 @@ async function run( if (activeFile.SAFMode === "single") { if (filename === reqPath) { sendText( - activeFile.session?.doc?.toString(), + getDocText(activeFile.session?.doc), reqId, mimeType.lookup(filename), ); @@ -283,7 +284,7 @@ async function run( const htmlUrl = Url.join(pathName, reqPath + ".html"); const htmlFile = editorManager.getFile(htmlUrl, "uri"); if (htmlFile?.loaded && htmlFile.isUnsaved) { - sendHTML(htmlFile.session?.doc?.toString(), reqId); + sendHTML(getDocText(htmlFile.session?.doc), reqId); return; } const htmlFs = fsOperation(htmlUrl); @@ -308,7 +309,7 @@ async function run( case ".htm": case ".html": if (!url || (file && file.loaded && file.isUnsaved)) { - sendHTML(file.session?.doc?.toString(), reqId); + sendHTML(getDocText(file.session?.doc), reqId); } else { sendFileContent(url, reqId, MIMETYPE_HTML); } @@ -325,7 +326,7 @@ async function run( .toLowerCase() .replace(/[^a-z0-9]+/g, "-"), }) - .render(file.session?.doc?.toString()); + .render(getDocText(file.session?.doc)); const doc = mustache.render($_markdown, { html, filename, @@ -338,7 +339,7 @@ async function run( default: if (!url || (file && file.loaded && file.isUnsaved)) { sendText( - file.session?.doc?.toString(), + getDocText(file.session?.doc), reqId, mimeType.lookup(file.filename), ); diff --git a/src/lib/saveFile.js b/src/lib/saveFile.js index 44d067fcc..a01286b84 100644 --- a/src/lib/saveFile.js +++ b/src/lib/saveFile.js @@ -1,4 +1,5 @@ import fsOperation from "fileSystem"; +import { getDocText } from "cm/editorUtils"; import prompt from "dialogs/prompt"; import select from "dialogs/select"; import recents from "lib/recents"; @@ -127,7 +128,7 @@ async function saveFile(file, isSaveAs = false) { const savedDoc = file.session?.doc || null; const savedVersion = file.docVersion; - const data = savedDoc ? savedDoc.toString() : ""; + const data = getDocText(savedDoc); await fileOnDevice.writeFile(data, encoding); const stat = await fileOnDevice.stat().catch(() => null); diff --git a/src/lib/showFileInfo.js b/src/lib/showFileInfo.js index 9b0650c3c..1520f7c3c 100644 --- a/src/lib/showFileInfo.js +++ b/src/lib/showFileInfo.js @@ -13,7 +13,9 @@ import settings from "./settings"; * @param {String} [url] */ export default async function showFileInfo(url) { - if (!url) url = editorManager.activeFile.uri; + const activeFile = editorManager.activeFile; + if (!url) url = activeFile?.uri; + if (!url) return; loader.showTitleLoader(); try { const fs = fsOperation(url); @@ -24,7 +26,7 @@ export default async function showFileInfo(url) { lastModified = new Date(lastModified).toLocaleString(); const protocol = Url.getProtocol(url); - const fileType = type.toLowerCase(); + const fileType = String(type || "").toLowerCase(); const options = { name: name.slice(0, name.length - Url.extname(name).length), extension: Url.extname(name), @@ -34,10 +36,11 @@ export default async function showFileInfo(url) { lang: strings, showUri: helpers.getVirtualPath(url), isEditor: - fileType === "text/plain" || editorManager.activeFile.type === "editor", + fileType === "text/plain" || + (activeFile?.uri === url && activeFile.type === "editor"), }; - if (editorManager.activeFile.type === "editor") { + if (options.isEditor) { const value = await fs.readFile(settings.value.defaultFileEncoding); options.lineCount = value.split(/\n+/).length; options.wordCount = value.split(/\s+|\n+/).length; diff --git a/src/main.js b/src/main.js index 08c64a618..efe610526 100644 --- a/src/main.js +++ b/src/main.js @@ -725,12 +725,16 @@ async function loadApp() { // if (!$editMenuToggler.isConnected) { // $header.insertBefore($editMenuToggler, $header.lastChild); // } - if (activeFile?.type === "page" || activeFile?.type === "terminal") { - $editMenuToggler.remove(); - } else { + if ( + activeFile && + activeFile.type !== "page" && + activeFile.type !== "terminal" + ) { if (!$editMenuToggler.isConnected) { $header.insertBefore($editMenuToggler, $header.lastChild); } + } else { + $editMenuToggler.remove(); } if (mode === "switch-file") { @@ -825,9 +829,9 @@ function createFileMenu({ top, bottom, toggler }) { toggler, transformOrigin: top ? "top right" : "bottom right", innerHTML: () => { - const file = window.editorManager.activeFile; + const file = window.editorManager?.activeFile; - if (file.type === "page") { + if (!file || file.type === "page" || file.type === "terminal") { return ""; } diff --git a/src/pages/markdownPreview/index.js b/src/pages/markdownPreview/index.js index bfcfd62f2..b89ce84a1 100644 --- a/src/pages/markdownPreview/index.js +++ b/src/pages/markdownPreview/index.js @@ -1,6 +1,7 @@ import "./style.scss"; import fsOperation from "fileSystem"; +import { getDocText } from "cm/editorUtils"; import Page from "components/page"; import DOMPurify from "dompurify"; import actionStack from "lib/actionStack"; @@ -480,7 +481,7 @@ function createMarkdownPreview(file) { revokeObjectUrls(previewState.objectUrls); previewState.objectUrls = []; - const markdownText = previewState.file.session?.doc?.toString?.() || ""; + const markdownText = getDocText(previewState.file.session?.doc); const pendingRenderTasks = [ renderMarkdown(markdownText, previewState.file), ]; diff --git a/src/sidebarApps/searchInFiles/index.js b/src/sidebarApps/searchInFiles/index.js index 185ad4049..b6bc032a6 100644 --- a/src/sidebarApps/searchInFiles/index.js +++ b/src/sidebarApps/searchInFiles/index.js @@ -2,6 +2,7 @@ import "./styles.scss"; import fsOperation from "fileSystem"; import { EditorView } from "@codemirror/view"; import autosize from "autosize"; +import { getDocText } from "cm/editorUtils"; import Checkbox from "components/checkbox"; import Sidebar, { preventSlide } from "components/sidebar"; import escapeStringRegexp from "escape-string-regexp"; @@ -621,7 +622,7 @@ async function readSearchFileContent(uri) { const editorFile = editorManager.getFile(uri, "uri"); if (editorFile?.session?.doc) { try { - return editorFile.session.doc.toString() || ""; + return getDocText(editorFile.session.doc); } catch (_) { return ""; } @@ -726,7 +727,7 @@ function getOpenFileOverlays(searchFiles) { if (!file.uri || !supportedUrls.has(file.uri)) return; if (!file.session?.doc) return; try { - overlays[file.uri] = file.session.doc.toString() || ""; + overlays[file.uri] = getDocText(file.session.doc); } catch (_) { // ignore invalid editor docs } diff --git a/src/styles/codemirror.scss b/src/styles/codemirror.scss index f0321b160..19fd31694 100644 --- a/src/styles/codemirror.scss +++ b/src/styles/codemirror.scss @@ -2,6 +2,191 @@ position: relative; } +.editor-pane-root { + display: flex; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-height: 0; + min-width: 0; + overflow: hidden; + + &[data-tabs-position="bottom"] { + .editor-pane-tabs { + order: 2; + border-top: 1px solid var(--border-color); + border-bottom: 0; + } + } + + &.hide-pane-tabs { + .editor-pane-tabs { + display: none; + } + } +} + +.editor-pane-split { + position: relative; + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; + + &[data-direction="horizontal"] { + flex-direction: row; + } + + &[data-direction="vertical"] { + flex-direction: column; + } +} + +.editor-pane { + position: relative; + display: flex; + flex: 1 1 0; + flex-direction: column; + min-width: min(360px, 100%); + min-height: min(220px, 100%); + overflow: hidden; + background: var(--secondary-color); +} + +.editor-pane-root.multi-pane { + .editor-pane.active { + box-shadow: inset 0 2px 0 var(--active-color); + } + + .editor-pane-split-handle { + display: block; + } +} + +.editor-pane-root:not(.multi-pane) { + .editor-pane-tabs { + border-top: 0; + border-bottom: 0; + } +} + +.editor-pane-split-handle { + position: relative; + z-index: 5; + display: none; + flex: 0 0 9px; + background: transparent; + touch-action: none; + + &::before { + content: ""; + position: absolute; + background: var(--border-color); + } + + &:hover::before, + &:active::before { + background: var(--active-color); + } + + &[data-direction="horizontal"] { + margin-right: -4px; + margin-left: -4px; + cursor: col-resize; + + &::before { + top: 0; + bottom: 0; + left: 4px; + width: 1px; + } + } + + &[data-direction="vertical"] { + margin-top: -4px; + margin-bottom: -4px; + cursor: row-resize; + + &::before { + top: 4px; + right: 0; + left: 0; + height: 1px; + } + } +} + +body.resizing-editor-pane { + cursor: col-resize !important; + user-select: none; + + * { + cursor: col-resize !important; + } + + &[data-editor-pane-resize-axis="y"] { + cursor: row-resize !important; + + * { + cursor: row-resize !important; + } + } +} + +.editor-pane-tabs { + flex: 0 0 30px; + border-bottom: 1px solid var(--border-color); + + li.tile { + min-width: min(var(--file-tab-width), 44%); + max-width: var(--file-tab-width); + } +} + +.editor-global-open-file-list { + display: none !important; +} + +.editor-pane-content { + position: relative; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.editor-pane-content > .editor-container { + height: 100%; +} + +@media (max-width: 720px) { + .editor-pane-root.multi-pane { + .editor-pane-split { + min-width: 100%; + min-height: 100%; + } + + .editor-pane-split > .editor-pane-split:not(.active-path) { + display: none; + } + + .editor-pane { + display: none; + min-width: 100%; + min-height: 100%; + } + + .editor-pane.active { + display: flex; + } + + .editor-pane-split-handle { + display: none; + } + } +} + .editor-container > .cursor-menu { z-index: 600; } diff --git a/src/styles/page.scss b/src/styles/page.scss index 9c770f5a6..62b2d5c20 100644 --- a/src/styles/page.scss +++ b/src/styles/page.scss @@ -14,7 +14,7 @@ body { } &.fullscreen-mode { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { height: 30px; left: 40px; } @@ -101,7 +101,7 @@ body { box-shadow: none !important; } - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { top: 0; width: calc(100% - 140px); } @@ -233,7 +233,7 @@ wc-page { box-shadow: 0 -32px 4px var(--box-shadow-color); } - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { top: auto !important; bottom: 0; z-index: 99; @@ -293,7 +293,7 @@ wc-page { &[footer-height="1"] { &[open-file-list-pos="bottom"] { &#root { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { bottom: 40px; } } @@ -322,7 +322,7 @@ wc-page { &[footer-height="2"] { &[open-file-list-pos="bottom"] { &#root { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { bottom: 80px; } } @@ -351,7 +351,7 @@ wc-page { &[footer-height="3"] { &[open-file-list-pos="bottom"] { &#root { - .open-file-list { + .open-file-list:not(.editor-pane-tabs) { bottom: 120px; } } @@ -501,4 +501,4 @@ wc-page { .editor-container { height: 100%; } -} \ No newline at end of file +}