mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
Initial config with a few files that build on client and server side.
This commit is contained in:
193
app/client/ui/PagePanels.ts
Normal file
193
app/client/ui/PagePanels.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Note that it assumes the presence of cssVars.cssRootVars on <body>.
|
||||
*/
|
||||
import {resizeFlexVHandle} from 'app/client/ui/resizeHandle';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {dom, DomArg, noTestId, Observable, styled, TestId} from "grainjs";
|
||||
|
||||
export interface PageSidePanel {
|
||||
// Note that widths need to start out with a correct default in JS (having them in CSS is not
|
||||
// enough), needed for open/close transitions.
|
||||
panelWidth: Observable<number>;
|
||||
panelOpen: Observable<boolean>;
|
||||
hideOpener?: boolean; // If true, don't show the opener handle.
|
||||
header: DomArg;
|
||||
content: DomArg;
|
||||
}
|
||||
|
||||
export interface PageContents {
|
||||
leftPanel: PageSidePanel;
|
||||
rightPanel?: PageSidePanel; // If omitted, the right panel isn't shown at all.
|
||||
|
||||
headerMain: DomArg;
|
||||
contentMain: DomArg;
|
||||
|
||||
onResize?: () => void; // Callback for when either pane is opened, closed, or resized.
|
||||
testId?: TestId;
|
||||
}
|
||||
|
||||
export function pagePanels(page: PageContents) {
|
||||
const testId = page.testId || noTestId;
|
||||
const left = page.leftPanel;
|
||||
const right = page.rightPanel;
|
||||
const onResize = page.onResize || (() => null);
|
||||
|
||||
return cssPageContainer(
|
||||
cssLeftPane(
|
||||
testId('left-panel'),
|
||||
cssTopHeader(left.header),
|
||||
left.content,
|
||||
|
||||
dom.style('width', (use) => use(left.panelOpen) ? use(left.panelWidth) + 'px' : ''),
|
||||
|
||||
// Opening/closing the left pane, with transitions.
|
||||
cssLeftPane.cls('-open', left.panelOpen),
|
||||
transition(left.panelOpen, {
|
||||
prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; },
|
||||
run(elem, open) { elem.style.marginRight = ''; },
|
||||
finish: onResize,
|
||||
}),
|
||||
),
|
||||
|
||||
// Resizer for the left pane.
|
||||
// TODO: resizing to small size should collapse. possibly should allow expanding too
|
||||
cssResizeFlexVHandle(
|
||||
{target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize(); }},
|
||||
testId('left-resizer'),
|
||||
dom.show(left.panelOpen)),
|
||||
|
||||
// Show plain border when the resize handle is hidden.
|
||||
cssResizeDisabledBorder(dom.hide(left.panelOpen)),
|
||||
|
||||
cssMainPane(
|
||||
cssTopHeader(
|
||||
(left.hideOpener ? null :
|
||||
cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
|
||||
testId('left-opener'),
|
||||
dom.on('click', () => toggleObs(left.panelOpen)))
|
||||
),
|
||||
|
||||
page.headerMain,
|
||||
|
||||
(!right || right.hideOpener ? null :
|
||||
cssPanelOpener('PanelLeft', cssPanelOpener.cls('-open', right.panelOpen),
|
||||
testId('right-opener'),
|
||||
dom.on('click', () => toggleObs(right.panelOpen)))
|
||||
),
|
||||
),
|
||||
page.contentMain,
|
||||
),
|
||||
(right ? [
|
||||
// Resizer for the right pane.
|
||||
cssResizeFlexVHandle(
|
||||
{target: 'right', onSave: (val) => { right.panelWidth.set(val); onResize(); }},
|
||||
testId('right-resizer'),
|
||||
dom.show(right.panelOpen)),
|
||||
|
||||
cssRightPane(
|
||||
testId('right-panel'),
|
||||
cssTopHeader(right.header),
|
||||
right.content,
|
||||
|
||||
dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''),
|
||||
|
||||
// Opening/closing the right pane, with transitions.
|
||||
cssRightPane.cls('-open', right.panelOpen),
|
||||
transition(right.panelOpen, {
|
||||
prepare(elem, open) { elem.style.marginLeft = (open ? -1 : 1) * right.panelWidth.get() + 'px'; },
|
||||
run(elem, open) { elem.style.marginLeft = ''; },
|
||||
finish: onResize,
|
||||
}),
|
||||
)] : null
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleObs(boolObs: Observable<boolean>) {
|
||||
boolObs.set(!boolObs.get());
|
||||
}
|
||||
|
||||
const cssVBox = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`);
|
||||
const cssHBox = styled('div', `
|
||||
display: flex;
|
||||
`);
|
||||
const cssPageContainer = styled(cssHBox, `
|
||||
position: absolute;
|
||||
isolation: isolate; /* Create a new stacking context */
|
||||
z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
background-color: ${colors.lightGrey};
|
||||
`);
|
||||
export const cssLeftPane = styled(cssVBox, `
|
||||
position: relative;
|
||||
background-color: ${colors.lightGrey};
|
||||
width: 48px;
|
||||
margin-right: 0px;
|
||||
overflow: hidden;
|
||||
transition: margin-right 0.4s;
|
||||
&-open {
|
||||
width: 240px;
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
`);
|
||||
const cssMainPane = styled(cssVBox, `
|
||||
position: relative;
|
||||
flex: 1 1 0px;
|
||||
min-width: 0px;
|
||||
background-color: white;
|
||||
z-index: 1;
|
||||
`);
|
||||
const cssRightPane = styled(cssVBox, `
|
||||
position: relative;
|
||||
background-color: ${colors.lightGrey};
|
||||
width: 0px;
|
||||
margin-left: 0px;
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.4s;
|
||||
z-index: 0;
|
||||
&-open {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
}
|
||||
`);
|
||||
const cssTopHeader = styled('div', `
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid ${colors.mediumGrey};
|
||||
`);
|
||||
const cssResizeFlexVHandle = styled(resizeFlexVHandle, `
|
||||
--resize-handle-color: ${colors.mediumGrey};
|
||||
--resize-handle-highlight: ${colors.lightGreen};
|
||||
`);
|
||||
const cssResizeDisabledBorder = styled('div', `
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background-color: ${colors.mediumGrey};
|
||||
`);
|
||||
const cssPanelOpener = styled(icon, `
|
||||
flex: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 8px 8px;
|
||||
cursor: pointer;
|
||||
-webkit-mask-size: 16px 16px;
|
||||
background-color: ${colors.lightGreen};
|
||||
transition: transform 0.4s;
|
||||
&:hover { background-color: ${colors.darkGreen}; }
|
||||
&-open { transform: rotateY(180deg); }
|
||||
`);
|
||||
47
app/client/ui/mouseDrag.ts
Normal file
47
app/client/ui/mouseDrag.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Small utility to help with processing mouse-drag events. Usage is:
|
||||
* dom('div', mouseDrag((startEvent, elem) => ({
|
||||
* onMove(moveEvent) { ... },
|
||||
* onStop(stopEvent) { ... },
|
||||
* })));
|
||||
*
|
||||
* The passed-in callback is called on 'mousedown' events. It may return null to ignore the event.
|
||||
* Otherwise, it should return a handler for mousemove/mouseup: we will then subscribe to these
|
||||
* events, and clean up on mouseup.
|
||||
*/
|
||||
import {dom, DomElementMethod, IDisposable} from "grainjs";
|
||||
|
||||
|
||||
export interface MouseDragHandler {
|
||||
onMove(moveEv: MouseEvent): void;
|
||||
onStop(endEv: MouseEvent): void;
|
||||
}
|
||||
|
||||
export type MouseDragStart = (startEv: MouseEvent, elem: Element) => MouseDragHandler|null;
|
||||
|
||||
export function mouseDragElem(elem: HTMLElement, onStart: MouseDragStart): IDisposable {
|
||||
|
||||
// This prevents the default text-drag behavior when elem is part of a text selection.
|
||||
elem.style.userSelect = 'none';
|
||||
|
||||
return dom.onElem(elem, 'mousedown', (ev, el) => _startDragging(ev, el, onStart));
|
||||
}
|
||||
export function mouseDrag(onStart: MouseDragStart): DomElementMethod {
|
||||
return (elem) => { mouseDragElem(elem, onStart); };
|
||||
}
|
||||
|
||||
function _startDragging(startEv: MouseEvent, elem: Element, onStart: MouseDragStart) {
|
||||
const dragHandler = onStart(startEv, elem);
|
||||
if (dragHandler) {
|
||||
|
||||
const {onMove, onStop} = dragHandler;
|
||||
const upLis = dom.onElem(document, 'mouseup', stop, {useCapture: true});
|
||||
const moveLis = dom.onElem(document, 'mousemove', onMove, {useCapture: true});
|
||||
|
||||
function stop(stopEv: MouseEvent) {
|
||||
moveLis.dispose();
|
||||
upLis.dispose();
|
||||
onStop(stopEv);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
app/client/ui/resizeHandle.ts
Normal file
155
app/client/ui/resizeHandle.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Exports resizeFlexVHandle() for resizing flex items. The returned handle should be attached to
|
||||
* a flexbox container next to the item that needs resizing. Usage:
|
||||
*
|
||||
* dom('div.flex',
|
||||
* dom('div.child-to-be-resized', ...),
|
||||
* resizeFlexVHandle({target: 'left', onSave: (width) => { ... })
|
||||
* dom('div.other-children', ...),
|
||||
* )
|
||||
*
|
||||
* The .target parameter determines whether to resize the left or right sibling of the handle. The
|
||||
* handle shows up as a 1px line of color --resize-handle-color. On hover and while resizing, it
|
||||
* changes to --resize-handle-highlight.
|
||||
*
|
||||
* The handle may be dragged to change the width of the target. It sets style.width while
|
||||
* dragging, and calls .onSave(width) at the end, and optionally .onDrag(width) while dragging.
|
||||
*
|
||||
* You may limit the width of the target with min-width, max-width properties as usual.
|
||||
*
|
||||
* At the moment, flexbox width resizing is the only need, but the same approach is intended to be
|
||||
* easily extended to non-flexbox situation, and to height-resizing.
|
||||
*/
|
||||
import {mouseDrag} from 'app/client/ui/mouseDrag';
|
||||
import {DomElementArg, styled} from "grainjs";
|
||||
|
||||
export type ChangeFunc = (value: number) => void;
|
||||
export type Edge = 'left' | 'right';
|
||||
|
||||
export interface IResizeFlexOptions {
|
||||
// Whether to change the width of the flex item to the left or to the right of this handle.
|
||||
target: 'left'|'right';
|
||||
onDrag?(value: number): void;
|
||||
onSave(value: number): void;
|
||||
}
|
||||
|
||||
export interface IResizeOptions {
|
||||
prop: 'width' | 'height';
|
||||
sign: 1 | -1;
|
||||
getTarget(handle: Element): Element|null;
|
||||
onDrag?(value: number): void;
|
||||
onSave(value: number): void;
|
||||
}
|
||||
|
||||
// See module documentation for usage.
|
||||
export function resizeFlexVHandle(options: IResizeFlexOptions, ...args: DomElementArg[]): Element {
|
||||
const resizeOptions: IResizeOptions = {
|
||||
prop: 'width',
|
||||
sign: options.target === 'left' ? 1 : -1,
|
||||
getTarget(handle: Element) {
|
||||
return options.target === 'left' ? handle.previousElementSibling : handle.nextElementSibling;
|
||||
},
|
||||
onDrag: options.onDrag,
|
||||
onSave: options.onSave,
|
||||
};
|
||||
return cssResizeFlexVHandle(
|
||||
mouseDrag((ev, handle) => onResizeStart(ev, handle, resizeOptions)),
|
||||
...args);
|
||||
}
|
||||
|
||||
// The core of the implementation. This is intended to be very general, so as to be adaptable to
|
||||
// other kinds of resizing (edge of parent, resizing height) by only attaching this to the
|
||||
// mouseDrag() event with suitable options. See resizeFlexVHandle().
|
||||
function onResizeStart(startEv: MouseEvent, handle: Element, options: IResizeOptions) {
|
||||
const target = options.getTarget(handle) as HTMLElement|null;
|
||||
if (!target) { return null; }
|
||||
|
||||
const {sign, prop, onDrag, onSave} = options;
|
||||
const startSize = getComputedSize(target, prop);
|
||||
|
||||
// Set the body cursor to that on the handle, so that it doesn't jump to different shapes as
|
||||
// the mouse moves temporarily outside of the handle. (Approach from jqueryui-resizable.)
|
||||
const startBodyCursor = document.body.style.cursor;
|
||||
document.body.style.cursor = window.getComputedStyle(handle).cursor;
|
||||
|
||||
handle.classList.add(cssResizeDragging.className);
|
||||
|
||||
return {
|
||||
// While moving, just adjust the size of the target, relying on min-width/max-width for
|
||||
// constraints.
|
||||
onMove(ev: MouseEvent) {
|
||||
target.style[prop] = (startSize + sign * (ev.pageX - startEv.pageX)) + 'px';
|
||||
if (onDrag) { onDrag(getComputedSize(target, prop)); }
|
||||
},
|
||||
|
||||
// At the end, undo temporary changes, and call onSave() with the actual size.
|
||||
onStop(ev: MouseEvent) {
|
||||
handle.classList.remove(cssResizeDragging.className);
|
||||
|
||||
// Restore the body cursor to what it was.
|
||||
document.body.style.cursor = startBodyCursor;
|
||||
|
||||
target.style[prop] = (startSize + sign * (ev.pageX - startEv.pageX)) + 'px';
|
||||
onSave(getComputedSize(target, prop));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Compute the CSS width or height of the element. If element.style[prop] is set to it, it should
|
||||
// be unchanged. (Note that when an element has borders or padding, the size from
|
||||
// getBoundingClientRect() would be different, and isn't suitable for style[prop].)
|
||||
function getComputedSize(elem: Element, prop: 'width'|'height'): number {
|
||||
const sizePx = window.getComputedStyle(elem)[prop];
|
||||
const sizeNum = sizePx && parseFloat(sizePx);
|
||||
// If we can't get the size, fall back to getBoundingClientRect().
|
||||
return Number.isFinite(sizeNum as number) ? sizeNum as number : elem.getBoundingClientRect()[prop];
|
||||
}
|
||||
|
||||
const cssResizeFlexVHandle = styled('div', `
|
||||
position: relative;
|
||||
flex: none;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
|
||||
/* width with negative margins leaves exactly 1px line in the middle */
|
||||
width: 7px;
|
||||
margin: auto -3px;
|
||||
|
||||
/* two highlighted 1px lines are placed in the middle, normal and highlighted one */
|
||||
&::before, &::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
left: 3px;
|
||||
}
|
||||
&::before {
|
||||
background-color: var(--resize-handle-color, lightgrey);
|
||||
}
|
||||
/* the highlighted line is shown on hover with opacity transition */
|
||||
&::after {
|
||||
background-color: var(--resize-handle-highlight, black);
|
||||
opacity: 0;
|
||||
transition: opacity linear 0.2s;
|
||||
}
|
||||
&:hover::after {
|
||||
opacity: 1;
|
||||
transition: opacity linear 0.2s 0.2s;
|
||||
}
|
||||
/* the highlighted line is also always shown while dragging */
|
||||
&-dragging::after {
|
||||
opacity: 1;
|
||||
transition: none !important;
|
||||
}
|
||||
`);
|
||||
|
||||
// May be applied to Handle class to show the highlighted line while dragging.
|
||||
const cssResizeDragging = styled('div', `
|
||||
&::after {
|
||||
opacity: 1;
|
||||
transition: none !important;
|
||||
}
|
||||
`);
|
||||
116
app/client/ui/transitions.ts
Normal file
116
app/client/ui/transitions.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* A helper for CSS transitions. Usage:
|
||||
*
|
||||
* dom(...,
|
||||
* transition(obs, {
|
||||
* prepare(elem, val) { SET STYLE WITH TRANSITIONS OFF },
|
||||
* run(elem, val) { SET STYLE WITH TRANSITIONS ON },
|
||||
* // finish(elem, val) { console.log("transition finished"); }
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* Allows modifiying styles in response to changes in an observable. Any time the observable
|
||||
* changes, the prepare() callback allows preparing the styles, with transitions off. Then
|
||||
* the run() callback can set the styles that will be subject to transitions.
|
||||
*
|
||||
* The actual transition styles (e.g. 'transition: width 0.2s') should be set on elem elsewhere.
|
||||
*
|
||||
* The optional finish() callback is called when the transition ends. If CSS transitions are set
|
||||
* on multiple properties, only the first one is used to determine when the transition ends.
|
||||
*
|
||||
* All callbacks are called with the element this is attached to, and the value of the observable.
|
||||
*
|
||||
* The recommendation is to avoid setting styles at transition end, since it's not entirely
|
||||
* reliable; it's better to arrange CSS so that the desired final styles can be set in run(). The
|
||||
* finish() callback is intended to tell other code that the element is in its transitioned state.
|
||||
*
|
||||
* When the observable changes during a transition, the prepare() callback is skipped, the run()
|
||||
* callback is called, and the finish() callback delayed until the new transition ends.
|
||||
*
|
||||
* If other styles are changed (or css classes applied) when the observable changes, subscriptions
|
||||
* triggered BEFORE the transition() subscription are applied with transitions OFF (like
|
||||
* prepare()); those triggered AFTER are subject to transitions (like run()).
|
||||
*/
|
||||
|
||||
import {BindableValue, Disposable, dom, DomElementMethod, subscribeElem} from 'grainjs';
|
||||
|
||||
export interface ITransitionLogic<T = void> {
|
||||
prepare(elem: HTMLElement, value: T): void;
|
||||
run(elem: HTMLElement, value: T): void;
|
||||
finish?(elem: HTMLElement, value: T): void;
|
||||
}
|
||||
|
||||
export function transition<T>(obs: BindableValue<T>, trans: ITransitionLogic<T>): DomElementMethod {
|
||||
const {prepare, run, finish} = trans;
|
||||
let watcher: TransitionWatcher|null = null;
|
||||
let firstCall = true;
|
||||
return (elem) => subscribeElem<T>(elem, obs, (val) => {
|
||||
// First call is initialization, don't treat it as a transition
|
||||
if (firstCall) { firstCall = false; return; }
|
||||
if (watcher) {
|
||||
watcher.reschedule();
|
||||
} else {
|
||||
watcher = new TransitionWatcher(elem);
|
||||
watcher.onDispose(() => {
|
||||
watcher = null;
|
||||
if (finish) { finish(elem, val); }
|
||||
});
|
||||
|
||||
// Call prepare() with transitions turned off.
|
||||
const prior = elem.style.transitionProperty;
|
||||
elem.style.transitionProperty = 'none';
|
||||
prepare(elem, val);
|
||||
|
||||
// Recompute styles while transitions are off. See https://stackoverflow.com/a/16575811/328565
|
||||
// for explanation and https://stackoverflow.com/a/31862081/328565 for the recommendation used
|
||||
// here to trigger a style computation without a reflow.
|
||||
window.getComputedStyle(elem).opacity; // tslint:disable-line:no-unused-expression
|
||||
|
||||
// Restore transitions before run().
|
||||
elem.style.transitionProperty = prior;
|
||||
}
|
||||
run(elem, val);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for waiting for an active transition to end. Beyond listening to 'transitionend', it
|
||||
* does a few things:
|
||||
*
|
||||
* (1) if the transition lists multiple properties, only the first property and duration are used
|
||||
* ('transitionend' on additional properties is inconsistent across browsers).
|
||||
* (2) if 'transitionend' fails to fire, the transition is considered ended when duration elapses,
|
||||
* plus 10ms grace period (to let 'transitionend' fire first normally).
|
||||
* (3) reschedule() allows resetting the timer if a new transition is known to have started.
|
||||
*
|
||||
* When the transition ends, TransitionWatcher disposes itself. Its onDispose() method allows
|
||||
* registering callbacks.
|
||||
*/
|
||||
class TransitionWatcher extends Disposable {
|
||||
private _propertyName: string;
|
||||
private _durationMs: number;
|
||||
private _timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(elem: Element) {
|
||||
super();
|
||||
const style = window.getComputedStyle(elem);
|
||||
this._propertyName = style.transitionProperty.split(",")[0].trim();
|
||||
|
||||
// Gets the duration of the transition from the styles of the given element, in ms.
|
||||
// FF and Chrome both return transitionDuration in seconds (e.g. "0.150s") In case of multiple
|
||||
// values, e.g. "0.150s, 2s"; parseFloat will just parse the first one.
|
||||
const duration = style.transitionDuration;
|
||||
this._durationMs = ((duration && parseFloat(duration)) || 0) * 1000;
|
||||
|
||||
this.autoDispose(dom.onElem(elem, 'transitionend', (e) =>
|
||||
(e.propertyName === this._propertyName) && this.dispose()));
|
||||
|
||||
this._timer = setTimeout(() => this.dispose(), this._durationMs + 10);
|
||||
this.onDispose(() => clearTimeout(this._timer));
|
||||
}
|
||||
|
||||
public reschedule() {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = setTimeout(() => this.dispose(), this._durationMs + 10);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user