Initial config with a few files that build on client and server side.

This commit is contained in:
Dmitry S
2020-05-20 00:50:46 -04:00
commit ec182792be
20 changed files with 4762 additions and 0 deletions

193
app/client/ui/PagePanels.ts Normal file
View 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); }
`);

View 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);
}
}
}

View 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;
}
`);

View 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);
}
}