From 9462d89bcda03367236d522918ade5813f624bde Mon Sep 17 00:00:00 2001 From: Florian Hammerschmidt Date: Fri, 4 Sep 2026 22:04:42 +0200 Subject: [PATCH 1/5] Developer playground: Make panes resizable with wrapping text --- packages/dev-playground/package.json | 2 +- .../scripts/pane-layout.test.mjs | 64 +++++ packages/dev-playground/src/Bindings.res | 48 +++- packages/dev-playground/src/Main.res | 267 +++++++++++++++--- packages/dev-playground/src/PaneLayout.res | 175 ++++++++++++ .../dev-playground/src/SourceHighlight.res | 42 ++- .../dev-playground/src/SourceHighlight.resi | 2 +- packages/dev-playground/src/styles.css | 192 ++++++++----- 8 files changed, 670 insertions(+), 122 deletions(-) create mode 100644 packages/dev-playground/scripts/pane-layout.test.mjs create mode 100644 packages/dev-playground/src/PaneLayout.res diff --git a/packages/dev-playground/package.json b/packages/dev-playground/package.json index 7e93f5ffee..e9f0c2b630 100644 --- a/packages/dev-playground/package.json +++ b/packages/dev-playground/package.json @@ -9,7 +9,7 @@ "prepare-pages-site": "node scripts/prepare-pages-site.mjs", "res:build": "rescript", "res:watch": "rescript -w", - "test": "rescript && node --test scripts/source-map-navigation.test.mjs", + "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs", "dev": "vite --host 127.0.0.1", "build": "rescript && vite build", "preview": "vite preview --host 127.0.0.1" diff --git a/packages/dev-playground/scripts/pane-layout.test.mjs b/packages/dev-playground/scripts/pane-layout.test.mjs new file mode 100644 index 0000000000..b486856bbf --- /dev/null +++ b/packages/dev-playground/scripts/pane-layout.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + current, + finishDrag, + make, + moveDrag, + nudge, + orientationForWidth, + ratioForPosition, + setOrientation, + startDrag, +} from "../src/PaneLayout.res.mjs"; + +const closeTo = (actual, expected) => + assert.ok( + Math.abs(actual - expected) < 0.000001, + `${actual} is not close to ${expected}`, + ); + +test("switches to stacked panes from the container width", () => { + assert.equal(orientationForWidth(901), "Columns"); + assert.equal(orientationForWidth(900), "Rows"); +}); + +test("calculates a divider ratio and respects both pane minimums", () => { + closeTo(ratioForPosition(500, 1000, 360, 420), 0.5); + closeTo(ratioForPosition(0, 1000, 360, 420), 360 / 992); + closeTo(ratioForPosition(1000, 1000, 360, 420), 1 - 420 / 992); + closeTo(ratioForPosition(100, 300, 180, 180), 0.5); +}); + +test("keeps drag and ratio state isolated between layout instances", () => { + const first = make(); + const second = make(); + + assert.notEqual(first.containerId, second.containerId); + startDrag(first, 7, 600, 1000, 360, 420); + + assert.equal(current(first).dragging, true); + assert.equal(current(second).dragging, false); + assert.equal(current(second).columnRatio, 0.5); + + const draggedRatio = current(first).columnRatio; + moveDrag(first, 8, 450); + assert.equal(current(first).columnRatio, draggedRatio); + finishDrag(first, 8); + assert.equal(current(first).dragging, true); + finishDrag(first, 7); + assert.equal(current(first).dragging, false); +}); + +test("stores independent ratios for columns and stacked rows", () => { + const layout = make(); + + nudge(layout, 0.025, 1000, 360, 420); + closeTo(current(layout).columnRatio, 0.525); + + setOrientation(layout, orientationForWidth(600)); + nudge(layout, -0.025, 800, 180, 180); + closeTo(current(layout).columnRatio, 0.525); + closeTo(current(layout).rowRatio, 0.475); +}); diff --git a/packages/dev-playground/src/Bindings.res b/packages/dev-playground/src/Bindings.res index b20bb78dae..3984c9607b 100644 --- a/packages/dev-playground/src/Bindings.res +++ b/packages/dev-playground/src/Bindings.res @@ -114,7 +114,13 @@ module Url = { module Event = { @get external target: Dom.event => {..} = "target" + @get external currentTarget: Dom.event => Dom.element = "currentTarget" @get external key: Dom.event => string = "key" + @get external button: Dom.event => int = "button" + @get external clientX: Dom.event => float = "clientX" + @get external clientY: Dom.event => float = "clientY" + @get external pointerId: Dom.event => int = "pointerId" + @get external detail: Dom.event => int = "detail" @send external preventDefault: Dom.event => unit = "preventDefault" let value = (event: Dom.event): string => (event->target)["value"] @@ -129,11 +135,6 @@ module Event = { let scrollTop: float = (event->target)["scrollTop"] scrollTop->Math.round->Float.toInt } - - let scrollLeft = (event: Dom.event): int => { - let scrollLeft: float = (event->target)["scrollLeft"] - scrollLeft->Math.round->Float.toInt - } } module EventTarget = { @@ -160,6 +161,20 @@ type scrollIntoViewOptions = { inline: string, } +type boundingRect = { + left: float, + top: float, + width: float, + height: float, +} + +type classList + +module ClassList = { + @send external add: (classList, string) => unit = "add" + @send external remove: (classList, string) => unit = "remove" +} + module Element = { @send external setAttribute: (Dom.element, string, string) => unit = "setAttribute" @send @@ -170,6 +185,15 @@ module Element = { @send external appendChild: (Dom.element, Dom.element) => unit = "appendChild" @send external removeChild: (Dom.element, Dom.element) => unit = "removeChild" @send external focus: Dom.element => unit = "focus" + @send external setPointerCapture: (Dom.element, int) => unit = "setPointerCapture" + @send external releasePointerCapture: (Dom.element, int) => unit = "releasePointerCapture" + @send external hasPointerCapture: (Dom.element, int) => bool = "hasPointerCapture" + @send external getBoundingClientRect: Dom.element => boundingRect = "getBoundingClientRect" + @get external classList: Dom.element => classList = "classList" + @send @return(nullable) + external querySelector: (Dom.element, string) => option = "querySelector" + @get @return(nullable) + external parentElement: Dom.element => option = "parentElement" @send external scrollIntoView: (Dom.element, scrollIntoViewOptions) => unit = "scrollIntoView" @get external style: Dom.element => CssStyle.t = "style" @@ -181,6 +205,20 @@ module Element = { "__devPlaygroundScrollHandler" } +type resizeObserver +type resizeObserverEntry + +module ResizeObserverEntry = { + @get external contentRect: resizeObserverEntry => boundingRect = "contentRect" +} + +module ResizeObserver = { + @val external supported: option = "globalThis.ResizeObserver" + @new external make: (array => unit) => resizeObserver = "ResizeObserver" + @send external observe: (resizeObserver, Dom.element) => unit = "observe" + @send external disconnect: resizeObserver => unit = "disconnect" +} + module ScriptElement = { @set external setSrc: (Dom.element, string) => unit = "src" @set external setAsync: (Dom.element, bool) => unit = "async" diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res index d2fae2b3e3..f36ab0e5c0 100644 --- a/packages/dev-playground/src/Main.res +++ b/packages/dev-playground/src/Main.res @@ -108,7 +108,6 @@ let configureSourceEditor = (scrollHandler: Dom.event => unit): unit => switch Document.current->Document.getElementById("source-editor") { | None => () | Some(editor) => - editor->Element.setAttribute("wrap", "off") switch editor->Element.getScrollHandler { | Some(existingHandler) if existingHandler === scrollHandler => () | existingHandler => @@ -122,13 +121,6 @@ let configureSourceEditor = (scrollHandler: Dom.event => unit): unit => } ) -let lineNumbersText = source => { - let lineCount = source->String.split("\n")->Array.length - Array.make(~length=lineCount, 0) - ->Array.mapWithIndex((_, index) => (index + 1)->Int.toString) - ->Array.join("\n") -} - let cursorPositionForOffset = (source, offset): sourcePosition => { let sourceLength = String.length(source) let boundedOffset = if offset < 0 { @@ -164,11 +156,27 @@ let keyMovesCursor = key => | _ => false } -let editorShellStyle = (activeLine, scrollTop, scrollLeft) => { - let activeLineIndex = activeLine <= 1 ? 0 : activeLine - 1 - let activeLineTop = 18 + activeLineIndex * 22 - scrollTop - `--active-line-top: ${activeLineTop->Int.toString}px; --editor-scroll-y: -${scrollTop->Int.toString}px; --editor-scroll-x: -${scrollLeft->Int.toString}px;` -} +let editorShellStyle = scrollTop => `--editor-scroll-y: -${scrollTop->Int.toString}px;` + +let updateActiveSourceLine = (editor, line) => + Window.requestAnimationFrame(() => + switch editor->Element.parentElement { + | Some(editorShell) => { + switch editorShell->Element.querySelector(".syntax-line-current") { + | Some(currentLine) => + currentLine->Element.classList->ClassList.remove("syntax-line-current") + | None => () + } + switch editorShell->Element.querySelector( + `.syntax-line[data-line="${line->Int.toString}"]`, + ) { + | Some(activeLine) => activeLine->Element.classList->ClassList.add("syntax-line-current") + | None => () + } + } + | None => () + } + ) let hasFeature = (features: array, feature: experimentalFeature) => features->Array.includes(feature) @@ -684,6 +692,124 @@ module StatusBadge = { } } +module PaneSeparator = { + let minSourceColumn = 360.0 + let minResultColumn = 420.0 + let minSourceRow = 180.0 + let minResultRow = 180.0 + + let metrics = (orientation, rect: boundingRect) => + switch orientation { + | PaneLayout.Columns => (rect.width, minSourceColumn, minResultColumn) + | PaneLayout.Rows => (rect.height, minSourceRow, minResultRow) + } + + let position = (orientation, event, rect: boundingRect) => + switch orientation { + | PaneLayout.Columns => event->Event.clientX -. rect.left + | PaneLayout.Rows => event->Event.clientY -. rect.top + } + + let withContainer = (event, callback) => + switch event->Event.currentTarget->Element.parentElement { + | Some(container) => callback(event->Event.currentTarget, container) + | None => () + } + + @jsx.component + let make = (~layout: PaneLayout.t) => { + let beginDrag = event => + if event->Event.button === 0 { + withContainer(event, (separator, container) => { + let rect = container->Element.getBoundingClientRect + let orientation = PaneLayout.orientationForWidth(rect.width) + let (size, minFirst, minSecond) = metrics(orientation, rect) + let pointerId = event->Event.pointerId + + PaneLayout.setOrientation(layout, orientation) + separator->Element.setPointerCapture(pointerId) + PaneLayout.startDrag( + layout, + ~pointerId, + ~position=position(orientation, event, rect), + ~size, + ~minFirst, + ~minSecond, + ) + event->Event.preventDefault + }) + } + + let moveDrag = event => + withContainer(event, (_, container) => { + let rect = container->Element.getBoundingClientRect + let orientation = PaneLayout.current(layout).orientation + PaneLayout.moveDrag( + layout, + ~pointerId=event->Event.pointerId, + ~position=position(orientation, event, rect), + ) + }) + + let finishDrag = event => { + let separator = event->Event.currentTarget + let pointerId = event->Event.pointerId + if separator->Element.hasPointerCapture(pointerId) { + separator->Element.releasePointerCapture(pointerId) + } + PaneLayout.finishDrag(layout, ~pointerId) + } + + let handleKeyDown = event => + withContainer(event, (_, container) => { + let rect = container->Element.getBoundingClientRect + let orientation = PaneLayout.orientationForWidth(rect.width) + let delta = switch (orientation, event->Event.key) { + | (PaneLayout.Columns, "ArrowLeft") | (PaneLayout.Rows, "ArrowUp") => + Some(-.PaneLayout.keyboardStep) + | (PaneLayout.Columns, "ArrowRight") | (PaneLayout.Rows, "ArrowDown") => + Some(PaneLayout.keyboardStep) + | _ => None + } + + switch delta { + | Some(delta) => { + let (size, minFirst, minSecond) = metrics(orientation, rect) + PaneLayout.setOrientation(layout, orientation) + PaneLayout.nudge(layout, delta, ~size, ~minFirst, ~minSecond) + event->Event.preventDefault + } + | None => () + } + }) + +