diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ee0d8c30..73e4623ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ #### :house: Internal +- Developer playground: Make panes resizable with wrapping text. https://github.com/rescript-lang/rescript/pull/8628 - Merge the duplicate Lam intermediate representation into Lambda, removing the conversion layer and obsolete supporting infrastructure. Lambda is now a single private, normalized representation, with generated JavaScript remaining semantically unchanged. https://github.com/rescript-lang/rescript/pull/8608 - Add genType and source map controls and output to the developer playground. https://github.com/rescript-lang/rescript/pull/8448 - Rework the object-type representation end to end: object rows are plain field chains carrying a per-field mutability state (no phantom setter members), object literals are typed directly and property access and assignment are first-class AST and Lambda nodes shared between the Lambda and JS pipelines, and dead class-system remnants (the field-presence lattice, the class-abbreviation memo on object types, method-send typing) are removed. https://github.com/rescript-lang/rescript/pull/8597 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..1c4653b1fc 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 = { @@ -150,6 +151,7 @@ module EventTarget = { module CssStyle = { type t + @send external setProperty: (t, string, string) => unit = "setProperty" @set external setPosition: (t, string) => unit = "position" @set external setTop: (t, string) => unit = "top" @set external setLeft: (t, string) => unit = "left" @@ -160,6 +162,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 +186,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 +206,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" @@ -193,6 +232,7 @@ module TextAreaElement = { @send external select: Dom.element => unit = "select" @send external setSelectionRange: (Dom.element, int, int) => unit = "setSelectionRange" @get external clientHeight: Dom.element => int = "clientHeight" + @get external clientWidth: Dom.element => int = "clientWidth" @get external scrollTop: Dom.element => int = "scrollTop" @set external setScrollTop: (Dom.element, int) => unit = "scrollTop" } diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res index d2fae2b3e3..f1571b6fea 100644 --- a/packages/dev-playground/src/Main.res +++ b/packages/dev-playground/src/Main.res @@ -103,12 +103,11 @@ let insertTabIndent = (event: Dom.event): option => Some(nextValue) } -let configureSourceEditor = (scrollHandler: Dom.event => unit): unit => +let configureSourceEditor = (editorId, scrollHandler: Dom.event => unit): unit => Window.requestAnimationFrame(() => - switch Document.current->Document.getElementById("source-editor") { + switch Document.current->Document.getElementById(editorId) { | 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,42 @@ 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 syncSourceOverlayWidth = editor => + switch editor->Element.parentElement { + | Some(editorShell) => + editorShell + ->Element.style + ->CssStyle.setProperty( + "--editor-overlay-width", + `${editor->TextAreaElement.clientWidth->Int.toString}px`, + ) + | None => () + } + +let scheduleSourceOverlayWidthSync = editor => + Window.requestAnimationFrame(() => syncSourceOverlayWidth(editor)) + +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 +707,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 => () + } + }) + +