remove JS layouting

This commit is contained in:
Aran-Fey
2024-06-09 10:16:36 +02:00
committed by Jakob Pinterits
parent 6e4975cce0
commit fe5c5abfa6
74 changed files with 373 additions and 2957 deletions

View File

@@ -1,6 +1,5 @@
import { getComponentByElement } from './componentManagement';
import { Debouncer } from './debouncer';
import { updateLayout } from './layouting';
import {
callRemoteMethodDiscardResponse,
incomingMessageQueue,
@@ -130,19 +129,6 @@ async function main(): Promise<void> {
window.innerWidth,
window.innerHeight
);
// Re-layout, but only if a root component already exists
let rootElement = document.body.querySelector(
'.rio-fundamental-root-component'
);
if (rootElement !== null) {
let rootInstance = getComponentByElement(
rootElement as HTMLElement
);
rootInstance.makeLayoutDirty();
updateLayout();
}
});
// Process initial messages

View File

@@ -1,4 +1,3 @@
import { AlignComponent } from './components/align';
import { BuildFailedComponent } from './components/buildFailed';
import { ButtonComponent } from './components/button';
import { CalendarComponent } from './components/calendar';
@@ -27,7 +26,6 @@ import { KeyEventListenerComponent } from './components/keyEventListener';
import { LayoutDisplayComponent } from './components/layoutDisplay';
import { LinkComponent } from './components/link';
import { ListViewComponent } from './components/listView';
import { MarginComponent } from './components/margin';
import { MarkdownComponent } from './components/markdown';
import { MediaPlayerComponent } from './components/mediaPlayer';
import { MouseEventListenerComponent } from './components/mouseEventListener';
@@ -43,7 +41,6 @@ import { ProgressCircleComponent } from './components/progressCircle';
import { RectangleComponent } from './components/rectangle';
import { reprElement, scrollToUrlFragment } from './utils';
import { RevealerComponent } from './components/revealer';
import { ScrollContainerComponent } from './components/scrollContainer';
import { ScrollTargetComponent } from './components/scrollTarget';
import { SeparatorComponent } from './components/separator';
import { SeparatorListItemComponent } from './components/separatorListItem';
@@ -58,10 +55,8 @@ import { TextComponent } from './components/text';
import { TextInputComponent } from './components/textInput';
import { ThemeContextSwitcherComponent } from './components/themeContextSwitcher';
import { TooltipComponent } from './components/tooltip';
import { updateLayout } from './layouting';
const COMPONENT_CLASSES = {
'Align-builtin': AlignComponent,
'BuildFailed-builtin': BuildFailedComponent,
'Button-builtin': ButtonComponent,
'Calendar-builtin': CalendarComponent,
@@ -88,7 +83,6 @@ const COMPONENT_CLASSES = {
'LayoutDisplay-builtin': LayoutDisplayComponent,
'Link-builtin': LinkComponent,
'ListView-builtin': ListViewComponent,
'Margin-builtin': MarginComponent,
'Markdown-builtin': MarkdownComponent,
'MediaPlayer-builtin': MediaPlayerComponent,
'MouseEventListener-builtin': MouseEventListenerComponent,
@@ -103,7 +97,6 @@ const COMPONENT_CLASSES = {
'Rectangle-builtin': RectangleComponent,
'Revealer-builtin': RevealerComponent,
'Row-builtin': RowComponent,
'ScrollContainer-builtin': ScrollContainerComponent,
'ScrollTarget-builtin': ScrollTargetComponent,
'Separator-builtin': SeparatorComponent,
'SeparatorListItem-builtin': SeparatorListItemComponent,
@@ -141,15 +134,6 @@ export function getRootComponent(): FundamentalRootComponent {
) as FundamentalRootComponent;
}
export function getRootScroller(): ScrollContainerComponent {
let rootComponent = getRootComponent();
return componentsById[
rootComponent.state.content
] as ScrollContainerComponent;
}
globalThis.getRootScroller = getRootScroller; // Used to scroll up after navigating to a different page
export function getComponentByElement(element: Element): ComponentBase {
let instance = tryGetComponentByElement(element);
@@ -183,14 +167,33 @@ globalThis.getInstanceByElement = getComponentByElement; // For debugging
export function tryGetComponentByElement(
element: Element
): ComponentBase | null {
return componentsByElement.get(element as HTMLElement) ?? null;
let component = componentsByElement.get(element as HTMLElement);
if (component !== undefined) {
return component;
}
// Components may create additional HTML elements for layouting purposes
// (alignment, scrolling, ...), so check if this is such an element
if (element instanceof HTMLElement) {
let ownerId = element.dataset.ownerId;
if (ownerId !== undefined) {
component = componentsById[ownerId];
if (component !== undefined) {
return component;
}
}
}
return null;
}
export function isComponentElement(element: Element): boolean {
return componentsByElement.has(element as HTMLElement);
}
export function getParentComponentElementIncludingInjected(
export function getParentComponentElement(
element: HTMLElement
): HTMLElement | null {
let curElement = element.parentElement;
@@ -206,84 +209,6 @@ export function getParentComponentElementIncludingInjected(
return null;
}
function getCurrentComponentState(
id: ComponentId,
deltaState: ComponentState
): ComponentState {
let instance = componentsById[id];
if (instance === undefined) {
return deltaState;
}
return {
...instance.state,
...deltaState,
};
}
function createLayoutComponentStates(
componentId: ComponentId,
message: { [id: string]: ComponentState }
): ComponentId {
let deltaState = message[componentId] || {};
let entireState = getCurrentComponentState(componentId, deltaState);
let resultId = componentId;
// Margin
let margin = entireState['_margin_']!;
if (margin === undefined) {
console.error(`Got incomplete state for component ${componentId}`);
} else if (
margin[0] !== 0 ||
margin[1] !== 0 ||
margin[2] !== 0 ||
margin[3] !== 0
) {
let marginId = (componentId * -10) as ComponentId;
message[marginId] = {
_type_: 'Margin-builtin',
_python_type_: 'Margin (injected)',
_key_: null,
_margin_: [0, 0, 0, 0],
_size_: [0, 0],
_grow_: entireState._grow_,
_rio_internal_: true,
// @ts-ignore
content: resultId,
margin_left: margin[0],
margin_top: margin[1],
margin_right: margin[2],
margin_bottom: margin[3],
};
resultId = marginId;
}
// Align
let align = entireState['_align_']!;
if (align === undefined) {
console.error(`Got incomplete state for component ${componentId}`);
} else if (align[0] !== null || align[1] !== null) {
let alignId = (componentId * -10 - 1) as ComponentId;
message[alignId] = {
_type_: 'Align-builtin',
_python_type_: 'Align (injected)',
_key_: null,
_margin_: [0, 0, 0, 0],
_size_: [0, 0],
_grow_: entireState._grow_,
_rio_internal_: true,
// @ts-ignore
content: resultId,
align_x: align[0],
align_y: align[1],
};
resultId = alignId;
}
return resultId;
}
/// Given a state, return the ids of all its children
export function getChildIds(state: ComponentState): ComponentId[] {
let result: ComponentId[] = [];
@@ -304,105 +229,10 @@ export function getChildIds(state: ComponentState): ComponentId[] {
return result;
}
function replaceChildrenWithLayoutComponents(
deltaState: ComponentState,
childIds: Set<ComponentId>,
message: { [id: string]: ComponentState }
): void {
let propertyNamesWithChildren =
globalThis.CHILD_ATTRIBUTE_NAMES[deltaState['_type_']!] || [];
function uninjectedId(id: ComponentId): ComponentId {
if (id >= 0) {
return id;
}
return Math.floor(id / -10) as ComponentId;
}
for (let propertyName of propertyNamesWithChildren) {
let propertyValue = deltaState[propertyName] as
| ComponentId[]
| ComponentId
| null
| undefined;
if (Array.isArray(propertyValue)) {
deltaState[propertyName] = propertyValue.map(
(childId: ComponentId): ComponentId => {
childId = uninjectedId(childId);
childIds.add(childId);
return createLayoutComponentStates(childId, message);
}
);
} else if (propertyValue !== null && propertyValue !== undefined) {
let childId = uninjectedId(propertyValue);
deltaState[propertyName] = createLayoutComponentStates(
childId,
message
);
childIds.add(childId);
}
}
}
function preprocessDeltaStates(message: {
[id: string]: ComponentState;
}): void {
// Fortunately the root component is created internally by the server, so we
// don't need to worry about it having a margin or alignment.
let originalComponentIds = Object.keys(message).map((id) =>
parseInt(id)
) as ComponentId[];
// Keep track of which components have their parents in the message
let childIds: Set<ComponentId> = new Set();
// Walk over all components in the message and inject layout components. The
// message is modified in-place, so take care to have a copy of all keys
// (`originalComponentIds`)
for (let componentId of originalComponentIds) {
replaceChildrenWithLayoutComponents(
message[componentId],
childIds,
message
);
}
// Find all components which have had a layout component injected, and make
// sure their parents are updated to point to the new component.
for (let componentId of originalComponentIds) {
// Child of another component in the message
if (childIds.has(componentId)) {
continue;
}
// The parent isn't contained in the message. Find and add it.
let child = componentsById[componentId];
if (child === undefined) {
continue;
}
let parent = child.getParentExcludingInjected();
if (parent === null) {
continue;
}
let newParentState = { ...parent.state };
replaceChildrenWithLayoutComponents(newParentState, childIds, message);
message[parent.id] = newParentState;
}
}
export function updateComponentStates(
deltaStates: { [id: string]: ComponentState },
rootComponentId: ComponentId | null
): void {
// Preprocess the message. This converts `_align_` and `_margin_` properties
// into actual components, amongst other things.
preprocessDeltaStates(deltaStates);
// Modifying the DOM makes the keyboard focus get lost. Remember which
// element had focus so we can restore it later.
let focusedElement = document.activeElement;
@@ -470,19 +300,6 @@ export function updateComponentStates(
// Perform updates specific to this component type
component.updateElement(deltaState, latentComponents);
// If the component's width or height has changed, request a re-layout.
let width_changed =
Math.abs(deltaState._size_![0] - component.state._size_[0]) > 1e-6;
let height_changed =
Math.abs(deltaState._size_![1] - component.state._size_[1]) > 1e-6;
if (width_changed || height_changed) {
console.debug(
`Triggering re-layout because component #${id} changed size: ${component.state._size_} -> ${deltaState._size_}`
);
component.makeLayoutDirty();
}
// Update the component's state
component.state = {
...component.state,
@@ -515,9 +332,6 @@ export function updateComponentStates(
}
}
// Update the layout
updateLayout();
// If this is the first time, check if there's an #url-fragment and scroll
// to it
if (rootComponentId !== null) {

View File

@@ -1,73 +0,0 @@
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
export type AlignState = ComponentState & {
_type_: 'Align-builtin';
content?: ComponentId;
align_x?: number | null;
align_y?: number | null;
};
export class AlignComponent extends ComponentBase {
state: Required<AlignState>;
createElement(): HTMLElement {
let element = document.createElement('div');
return element;
}
updateElement(
deltaState: AlignState,
latentComponents: Set<ComponentBase>
): void {
this.replaceOnlyChild(latentComponents, deltaState.content);
if (
deltaState.align_x !== undefined ||
deltaState.align_y !== undefined
) {
this.makeLayoutDirty();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = componentsById[this.state.content]!.requestedWidth;
}
updateAllocatedWidth(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
if (this.state.align_x === null) {
child.allocatedWidth = this.allocatedWidth;
child.element.style.left = '0';
} else {
child.allocatedWidth = child.requestedWidth;
let additionalSpace = this.allocatedWidth - child.requestedWidth;
child.element.style.left =
additionalSpace * this.state.align_x + 'rem';
}
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight =
componentsById[this.state.content]!.requestedHeight;
}
updateAllocatedHeight(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
if (this.state.align_y === null) {
child.allocatedHeight = this.allocatedHeight;
child.element.style.top = '0';
} else {
child.allocatedHeight = child.requestedHeight;
let additionalSpace = this.allocatedHeight - child.requestedHeight;
child.element.style.top =
additionalSpace * this.state.align_y + 'rem';
}
}
}

View File

@@ -1,6 +1,4 @@
import { applyIcon } from '../designApplication';
import { getElementDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { ComponentBase, ComponentState } from './componentBase';
export type BuildFailedState = ComponentState & {
@@ -60,6 +58,8 @@ export class BuildFailedComponent extends ComponentBase {
deltaState: BuildFailedState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.error_summary !== undefined) {
this.summaryElement.innerText = deltaState.error_summary;
}
@@ -68,33 +68,4 @@ export class BuildFailedComponent extends ComponentBase {
this.detailsElement.innerText = deltaState.error_details;
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 4;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 4;
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Display the contents based on how much space the component has
// received
let summaryDims = getElementDimensions(this.summaryElement);
let detailsDims = getElementDimensions(this.detailsElement);
let summaryVisible = this.allocatedWidth > summaryDims[0] + 6; // The padding is a guess
let detailsVisible =
summaryVisible &&
this.allocatedWidth > detailsDims[0] + 1 && // The padding is a guess
this.allocatedHeight > detailsDims[1] + 6; // The padding is a guess
// Special case: No contents provided
summaryVisible = summaryVisible && this.state.error_summary.length > 0;
detailsVisible = detailsVisible && this.state.error_details.length > 0;
// Show/hide the elements
this.summaryElement.style.display = summaryVisible ? '' : 'none';
this.detailsElement.style.display = detailsVisible ? '' : 'none';
}
}

View File

@@ -61,6 +61,8 @@ export class ButtonComponent extends SingleContainer {
deltaState: ButtonState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the child
this.replaceOnlyChild(
latentComponents,

View File

@@ -1,5 +1,4 @@
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import { applyIcon } from '../designApplication';
const CALENDAR_WIDTH = 15.7;
@@ -126,6 +125,8 @@ export class CalendarComponent extends ComponentBase {
deltaState: CalendarState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Apply latent changes to the state
let dateChanged: boolean = false;
@@ -358,12 +359,4 @@ export class CalendarComponent extends ComponentBase {
// Update the grid
this.updateGrid();
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = CALENDAR_WIDTH;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = CALENDAR_HEIGHT;
}
}

View File

@@ -48,6 +48,8 @@ export class CardComponent extends SingleContainer {
deltaState: CardState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the child
this.replaceOnlyChild(latentComponents, deltaState.content);

View File

@@ -1,5 +1,4 @@
import { applyIcon } from '../designApplication';
import { LayoutContext } from '../layouting';
import { ComponentBase, ComponentState } from './componentBase';
export type CheckboxState = ComponentState & {
@@ -56,6 +55,8 @@ export class CheckboxComponent extends ComponentBase {
deltaState: CheckboxState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.is_on !== undefined) {
if (deltaState.is_on) {
this.element.classList.add('is-on');
@@ -79,12 +80,4 @@ export class CheckboxComponent extends ComponentBase {
this.checkboxElement.disabled = true;
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 1.5;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 1.5;
}
}

View File

@@ -19,6 +19,8 @@ export class ClassContainerComponent extends SingleContainer {
deltaState: ClassContainerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceOnlyChild(latentComponents, deltaState.content);
if (deltaState.classes !== undefined) {

View File

@@ -7,8 +7,6 @@ import { ComponentBase, ComponentState } from './componentBase';
import hljs from 'highlight.js/lib/common';
import { Language } from 'highlight.js';
import { LayoutContext } from '../layouting';
import { getElementHeight, getElementWidth } from '../layoutHelpers';
import { setClipboard, firstDefined } from '../utils';
import { applyIcon } from '../designApplication';
@@ -142,12 +140,6 @@ export function convertDivToCodeBlock(
export class CodeBlockComponent extends ComponentBase {
state: Required<CodeBlockState>;
// Since laying out an entire codeblock may be intensive, this component
// does its best not to re-layout unless needed. This is done by setting the
// height request lazily, and only if the width has changed. This value here
// is the component's allocated width when the height request was last set.
private heightRequestAssumesWidth: number;
createElement(): HTMLElement {
const element = document.createElement('div');
return element;
@@ -157,6 +149,8 @@ export class CodeBlockComponent extends ComponentBase {
deltaState: CodeBlockState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Find the value sto use
let code = firstDefined(deltaState.code, this.state.code);
@@ -174,28 +168,5 @@ export class CodeBlockComponent extends ComponentBase {
language,
displayControls
);
// Update the width request
//
// For some reason the element takes up the whole parent's width
// without explicitly setting its width
this.element.style.width = 'min-content';
this.naturalWidth = getElementWidth(this.element);
// Any previously calculated height request is no longer valid
this.heightRequestAssumesWidth = -1;
this.makeLayoutDirty();
}
updateNaturalHeight(ctx: LayoutContext): void {
// Is the previous height request still value?
if (this.heightRequestAssumesWidth === this.allocatedWidth) {
return;
}
// No, re-layout
this.element.style.height = 'min-content';
this.naturalHeight = getElementHeight(this.element);
this.heightRequestAssumesWidth = this.allocatedWidth;
}
}

View File

@@ -1,17 +1,9 @@
import hljs from 'highlight.js/lib/common';
import { componentsByElement, componentsById } from '../componentManagement';
import { getElementDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { applyIcon } from '../designApplication';
// Layouting variables needed by both JS and CSS
const MAIN_GAP = 1;
const BOX_PADDING = 1;
const ARROW_SIZE = 3;
const ADDITIONAL_SPACE = (BOX_PADDING * 2 + MAIN_GAP) * 2 + ARROW_SIZE;
export type CodeExplorerState = ComponentState & {
_type_: 'CodeExplorer-builtin';
source_code?: string;
@@ -30,8 +22,6 @@ export class CodeExplorerComponent extends ComponentBase {
private sourceHighlighterElement: HTMLElement;
private resultHighlighterElement: HTMLElement;
private sourceCodeDimensions: [number, number];
createElement(): HTMLElement {
// Build the HTML
let element = document.createElement('div');
@@ -57,16 +47,6 @@ export class CodeExplorerComponent extends ComponentBase {
[this.sourceCodeElement, this.arrowElement, this.buildResultElement] =
Array.from(element.children) as HTMLElement[];
// Finish initialization
this.sourceCodeElement.style.padding = `${BOX_PADDING}rem`;
element.style.gap = `${MAIN_GAP}rem`;
this.arrowElement.style.width = `${ARROW_SIZE}rem`;
this.arrowElement.style.height = `${ARROW_SIZE}rem`;
// this.arrowElement.style.opacity = '0.3';
// Listen for mouse events
this.buildResultElement.addEventListener(
'mousemove',
@@ -85,6 +65,8 @@ export class CodeExplorerComponent extends ComponentBase {
deltaState: CodeExplorerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the source
if (deltaState.source_code !== undefined) {
let hlResult = hljs.highlight(deltaState.source_code, {
@@ -93,11 +75,6 @@ export class CodeExplorerComponent extends ComponentBase {
});
this.sourceCodeElement.innerHTML = hlResult.value;
// Remember the dimensions now, for faster layouting
this.sourceCodeDimensions = getElementDimensions(
this.sourceCodeElement
);
// Connect event handlers
this._connectHighlightEventListeners();
@@ -390,50 +367,4 @@ export class CodeExplorerComponent extends ComponentBase {
// Exhausted all children
return null;
}
updateNaturalWidth(ctx: LayoutContext): void {
let buildResultElement = componentsById[this.state.build_result]!;
if (this.state.style === 'horizontal') {
this.naturalWidth =
this.sourceCodeDimensions[0] +
ADDITIONAL_SPACE +
buildResultElement.requestedWidth;
} else {
this.naturalWidth = Math.max(
this.sourceCodeDimensions[0],
ADDITIONAL_SPACE,
buildResultElement.requestedWidth
);
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
let buildResultElement = componentsById[this.state.build_result]!;
buildResultElement.allocatedWidth = buildResultElement.requestedWidth;
}
updateNaturalHeight(ctx: LayoutContext): void {
let buildResultElement = componentsById[this.state.build_result]!;
if (this.state.style === 'horizontal') {
this.naturalHeight = Math.max(
this.sourceCodeDimensions[1],
ADDITIONAL_SPACE,
buildResultElement.requestedHeight
);
} else {
this.naturalHeight =
this.sourceCodeDimensions[1] +
ADDITIONAL_SPACE +
buildResultElement.requestedHeight;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
let buildResultElement = componentsById[this.state.build_result]!;
buildResultElement.allocatedHeight = buildResultElement.requestedHeight;
// Positioning the child is already done in `updateElement`
}
}

View File

@@ -1,8 +1,6 @@
import { Color } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { hsvToRgb, rgbToHsv, rgbToHex, rgbaToHex } from '../colorConversion';
import { LayoutContext } from '../layouting';
import { getElementDimensions } from '../layoutHelpers';
export type ColorPickerState = ComponentState & {
_type_: 'ColorPicker-builtin';
@@ -17,11 +15,9 @@ export class ColorPickerComponent extends ComponentBase {
private squareKnob: HTMLElement;
private hueBarOuter: HTMLElement;
private hueBarInner: HTMLElement;
private hueIndicator: HTMLElement;
private opacityBarOuter: HTMLElement;
private opacityBarInner: HTMLElement;
private opacityIndicator: HTMLElement;
private selectedColorLabel: HTMLInputElement;
@@ -72,9 +68,6 @@ export class ColorPickerComponent extends ComponentBase {
this.hueBarOuter = containerElement.querySelector(
'.rio-color-picker-hue-bar'
)!;
this.hueBarInner = this.hueBarOuter.querySelector(
'.rio-color-slider-inner'
)!;
this.hueIndicator = this.hueBarOuter.querySelector(
'.rio-color-picker-knob'
)!;
@@ -82,9 +75,6 @@ export class ColorPickerComponent extends ComponentBase {
this.opacityBarOuter = containerElement.querySelector(
'.rio-color-picker-opacity-bar'
)!;
this.opacityBarInner = this.opacityBarOuter.querySelector(
'.rio-color-slider-inner'
)!;
this.opacityIndicator = this.opacityBarOuter.querySelector(
'.rio-color-picker-knob'
)!;
@@ -122,6 +112,8 @@ export class ColorPickerComponent extends ComponentBase {
deltaState: ColorPickerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Color
//
// Many combination of HSV values correspond to the same RGB color.
@@ -420,19 +412,4 @@ export class ColorPickerComponent extends ComponentBase {
color: this.state.color,
});
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 12;
}
updateAllocatedWidth(ctx: LayoutContext): void {}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = this.state.pick_opacity ? 16 : 12;
}
updateAllocatedHeight(ctx: LayoutContext): void {
// The knobs are positioned based on the component's size. Update them.
this.matchComponentToSelectedHsv();
}
}

View File

@@ -1,5 +1,4 @@
import { componentsById, getComponentByElement } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { callRemoteMethodDiscardResponse } from '../rpc';
import {
EventHandler,
@@ -9,7 +8,8 @@ import {
ClickHandler,
} from '../eventHandling';
import { DevToolsConnectorComponent } from './devToolsConnector';
import { ComponentId } from '../dataModels';
import { ComponentId, RioScrollBehavior } from '../dataModels';
import { insertWrapperElement, replaceElement } from '../utils';
/// Base for all component states. Updates received from the backend are
/// partial, hence most properties may be undefined.
@@ -28,6 +28,8 @@ export type ComponentState = {
_size_?: [number, number];
// Alignment of the component within its parent, if any
_align_?: [number | null, number | null];
// Scrolling behavior
_scroll_?: [RioScrollBehavior, RioScrollBehavior];
// Whether the component would like to receive additional space if there is
// any left over
_grow_?: [boolean, boolean];
@@ -53,19 +55,15 @@ export abstract class ComponentBase {
parent: ComponentBase | null = null;
children = new Set<ComponentBase>();
isLayoutDirty: boolean;
naturalWidth: number = 0;
naturalHeight: number = 0;
requestedWidth: number;
requestedHeight: number;
allocatedWidth: number = 0;
allocatedHeight: number = 0;
_eventHandlers = new Set<EventHandler>();
// Alignment requires an extra HTML element, which will be created on
// demand. So when a component is moved around in the DOM, make sure to use
// `outerElement` instead of `element`.
outerElement: HTMLElement;
private outerAlignElement: HTMLElement | null = null;
private innerAlignElement: HTMLElement | null = null;
constructor(id: ComponentId, state: Required<ComponentState>) {
this.id = id;
this.state = state;
@@ -73,25 +71,87 @@ export abstract class ComponentBase {
this.element = this.createElement();
this.element.classList.add('rio-component');
this.isLayoutDirty = true;
this.outerElement = this.element;
}
/// Mark this element's layout as dirty, and chain up to the parent.
makeLayoutDirty(): void {
let cur: ComponentBase | null = this;
/// Given a partial state update, this function updates the component's HTML
/// element to reflect the new state.
///
/// The `element` parameter is identical to `this.element`. It's passed as
/// an argument because it's more efficient than calling `this.element`.
updateElement(
deltaState: ComponentState,
latentComponents: Set<ComponentBase>
): void {
if (deltaState._margin_ !== undefined) {
this.element.style.marginLeft = `${deltaState._margin_[0]}rem`;
this.element.style.marginTop = `${deltaState._margin_[1]}rem`;
this.element.style.marginRight = `${deltaState._margin_[2]}rem`;
this.element.style.marginBottom = `${deltaState._margin_[3]}rem`;
}
while (cur !== null && !cur.isLayoutDirty) {
cur.isLayoutDirty = true;
cur = cur.parent;
if (deltaState._align_ !== undefined) {
this._updateAlign(deltaState._align_);
}
if (deltaState._scroll_ !== undefined) {
}
}
isInjectedLayoutComponent(): boolean {
// Injected layout components have negative ids
return this.id < 0;
private _updateAlign(align: [number | null, number | null]): void {
if (align[0] === null && align[1] === null) {
// Remove the alignElement if we have one
if (this.outerAlignElement !== null) {
replaceElement(this.outerAlignElement, this.element);
this.outerAlignElement.remove();
this.outerAlignElement = null;
}
} else {
// Create the alignElement if we don't have one already
if (this.outerAlignElement === null) {
this.innerAlignElement = insertWrapperElement(this.element);
this.outerAlignElement = insertWrapperElement(
this.innerAlignElement
);
this.innerAlignElement.classList.add('rio-align-inner');
this.outerAlignElement.classList.add('rio-align-outer');
this.innerAlignElement.dataset.ownerId = `${this.id}`;
this.outerAlignElement.dataset.ownerId = `${this.id}`;
this.outerElement = this.outerAlignElement;
}
let transform = '';
if (align[0] === null) {
this.innerAlignElement!.style.removeProperty('left');
this.innerAlignElement!.style.width = '100%';
this.innerAlignElement!.classList.add('stretch-child-x');
} else {
this.innerAlignElement!.style.left = `${align[0] * 100}%`;
this.innerAlignElement!.style.width = 'min-content';
this.innerAlignElement!.classList.remove('stretch-child-x');
transform += `translateX(-${align[0] * 100}%) `;
}
if (align[1] === null) {
this.innerAlignElement!.style.removeProperty('top');
this.innerAlignElement!.style.height = '100%';
this.innerAlignElement!.classList.add('stretch-child-y');
} else {
this.innerAlignElement!.style.top = `${align[1] * 100}%`;
this.innerAlignElement!.style.height = 'min-content';
this.innerAlignElement!.classList.remove('stretch-child-y');
transform += `translateY(-${align[1] * 100}%) `;
}
this.innerAlignElement!.style.transform = transform;
}
}
getParentExcludingInjected(): ComponentBase | null {
getParent(): ComponentBase | null {
let parent: ComponentBase | null = this.parent;
while (true) {
@@ -99,10 +159,6 @@ export abstract class ComponentBase {
return null;
}
if (!parent.isInjectedLayoutComponent()) {
return parent;
}
parent = parent.parent;
}
}
@@ -144,11 +200,9 @@ export abstract class ComponentBase {
// Add the child
let child = componentsById[childId]!;
parentElement.appendChild(child.element);
parentElement.appendChild(child.outerElement);
this.registerChild(latentComponents, child);
this.makeLayoutDirty();
}
/// Replaces the child of the given HTML element with the given child. The
@@ -173,8 +227,6 @@ export abstract class ComponentBase {
currentChildElement.remove();
child.unparent(latentComponents);
this.makeLayoutDirty();
}
console.assert(parentElement.firstElementChild === null);
@@ -201,8 +253,6 @@ export abstract class ComponentBase {
parentElement.appendChild(child.element);
this.registerChild(latentComponents, child);
this.makeLayoutDirty();
}
/// Replaces all children of the given HTML element with the given children.
@@ -221,8 +271,6 @@ export abstract class ComponentBase {
return;
}
let dirty = false;
let curElement = parentElement.firstElementChild;
let children = childIds.map((id) => componentsById[id]!);
let curIndex = 0;
@@ -235,6 +283,7 @@ export abstract class ComponentBase {
if (wrapInDivs) {
wrap = (element: HTMLElement) => {
let wrapper = document.createElement('div');
wrapper.classList.add('rio-child-wrapper');
wrapper.appendChild(element);
return wrapper;
};
@@ -252,10 +301,9 @@ export abstract class ComponentBase {
while (curIndex < children.length) {
let child = children[curIndex];
parentElement.appendChild(wrap(child.element));
parentElement.appendChild(wrap(child.outerElement));
this.registerChild(latentComponents, child);
dirty = true;
curIndex++;
}
break;
@@ -274,7 +322,6 @@ export abstract class ComponentBase {
child.unparent(latentComponents);
}
dirty = true;
curElement = nextElement;
}
break;
@@ -287,7 +334,6 @@ export abstract class ComponentBase {
if (childElement === null) {
let nextElement = curElement.nextElementSibling;
curElement.remove();
dirty = true;
curElement = nextElement;
continue;
}
@@ -303,15 +349,13 @@ export abstract class ComponentBase {
// This element is not the correct element, insert the correct one
// instead
parentElement.insertBefore(wrap(expectedChild.element), curElement);
parentElement.insertBefore(
wrap(expectedChild.outerElement),
curElement
);
this.registerChild(latentComponents, expectedChild);
curIndex++;
dirty = true;
}
if (dirty) {
this.makeLayoutDirty();
}
}
@@ -328,16 +372,6 @@ export abstract class ComponentBase {
}
}
/// Given a partial state update, this function updates the component's HTML
/// element to reflect the new state.
///
/// The `element` parameter is identical to `this.element`. It's passed as
/// an argument because it's more efficient than calling `this.element`.
abstract updateElement(
deltaState: ComponentState,
latentComponents: Set<ComponentBase>
): void;
/// Send a message to the python instance corresponding to this component. The
/// message is an arbitrary JSON object and will be passed to the instance's
/// `_on_message` method.
@@ -388,12 +422,6 @@ export abstract class ComponentBase {
return new DragHandler(this, args);
}
updateNaturalWidth(ctx: LayoutContext): void {}
updateNaturalHeight(ctx: LayoutContext): void {}
updateAllocatedWidth(ctx: LayoutContext): void {}
updateAllocatedHeight(ctx: LayoutContext): void {}
toString(): string {
let class_name = this.constructor.name;
return `<${class_name} id:${this.id}>`;

View File

@@ -1,6 +1,5 @@
import { componentsById, getRootScroller } from '../componentManagement';
import { componentsById } from '../componentManagement';
import { applyIcon } from '../designApplication';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { DevToolsConnectorComponent } from './devToolsConnector';
import { Highlighter } from '../highlighter';
@@ -61,7 +60,7 @@ export class ComponentTreeComponent extends ComponentBase {
deltaState: ComponentTreeState,
latentComponents: Set<ComponentBase>
): void {
console.debug('TREE', deltaState);
super.updateElement(deltaState, latentComponents);
if (deltaState.component_id !== undefined) {
// Highlight the tree item

View File

@@ -1,7 +1,6 @@
import { RippleEffect } from '../rippleEffect';
import { ComponentBase, ComponentState } from './componentBase';
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
const PADDING_X: number = 1.5;
@@ -28,6 +27,8 @@ export class CustomListItemComponent extends ComponentBase {
deltaState: CustomListItemState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the child
this.replaceOnlyChild(latentComponents, deltaState.content);
@@ -59,29 +60,4 @@ export class CustomListItemComponent extends ComponentBase {
type: 'press',
});
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth =
componentsById[this.state.content]!.requestedWidth + PADDING_X * 2;
}
updateAllocatedWidth(ctx: LayoutContext): void {
componentsById[this.state.content]!.allocatedWidth =
this.allocatedWidth - PADDING_X * 2;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight =
componentsById[this.state.content]!.requestedHeight + PADDING_Y * 2;
}
updateAllocatedHeight(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
child.allocatedHeight = this.allocatedHeight - PADDING_Y * 2;
// Position the child
let element = child.element;
element.style.left = `${PADDING_X}rem`;
element.style.top = `${PADDING_Y}rem`;
}
}

View File

@@ -1,5 +1,4 @@
import { ComponentId } from '../dataModels';
import { LayoutContext } from '../layouting';
import { ComponentBase, ComponentState } from './componentBase';
import { ComponentTreeComponent } from './componentTree';
import { LayoutDisplayComponent } from './layoutDisplay';
@@ -40,19 +39,6 @@ export class DevToolsConnectorComponent extends ComponentBase {
return element;
}
updateElement(
deltaState: DevToolsConnectorState,
latentComponents: Set<ComponentBase>
): void {}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 3;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 7;
}
/// Called when the state of any component changes. This allows the dev
/// tools to update their display.
public afterComponentStateChange(deltaStates: {

View File

@@ -2,7 +2,6 @@ import { pixelsPerRem } from '../app';
import { commitCss } from '../utils';
import { componentsById } from '../componentManagement';
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import { ColorSet, ComponentId } from '../dataModels';
import { applySwitcheroo } from '../designApplication';
@@ -78,6 +77,8 @@ export class DrawerComponent extends ComponentBase {
deltaState: DrawerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the children
this.replaceOnlyChild(
latentComponents,
@@ -99,8 +100,6 @@ export class DrawerComponent extends ComponentBase {
'rio-drawer-bottom'
);
this.element.classList.add(`rio-drawer-${deltaState.side}`);
this.makeLayoutDirty();
}
// Colorize
@@ -333,50 +332,4 @@ export class DrawerComponent extends ComponentBase {
this.closeDrawer();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
let anchorInst = componentsById[this.state.anchor]!;
let contentInst = componentsById[this.state.content]!;
this.naturalWidth = Math.max(
anchorInst.requestedWidth,
contentInst.requestedWidth
);
}
updateAllocatedWidth(ctx: LayoutContext): void {
let anchorInst = componentsById[this.state.anchor]!;
let contentInst = componentsById[this.state.content]!;
anchorInst.allocatedWidth = this.allocatedWidth;
if (this.state.side === 'left' || this.state.side === 'right') {
contentInst.allocatedWidth = contentInst.requestedWidth;
} else {
contentInst.allocatedWidth = this.allocatedWidth;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
let anchorInst = componentsById[this.state.anchor]!;
let contentInst = componentsById[this.state.content]!;
this.naturalHeight = Math.max(
anchorInst.requestedHeight,
contentInst.requestedHeight
);
}
updateAllocatedHeight(ctx: LayoutContext): void {
let anchorInst = componentsById[this.state.anchor]!;
let contentInst = componentsById[this.state.content]!;
anchorInst.allocatedHeight = this.allocatedHeight;
if (this.state.side === 'top' || this.state.side === 'bottom') {
contentInst.allocatedHeight = contentInst.requestedHeight;
} else {
contentInst.allocatedHeight = this.allocatedHeight;
}
}
}

View File

@@ -1,16 +1,6 @@
import { ComponentBase, ComponentState } from './componentBase';
import { applyIcon } from '../designApplication';
import {
updateInputBoxNaturalHeight,
updateInputBoxNaturalWidth,
} from '../inputBoxTools';
import { LayoutContext } from '../layouting';
import { pixelsPerRem, scrollBarSize } from '../app';
import { getTextDimensions } from '../layoutHelpers';
// Make sure this is in sync with the CSS
const RESERVED_WIDTH_FOR_ARROW = 1.3;
const DROPDOWN_LIST_HORIZONTAL_PADDING = 1;
import { pixelsPerRem } from '../app';
export type DropdownState = ComponentState & {
_type_: 'Dropdown-builtin';
@@ -33,8 +23,6 @@ export class DropdownComponent extends ComponentBase {
// The currently highlighted option, if any
private highlightedOptionElement: HTMLElement | null = null;
private longestOptionWidth: number = 0;
createElement(): HTMLElement {
// Create the elements
let element = document.createElement('div');
@@ -415,7 +403,6 @@ export class DropdownComponent extends ComponentBase {
}
match.classList.add('rio-dropdown-option');
match.style.padding = `0.6rem ${DROPDOWN_LIST_HORIZONTAL_PADDING}rem`;
this.optionsElement.appendChild(match);
match.addEventListener('mouseenter', () => {
@@ -456,18 +443,13 @@ export class DropdownComponent extends ComponentBase {
deltaState: DropdownState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
let element = this.element;
if (deltaState.optionNames !== undefined) {
this.state.optionNames = deltaState.optionNames;
this.longestOptionWidth = Math.max(
0,
...this.state.optionNames.map(
(option) => getTextDimensions(option, 'text')[0]
)
);
if (this.isOpen) {
this._updateOptionEntries();
}
@@ -478,9 +460,6 @@ export class DropdownComponent extends ComponentBase {
'.rio-input-box-label'
) as HTMLElement;
labelElement.textContent = deltaState.label;
// Update the layout
updateInputBoxNaturalHeight(this, deltaState.label, 0);
}
if (deltaState.selectedName !== undefined) {
@@ -510,23 +489,4 @@ export class DropdownComponent extends ComponentBase {
this.element.style.removeProperty('--rio-local-text-color');
}
}
updateNaturalWidth(ctx: LayoutContext): void {
updateInputBoxNaturalWidth(
this,
this.longestOptionWidth +
scrollBarSize +
RESERVED_WIDTH_FOR_ARROW +
2 * DROPDOWN_LIST_HORIZONTAL_PADDING
);
}
updateAllocatedWidth(ctx: LayoutContext): void {
this.popupElement.style.width = `${this.allocatedWidth}rem`;
}
updateNaturalHeight(ctx: LayoutContext): void {
// This is set during the updateElement() call, so there is nothing to
// do here.
}
}

View File

@@ -1,4 +1,3 @@
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { componentsById } from '../componentManagement';
@@ -24,23 +23,10 @@ export class FlowComponent extends ComponentBase {
deltaState: FlowState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the children
this.replaceChildren(latentComponents, deltaState.children);
// Regardless of whether the children or the spacing changed, a
// re-layout is required
this.makeLayoutDirty();
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 0;
for (let child of this.children) {
this.naturalWidth = Math.max(
this.naturalWidth,
child.requestedWidth
);
}
}
private processRow(row: ComponentBase[], rowWidth: number): void {
@@ -92,93 +78,4 @@ export class FlowComponent extends ComponentBase {
child.allocatedWidth = child.requestedWidth + spaceToGrow;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
// Allow the code below to assume there's at least one child
if (this.children.size === 0) {
return;
}
// Divide the children into rows
//
// For performance and simplicity, store the row index and x positions
// right inside the children.
let posX = 0;
let rowIndex = 0;
let currentRow: ComponentBase[] = [];
for (let childId of this.state.children) {
let child = componentsById[childId]!;
// If the child is too wide, move on to the next row
if (posX + child.requestedWidth > this.allocatedWidth) {
this.processRow(
currentRow,
Math.max(posX - this.state.column_spacing, 0)
);
posX = 0;
++rowIndex;
currentRow = [];
}
// Assign the child to the row
(child as any)._flowContainer_rowIndex = rowIndex;
(child as any)._flowContainer_posX = posX;
currentRow.push(child);
// Advance the position
posX += child.requestedWidth + this.state.column_spacing;
}
// Process the final row
this.processRow(currentRow, posX - this.state.column_spacing);
}
updateNaturalHeight(ctx: LayoutContext): void {
// Allow the code below to assume there's at least one child
if (this.children.size === 0) {
this.naturalHeight = 0;
return;
}
// Find the tallest child for each row
let rowHeights: number[] = [];
for (let child of this.children) {
let rowIndex = (child as any)._flowContainer_rowIndex;
let childHeight = child.requestedHeight;
if (rowHeights[rowIndex] === undefined) {
rowHeights[rowIndex] = childHeight;
} else {
rowHeights[rowIndex] = Math.max(
rowHeights[rowIndex],
childHeight
);
}
}
// Determine the total height
let rowTops: number[] = [0];
for (let ii = 0; ii < rowHeights.length; ii++) {
rowTops.push(rowTops[ii] + rowHeights[ii] + this.state.row_spacing);
}
this.naturalHeight = rowTops[rowTops.length - 1];
// Position the children
for (let child of this.children) {
let rowIndex = (child as any)._flowContainer_rowIndex;
let rowTop = rowTops[rowIndex];
let childHeight = rowHeights[rowIndex];
child.element.style.top = `${rowTop}rem`;
// child.element.style.height = `${childHeight}rem`;
child.allocatedHeight = childHeight;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {}
}

View File

@@ -1,6 +1,3 @@
import { pixelsPerRem } from '../app';
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { setConnectionLostPopupVisibleUnlessGoingAway } from '../rpc';
import { ComponentBase, ComponentState } from './componentBase';
@@ -15,16 +12,10 @@ export type FundamentalRootComponentState = ComponentState & {
export class FundamentalRootComponent extends ComponentBase {
state: Required<FundamentalRootComponentState>;
// The width and height for any components that want to span the entire
// screen and not scroll. This differs from just the window width/height,
// because the dev tools can also take up space and doesn't count as part of
// the user's app.
public overlayWidth: number = 0;
public overlayHeight: number = 0;
createElement(): HTMLElement {
let element = document.createElement('div');
element.classList.add('rio-fundamental-root-component');
return element;
}
@@ -32,6 +23,8 @@ export class FundamentalRootComponent extends ComponentBase {
deltaState: FundamentalRootComponentState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the children
let content = deltaState.content ?? this.state.content;
let connectionLostComponent =
@@ -75,91 +68,5 @@ export class FundamentalRootComponent extends ComponentBase {
),
0
);
this.makeLayoutDirty();
}
updateNaturalWidth(ctx: LayoutContext): void {
// Don't use `window.innerWidth`. It appears to be rounded to the
// nearest integer, so it's inaccurate.
//
// `getBoundingClientRect()` doesn't account for scroll bars, but our
// <html> element is set to `overflow: hidden` anyway, so that's not an
// issue.
let rect = document.documentElement.getBoundingClientRect();
this.naturalWidth = this.allocatedWidth = rect.width / pixelsPerRem;
this.naturalHeight = this.allocatedHeight = rect.height / pixelsPerRem;
}
updateAllocatedWidth(ctx: LayoutContext): void {
// Overlays take up the full window
this.overlayWidth = this.allocatedWidth;
// If the dev tools are visible, account for that
if (this.state.dev_tools !== null) {
let devToolsComponent = componentsById[this.state.dev_tools]!;
devToolsComponent.allocatedWidth = devToolsComponent.requestedWidth;
// Even if dev tools are provided, only display them if the screen
// is wide enough. Having them show up on a tall mobile screen is
// very awkward.
//
// Since the allocated height isn't available here yet, use the
// window size instead.
let screenWidth = window.innerWidth / pixelsPerRem;
let screenHeight = window.innerHeight / pixelsPerRem;
if (screenWidth > 50 && screenHeight > 30) {
devToolsComponent.element.style.removeProperty('display');
this.element.classList.remove('rio-dev-tools-hidden');
this.overlayWidth -= devToolsComponent.allocatedWidth;
} else {
devToolsComponent.element.style.display = 'none';
this.element.classList.add('rio-dev-tools-hidden');
}
}
// The child receives the remaining width. (The child is a
// ScrollContainer, it takes care of scrolling if the user content is
// too large)
let child = componentsById[this.state.content]!;
child.allocatedWidth = this.overlayWidth;
// Despite being an overlay, the connection lost popup should also cover
// the dev tools
let connectionLostPopup =
componentsById[this.state.connection_lost_component]!;
connectionLostPopup.allocatedWidth = this.allocatedWidth;
}
updateNaturalHeight(ctx: LayoutContext): void {
// Already done in updateNaturalWidth
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Overlays take up the full window
this.overlayHeight = this.allocatedHeight;
// If dev tools are present, set their height and position it
if (this.state.dev_tools !== null) {
let dbgInst = componentsById[this.state.dev_tools]!;
dbgInst.allocatedHeight = this.overlayHeight;
// Position it
let dbgElement = dbgInst.element;
dbgElement.style.left = `${this.overlayWidth}rem`;
dbgElement.style.top = '0';
}
// The connection lost popup is an overlay
let connectionLostPopup =
componentsById[this.state.connection_lost_component]!;
connectionLostPopup.allocatedHeight = this.overlayHeight;
// The child once again receives the remaining width. (The child is a
// ScrollContainer, it takes care of scrolling if the user content is
// too large)
let child = componentsById[this.state.content]!;
child.allocatedHeight = this.overlayHeight;
}
}

View File

@@ -1,5 +1,4 @@
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { range } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
@@ -134,6 +133,8 @@ export class GridComponent extends ComponentBase {
deltaState: GridState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
let element = this.element;
if (deltaState._children !== undefined) {
@@ -148,183 +149,5 @@ export class GridComponent extends ComponentBase {
if (deltaState.column_spacing !== undefined) {
element.style.columnGap = `${deltaState.column_spacing}rem`;
}
this.makeLayoutDirty();
}
updateNaturalWidth(ctx: LayoutContext): void {
this.columnNaturalWidths = new Array(this.nColumns).fill(0);
// Determine the width of each column
for (let gridChild of this.childrenByNumberOfColumns) {
let childInstance = componentsById[gridChild.id]!;
// How much of the child's width can the columns already
// accommodate?
let availableWidthTotal: number =
(gridChild.width - 1) * this.state.column_spacing;
let growCols: number[] = [];
for (let ii of gridChild.allColumns) {
let columnWidth = this.columnNaturalWidths[ii];
availableWidthTotal += columnWidth;
if (this.growingColumns.has(ii)) {
growCols.push(ii);
}
}
let neededSpace =
childInstance.requestedWidth - availableWidthTotal;
// The columns have enough free space
if (neededSpace <= 0) {
continue;
}
// Expand the columns
let targetColumns =
growCols.length > 0 ? growCols : gridChild.allColumns;
let spacePerColumn = neededSpace / targetColumns.length;
for (let column of targetColumns) {
this.columnNaturalWidths[column] += spacePerColumn;
}
}
// Sum over all widths
this.naturalWidth = this.state.column_spacing * (this.nColumns - 1);
for (let columnWidth of this.columnNaturalWidths) {
this.naturalWidth += columnWidth;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
// Determine the width of each column
let additionalSpace = this.allocatedWidth - this.naturalWidth;
let targetColumns =
this.growingColumns.size > 0
? this.growingColumns
: new Set(range(0, this.nColumns));
let spacePerColumn = additionalSpace / targetColumns.size;
this.columnAllocatedWidths = [...this.columnNaturalWidths];
for (let column of targetColumns) {
this.columnAllocatedWidths[column] += spacePerColumn;
}
// Pass on the space to the children
for (let gridChild of this.childrenByNumberOfColumns) {
let childInstance = componentsById[gridChild.id]!;
childInstance.allocatedWidth =
this.state.column_spacing * (gridChild.width - 1);
for (let column of gridChild.allColumns) {
childInstance.allocatedWidth +=
this.columnAllocatedWidths[column];
}
}
}
updateNaturalHeight(ctx: LayoutContext): void {
this.rowNaturalHeights = new Array(this.nRows).fill(0);
// Determine the height of each row
for (let gridChild of this.childrenByNumberOfRows) {
let childInstance = componentsById[gridChild.id]!;
// How much of the child's height can the rows already accommodate?
let availableHeightTotal: number =
(gridChild.height - 1) * this.state.row_spacing;
let growRows: number[] = [];
for (let ii of gridChild.allRows) {
let rowHeight = this.rowNaturalHeights[ii];
availableHeightTotal += rowHeight;
if (this.growingRows.has(ii)) {
growRows.push(ii);
}
}
let neededSpace =
childInstance.requestedHeight - availableHeightTotal;
// The rows have enough free space
if (neededSpace <= 0) {
continue;
}
// Expand the rows
let targetRows = growRows.length > 0 ? growRows : gridChild.allRows;
let spacePerRow = neededSpace / targetRows.length;
for (let row of targetRows) {
this.rowNaturalHeights[row] += spacePerRow;
}
}
// Sum over all heights
this.naturalHeight = this.state.row_spacing * (this.nRows - 1);
for (let rowHeight of this.rowNaturalHeights) {
this.naturalHeight += rowHeight;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Determine the height of each row
let additionalSpace = this.allocatedHeight - this.naturalHeight;
let targetRows =
this.growingRows.size > 0
? this.growingRows
: new Set(range(0, this.nRows));
let spacePerRow = additionalSpace / targetRows.size;
this.rowAllocatedHeights = [...this.rowNaturalHeights];
for (let row of targetRows) {
this.rowAllocatedHeights[row] += spacePerRow;
}
// Precompute position data
let columnWidthCumSum: number[] = [0];
for (let columnWidth of this.columnAllocatedWidths) {
columnWidthCumSum.push(
columnWidthCumSum[columnWidthCumSum.length - 1] +
columnWidth +
this.state.column_spacing
);
}
let rowHeightCumSum: number[] = [0];
for (let rowHeight of this.rowAllocatedHeights) {
rowHeightCumSum.push(
rowHeightCumSum[rowHeightCumSum.length - 1] +
rowHeight +
this.state.row_spacing
);
}
// Pass on the space to the children
for (let gridChild of this.childrenByNumberOfRows) {
let childInstance = componentsById[gridChild.id]!;
childInstance.allocatedHeight =
this.state.row_spacing * (gridChild.height - 1);
for (let row of gridChild.allRows) {
childInstance.allocatedHeight += this.rowAllocatedHeights[row];
}
// Set everybody's position
let childElement = componentsById[gridChild.id]!.element;
childElement.style.left = `${
columnWidthCumSum[gridChild.column]
}rem`;
childElement.style.top = `${rowHeightCumSum[gridChild.row]}rem`;
}
}
}

View File

@@ -1,12 +1,5 @@
import { ComponentBase, ComponentState } from './componentBase';
import { textStyleToCss } from '../cssUtils';
import { getTextDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
const PADDING_LEFT: number = 1.0;
const PADDING_TOP: number = 1.3;
const PADDING_RIGHT: number = 1.0;
const PADDING_BOTTOM: number = 0.3;
export type HeadingListItemState = ComponentState & {
_type_: 'HeadingListItem-builtin';
@@ -16,9 +9,6 @@ export type HeadingListItemState = ComponentState & {
export class HeadingListItemComponent extends ComponentBase {
state: Required<HeadingListItemState>;
private textWidth: number;
private textHeight: number;
createElement(): HTMLElement {
// Create the element
let element = document.createElement('div');
@@ -29,12 +19,6 @@ export class HeadingListItemComponent extends ComponentBase {
// duplicate code.
Object.assign(element.style, textStyleToCss('heading3'));
// Apply the padding
element.style.paddingLeft = `${PADDING_LEFT}rem`;
element.style.paddingTop = `${PADDING_TOP}rem`;
element.style.paddingRight = `${PADDING_RIGHT}rem`;
element.style.paddingBottom = `${PADDING_BOTTOM}rem`;
return element;
}
@@ -42,28 +26,10 @@ export class HeadingListItemComponent extends ComponentBase {
deltaState: HeadingListItemState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.text !== undefined) {
this.element.textContent = deltaState.text;
// Cache the text's dimensions
[this.textWidth, this.textHeight] = getTextDimensions(
deltaState.text,
'heading3'
);
this.makeLayoutDirty();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = PADDING_LEFT + this.textWidth + PADDING_RIGHT;
}
updateAllocatedWidth(ctx: LayoutContext): void {}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = PADDING_TOP + this.textHeight + PADDING_BOTTOM;
}
updateAllocatedHeight(ctx: LayoutContext): void {}
}

View File

@@ -1,6 +1,3 @@
import { getElementDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { firstDefined } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
export type HtmlState = ComponentState & {
@@ -57,6 +54,8 @@ export class HtmlComponent extends ComponentBase {
deltaState: HtmlState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.html !== undefined) {
// If the HTML hasn't actually changed from last time, don't do
// anything. This is important so scripts don't get re-executed each
@@ -71,12 +70,6 @@ export class HtmlComponent extends ComponentBase {
// Just setting the innerHTML doesn't run scripts. Do that manually.
this.runScriptsInElement(this.containerElement);
// Determine the dimensions of the HTML element
[this.naturalWidth, this.naturalHeight] = getElementDimensions(
this.containerElement
);
this.makeLayoutDirty();
}
}
}

View File

@@ -8,7 +8,6 @@ import {
import { ComponentBase, ComponentState } from './componentBase';
import { applyFillToSVG, applyIcon } from '../designApplication';
import { pixelsPerRem } from '../app';
import { LayoutContext } from '../layouting';
export type IconState = ComponentState & {
_type_: 'Icon-builtin';

View File

@@ -39,6 +39,8 @@ export class ImageComponent extends ComponentBase {
deltaState: ImageState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
let imgElement = this.imageElement;
if (

View File

@@ -706,6 +706,8 @@ export class KeyEventListenerComponent extends SingleContainer {
deltaState: KeyEventListenerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
let element = this.element;
let reportKeyDown = firstDefined(

View File

@@ -1,5 +1,4 @@
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import { componentsById } from '../componentManagement';
import { pixelsPerRem } from '../app';
import { getDisplayableChildren } from '../devToolsTreeWalk';
@@ -73,7 +72,7 @@ export class LayoutDisplayComponent extends ComponentBase {
return;
}
let parentComponent = targetComponent.getParentExcludingInjected();
let parentComponent = targetComponent.getParent();
if (parentComponent === null) {
return;
}
@@ -106,11 +105,10 @@ export class LayoutDisplayComponent extends ComponentBase {
deltaState: LayoutDisplayState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Has the target component changed?
if (deltaState.component_id !== undefined) {
// Trigger a re-layout
this.makeLayoutDirty();
// Update the content
//
// This is necessary because the layout update may not trigger a
@@ -181,49 +179,6 @@ export class LayoutDisplayComponent extends ComponentBase {
this.onChangeLimiter.call();
}
updateNaturalHeight(ctx: LayoutContext): void {
// This component doesn't particularly care about its size. However, it
// would be nice to have the correct aspect ratio.
//
// It's probably not remotely legal to access the natural width of
// another component during layouting, but what the heck. This doesn't
// do anything other than _attempting_ to get the correct aspect ratio.
// Without this we're guaranteed to get a wrong one.
let targetComponent: ComponentBase =
componentsById[this.state.component_id];
if (targetComponent === undefined) {
this.naturalHeight = 0;
return;
}
let parentComponent = targetComponent.getParentExcludingInjected();
if (parentComponent === null || parentComponent.allocatedWidth === 0) {
this.naturalHeight = 0;
} else {
this.naturalHeight =
(this.allocatedWidth * parentComponent.allocatedHeight) /
parentComponent.allocatedWidth;
}
// With all of that said, never request more than the max requested
// height
if (this.state.max_requested_height !== null) {
this.naturalHeight = Math.min(
this.naturalHeight,
this.state.max_requested_height
);
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Let the code below assume that we have a reasonable size
if (this.allocatedWidth === 0 || this.allocatedHeight === 0) {
return;
}
}
updateContent(): void {
// Remove any previous content
this.parentElement.innerHTML = '';
@@ -241,7 +196,7 @@ export class LayoutDisplayComponent extends ComponentBase {
}
// Look up the parent
let parentComponent = targetComponent.getParentExcludingInjected();
let parentComponent = targetComponent.getParent();
let parentLayout: number[];
if (parentComponent === null) {
@@ -433,7 +388,7 @@ export class LayoutDisplayComponent extends ComponentBase {
return;
}
let parentComponent = targetComponent.getParentExcludingInjected();
let parentComponent = targetComponent.getParent();
if (parentComponent === null) {
this.highlighter.moveTo(null);

View File

@@ -1,5 +1,4 @@
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
@@ -10,7 +9,7 @@ export type LinearContainerState = ComponentState & {
proportions?: 'homogeneous' | number[] | null;
};
class LinearContainer extends ComponentBase {
abstract class LinearContainer extends ComponentBase {
state: Required<LinearContainerState>;
protected nGrowers: number; // Number of children that grow in the major axis
@@ -27,19 +26,36 @@ class LinearContainer extends ComponentBase {
deltaState: LinearContainerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Children
if (deltaState.children !== undefined) {
this.replaceChildren(
latentComponents,
deltaState.children,
this.element
this.element,
true
);
// Clear everybody's position
for (let childElement of this.element
.children as Iterable<HTMLElement>) {
childElement.style.left = '0';
childElement.style.top = '0';
// Set the children's `flex-grow`
let hasGrowers = false;
for (let [index, childId] of deltaState.children.entries()) {
let childComponent = componentsById[childId]!;
let childWrapper = this.element.children[index] as HTMLElement;
if (this.getGrow(childComponent)) {
hasGrowers = true;
childWrapper.style.flexGrow = '1';
} else {
childWrapper.style.flexGrow = '0';
}
}
// If nobody wants to grow, all of them do
if (!hasGrowers) {
for (let childWrapper of this.element.children) {
(childWrapper as HTMLElement).style.flexGrow = '1';
}
}
}
@@ -54,118 +70,24 @@ class LinearContainer extends ComponentBase {
deltaState.proportions === null
) {
} else if (deltaState.proportions === 'homogeneous') {
throw new Error('not implemented yet');
this.totalProportions = this.children.size;
} else {
throw new Error('not implemented yet');
this.totalProportions = deltaState.proportions.reduce(
(a, b) => a + b
);
}
// Re-layout
this.makeLayoutDirty();
}
/// Returns whether the given component grows in the direction of the
/// container
abstract getGrow(childComponent: ComponentBase): boolean;
}
export class RowComponent extends LinearContainer {
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.proportions === null) {
this.naturalWidth = 0;
this.nGrowers = 0;
// Add up all children's requested widths
for (let child of this.children) {
this.naturalWidth += child.requestedWidth;
this.nGrowers += child.state._grow_[0] as any as number;
}
} else {
// When proportions are set, growers are ignored. Extra space is
// distributed among all children.
// Each child has a requested width and a proportion number, which
// essentially "cuts" the child into a certain number of equally
// sized pieces. In order to find our natural width, we need to
// determine the width of the largest piece, then multiply that by
// the number of total pieces.
let proportions =
this.state.proportions === 'homogeneous'
? Array(this.children.size).fill(1)
: this.state.proportions;
let maxProportionSize = 0;
for (let i = 0; i < proportions.length; i++) {
let child = componentsById[this.state.children[i]]!;
let proportion = proportions[i];
if (proportion !== 0) {
let proportionSize = child.requestedWidth / proportion;
maxProportionSize = Math.max(
maxProportionSize,
proportionSize
);
}
}
this.naturalWidth = maxProportionSize * this.totalProportions;
}
// Account for spacing
this.naturalWidth +=
Math.max(this.children.size - 1, 0) * this.state.spacing;
}
updateAllocatedWidth(ctx: LayoutContext): void {
if (this.state.proportions === null) {
// If no child wants to grow, we pretend that all of them do.
let forceGrow = this.nGrowers === 0;
let nGrowers = forceGrow
? this.state.children.length
: this.nGrowers;
let additionalSpace = this.allocatedWidth - this.naturalWidth;
let additionalSpacePerGrower = additionalSpace / nGrowers;
for (let child of this.children) {
child.allocatedWidth = child.requestedWidth;
if (child.state._grow_[0] || forceGrow) {
child.allocatedWidth += additionalSpacePerGrower;
}
}
} else {
let proportions =
this.state.proportions === 'homogeneous'
? Array(this.children.size).fill(1)
: this.state.proportions;
let spacing =
Math.max(this.children.size - 1, 0) * this.state.spacing;
let proportionSize =
(this.allocatedWidth - spacing) / this.totalProportions;
for (let i = 0; i < proportions.length; i++) {
let child = componentsById[this.state.children[i]]!;
child.allocatedWidth = proportionSize * proportions[i];
}
}
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 0;
for (let child of this.children) {
this.naturalHeight = Math.max(
this.naturalHeight,
child.requestedHeight
);
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Assign the allocated height to the children
for (let child of this.children) {
child.allocatedHeight = this.allocatedHeight;
}
getGrow(childComponent: ComponentBase): boolean {
return childComponent.state._grow_[0];
}
}
@@ -176,105 +98,7 @@ export class ColumnComponent extends LinearContainer {
return element;
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 0;
for (let child of this.children) {
this.naturalWidth = Math.max(
this.naturalWidth,
child.requestedWidth
);
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
// Assign the allocated width to the children
for (let child of this.children) {
child.allocatedWidth = this.allocatedWidth;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
if (this.state.proportions === null) {
this.naturalHeight = 0;
this.nGrowers = 0;
// Add up all children's requested heights
for (let child of this.children) {
this.naturalHeight += child.requestedHeight;
this.nGrowers += child.state._grow_[1] as any as number;
}
} else {
// When proportions are set, growers are ignored. Extra space is
// distributed among all children.
// Each child has a requested width and a proportion number, which
// essentially "cuts" the child into a certain number of equally
// sized pieces. In order to find our natural width, we need to
// determine the width of the largest piece, then multiply that by
// the number of total pieces.
let proportions =
this.state.proportions === 'homogeneous'
? Array(this.children.size).fill(1)
: this.state.proportions;
let maxProportionSize = 0;
for (let i = 0; i < proportions.length; i++) {
let child = componentsById[this.state.children[i]]!;
let proportion = proportions[i];
if (proportion !== 0) {
let proportionSize = child.requestedHeight / proportion;
maxProportionSize = Math.max(
maxProportionSize,
proportionSize
);
}
}
this.naturalHeight = maxProportionSize * this.totalProportions;
}
// Account for spacing
this.naturalHeight +=
Math.max(this.children.size - 1, 0) * this.state.spacing;
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Assign the allocated height to the children
if (this.state.proportions === null) {
// If no child wants to grow, we pretend that all of them do.
let forceGrow = this.nGrowers === 0;
let nGrowers = forceGrow
? this.state.children.length
: this.nGrowers;
let additionalSpace = this.allocatedHeight - this.naturalHeight;
let additionalSpacePerGrower = additionalSpace / nGrowers;
for (let child of this.children) {
child.allocatedHeight = child.requestedHeight;
if (child.state._grow_[1] || forceGrow) {
child.allocatedHeight += additionalSpacePerGrower;
}
}
} else {
let proportions =
this.state.proportions === 'homogeneous'
? Array(this.children.size).fill(1)
: this.state.proportions;
let spacing =
Math.max(this.children.size - 1, 0) * this.state.spacing;
let proportionSize =
(this.allocatedHeight - spacing) / this.totalProportions;
for (let i = 0; i < proportions.length; i++) {
let child = componentsById[this.state.children[i]]!;
child.allocatedHeight = proportionSize * proportions[i];
}
}
getGrow(childComponent: ComponentBase): boolean {
return childComponent.state._grow_[1];
}
}

View File

@@ -1,9 +1,7 @@
import { componentsById } from '../componentManagement';
import { getTextDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { hijackLinkElement, navigateToUrl } from '../utils';
import { hijackLinkElement } from '../utils';
export type LinkState = ComponentState & {
_type_: 'Link-builtin';
@@ -50,6 +48,8 @@ export class LinkComponent extends ComponentBase {
deltaState: LinkState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
let element = this.element as HTMLAnchorElement;
// Child Text?
@@ -96,43 +96,4 @@ export class LinkComponent extends ComponentBase {
element.target = '';
}
}
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.child_component === null) {
[this.naturalWidth, this.naturalHeight] = getTextDimensions(
this.state.child_text!,
'text'
);
} else {
this.naturalWidth =
componentsById[this.state.child_component]!.requestedWidth;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
if (this.state.child_component !== null) {
componentsById[this.state.child_component]!.allocatedWidth =
this.allocatedWidth;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
if (this.state.child_component === null) {
// Already set in updateRequestedWidth
} else {
this.naturalHeight =
componentsById[this.state.child_component]!.requestedHeight;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
if (this.state.child_component !== null) {
componentsById[this.state.child_component]!.allocatedHeight =
this.allocatedHeight;
let element = componentsById[this.state.child_component]!.element;
element.style.left = '0';
element.style.top = '0';
}
}
}

View File

@@ -23,6 +23,8 @@ export class ListViewComponent extends ColumnComponent {
deltaState: LinearContainerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Columns don't wrap their children in divs, but ListView does. Hence
// the overridden updateElement.
this.replaceChildren(

View File

@@ -1,72 +0,0 @@
import { ComponentBase, ComponentState } from './componentBase';
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
export type MarginState = ComponentState & {
_type_: 'Margin-builtin';
content?: ComponentId;
margin_left?: number;
margin_top?: number;
margin_right?: number;
margin_bottom?: number;
};
export class MarginComponent extends ComponentBase {
state: Required<MarginState>;
createElement(): HTMLElement {
let element = document.createElement('div');
return element;
}
updateElement(
deltaState: MarginState,
latentComponents: Set<ComponentBase>
): void {
this.replaceOnlyChild(latentComponents, deltaState.content);
if (
deltaState.margin_left !== undefined ||
deltaState.margin_top !== undefined ||
deltaState.margin_right !== undefined ||
deltaState.margin_bottom !== undefined
) {
this.makeLayoutDirty();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth =
componentsById[this.state.content]!.requestedWidth +
this.state.margin_left +
this.state.margin_right;
}
updateAllocatedWidth(ctx: LayoutContext): void {
let childInstance = componentsById[this.state.content]!;
childInstance.allocatedWidth =
this.allocatedWidth -
this.state.margin_left -
this.state.margin_right;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight =
componentsById[this.state.content]!.requestedHeight +
this.state.margin_top +
this.state.margin_bottom;
}
updateAllocatedHeight(ctx: LayoutContext): void {
let childInstance = componentsById[this.state.content]!;
childInstance.allocatedHeight =
this.allocatedHeight -
this.state.margin_top -
this.state.margin_bottom;
let childElement = childInstance.element;
childElement.style.left = `${this.state.margin_left}rem`;
childElement.style.top = `${this.state.margin_top}rem`;
}
}

View File

@@ -7,8 +7,6 @@ import { micromark } from 'micromark';
// https://github.com/highlightjs/highlight.js#importing-the-library
import hljs from 'highlight.js/lib/common';
import { LayoutContext } from '../layouting';
import { getElementHeight, getElementWidth } from '../layoutHelpers';
import { firstDefined, hijackLinkElement } from '../utils';
import { convertDivToCodeBlock } from './codeBlock';
@@ -130,6 +128,8 @@ export class MarkdownComponent extends ComponentBase {
deltaState: MarkdownState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.text !== undefined) {
// Create a new div to hold the markdown content. This is so the
// layouting code can move it around as needed.
@@ -139,31 +139,6 @@ export class MarkdownComponent extends ComponentBase {
);
convertMarkdown(deltaState.text, this.element, defaultLanguage);
// Update the width request
//
// For some reason the element takes up the whole parent's width
// without explicitly setting its width
this.element.style.width = 'min-content';
this.naturalWidth = getElementWidth(this.element);
// Any previously calculated height request is no longer valid
this.heightRequestAssumesWidth = -1;
this.makeLayoutDirty();
}
}
updateNaturalHeight(ctx: LayoutContext): void {
// Is the previous height request still value?
if (this.heightRequestAssumesWidth === this.allocatedWidth) {
return;
}
// No, re-layout
this.element.style.height = 'min-content';
this.naturalHeight = getElementHeight(this.element);
this.heightRequestAssumesWidth = this.allocatedWidth;
}
updateAllocatedHeight(ctx: LayoutContext): void {}
}

View File

@@ -1,6 +1,5 @@
import { fillToCss } from '../cssUtils';
import { applyIcon } from '../designApplication';
import { LayoutContext } from '../layouting';
import { AnyFill } from '../dataModels';
import { sleep } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
@@ -541,6 +540,8 @@ export class MediaPlayerComponent extends ComponentBase {
deltaState: MediaPlayerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.mediaUrl !== undefined) {
let mediaUrl = new URL(deltaState.mediaUrl, document.location.href)
.href;
@@ -846,12 +847,4 @@ export class MediaPlayerComponent extends ComponentBase {
this.mediaPlayer.src = '';
this.mediaPlayer.load();
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 16;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 5;
}
}

View File

@@ -70,6 +70,8 @@ export class MouseEventListenerComponent extends SingleContainer {
deltaState: MouseEventListenerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceOnlyChild(latentComponents, deltaState.content);
if (deltaState.reportPress) {

View File

@@ -1,9 +1,4 @@
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import {
updateInputBoxNaturalHeight,
updateInputBoxNaturalWidth,
} from '../inputBoxTools';
export type MultiLineTextInputState = ComponentState & {
_type_: 'MultiLineTextInput-builtin';
@@ -83,15 +78,14 @@ export class MultiLineTextInputComponent extends ComponentBase {
deltaState: MultiLineTextInputState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.text !== undefined) {
this.inputElement.value = deltaState.text;
}
if (deltaState.label !== undefined) {
this.labelElement.textContent = deltaState.label;
// Update the layout
updateInputBoxNaturalHeight(this, deltaState.label, 0);
}
if (deltaState.is_sensitive === true) {
@@ -115,13 +109,4 @@ export class MultiLineTextInputComponent extends ComponentBase {
grabKeyboardFocus(): void {
this.inputElement.focus();
}
updateNaturalWidth(ctx: LayoutContext): void {
updateInputBoxNaturalWidth(this, 0);
}
updateNaturalHeight(ctx: LayoutContext): void {
// This is set during the updateElement() call, so there is nothing to
// do here.
}
}

View File

@@ -1,7 +1,5 @@
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import { Color } from '../dataModels';
import { getTextDimensions } from '../layoutHelpers';
import { colorToCssString } from '../cssUtils';
export type NodeInputState = ComponentState & {
@@ -41,14 +39,11 @@ export class NodeInputComponent extends ComponentBase {
deltaState: NodeInputState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Name
if (deltaState.name !== undefined) {
this.textElement.textContent = deltaState.name;
// Cache the dimensions
let textDimensions = getTextDimensions(deltaState.name, 'text');
this.naturalWidth = textDimensions[0];
this.naturalHeight = textDimensions[1];
}
// Color

View File

@@ -1,7 +1,5 @@
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import { Color } from '../dataModels';
import { getTextDimensions } from '../layoutHelpers';
import { colorToCssString } from '../cssUtils';
export type NodeOutputState = ComponentState & {
@@ -41,14 +39,11 @@ export class NodeOutputComponent extends ComponentBase {
deltaState: NodeOutputState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Name
if (deltaState.name !== undefined) {
this.textElement.textContent = deltaState.name;
// Cache the dimensions
let textDimensions = getTextDimensions(deltaState.name, 'text');
this.naturalWidth = textDimensions[0];
this.naturalHeight = textDimensions[1];
}
// Color

View File

@@ -1,5 +1,4 @@
import { componentsById, getRootComponent } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
@@ -16,20 +15,6 @@ export class OverlayComponent extends ComponentBase {
createElement(): HTMLElement {
let element = document.createElement('div');
element.classList.add('rio-overlay');
// When the window is resized, we need re-layouting. This isn't
// guaranteed to happen automatically, because if a parent component's
// size doesn't change, then its children won't be re-layouted. So we
// have to explicitly listen for the resize event and mark ourselves as
// dirty.
//
// The `capture: true` is there to ensure that we're already marked as
// dirty when the re-layout is triggered.
this.onWindowResize = this.makeLayoutDirty.bind(this);
window.addEventListener('resize', this.onWindowResize, {
capture: true,
});
return element;
}
@@ -41,25 +26,8 @@ export class OverlayComponent extends ComponentBase {
deltaState: OverlayState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceOnlyChild(latentComponents, deltaState.content);
}
updateAllocatedWidth(ctx: LayoutContext): void {
// The root component keeps track of the correct overlay size. Take it
// from there. To heck with what the parent says.
let root = getRootComponent();
componentsById[this.state.content]!.allocatedWidth = root.overlayWidth;
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Honor the global overlay height.
let root = getRootComponent();
componentsById[this.state.content]!.allocatedHeight =
root.overlayHeight;
// Position the child
let element = componentsById[this.state.content]!.element;
element.style.left = '0';
element.style.top = '0';
}
}

View File

@@ -11,13 +11,17 @@ export class PlaceholderComponent extends SingleContainer {
state: Required<PlaceholderState>;
createElement(): HTMLElement {
return document.createElement('div');
let element = document.createElement('div');
element.classList.add('rio-placeholder');
return element;
}
updateElement(
deltaState: PlaceholderState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceOnlyChild(latentComponents, deltaState._child_);
}
}

View File

@@ -1,6 +1,5 @@
import { pixelsPerRem } from '../app';
import { fillToCss } from '../cssUtils';
import { LayoutContext } from '../layouting';
import { AnyFill } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
@@ -62,6 +61,8 @@ export class PlotComponent extends ComponentBase {
deltaState: PlotState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.plot !== undefined) {
this.element.innerHTML = '';
@@ -123,14 +124,4 @@ export class PlotComponent extends ComponentBase {
}
);
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Plotly is too dumb to layout itself. Help out.
if (
this.state.plot.type === 'plotly' &&
window['Plotly'] !== undefined
) {
this.updatePlotlyLayout();
}
}
}

View File

@@ -1,7 +1,5 @@
import { pixelsPerRem } from '../app';
import { componentsById } from '../componentManagement';
import { applySwitcheroo } from '../designApplication';
import { LayoutContext } from '../layouting';
import { ColorSet, ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { PopupManager } from '../popupManager';
@@ -55,6 +53,8 @@ export class PopupComponent extends ComponentBase {
deltaState: PopupState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the children
this.replaceOnlyChild(
latentComponents,
@@ -105,38 +105,4 @@ export class PopupComponent extends ComponentBase {
this.popupManager.destroy();
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = componentsById[this.state.anchor]!.requestedWidth;
}
updateAllocatedWidth(ctx: LayoutContext): void {
let anchorInst = componentsById[this.state.anchor]!;
anchorInst.allocatedWidth = this.allocatedWidth;
let contentInst = componentsById[this.state.content]!;
contentInst.allocatedWidth = contentInst.requestedWidth;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = componentsById[this.state.anchor]!.requestedHeight;
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Pass on the allocated space
let anchorInst = componentsById[this.state.anchor]!;
anchorInst.allocatedHeight = this.allocatedHeight;
let contentInst = componentsById[this.state.content]!;
contentInst.allocatedHeight = contentInst.requestedHeight;
// And position the children
let anchorElem = anchorInst.element;
anchorElem.style.left = '0';
anchorElem.style.top = '0';
let contentElem = contentInst.element;
contentElem.style.left = '0';
contentElem.style.top = '0';
}
}

View File

@@ -1,6 +1,5 @@
import { ColorSet } from '../dataModels';
import { applySwitcheroo } from '../designApplication';
import { LayoutContext } from '../layouting';
import { ComponentBase, ComponentState } from './componentBase';
export type ProgressBarState = ComponentState & {
@@ -37,6 +36,8 @@ export class ProgressBarComponent extends ComponentBase {
deltaState: ProgressBarState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// No progress specified
if (deltaState.progress === undefined) {
}
@@ -78,12 +79,4 @@ export class ProgressBarComponent extends ComponentBase {
this.element.style.removeProperty('border-radius');
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 3;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 0.25;
}
}

View File

@@ -27,6 +27,8 @@ export class ProgressCircleComponent extends ComponentBase {
deltaState: ProgressCircleState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Apply the progress
if (deltaState.progress !== undefined) {
if (deltaState.progress === null) {

View File

@@ -67,6 +67,8 @@ export class RectangleComponent extends SingleContainer {
deltaState: RectangleState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceOnlyChild(latentComponents, deltaState.content);
if (deltaState.transition_time !== undefined) {

View File

@@ -1,9 +1,5 @@
import { componentsById } from '../componentManagement';
import { textStyleToCss } from '../cssUtils';
import { applyIcon } from '../designApplication';
import { easeInOut } from '../easeFunctions';
import { getTextDimensions } from '../layoutHelpers';
import { LayoutContext, updateLayout } from '../layouting';
import { ComponentId, TextStyle } from '../dataModels';
import { firstDefined } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
@@ -123,6 +119,8 @@ export class RevealerComponent extends ComponentBase {
deltaState: RevealerState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the header
if (deltaState.header === null) {
this.headerElement.style.display = 'none';
@@ -189,29 +187,6 @@ export class RevealerComponent extends ComponentBase {
this.element.classList.remove('rio-revealer-open');
}
}
// Cache the header text's dimensions
if (
deltaState.header !== undefined ||
deltaState.header_style !== undefined
) {
let headerText = firstDefined(deltaState.header, this.state.header);
if (headerText !== null) {
let headerStyle = firstDefined(
deltaState.header_style,
this.state.header_style
);
[this.labelWidth, this.labelHeight] = getTextDimensions(
headerText,
headerStyle
);
}
}
// Re-layout
this.makeLayoutDirty();
}
/// If the animation is not yet running, start it. Does nothing otherwise.
@@ -244,10 +219,6 @@ export class RevealerComponent extends ComponentBase {
Math.min(1, this.openFractionBeforeEase)
);
// Re-layout
this.makeLayoutDirty();
updateLayout();
// If the animation is not yet finished, continue it.
let target = this.state.is_open ? 1 : 0;
if (this.openFractionBeforeEase === target) {
@@ -256,67 +227,4 @@ export class RevealerComponent extends ComponentBase {
requestAnimationFrame(() => this.animationWorker());
}
}
updateNaturalWidth(ctx: LayoutContext): void {
// Account for the content
this.naturalWidth = componentsById[this.state.content]!.requestedWidth;
// If a header is present, consider that as well
if (this.state.header !== null) {
let headerWidth =
this.labelWidth + 4 + 2 * HEADER_PADDING * this.headerScale;
this.naturalWidth = Math.max(this.naturalWidth, headerWidth);
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
// Pass on space to the child, but only if the revealer is open. If not,
// avoid forcing a re-layout of the child.
if (this.openFractionBeforeEase > 0) {
componentsById[this.state.content]!.allocatedWidth =
this.allocatedWidth;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 0;
// Account for the header, if present
if (this.state.header !== null) {
this.naturalHeight +=
this.labelHeight + 2 * HEADER_PADDING * this.headerScale;
}
// Account for the content
if (this.openFractionBeforeEase > 0) {
let t = easeInOut(this.openFractionBeforeEase);
let innerHeight =
componentsById[this.state.content]!.requestedHeight;
this.naturalHeight += t * innerHeight;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Avoid forcing a re-layout of the child if the revealer is closed.
if (this.openFractionBeforeEase === 0) {
return;
}
// Pass on space to the child
let headerHeight =
this.state.header === null
? 0
: this.labelHeight + 2 * HEADER_PADDING * this.headerScale;
let child = componentsById[this.state.content]!;
child.allocatedHeight = Math.max(
this.allocatedHeight - headerHeight,
componentsById[this.state.content]!.requestedHeight
);
// Position the child
let element = child.element;
element.style.left = '0';
element.style.top = '0';
}
}

View File

@@ -1,248 +0,0 @@
import { pixelsPerRem, scrollBarSize } from '../app';
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
export type ScrollContainerState = ComponentState & {
_type_: 'ScrollContainer-builtin';
content?: ComponentId;
scroll_x?: 'never' | 'auto' | 'always';
scroll_y?: 'never' | 'auto' | 'always';
initial_x?: number;
initial_y?: number;
sticky_bottom?: boolean;
};
const NATURAL_SIZE = 1.0;
export class ScrollContainerComponent extends ComponentBase {
state: Required<ScrollContainerState>;
// Sometimes components are temporarily removed from the DOM or resized (for
// example, `getElementDimensions` does both), which can lead to the scroll
// position being changed or reset. In order to prevent this, we'll wrap our
// child in a container element.
private childContainer: HTMLElement;
private isFirstLayout: boolean = true;
private assumeVerticalScrollBarWillBeNeeded: boolean = true;
private numSequentialIncorrectAssumptions: number = 0;
private wasScrolledToBottom: boolean | null = null;
private shouldLayoutWithVerticalScrollbar(): boolean {
switch (this.state.scroll_y) {
case 'always':
return true;
case 'auto':
return this.assumeVerticalScrollBarWillBeNeeded;
case 'never':
return false;
}
}
createElement(): HTMLElement {
let element = document.createElement('div');
element.classList.add('rio-scroll-container');
this.childContainer = document.createElement('div');
element.appendChild(this.childContainer);
return element;
}
updateElement(
deltaState: ScrollContainerState,
latentComponents: Set<ComponentBase>
): void {
this.replaceOnlyChild(
latentComponents,
deltaState.content,
this.childContainer
);
}
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.scroll_x === 'never') {
let child = componentsById[this.state.content]!;
this.naturalWidth = child.requestedWidth;
} else {
this.naturalWidth = NATURAL_SIZE;
}
// If there will be a vertical scroll bar, reserve space for it
if (this.shouldLayoutWithVerticalScrollbar()) {
this.naturalWidth += scrollBarSize;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
// If `sticky_bottom` is enabled, we need to find out whether we're
// scrolled all the way to the bottom before we change the child's
// allocation.
//
// We can't do this in `updateNaturalWidth` because the layouting
// algorithm doesn't always call that method.
if (
this.state.sticky_bottom &&
this.numSequentialIncorrectAssumptions === 0
) {
this.wasScrolledToBottom = this._checkIfScrolledToBottom(child);
}
let availableWidth = this.allocatedWidth;
if (this.shouldLayoutWithVerticalScrollbar()) {
availableWidth -= scrollBarSize;
}
// If the child needs more space than we have, we'll need to display a
// scroll bar. So just give the child the width it wants.
if (child.requestedWidth > availableWidth) {
child.allocatedWidth = child.requestedWidth;
this.element.style.overflowX = 'scroll';
} else {
// Otherwise, stretch the child to use up all the available width
child.allocatedWidth = availableWidth;
this.element.style.overflowX =
this.state.scroll_x === 'always' ? 'scroll' : 'hidden';
}
this.childContainer.style.width = `${child.allocatedWidth}rem`;
}
updateNaturalHeight(ctx: LayoutContext): void {
if (this.state.scroll_y === 'never') {
let child = componentsById[this.state.content]!;
this.naturalHeight = child.requestedHeight;
} else {
this.naturalHeight = NATURAL_SIZE;
}
// If there will be a horizontal scroll bar, reserve space for it
if (this.element.style.overflowX === 'scroll') {
this.naturalHeight += scrollBarSize;
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
let child = componentsById[this.state.content]!;
let availableHeight = this.allocatedHeight;
if (this.element.style.overflowX === 'scroll') {
availableHeight -= scrollBarSize;
}
// If the child needs more space than we have, we'll need to display a
// scroll bar. So just give the child the height it wants.
let newAllocatedHeight: number;
if (child.requestedHeight > availableHeight) {
newAllocatedHeight = child.requestedHeight;
this.element.style.overflowY = 'scroll';
} else {
// Otherwise, stretch the child to use up all the available height
newAllocatedHeight = availableHeight;
if (this.state.scroll_y === 'always') {
this.element.style.overflowY = 'scroll';
} else {
this.element.style.overflowY = 'hidden';
}
}
// Now check if our assumption for the vertical scroll bar was correct.
// If not, we have to immediately re-layout the child.
let hasVerticalScrollbar = this.element.style.overflowY === 'scroll';
if (
this.state.scroll_y === 'auto' &&
this.assumeVerticalScrollBarWillBeNeeded !== hasVerticalScrollbar
) {
// Theoretically, there could be a situation where our assumptions
// are always wrong and we re-layout endlessly.
//
// It's acceptable to have an unnecessary scroll bar, but it's not
// acceptable to be missing a scroll bar when one is required. So we
// will only re-layout if this is the first time our assumption was
// wrong, or if we don't currently have a scroll bar.
if (
this.numSequentialIncorrectAssumptions == 0 ||
!this.assumeVerticalScrollBarWillBeNeeded
) {
this.numSequentialIncorrectAssumptions++;
this.assumeVerticalScrollBarWillBeNeeded =
!this.assumeVerticalScrollBarWillBeNeeded;
// While a re-layout is about to be requested, this doesn't mean
// that the current layouting process won't continue. Assign a
// reasonable amount of space to the child so any subsequent
// layouting functions don't crash because of unassigned values.
//
// The exact value doesn't matter, but this one is noticeable
// when debugging and easy to find in the code.
child.allocatedHeight = 7.77777777;
// Then, request a re-layout
ctx.requestImmediateReLayout(() => {
this.makeLayoutDirty();
});
return;
}
}
this.numSequentialIncorrectAssumptions = 0;
// Only change the allocatedHeight once we're sure that we won't be
// re-layouting again
child.allocatedHeight = newAllocatedHeight;
this.childContainer.style.height = `${child.allocatedHeight}rem`;
if (this.isFirstLayout) {
this.isFirstLayout = false;
// Our CSS `height` hasn't been updated yet, so we can't scroll
// down any further. We must assign the `height` manually.
this.element.style.height = `${this.allocatedHeight}rem`;
this.element.scroll({
top:
(child.allocatedHeight - this.allocatedHeight) *
pixelsPerRem *
this.state.initial_y,
left:
(child.allocatedWidth - this.allocatedWidth) *
pixelsPerRem *
this.state.initial_x,
behavior: 'instant',
});
}
// If `sticky_bottom` is enabled, check if we have to scroll down
else if (this.state.sticky_bottom && this.wasScrolledToBottom) {
// Our CSS `height` hasn't been updated yet, so we can't scroll
// down any further. We must assign the `height` manually.
this.element.style.height = `${this.allocatedHeight}rem`;
this.element.scroll({
top: child.allocatedHeight * pixelsPerRem + 999,
left: this.element.scrollLeft,
behavior: 'instant',
});
}
}
private _checkIfScrolledToBottom(child: ComponentBase): boolean {
// Calculate how much of the child is visible
let visibleHeight = this.allocatedHeight;
if (this.element.style.overflowX === 'scroll') {
visibleHeight -= scrollBarSize;
}
return (
(this.element.scrollTop + 1) / pixelsPerRem + visibleHeight >=
child.allocatedHeight - 0.00001
);
}
}

View File

@@ -1,10 +1,5 @@
import {
componentsById,
tryGetComponentByElement,
} from '../componentManagement';
import { tryGetComponentByElement } from '../componentManagement';
import { ComponentId } from '../dataModels';
import { getTextDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { setClipboard } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
@@ -22,7 +17,6 @@ export class ScrollTargetComponent extends ComponentBase {
childContainerElement: HTMLElement;
buttonContainerElement: HTMLElement;
cachedButtonTextSize: [number, number];
createElement(): HTMLElement {
let element = document.createElement('a');
@@ -48,6 +42,8 @@ export class ScrollTargetComponent extends ComponentBase {
deltaState: ScrollTargetState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceOnlyChild(
latentComponents,
deltaState.content,
@@ -77,11 +73,6 @@ export class ScrollTargetComponent extends ComponentBase {
let textElement = document.createElement('span');
textElement.textContent = deltaState.copy_button_text;
this.buttonContainerElement.appendChild(textElement);
this.cachedButtonTextSize = getTextDimensions(
deltaState.copy_button_text,
'text'
);
}
}
@@ -108,91 +99,4 @@ export class ScrollTargetComponent extends ComponentBase {
setClipboard(url.toString());
}
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.content === null) {
this.naturalWidth = 0;
} else {
this.naturalWidth =
componentsById[this.state.content]!.requestedWidth;
}
if (this.state.copy_button_content !== null) {
this.naturalWidth +=
componentsById[this.state.copy_button_content]!.requestedWidth;
} else if (this.state.copy_button_text !== null) {
this.naturalWidth += this.cachedButtonTextSize[0];
}
// If both children exist, add the spacing
if (
this.state.content !== null &&
(this.state.copy_button_content !== null ||
this.state.copy_button_text !== null)
) {
this.naturalWidth += this.state.copy_button_spacing;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
// The button component gets as much space as it requested, and the
// other child gets all the rest
let remainingWidth =
this.allocatedWidth - this.state.copy_button_spacing;
let buttonX = 0;
if (this.state.copy_button_content !== null) {
let buttonComponent =
componentsById[this.state.copy_button_content]!;
buttonComponent.allocatedWidth = buttonComponent.requestedWidth;
remainingWidth -= buttonComponent.allocatedWidth;
} else if (this.state.copy_button_text !== null) {
remainingWidth -= this.cachedButtonTextSize[0];
}
if (this.state.content !== null) {
componentsById[this.state.content]!.allocatedWidth = remainingWidth;
buttonX = remainingWidth + this.state.copy_button_spacing;
}
if (
this.state.copy_button_content !== null ||
this.state.copy_button_text !== null
) {
let childElement = this.buttonContainerElement
.firstElementChild as HTMLElement;
childElement.style.left = `${buttonX}rem`;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
let contentHeight = 0;
let copyButtonHeight = 0;
if (this.state.content !== null) {
contentHeight = componentsById[this.state.content]!.requestedHeight;
}
if (this.state.copy_button_content !== null) {
copyButtonHeight =
componentsById[this.state.copy_button_content]!.requestedHeight;
} else if (this.state.copy_button_text !== null) {
copyButtonHeight = this.cachedButtonTextSize[1];
}
this.naturalHeight = Math.max(contentHeight, copyButtonHeight);
}
updateAllocatedHeight(ctx: LayoutContext): void {
if (this.state.content !== null) {
componentsById[this.state.content]!.allocatedHeight =
this.allocatedHeight;
}
if (this.state.copy_button_content !== null) {
componentsById[this.state.copy_button_content]!.allocatedHeight =
this.allocatedHeight;
}
}
}

View File

@@ -1,7 +1,6 @@
import { Color } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { pixelsPerRem } from '../app';
import { LayoutContext } from '../layouting';
import { colorToCssString } from '../cssUtils';
export type SeparatorState = ComponentState & {
@@ -23,6 +22,8 @@ export class SeparatorComponent extends ComponentBase {
deltaState: SeparatorState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Color
if (deltaState.color === undefined) {
// Nothing to do
@@ -43,18 +44,5 @@ export class SeparatorComponent extends ComponentBase {
);
this.element.style.setProperty('--separator-opacity', '1');
}
// Orientation
if (deltaState.orientation !== undefined) {
this.makeLayoutDirty();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 1 / pixelsPerRem;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 1 / pixelsPerRem;
}
}

View File

@@ -1,5 +1,4 @@
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
export type SeparatorListItemState = ComponentState & {
_type_: 'SeparatorListItem-builtin';
@@ -11,13 +10,4 @@ export class SeparatorListItemComponent extends ComponentBase {
createElement(): HTMLElement {
return document.createElement('div');
}
updateElement(
deltaState: SeparatorListItemState,
latentComponents: Set<ComponentBase>
): void {}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 1;
}
}

View File

@@ -1,42 +1,3 @@
import { LayoutContext } from '../layouting';
import { ComponentBase } from './componentBase';
export abstract class SingleContainer extends ComponentBase {
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 0;
for (let child of this.children) {
this.naturalWidth = Math.max(
this.naturalWidth,
child.requestedWidth
);
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
for (let child of this.children) {
child.allocatedWidth = this.allocatedWidth;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 0;
for (let child of this.children) {
this.naturalHeight = Math.max(
this.naturalHeight,
child.requestedHeight
);
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
for (let child of this.children) {
child.allocatedHeight = this.allocatedHeight;
let element = child.element;
element.style.left = '0';
element.style.top = '0';
}
}
}
export abstract class SingleContainer extends ComponentBase {}

View File

@@ -1,6 +1,4 @@
import { pixelsPerRem } from '../app';
import { applySwitcheroo } from '../designApplication';
import { LayoutContext } from '../layouting';
import { firstDefined } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
@@ -144,6 +142,8 @@ export class SliderComponent extends ComponentBase {
deltaState: SliderState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (
deltaState.minimum !== undefined ||
deltaState.maximum !== undefined ||
@@ -192,16 +192,4 @@ export class SliderComponent extends ComponentBase {
this.makeLayoutDirty();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 3;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 1.4;
if (this.state.show_values) {
this.naturalHeight += 0.9;
}
}
}

View File

@@ -73,6 +73,8 @@ export class SlideshowComponent extends SingleContainer {
deltaState: SlideshowState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the children
if (deltaState.children !== undefined) {
this.replaceChildren(

View File

@@ -20,6 +20,8 @@ export class StackComponent extends SingleContainer {
deltaState: StackState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
this.replaceChildren(latentComponents, deltaState.children);
}
}

View File

@@ -1,4 +1,3 @@
import { LayoutContext } from '../layouting';
import { ComponentBase, ComponentState } from './componentBase';
export type SwitchState = ComponentState & {
@@ -38,6 +37,8 @@ export class SwitchComponent extends ComponentBase {
deltaState: SwitchState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.is_on !== undefined) {
if (deltaState.is_on) {
this.element.classList.add('is-on');
@@ -68,12 +69,4 @@ export class SwitchComponent extends ComponentBase {
// identical. Make them look different. The switch animation also kinda
// reacts to user input even if not sensitive.
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = 3.18;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight = 1.54;
}
}

View File

@@ -1,7 +1,6 @@
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { componentsById } from '../componentManagement';
import { LayoutContext, updateLayout } from '../layouting';
import { easeInOut } from '../easeFunctions';
import { commitCss } from '../utils';
@@ -47,6 +46,8 @@ export class SwitcherComponent extends ComponentBase {
deltaState: SwitcherState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// FIXME: Too low transition times cause issues for some reason.
// Until that is fixed, clamp the value.
if (
@@ -138,9 +139,6 @@ export class SwitcherComponent extends ComponentBase {
commitCss(this.activeChildContainer);
this.activeChildContainer.classList.add('rio-switcher-active');
}
// Start the layouting process
this.makeLayoutDirty();
}
// The component is now initialized
@@ -154,149 +152,5 @@ export class SwitcherComponent extends ComponentBase {
}
this.animationStartedAt = Date.now();
requestAnimationFrame(() => {
this.makeLayoutDirty();
updateLayout();
});
}
updateNaturalWidth(ctx: LayoutContext): void {
// If the child's requested size has changed, start the animation
let childRequestedWidth: number, childRequestedHeight: number;
if (this.activeChildInstance === null) {
childRequestedWidth = 0;
childRequestedHeight = 0;
} else {
childRequestedWidth = this.activeChildInstance.requestedWidth;
childRequestedHeight = this.activeChildInstance.requestedHeight;
}
if (
this.previousChildRequestedWidth !== childRequestedWidth ||
this.previousChildRequestedHeight !== childRequestedHeight
) {
this.isDeterminingLayout = true;
this.previousChildRequestedWidth = childRequestedWidth;
this.previousChildRequestedHeight = childRequestedHeight;
}
// Case: Trying to determine the size the child will receive once the
// animation finishes
if (this.isDeterminingLayout) {
this.naturalWidth = childRequestedWidth;
return;
}
// Case: animated layouting
let now = Date.now();
let linearT = Math.min(
1,
(now - this.animationStartedAt) / 1000 / this.state.transition_time
);
let easedT = easeInOut(linearT);
this.naturalWidth =
this.initialRequestedWidth +
easedT * (childRequestedWidth - this.initialRequestedWidth);
this.naturalHeight =
this.initialRequestedHeight +
easedT * (childRequestedHeight - this.initialRequestedHeight);
// Keep going?
if (linearT < 1) {
requestAnimationFrame(() => {
this.makeLayoutDirty();
updateLayout();
});
} else {
this.initialRequestedWidth = Math.max(
this.naturalWidth,
this.state._size_[0]
);
this.initialRequestedHeight = Math.max(
this.naturalHeight,
this.state._size_[1]
);
this.animationStartedAt = -1;
}
}
updateAllocatedWidth(ctx: LayoutContext): void {
// Case: Trying to determine the size the child will receive once the
// animation finishes
// OR
// Case: The parent component resized us
if (this.isDeterminingLayout || this.animationStartedAt === -1) {
if (this.activeChildInstance !== null) {
this.activeChildInstance.allocatedWidth = this.allocatedWidth;
}
return;
}
// Case: animated layouting
//
// Nothing to do here
}
updateNaturalHeight(ctx: LayoutContext): void {
// Case: Trying to determine the size the child will receive once the
// animation finishes
if (this.isDeterminingLayout) {
this.naturalHeight =
this.activeChildInstance === null
? 0
: this.activeChildInstance.requestedHeight;
return;
}
// Case: animated layouting
//
// Already handled above
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Case: Trying to determine the size the child will receive once the
// animation finishes
if (this.isDeterminingLayout) {
if (this.activeChildInstance !== null) {
this.activeChildInstance.allocatedHeight = this.allocatedHeight;
}
this.isDeterminingLayout = false;
// If this is the first layout don't animate, just assume the
// correct size
if (!this.hasBeenLaidOut) {
this.hasBeenLaidOut = true;
this.initialRequestedWidth = this.allocatedWidth;
this.initialRequestedHeight = this.allocatedHeight;
return;
}
// Start the animation
if (this.animationStartedAt === -1) {
this.animationStartedAt = Date.now();
}
ctx.requestImmediateReLayout(() => {
this.makeLayoutDirty();
});
return;
}
// Case: The parent component resized us
if (this.animationStartedAt === -1) {
if (this.activeChildInstance !== null) {
this.activeChildInstance.allocatedHeight = this.allocatedHeight;
}
return;
}
// Case: animated layouting
//
// Nothing to do here
}
}

View File

@@ -1,8 +1,6 @@
import { ComponentBase, ComponentState } from './componentBase';
import { ColorSet } from '../dataModels';
import { applySwitcheroo } from '../designApplication';
import { getTextDimensionsWithCss } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { easeInOut } from '../easeFunctions';
import { firstDefined } from '../utils';
@@ -422,8 +420,9 @@ export class SwitcherBarComponent extends ComponentBase {
deltaState: SwitcherBarState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
let markerPositionNeedsUpdate = false;
let needsReLayout = false;
// Have the options changed?
if (
@@ -487,7 +486,6 @@ export class SwitcherBarComponent extends ComponentBase {
// Request updates
markerPositionNeedsUpdate = true;
needsReLayout = true;
}
// Color
@@ -509,13 +507,11 @@ export class SwitcherBarComponent extends ComponentBase {
// Request updates
markerPositionNeedsUpdate = true;
needsReLayout = true;
}
// Spacing
if (deltaState.spacing !== undefined) {
markerPositionNeedsUpdate = true;
needsReLayout = true;
}
// If the selection has changed make sure to move the marker
@@ -538,70 +534,5 @@ export class SwitcherBarComponent extends ComponentBase {
if (markerPositionNeedsUpdate) {
this.moveMarkerInstantlyIfAnimationIsntRunning();
}
if (needsReLayout) {
this.makeLayoutDirty();
}
}
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.orientation == 'horizontal') {
// Spacing
this.naturalWidth =
this.state.spacing * (this.state.names.length - 1);
// Options
this.optionWidths.forEach((width) => {
this.naturalWidth += width;
});
} else {
// Options
this.naturalWidth = 0;
this.optionWidths.forEach((width) => {
this.naturalWidth = Math.max(this.naturalWidth, width);
});
}
}
updateNaturalHeight(ctx: LayoutContext): void {
if (this.state.orientation == 'horizontal') {
// Options
this.naturalHeight = 0;
this.optionHeights.forEach((height) => {
this.naturalHeight = Math.max(this.naturalHeight, height);
});
} else {
// Spacing
this.naturalHeight =
this.state.spacing * (this.state.names.length - 1);
// Options
this.optionHeights.forEach((height) => {
this.naturalHeight += height;
});
}
}
updateAllocatedHeight(ctx: LayoutContext): void {
// Pass on the allocated size to the options
let width, height;
if (this.state.orientation == 'horizontal') {
width = `${this.allocatedWidth}rem`;
height = '';
} else {
width = '';
height = `${this.allocatedHeight}rem`;
}
this.backgroundOptionsElement.style.width = width;
this.backgroundOptionsElement.style.height = height;
this.markerOptionsElement.style.width = width;
this.markerOptionsElement.style.height = height;
// Reposition the marker
this.moveMarkerInstantlyIfAnimationIsntRunning();
}
}

View File

@@ -1,5 +1,3 @@
import { getElementDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import { ComponentBase, ComponentState } from './componentBase';
type TableValue = number | string;
@@ -139,6 +137,8 @@ export class TableComponent extends ComponentBase {
deltaState: TableDeltaState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.data !== undefined) {
this.state.data = dataToColumns(deltaState.data);
@@ -163,11 +163,6 @@ export class TableComponent extends ComponentBase {
this.hideRowNumbers();
}
}
this.makeLayoutDirty();
[this.naturalWidth, this.naturalHeight] = getElementDimensions(
this.tableElement
);
}
private displayData(): void {

View File

@@ -1,8 +1,6 @@
import { TextStyle } from '../dataModels';
import { textStyleToCss } from '../cssUtils';
import { ComponentBase, ComponentState } from './componentBase';
import { LayoutContext } from '../layouting';
import { getTextDimensions } from '../layoutHelpers';
export type TextState = ComponentState & {
_type_: 'Text-builtin';
@@ -33,6 +31,8 @@ export class TextComponent extends ComponentBase {
deltaState: TextState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// BEFORE WE DO ANYTHING ELSE, update the text style
if (deltaState.style !== undefined) {
// Change the element to <h1>, <h2>, <h3> or <span> as necessary
@@ -101,42 +101,5 @@ export class TextComponent extends ComponentBase {
if (deltaState.justify !== undefined) {
this.inner.style.textAlign = deltaState.justify;
}
if (
deltaState.text !== undefined ||
deltaState.wrap !== undefined ||
deltaState.style !== undefined
) {
this.makeLayoutDirty();
// Compute and cache the dimensions that our text requires if line
// wrapping is disabled
this.cachedNoWrapDimensions = getTextDimensions(
this.element.textContent!,
this.state.style
);
}
}
updateNaturalWidth(ctx: LayoutContext): void {
if (this.state.wrap === false) {
this.naturalWidth = this.cachedNoWrapDimensions[0];
} else {
this.naturalWidth = 0;
}
}
updateNaturalHeight(ctx: LayoutContext): void {
if (this.state.wrap === true) {
// Calculate how much height we need given the allocated width
this.naturalHeight = getTextDimensions(
this.state.text,
this.state.style,
this.allocatedWidth
)[1];
} else {
// 'wrap' and 'ellipsize' both require the same height
this.naturalHeight = this.cachedNoWrapDimensions[1];
}
}
}

View File

@@ -1,11 +1,4 @@
import { ComponentBase, ComponentState } from './componentBase';
import { getTextDimensions } from '../layoutHelpers';
import { LayoutContext } from '../layouting';
import {
HORIZONTAL_PADDING as INPUT_BOX_HORIZONTAL_PADDING,
updateInputBoxNaturalHeight,
updateInputBoxNaturalWidth,
} from '../inputBoxTools';
import { Debouncer } from '../debouncer';
export type TextInputState = ComponentState & {
@@ -27,9 +20,6 @@ export class TextInputComponent extends ComponentBase {
private prefixTextElement: HTMLElement;
private suffixTextElement: HTMLElement;
private prefixTextWidth: number = 0;
private suffixTextWidth: number = 0;
onChangeLimiter: Debouncer;
createElement(): HTMLElement {
@@ -162,47 +152,30 @@ export class TextInputComponent extends ComponentBase {
deltaState: TextInputState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.text !== undefined) {
this.inputElement.value = deltaState.text;
}
if (deltaState.label !== undefined) {
this.labelElement.textContent = deltaState.label;
// Update the layout
updateInputBoxNaturalHeight(this, deltaState.label, 0);
}
if (deltaState.prefix_text === '') {
this.prefixTextElement.style.display = 'none';
this.prefixTextWidth = 0;
this.inputElement.style.paddingLeft = `${INPUT_BOX_HORIZONTAL_PADDING}rem`;
this.makeLayoutDirty();
} else if (deltaState.prefix_text !== undefined) {
this.prefixTextElement.textContent = deltaState.prefix_text;
this.prefixTextElement.style.removeProperty('display');
this.inputElement.style.removeProperty('padding-left');
// Update the layout, if needed
this.prefixTextWidth =
getTextDimensions(deltaState.prefix_text, 'text')[0] + 0.2;
this.makeLayoutDirty();
}
if (deltaState.suffix_text === '') {
this.suffixTextElement.style.display = 'none';
this.suffixTextWidth = 0;
this.inputElement.style.paddingRight = `${INPUT_BOX_HORIZONTAL_PADDING}rem`;
this.makeLayoutDirty();
} else if (deltaState.suffix_text !== undefined) {
this.suffixTextElement.textContent = deltaState.suffix_text;
this.suffixTextElement.style.removeProperty('display');
this.inputElement.style.removeProperty('padding-right');
// Update the layout, if needed
this.suffixTextWidth =
getTextDimensions(deltaState.suffix_text, 'text')[0] + 0.2;
this.makeLayoutDirty();
}
if (deltaState.is_secret !== undefined) {
@@ -230,16 +203,4 @@ export class TextInputComponent extends ComponentBase {
grabKeyboardFocus(): void {
this.inputElement.focus();
}
updateNaturalWidth(ctx: LayoutContext): void {
updateInputBoxNaturalWidth(
this,
this.prefixTextWidth + this.suffixTextWidth
);
}
updateNaturalHeight(ctx: LayoutContext): void {
// This is set during the updateElement() call, so there is nothing to
// do here.
}
}

View File

@@ -21,6 +21,8 @@ export class ThemeContextSwitcherComponent extends SingleContainer {
deltaState: ThemeContextSwitcherState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the child
this.replaceOnlyChild(latentComponents, deltaState.content);

View File

@@ -1,5 +1,4 @@
import { componentsById } from '../componentManagement';
import { LayoutContext } from '../layouting';
import { ComponentId } from '../dataModels';
import { ComponentBase, ComponentState } from './componentBase';
import { PopupManager } from '../popupManager';
@@ -64,6 +63,8 @@ export class TooltipComponent extends ComponentBase {
deltaState: TooltipState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Update the anchor
if (deltaState.anchor !== undefined) {
this.replaceOnlyChild(
@@ -98,36 +99,4 @@ export class TooltipComponent extends ComponentBase {
this.popupManager.destroy();
}
updateNaturalWidth(ctx: LayoutContext): void {
this.naturalWidth = componentsById[this.state.anchor!]!.requestedWidth;
}
updateAllocatedWidth(ctx: LayoutContext): void {
let anchor = componentsById[this.state.anchor!]!;
let tip = componentsById[this.state._tip_component!]!;
anchor.allocatedWidth = this.allocatedWidth;
tip.allocatedWidth = tip.requestedWidth;
}
updateNaturalHeight(ctx: LayoutContext): void {
this.naturalHeight =
componentsById[this.state.anchor!]!.requestedHeight;
}
updateAllocatedHeight(ctx: LayoutContext): void {
let anchor = componentsById[this.state.anchor!]!;
let tip = componentsById[this.state._tip_component!]!;
anchor.allocatedHeight = this.allocatedHeight;
tip.allocatedHeight = tip.requestedHeight;
// Position the children
anchor.element.style.left = '0';
anchor.element.style.top = '0';
tip.element.style.left = '0';
tip.element.style.top = '0';
}
}

View File

@@ -17,6 +17,8 @@ export class WebsiteComponent extends ComponentBase {
deltaState: WebsiteState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (
deltaState.url !== undefined &&
deltaState.url !== this.element.src

View File

@@ -1,5 +1,7 @@
export type ComponentId = number & { __brand: 'ComponentId' };
export type RioScrollBehavior = 'never' | 'auto' | 'always';
export type Color = [number, number, number, number];
export const COLOR_SET_NAMES = [

View File

@@ -3,7 +3,7 @@
/// don't exist. This module contains the logic for walking the tree and
/// filtering out the nodes that shouldn't be displayed.
import { getRootScroller, componentsById } from './componentManagement';
import { componentsById } from './componentManagement';
import { ComponentBase } from './components/componentBase';
/// Many of the spawned components are internal to Rio and shouldn't be

View File

@@ -1,40 +0,0 @@
// Several components share the same overall style: The input box.
//
// This file contains tools helpful for implementing them.
import { ComponentBase } from './components/componentBase';
export const HORIZONTAL_PADDING: number = 0.8;
export function updateInputBoxNaturalWidth(
component: ComponentBase,
additionalSpace: number
): void {
// Enforce a minimum width, common to all input boxes
let newWidth = Math.max(8, additionalSpace + HORIZONTAL_PADDING * 2);
// Dirty?
if (newWidth !== component.naturalWidth) {
component.naturalWidth = newWidth;
component.makeLayoutDirty();
}
}
/// Update the component's natural height property, and make the layout dirty
/// if needed.
export function updateInputBoxNaturalHeight(
component: ComponentBase,
label: string,
additionalSpace: number
) {
// Calculate the new height. If a label is set, the height needs to increase
// to make room for it, when floating above the entered text.
let newHeight = label.length === 0 ? 2.375 : 3.3;
newHeight += additionalSpace;
// Dirty?
if (newHeight !== component.naturalHeight) {
component.naturalHeight = newHeight;
component.makeLayoutDirty();
}
}

View File

@@ -1,169 +0,0 @@
import { pixelsPerRem } from './app';
import { textStyleToCss } from './cssUtils';
import { TextStyle } from './dataModels';
const _textDimensionsCache = new Map<string, [number, number]>();
let cacheHits: number = 0;
let cacheMisses: number = 0;
/// Returns the width and height of the given text in pixels. Caches the result.
export function getTextDimensions(
text: string,
style: 'heading1' | 'heading2' | 'heading3' | 'text' | 'dim' | TextStyle,
restrictWidth: number | null = null
): [number, number] {
// Make sure the text isn't just whitespace, as that results in a wrong [0,
// 0]
//
// FIXME: Still imperfect, as now all whitespace is the same width, and an
// empty string has a width.
if (text.trim().length === 0) {
text = 'l';
}
// Build a key for the cache
let key: string;
let sizeNormalizationFactor: number;
if (typeof style === 'string') {
key = `${style}+${text}`;
sizeNormalizationFactor = 1;
} else {
key = `${style.fontName}+${style.fontWeight}+${style.italic}+${style.underlined}+${style.allCaps}+${text}`;
sizeNormalizationFactor = style.fontSize;
}
// Restrict the width?
if (restrictWidth !== null) {
key += `+${restrictWidth}`;
}
// Check the cache
let cached = _textDimensionsCache.get(key);
if (cached !== undefined) {
cacheHits++;
return [
cached[0] * sizeNormalizationFactor,
cached[1] * sizeNormalizationFactor,
];
}
cacheMisses++;
let result = getTextDimensionsWithCss(
text,
textStyleToCss(style),
restrictWidth
);
// Store in the cache and return
_textDimensionsCache.set(key, [
result[0] / sizeNormalizationFactor,
result[1] / sizeNormalizationFactor,
]);
return result;
}
export function getTextDimensionsWithCss(
text: string,
style: object,
restrictWidth: number | null = null
): [number, number] {
let element = document.createElement('div');
element.textContent = text;
Object.assign(element.style, style);
element.style.position = 'absolute';
element.style.whiteSpace = 'pre-wrap'; // Required for multi-line text
document.body.appendChild(element);
if (restrictWidth !== null) {
element.style.width = `${restrictWidth}rem`;
}
let rect = element.getBoundingClientRect();
let result = [rect.width / pixelsPerRem, rect.height / pixelsPerRem] as [
number,
number,
];
element.remove();
return result;
}
globalThis.getTextDimensions = getTextDimensions; // For debugging
/// Get the width and height an element takes up on the screen, in rems.
///
/// This works even if the element is not visible, e.g. because a parent is
/// hidden.
export function getElementDimensions(element: HTMLElement): [number, number] {
let result: [number, number];
for (const _ of prepareElementForGetDimensions(element)) {
result = [
element.scrollWidth / pixelsPerRem,
element.scrollHeight / pixelsPerRem,
];
}
// @ts-ignore ("used before assignment")
return result;
}
globalThis.getElementDimensions = getElementDimensions; // For debugging
export function getElementWidth(element: HTMLElement): number {
let result: number;
for (const _ of prepareElementForGetDimensions(element)) {
result = element.scrollWidth / pixelsPerRem;
}
// @ts-ignore ("used before assignment")
return result;
}
export function getElementHeight(element: HTMLElement): number {
let result: number;
for (const _ of prepareElementForGetDimensions(element)) {
result = element.scrollHeight / pixelsPerRem;
}
// @ts-ignore ("used before assignment")
return result;
}
function* prepareElementForGetDimensions(element: HTMLElement) {
// Ensure the element is in the DOM
let isInDom = element.isConnected;
let parentElement: HTMLElement | null = null;
let nextSibling: Node | null = null;
if (!isInDom) {
parentElement = element.parentElement;
nextSibling = element.nextSibling;
document.body.appendChild(element);
}
// Ensure it doesn't feel compelled to fill the entire parent element
let originalPosition = element.style.position;
element.style.position = 'fixed';
try {
yield;
} finally {
// Restore the original state
element.style.position = originalPosition;
if (!isInDom) {
if (parentElement === null) {
element.remove();
} else if (nextSibling === null) {
parentElement.appendChild(element);
} else {
parentElement.insertBefore(element, nextSibling);
}
}
}
}

View File

@@ -1,157 +0,0 @@
import { pixelsPerRem } from './app';
import { getRootComponent } from './componentManagement';
import { ComponentBase } from './components/componentBase';
import { DevToolsConnectorComponent } from './components/devToolsConnector';
export class LayoutContext {
_immediateReLayoutCallbacks: (() => void)[] = [];
private updateRequestedWidthRecursive(component: ComponentBase): void {
if (!component.isLayoutDirty) return;
for (let child of component.children) {
this.updateRequestedWidthRecursive(child);
}
component.updateNaturalWidth(this);
component.requestedWidth = Math.max(
component.naturalWidth,
component.state._size_[0]
);
}
private updateAllocatedWidthRecursive(component: ComponentBase): void {
if (!component.isLayoutDirty) return;
let children = Array.from(component.children);
let childAllocatedWidths = children.map(
(child) => child.allocatedWidth
);
component.updateAllocatedWidth(this);
// The FundamentalRootComponent always has a width of 100vw, so we don't
// want to assign the width here. We'll only assign the width of this
// component's children.
for (let i = 0; i < children.length; i++) {
let child = children[i];
if (child.allocatedWidth !== childAllocatedWidths[i]) {
child.isLayoutDirty = true;
}
if (child.isLayoutDirty) {
this.updateAllocatedWidthRecursive(child);
}
let element = child.element;
element.style.width = `${child.allocatedWidth * pixelsPerRem}px`;
}
}
private updateRequestedHeightRecursive(component: ComponentBase): void {
if (!component.isLayoutDirty) return;
for (let child of component.children) {
this.updateRequestedHeightRecursive(child);
}
component.updateNaturalHeight(this);
component.requestedHeight = Math.max(
component.naturalHeight,
component.state._size_[1]
);
}
private updateAllocatedHeightRecursive(component: ComponentBase): void {
if (!component.isLayoutDirty) return;
let children = Array.from(component.children);
let childAllocatedHeights = children.map(
(child) => child.allocatedHeight
);
component.updateAllocatedHeight(this);
// The FundamentalRootComponent always has a height of 100vh, so we
// don't want to assign the width here. We'll only assign the height of
// this component's children.
for (let i = 0; i < children.length; i++) {
let child = children[i];
if (child.allocatedHeight !== childAllocatedHeights[i]) {
child.isLayoutDirty = true;
}
if (child.isLayoutDirty) {
this.updateAllocatedHeightRecursive(child);
}
child.isLayoutDirty = false;
let element = child.element;
element.style.height = `${child.allocatedHeight * pixelsPerRem}px`;
}
}
public updateLayout(): void {
let rootComponent = getRootComponent();
// Find out how large all components would like to be
this.updateRequestedWidthRecursive(rootComponent);
// Note: The FundamentalRootComponent is responsible for allocating the
// available window space. There is no need to take care of anything
// here.
// Distribute the just received width to all children
this.updateAllocatedWidthRecursive(rootComponent);
// Now that all components have their width set, find out their height.
// This is done later on, so that text can request height based on its
// width.
this.updateRequestedHeightRecursive(rootComponent);
// Distribute the just received height to all children
this.updateAllocatedHeightRecursive(rootComponent);
}
/// Signal to the layout engine that it should re-layout the component tree
/// immediately after the current layout cycle finishes. The given function
/// will be called before the re-layout happens, allowing the caller to
/// dirty components or do other things.
public requestImmediateReLayout(callback: () => void): void {
this._immediateReLayoutCallbacks.push(callback);
}
}
export function updateLayout(): void {
let context = new LayoutContext();
while (true) {
// Update the layout
context.updateLayout();
// Are any re-layouts requested?
if (context._immediateReLayoutCallbacks.length === 0) {
break;
}
// Call all hooks
for (let callback of context._immediateReLayoutCallbacks) {
callback();
}
context._immediateReLayoutCallbacks = [];
}
// Notify the dev tools, if any
if (globalThis.RIO_DEV_TOOLS !== null) {
let devToolsComponent =
globalThis.RIO_DEV_TOOLS as DevToolsConnectorComponent;
devToolsComponent.afterLayoutUpdate();
}
}

View File

@@ -101,6 +101,22 @@ export function firstDefined(...args: any[]): any {
return undefined;
}
/// Removes `oldElement` from the DOM and inserts `newElement` at its position
export function replaceElement(oldElement: Element, newElement: Node): void {
oldElement.parentElement?.insertBefore(newElement, oldElement);
oldElement.remove();
}
/// Wraps the given Element in a DIV
export function insertWrapperElement(wrappedElement: Element): HTMLElement {
let wrapperElement = document.createElement('div');
replaceElement(wrappedElement, wrapperElement);
wrapperElement.appendChild(wrappedElement);
return wrapperElement;
}
/// Adds a timeout to a promise. Throws TimeoutError if the time limit is
/// exceeded before the promise resolves.
export function timeout<T>(
@@ -356,7 +372,7 @@ export async function getComponentLayouts(
}
// And its parent
let parentComponent = component.getParentExcludingInjected()!;
let parentComponent = component.getParent()!;
if (parentComponent === null) {
result.push(null);

View File

@@ -1,12 +1,27 @@
@use 'sass:meta';
@use 'switcheroos.scss';
@mixin single-container {
display: inline-grid;
}
@mixin center-content {
display: flex;
justify-content: center;
align-items: center;
}
/// Kills the element's size request so it can't make its parent element grow.
/// Always takes the size of the nearest *positioned* parent element.
@mixin kill-size-request {
// Prevent it from making the parent element grow
position: absolute;
// Fill the entire parent
width: 100%;
height: 100%;
}
// Light / Dark highlight.js themes
//
// Switch between these by setting the `data-theme` attribute on the `html`
@@ -109,15 +124,19 @@ code {
html {
background: var(--rio-global-background-bg);
// Don't scroll under any circumstances. Our layouting system takes care of
// scrolling.
overflow: hidden;
// Fill the whole screen, at least
min-width: 100%;
min-height: 100%;
@include single-container;
}
body {
margin: 0;
padding: 0;
@include single-container;
font-family: var(--rio-global-font, sans-serif);
}
@@ -130,27 +149,65 @@ select {
font-size: 1rem;
}
// All components
.rio-component {
position: absolute;
// Alignment helper elements
.rio-align-outer {
position: relative;
}
.rio-align-inner {
position: relative;
&.stretch-child-x > * {
width: 100%;
}
&.stretch-child-y > * {
height: 100%;
}
}
// Container elements for child components
.rio-child-wrapper {
@include single-container();
}
// User-defined components
.rio-placeholder {
@include single-container();
}
// Fundamental Root Component
.rio-fundamental-root-component {
position: relative !important;
display: inline-grid;
width: 100vw;
height: 100vh;
& > .rio-scroll-container {
// The user's root component
& > *:first-child {
z-index: $z-index-user-root;
grid-row: 1;
grid-column: 1;
}
// The connection lost popup
& > *:nth-child(2) {
z-index: $z-index-error-popup;
grid-row: 1;
grid-column: 1;
}
// The dev tools sidebar
& > *:nth-child(3) {
z-index: $z-index-dev-tools;
grid-row: 1;
grid-column: 2;
}
}
// Dev Tools
.rio-dev-tools {
pointer-events: auto;
z-index: $z-index-dev-tools;
}
.rio-dev-tools-hidden::after {
@@ -1034,12 +1091,16 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label,
// MediaPlayer
.rio-media-player {
pointer-events: auto;
position: relative;
@include center-content();
}
.rio-media-player video {
pointer-events: none;
width: 100%;
height: 100%;
@include kill-size-request();
object-fit: contain;
}
@@ -1431,6 +1492,13 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label,
pointer-events: auto;
scroll-behavior: smooth;
& > * {
min-width: 100%;
min-height: 100%;
@include single-container();
}
}
// ScrollTarget
@@ -1651,6 +1719,8 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label,
.rio-drawer-anchor {
pointer-events: none;
@include single-container();
flex-grow: 1;
}
@@ -2000,7 +2070,6 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label,
top: 0;
width: 100vw;
height: 100vh;
z-index: $z-index-error-popup;
background-color: transparent;

View File

@@ -39,7 +39,6 @@ from .progress_bar import *
from .progress_circle import *
from .rectangle import *
from .revealer import *
from .scroll_container import *
from .scroll_target import *
from .separator import *
from .slider import *

View File

@@ -218,6 +218,9 @@ class Component(abc.ABC, metaclass=ComponentMeta):
align_x: float | None = None
align_y: float | None = None
scroll_x: Literal["never", "auto", "always"] = "never"
scroll_y: Literal["never", "auto", "always"] = "never"
margin_left: float | None = None
margin_top: float | None = None
margin_right: float | None = None

View File

@@ -6,7 +6,6 @@ from typing import * # type: ignore
from .. import utils
from .component import Component
from .fundamental_component import FundamentalComponent
from .scroll_container import ScrollContainer
__all__ = ["HighLevelRootComponent"]
@@ -53,17 +52,12 @@ class HighLevelRootComponent(Component):
# Build the user's root component
user_root = utils.safe_build(self.build_function)
# Wrap it up in a scroll container, so the dev-tools don't scroll
user_content = ScrollContainer(
user_root,
scroll_x=scroll,
scroll_y=scroll,
)
return FundamentalRootComponent(
user_content,
user_root,
utils.safe_build(self.build_connection_lost_message_function),
dev_tools=dev_tools,
scroll_x=scroll,
scroll_y=scroll,
)

View File

@@ -1,71 +0,0 @@
from __future__ import annotations
from dataclasses import KW_ONLY
from typing import Literal, final
import rio
from .fundamental_component import FundamentalComponent
__all__ = ["ScrollContainer"]
@final
class ScrollContainer(FundamentalComponent):
"""
Displays a scroll bar if its content grows too large.
`ScrollContainer` is a container which can display child components that
would normally be too large to fit on the screen. It accepts and displays a
single child and adds scroll bars if smaller than the child. If you can also
force enable or disable scrollbars using the `scroll_x` and `scroll_y`
properties.
## Attributes
`content`: The child component to display inside the `ScrollContainer`.
`scroll_x`: Controls horizontal scrolling. The default is `"auto"`, which
means that a scroll bar will be displayed only if it is needed.
`"always"` displays a scroll bar even if it isn't needed, and
`"never"` disables horizontal scrolling altogether.
`scroll_y`: Controls vertical scrolling. The default is `"auto"`, which
means that a scroll bar will be displayed only if it is needed.
`"always"` displays a scroll bar even if it isn't needed, and
`"never"` disables vertical scrolling altogether.
`initial_x`: The initial location of the scroll knob along the horizontal
axis. Ranges from 0 (left) to 1 (right).
`initial_y`: The initial location of the scroll knob along the vertical
axis. Ranges from 0 (top) to 1 (bottom).
`sticky_bottom`: If `True`, when the user has scrolled to the bottom and
the content of the `ScrollContainer` grows larger, the scroll bar
will automatically scroll to the bottom again.
## Examples
A minimal example of `ScrollContainer` displaying an icon:
```python
rio.ScrollContainer(
content=rio.Icon("material/castle", width=50, height=50),
height=10,
)
```
"""
content: rio.Component
_: KW_ONLY
scroll_x: Literal["never", "auto", "always"] = "auto"
scroll_y: Literal["never", "auto", "always"] = "auto"
initial_x: float = 0
initial_y: float = 0
sticky_bottom: bool = False
ScrollContainer._unique_id = "ScrollContainer-builtin"

View File

@@ -79,6 +79,7 @@ def serialize_and_host_component(component: rio.Component) -> JsonDoc:
"_python_type_": type(component).__name__,
"_key_": component.key,
"_rio_internal_": component._rio_internal_,
"_scroll_": [component.scroll_x, component.scroll_y],
}
# Accessing state properties is pretty slow, so we'll store these in local
@@ -149,6 +150,9 @@ def get_attribute_serializers(
Returns a dictionary of attribute names to their types that should be
serialized for the given component class.
"""
if cls is rio.Component:
return {}
serializers: dict[str, Serializer] = {}
for base_cls in reversed(cls.__bases__):