@frontmeans/drek
Version:
Document render kit
877 lines (854 loc) • 28.7 kB
JavaScript
import { newNamespaceAliaser, css__naming, NamespaceAliaser, NamespaceDef } from '@frontmeans/namespace-aliaser';
import { Supply } from '@proc7ts/supply';
import { isDocumentNode, nodeHost, isElementNode, nodeDocument, removeNodeContent } from '@frontmeans/dom-primitives';
import { onceOn, onEventBy, AfterEvent__symbol, afterThe, trackValue, EventEmitter, translateAfter_ } from '@proc7ts/fun-events';
import { valueProvider } from '@proc7ts/primitives';
import { newRenderSchedule, RenderScheduler, queuedRenderScheduler } from '@frontmeans/render-scheduler';
import { cxDefaultScoped, CxGlobals, cxSingle } from '@proc7ts/context-values';
let DrekContext$registrar = DrekContext$autoRegister;
/**
* @internal
*/
function DrekContext$register(context) {
DrekContext$registrar(context);
}
/**
* @internal
*/
function DrekContext$setRegistrar(registrar) {
const priorRegistrar = DrekContext$registrar;
DrekContext$registrar = registrar;
return priorRegistrar === DrekContext$autoRegister
? () => {
DrekContext$registrar = priorRegistrar;
return DrekContext$dontRegister;
}
: () => (DrekContext$registrar = priorRegistrar);
}
function DrekContext$dontRegister(_context) {
// Do not auto-register the context already failed to lift.
}
let DrekContext$autoRegistrar = DrekContext$autoRegisterFirst;
function DrekContext$autoRegister(context) {
DrekContext$autoRegistrar(context);
}
function DrekContext$autoRegisterFirst(context) {
const registered = [context];
DrekContext$autoRegistrar = DrekContext$createAutoRegistrar(registered);
Promise.resolve()
.then(() => {
DrekContext$autoRegistrar = DrekContext$autoRegisterFirst;
for (const context of registered) {
context.lift();
}
})
.catch(console.error);
}
function DrekContext$createAutoRegistrar(registered) {
return context => registered.push(context);
}
/**
* Executes a DOM builder function and then {@link DrekContext.lift lifts} all unrooted rendering contexts created by
* it.
*
* This helps to track a {@link DrekContext.whenConnected document connection} or {@link DrekContext.whenSettled
* settlement} of any unrooted rendering contexts that created before its node added to document or
* {@link DrekFragment rendered fragment}. This may happen e.g. when the rendering context {@link drekContextOf
* accessed} from inside a custom element constructor when calling `document.createElement('custom-element')`.
*
* @typeParam TResult - DOM builder result type.
* @param builder - A DOM builder function to call.
*
* @returns The value returned from DOM `builder` function.
*/
function drekBuild(builder) {
const registered = [];
const resetRegistrar = DrekContext$setRegistrar(context => registered.push(context));
try {
return builder();
}
finally {
const registrar = resetRegistrar();
for (const context of registered) {
const lifted = context.lift();
if (lifted === context) {
// Not lifted.
// Try next time.
registrar(context);
}
}
}
}
/**
* @internal
*/
const DrekPlacement$Status__symbol = /*#__PURE__*/ Symbol('DrekPlacement.status');
/**
* @internal
*/
class DrekPlacement$Status {
constructor(placement) {
this.placement = placement;
}
onceConnected() {
return (this.onceConnected = valueProvider(this.placement.readStatus.do(DrekPlacement$once(({ connected }) => connected))))();
}
whenConnected() {
return (this.whenConnected = valueProvider(this.onceConnected().do(onceOn)))();
}
}
function DrekPlacement$once(test) {
return input => onEventBy(receiver => {
let value = false;
input({
supply: receiver.supply,
receive(eventCtx, ...status) {
const newValue = test(...status);
if (newValue || value !== newValue) {
value = newValue;
receiver.receive(eventCtx, ...status);
}
},
});
});
}
/**
* A rendered content placement.
*
* @typeParam TStatus - A type of the tuple containing a rendered content status as its first element.
*/
class DrekPlacement {
constructor() {
this[DrekPlacement$Status__symbol] = new DrekPlacement$Status(this);
}
/**
* An alias of {@link readStatus}.
*
* @returns An `AfterEvent` keeper of content placement status.
*/
[AfterEvent__symbol]() {
return this.readStatus;
}
/**
* An `OnEvent` sender of placed content connection event.
*
* The registered receiver is called when placed content is {@link DrekContentStatus.connected connected}.
* If connected already the receiver is called immediately.
*/
get onceConnected() {
return this[DrekPlacement$Status__symbol].onceConnected();
}
/**
* An `OnEvent` sender of single placed content connection event.
*
* The registered receiver is called when placed content is {@link DrekContentStatus.connected connected}.
* If connected already the receiver is called immediately.
*
* In contrast to {@link onceConnected}, cuts off the event supply after sending the first event.
*/
get whenConnected() {
return this[DrekPlacement$Status__symbol].whenConnected();
}
}
/**
* Document rendering context.
*
* Can be obtained by {@link drekContextOf} function, or {@link DrekFragment#innerContext provided} by rendered
* fragment.
*
* There are three kinds of rendering contexts:
*
* 1. Document rendering context.
*
* Such context is always available in document and returned by {@link drekContextOf} function for any DOM node
* connected to the document.
*
* 2. Fragment content rendering context.
*
* It is created for each rendered fragment and is available via {@link DrekFragment#innerContext} property.
* The {@link drekContextOf} function returns this context for fragment's {@link DrekFragment#content content},
* as well as for each DOM node added to it.
*
* 3. Unrooted rendering context.
*
* When a DOM node is neither connected to a document, nor part of a rendered fragment's
* {@link DrekFragment#content content}, the {@link drekContextOf} function creates an unrooted context for the
* [root node] of that node.
*
* Unrooted context tracks a {@link DrekPlacement#whenConnected document connection} and
* {@link DrekContext#whenSettled settlement} semi-automatically. A {@link DrekContext#lift} method can be used
* to forcibly update them.
*
* Semi-automatic tracking means that each time an unrooted context {@link drekContextOf created}, it is registered
* for automatic lifting. The lifting happens either asynchronously, or synchronously right before the
* {@link drekBuild} function exit.
*
* Alternatively, a {@link drekLift} function can be used to lift a context of the [root node] after adding it to
* another one.
*
* [root node]: https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode
*
* @typeParam TStatus - A type of the tuple containing a context content status as its first element.
*/
class DrekContext extends DrekPlacement {
/**
* An `OnEvent` sender of a settlement event.
*
* Such event can be sent by {@link DrekFragment.settle rendered fragment}.
*
* The same as {@link whenConnected} by default.
*
* Cuts off the event supply after sending the first event.
*/
get whenSettled() {
return this.whenConnected;
}
}
/**
* @internal
*/
const DrekContext__symbol = /*#__PURE__*/ Symbol('DrekContext');
/**
* @internal
*/
class DrekContext$State {
constructor({ nsAlias, scheduler }) {
this._nsAlias = nsAlias;
this.nsAlias = ns => this._nsAlias(ns);
this._scheduler = scheduler;
this.scheduler = options => {
let scheduler = this._scheduler;
let schedule = scheduler(options);
return shot => {
if (scheduler !== this._scheduler) {
scheduler = this._scheduler;
schedule = scheduler(options);
}
return schedule(shot);
};
};
}
set({ nsAlias, scheduler }) {
this._nsAlias = nsAlias;
this._scheduler = scheduler;
}
}
/**
* @internal
*/
function DrekContext$ofDocument(document) {
const existing = document[DrekContext__symbol];
if (existing) {
return existing;
}
const state = new DrekContext$State({
nsAlias: newNamespaceAliaser(),
scheduler: newRenderSchedule,
});
const view = document.defaultView || window;
const scheduler = (options) => state.scheduler({
window: view,
...options,
});
const readStatus = afterThe({ connected: true });
class DrekContext$OfDocument extends DrekContext {
get fragment() {
return;
}
get window() {
return view;
}
get document() {
return document;
}
get nsAlias() {
return state.nsAlias;
}
get scheduler() {
return scheduler;
}
get readStatus() {
return readStatus;
}
lift() {
return this;
}
update({ nsAlias = state._nsAlias, scheduler = state._scheduler }) {
state.set({ nsAlias, scheduler });
return this;
}
}
return (document[DrekContext__symbol] = new DrekContext$OfDocument());
}
/**
* @internal
*/
function DrekContext$ofRootNode(root) {
return isDocumentNode(root) ? DrekContext$ofDocument(root) : DrekContext$unrooted(root);
}
function DrekContext$unrooted(root) {
const existing = root[DrekContext__symbol];
if (existing) {
return existing.lift();
}
const status = trackValue({ connected: false });
const settled = new EventEmitter();
let derivedCtx = DrekContext$ofDocument(root.ownerDocument /* Not a document, so `ownerDocument` is set */);
const scheduler = new DrekContext$State(derivedCtx);
let getFragment = () => derivedCtx.fragment;
let lift = (ctx) => {
const newRoot = root.getRootNode({ composed: true });
if (newRoot === root) {
return ctx;
}
const lifted = DrekContext$ofRootNode(newRoot);
root[DrekContext__symbol] = undefined;
getFragment = () => lifted.fragment;
scheduler.set(lifted);
lifted.whenSettled(status => settled.send(status)).cuts(settled);
status.by(lifted);
lift = _ctx => lifted;
derivedCtx = lifted;
return lifted;
};
class DrekContext$Unrooted extends DrekContext {
get fragment() {
return getFragment();
}
get window() {
return derivedCtx.window;
}
get document() {
return derivedCtx.document;
}
get nsAlias() {
return derivedCtx.nsAlias;
}
get scheduler() {
return scheduler.scheduler;
}
get readStatus() {
return status.read;
}
get whenSettled() {
return settled.on;
}
lift() {
return lift(this);
}
}
const context = (root[DrekContext__symbol] = new DrekContext$Unrooted());
DrekContext$register(context);
return context;
}
function drekContextOf(node) {
for (;;) {
const root = node.getRootNode({ composed: true });
if (root === node) {
return DrekContext$ofRootNode(node);
}
node = root;
}
}
const DrekCssClasses__symbol = /*#__PURE__*/ Symbol('DrekCssClasses');
function drekCssClassesOf(element) {
return (element[DrekCssClasses__symbol]
|| (element[DrekCssClasses__symbol] = new DrekCssClasses$(element)));
}
class DrekCssClasses$ {
constructor(_element) {
this._element = _element;
this._uses = new Map();
this._context = drekContextOf(_element);
}
add(className, user) {
return this._add(this._context, className, user);
}
_add({ nsAlias, scheduler }, className, user) {
const supply = user ? user.supply : new Supply();
if (supply.isOff) {
return supply;
}
const name = css__naming.name(className, nsAlias);
const schedule = scheduler({ node: this._element });
const use = this._use(name);
const render = () => {
if (use.n) {
if (!use.s) {
this._element.classList.add(name);
use.s = 1;
}
}
else {
if (use.s && !use.i) {
// Do not remove the class if it present initially.
this._element.classList.remove(name);
use.s = 0;
}
this._uses.delete(name);
}
};
if (use.n === 1) {
schedule(render);
}
return supply.whenOff(() => {
if (!--use.n) {
schedule(render);
}
});
}
_use(name) {
let use = this._uses.get(name);
if (use) {
++use.n;
}
else {
if (this._element.classList.contains(name)) {
use = {
i: 1,
n: 1,
s: 1,
};
}
else {
use = {
i: 0,
n: 1,
s: 0,
};
}
this._uses.set(name, use);
}
return use;
}
has(className) {
return this._has(this._context, className);
}
_has({ nsAlias }, className) {
const name = css__naming.name(className, nsAlias);
const use = this._uses.get(name);
return use ? !!use.n || !!use.i : this._element.classList.contains(name);
}
renderIn(context) {
return context !== this._context
? {
add: className => this._add(context, className),
has: className => this._has(context, className),
renderIn: newContext => this.renderIn(newContext),
}
: this;
}
}
/**
* Creates a rendering context based on another one.
*
* @typeParam TStatus - A type of the tuple containing a context content status as its first element.
* @param base - Base rendering context.
* @param update - Context update.
*
* @returns Updated rendering context, or the `base` one if nothing to update.
*/
function deriveDrekContext(base, update = {}) {
const { nsAlias: initialNsAlias = base.nsAlias, scheduler: initialScheduler = base.scheduler } = update;
if (initialNsAlias === base.nsAlias && initialScheduler === base.scheduler) {
return base;
}
const state = new DrekContext$State({
nsAlias: initialNsAlias,
scheduler: initialScheduler,
});
let lift = (derived) => {
const lifted = base.lift();
if (lifted === base) {
return derived;
}
state.set(lifted);
lift = _derived => lifted;
return lifted;
};
class DrekContext$Derived extends DrekContext {
get fragment() {
return base.fragment;
}
get window() {
return base.window;
}
get document() {
return base.document;
}
get nsAlias() {
return state.nsAlias;
}
get scheduler() {
return state.scheduler;
}
get readStatus() {
return base.readStatus;
}
lift() {
return lift(this);
}
}
return new DrekContext$Derived();
}
/**
* Finds a host element of the given DOM node with respect to rendering targets.
*
* Crosses shadow DOM and {@link DrekFragment rendered fragment} bounds. In the latter case returns a
* {@link DrekTarget.host rendering target host} instead of the document fragment.
*
* @param node - Target DOM element.
*
* @returns Either parent element of the given node, or `undefined` when not found.
*/
function drekHost(node) {
var _a, _b;
const host = nodeHost(node);
if (host) {
return host;
}
const parent = node.parentNode || node;
const renderHost = (_b = (_a = parent[DrekContext__symbol]) === null || _a === void 0 ? void 0 : _a.fragment) === null || _b === void 0 ? void 0 : _b.target.host;
return !renderHost || isElementNode(renderHost) ? renderHost : drekHost(renderHost);
}
function drekLift(node) {
var _a;
(_a = node[DrekContext__symbol]) === null || _a === void 0 ? void 0 : _a.lift();
return node;
}
/**
* Context entry containing {@link DocumentRenderKit} instance.
*
* Initiated lazily. So the replacement should be provided before the kit used for the first time.
*
* Constructs global render kit instance by default.
*/
const DocumentRenderKit = {
perContext: /*#__PURE__*/ cxDefaultScoped(CxGlobals,
/*#__PURE__*/ cxSingle({
byDefault: DocumentRenderKit$byDefault,
})),
toString: () => '[DocumentRenderKit]',
};
function DocumentRenderKit$byDefault(target) {
const docs = new WeakMap();
const initDoc = (doc) => {
if (!docs.get(doc)) {
docs.set(doc, 1);
drekContextOf(doc).update({
nsAlias: target.get(NamespaceAliaser),
scheduler: target.get(RenderScheduler),
});
}
};
return {
contextOf(node) {
initDoc(nodeDocument(node));
return drekContextOf(node);
},
};
}
/**
* Default Drek namespace definition.
*/
const Drek__NS = /*#__PURE__*/ new NamespaceDef('https://frontmeans.github.io/ns/drek', 'drek');
/**
* @internal
*/
const DrekFragment$Context__symbol = /*#__PURE__*/ Symbol('DrekFragment.context');
/**
* @internal
*/
class DrekFragment$Context extends DrekContext {
static attach(fragment, target, { nsAlias = target.context.nsAlias, scheduler = queuedRenderScheduler, content, }) {
if (!content) {
content = target.context.document.createDocumentFragment();
}
else if (content.getRootNode({ composed: true }) !== content) {
throw new TypeError('Not a standalone DocumentFragment');
}
else if (content[DrekContext__symbol]) {
throw new TypeError('Can not render content of another fragment');
}
return (content[DrekContext__symbol] = new DrekFragment$Context(fragment, target, content, nsAlias, scheduler));
}
constructor(_fragment, _target, _content, nsAlias, scheduler) {
super();
this._fragment = _fragment;
this._target = _target;
this._content = _content;
this._status = trackValue([
{ connected: false, withinFragment: 'added' },
]);
this._settled = new EventEmitter();
this._rendered = new EventEmitter();
this._getFragment = () => _fragment;
this._lift = this;
this.readStatus = this._status.read.do(translateAfter_((send, status) => send(...status)));
this._state = new DrekContext$State({ nsAlias, scheduler });
this.scheduler = this._createSchedule.bind(this);
this.whenConnected((...status) => {
// `whenSettled` is the same as `whenConnected` now.
this._whenSettled = this.whenConnected;
// Send a settlement event one last time.
this._settled.send(...status);
});
}
get fragment() {
return this._getFragment();
}
get window() {
return this._target.context.window;
}
get document() {
return this._target.context.document;
}
get nsAlias() {
return this._state.nsAlias;
}
get whenSettled() {
return this._whenSettled || (this._whenSettled = this._settled.on.do(onceOn));
}
lift() {
return this._lift;
}
_settle() {
this.scheduler()(_ => {
this._settled.send(...this._status.it);
});
}
_render() {
// Make the `.lift()` method return the target context.
this._lift = this._target.context;
// Signal the rendering started.
this._status.it = [{ connected: false, withinFragment: 'rendered' }];
const schedule = this._state._scheduler();
this._state.set(this._target.context);
schedule(({ postpone }) => {
// Await for all scheduled shots to render.
postpone(() => {
this._target.context.scheduler()(() => {
// Place the rendered content within target's scheduler.
const placement = this._target.placeContent(this._content);
// Update target fragment.
this._getFragment = () => placement.fragment;
// Reset the inner context.
this._content[DrekContext__symbol] = this._fragment[DrekFragment$Context__symbol] =
new DrekFragment$Context(this._fragment, this._target, this._content, this.nsAlias, this.scheduler);
// Derive the status from the target context.
this._status.by(placement, (...status) => afterThe(status));
// Send `whenRendered` event.
this._rendered.send(placement);
});
});
});
return this;
}
_whenRendered() {
return (this._whenRendered = valueProvider(this._rendered.on.do(onceOn)))();
}
_createSchedule(options = {}) {
const schedule = this._state.scheduler({
...options,
window: this.window,
});
return shot => schedule(execution => shot(this._createExecution(execution)));
}
_createExecution(execution) {
const fragmentExecution = {
...execution,
fragment: this._fragment,
content: this._content,
postpone(postponed) {
execution.postpone(_execution => postponed(fragmentExecution));
},
};
return fragmentExecution;
}
}
/**
* A fragment of DOM tree, which content is to be {@link DrekTarget#placeContent placed} to the document once rendered.
*
* Provides separate {@link DrekContext rendering context} for its nodes.
*
* @typeParam TStatus - A type of the tuple containing a rendered content status as its first element.
*/
class DrekFragment {
/**
* Rendering target.
*
* When the fragment is {@link render rendered}, the rendered content is placed to this target.
*/
get target() {
return this[DrekFragment$Context__symbol]._target;
}
/**
* Inner rendering context of the fragment.
*
* This context as available to the {@link content} nodes.
*
* This context updated each time the fragment is {@link render rendered}.
*/
get innerContext() {
return this[DrekFragment$Context__symbol];
}
/**
* The content of the fragment.
*/
get content() {
return this[DrekFragment$Context__symbol]._content;
}
/**
* An `OnEvent` sender of fragment rendering event.
*
* Sends a fragment content {@link DrekTarget#placeContent placement} to {@link target} when the fragment is actually
* {@link render rendered}.
*
* Cuts off the event supply after sending the first event.
*/
get whenRendered() {
return this[DrekFragment$Context__symbol]._whenRendered();
}
/**
* Construct rendered fragment.
*
* @param target - Rendering target to place the
* @param options - Fragment rendering options.
*/
constructor(target, options = {}) {
this[DrekFragment$Context__symbol] = DrekFragment$Context.attach(this, target, options);
}
/**
* Settles previously rendered content.
*
* A {@link DrekContext#whenSettled} event sender notifies its receivers once settled.
*
* @returns `this` instance.
*/
settle() {
this[DrekFragment$Context__symbol]._settle();
return this;
}
/**
* Renders this fragment by {@link DrekTarget#placeContent placing} its {@link DrekFragmentRenderExecution#content
* content} to {@link target rendering target}.
*
* Once rendered the fragment {@link content} becomes empty and can be reused. Its rendering context is updated.
*
* @returns Content {@link DrekTarget#placeContent placement} to {@link target}.
*/
render() {
return this[DrekFragment$Context__symbol]._render();
}
}
/**
* Creates a rendering target that appends content to parent node.
*
* @param host - A node to append content to.
* @param context - Custom rendering context. Defaults to `host` node context.
*
* @returns Rendering target.
*/
function drekAppender(host, context = drekContextOf(host)) {
return {
context,
host,
placeContent(content) {
host.appendChild(content);
return context;
},
};
}
/**
* Creates a rendering target that charges rendered content prior to placing it to another target.
*
* @typeParam TStatus - A tuple type reflecting a content {@link DrekContentStatus placement status}.
* @param target - Rendering target of charged content.
* @param spec - Content charging options.
*
* @returns Rendering target.
*/
function drekCharger(target, spec) {
const charger = DrekCharger$custom(target, spec);
return {
context: target.context,
host: target.host,
placeContent(content) {
return charger.charge(content, target);
},
};
}
function DrekCharger$custom(target, spec) {
if (typeof spec === 'function') {
return DrekCharger$custom(target, spec(target));
}
if (typeof spec === 'string') {
return DrekCharger$commentWrapper(target, spec);
}
if (spec) {
return spec;
}
return DrekCharger$commentWrapper(target, Math.random().toString(32).substr(2));
}
function DrekCharger$commentWrapper({ context: { document } }, rem) {
let wrapContent = (content, target) => {
const start = document.createComment(` [[ ${rem} [[ `);
const end = document.createComment(` ]] ${rem} ]] `);
let placement;
wrapContent = (content, _target) => {
const range = document.createRange();
range.setStartAfter(start);
range.setEndBefore(end);
range.deleteContents();
range.insertNode(content);
return placement;
};
const fragment = document.createDocumentFragment();
fragment.append(start, content, end);
return (placement = target.placeContent(fragment));
};
return {
charge: (content, target) => wrapContent(content, target),
};
}
/**
* Creates a rendering target that inserts content to parent node at particular position.
*
* @param host - A node to insert content to.
* @param before - A child node of `host` one to insert the content before, or `null` to append it as the last child
* of `host` node.
* @param context - Custom rendering context. Defaults to `host` node context.
*
* @returns Rendering target.
*/
function drekInserter(host, before, context = drekContextOf(host)) {
return {
context,
host,
placeContent(content) {
host.insertBefore(content, before);
return context;
},
};
}
/**
* Creates a rendering target that replaces content of the `host` node.
*
* @param host - A node to replace the content of.
* @param context - Custom rendering context. Defaults to `host` node context.
*
* @returns Rendering target.
*/
function drekReplacer(host, context = drekContextOf(host)) {
return {
context,
host,
placeContent(content) {
removeNodeContent(host);
host.appendChild(content);
return context;
},
};
}
export { DocumentRenderKit, DrekContext, DrekFragment, DrekPlacement, Drek__NS, deriveDrekContext, drekAppender, drekBuild, drekCharger, drekContextOf, drekCssClassesOf, drekHost, drekInserter, drekLift, drekReplacer };
//# sourceMappingURL=drek.js.map