aspen-decorations
Version:
Complex styling for react-aspen w/ inheritance and negations
380 lines (328 loc) • 14.9 kB
text/typescript
import { FileOrDir } from 'aspen-core'
import { DisposablesComposite } from 'notificar'
import { Decoration } from './Decoration'
import { TargetMatchMode } from './types'
/**
* The "consumer level" part of Decoration
*
* `ClasslistComposite` contains composited classnames from all the decorations applicable to a target
*
* The composite includes all the applicable inheritances and negations.
*
* Note that due to performance considerations, `ClasslistComposite` is one of the few cases where
* we *do not* use `Disposables` for event management and instead use old school `addChangeListener`/`removeChangeListener` methods.
*
* Remember to pass same **named** function reference to `addChangeListener` and `removeChangeLiseneer` while subscribing and unsusbscribing
* in `componentDidMount` and `componentWillUnmount` respectively.
*
* Note: You should also implement a `componentDidUpdate` hook where you can unsubscribe from previous decoration object and subscribe to the new one.
*/
export class ClasslistComposite {
public classlist: ReadonlyArray<string>
/** @internal */
constructor(
/**
* Registers a function to be called when composited classlist changes
*
* *⚠ Remember not to use anonymous function!! (pass a named function reference instead)*
*/
public readonly addChangeListener: (namedCallback: () => void) => void,
/**
* Unregisters a previsously registered classlist change listener
*
* *⚠ Remember not to use anonymous function!! (pass a named function reference instead)*
*/
public readonly removeChangeListener: (namedCallback: () => void) => void,
) { }
}
export enum DecorationCompositeType {
Applicable = 1,
Inheritable,
}
export enum ChangeReason {
UnTargetDecoration = 1,
TargetDecoration,
}
/**
* Compositer for decorations
*
* When multiple `Decoration`s are applied to a target, they get grouped into a `DecorationComposite`
*
* @internal
*/
export class DecorationComposite {
// speaking of memory consumption, next three Maps aren't constructed for each DecorationComposite unless it becomes self owned (due to special application or negation)
public renderedDecorations: Map<Decoration, DisposablesComposite>
public targetedDecorations: Set<Decoration>
public negatedDecorations: Set<Decoration>
public parent: DecorationComposite
public compositeCssClasslist: ClasslistComposite
private target: FileOrDir
private type: DecorationCompositeType
private selfOwned: boolean
private linkedComposites: Set<DecorationComposite>
private classlistChangeCallbacks: Set<() => void>
constructor(target: FileOrDir, type: DecorationCompositeType, parent: DecorationComposite) {
this.target = target
this.type = type
this.linkedComposites = new Set()
this.classlistChangeCallbacks = new Set()
this.compositeCssClasslist = new ClasslistComposite(
this.classlistChangeCallbacks.add.bind(this.classlistChangeCallbacks),
this.classlistChangeCallbacks.delete.bind(this.classlistChangeCallbacks))
if (parent) {
this.selfOwned = false
this.parent = parent
this.renderedDecorations = parent.renderedDecorations
this.compositeCssClasslist.classlist = parent.compositeCssClasslist.classlist
parent.linkedComposites.add(this)
} else {
this.renderedDecorations = new Map()
this.targetedDecorations = new Set()
this.negatedDecorations = new Set()
this.compositeCssClasslist.classlist = []
this.selfOwned = true
}
}
public changeParent(newParent: DecorationComposite) {
if (!this.selfOwned) {
return this.parentOwn(newParent)
}
// first purge all the decorations (unless applicable)
for (const [decoration] of this.renderedDecorations) {
this.recursiveRefresh(this, false, ChangeReason.UnTargetDecoration, decoration, false)
}
if (this.parent !== newParent) {
this.parent.linkedComposites.delete(this)
this.parent = newParent
newParent.linkedComposites.add(this)
}
// then add all the inherited decorations (unless not applicable)
for (const [decoration] of newParent.renderedDecorations) {
this.recursiveRefresh(this, false, ChangeReason.TargetDecoration, decoration, false)
}
}
public add(decoration: Decoration): void {
const applicationMode = decoration.appliedTargets.get(this.target)
const applicableToSelf = applicationMode && (applicationMode === TargetMatchMode.Self || applicationMode === TargetMatchMode.SelfAndChildren)
const applicableToChildren = applicationMode && (applicationMode === TargetMatchMode.Children || applicationMode === TargetMatchMode.SelfAndChildren)
if (this.type === DecorationCompositeType.Applicable && !applicableToSelf) { return }
if (this.type === DecorationCompositeType.Inheritable && !applicableToChildren) { return }
if (!this.selfOwned) {
this.selfOwn(ChangeReason.TargetDecoration, decoration)
this.targetedDecorations.add(decoration)
return
}
if (this.targetedDecorations.has(decoration)) { return }
this.targetedDecorations.add(decoration)
this.recursiveRefresh(this, false, ChangeReason.TargetDecoration, decoration)
}
public remove(decoration: Decoration): void {
// a non-self owned composite wouldn't have had a decoration to begin with
if (!this.selfOwned) {
return
}
if (this.targetedDecorations.delete(decoration)) {
if (this.negatedDecorations.size === 0 && this.targetedDecorations.size === 0 && this.parent) {
return this.parentOwn(null, ChangeReason.UnTargetDecoration, decoration)
}
this.recursiveRefresh(this, false, ChangeReason.UnTargetDecoration, decoration)
}
}
public negate(decoration: Decoration): void {
const negationMode = decoration.negatedTargets.get(this.target)
const negatedOnSelf = negationMode && (negationMode === TargetMatchMode.Self || negationMode === TargetMatchMode.SelfAndChildren)
const negatedOnChildren = negationMode && (negationMode === TargetMatchMode.Children || negationMode === TargetMatchMode.SelfAndChildren)
if (this.type === DecorationCompositeType.Applicable && !negatedOnSelf) { return }
if (this.type === DecorationCompositeType.Inheritable && !negatedOnChildren) { return }
if (!this.selfOwned) {
this.selfOwn(ChangeReason.UnTargetDecoration, decoration)
this.negatedDecorations.add(decoration)
return
}
if (this.negatedDecorations.has(decoration)) { return }
this.negatedDecorations.add(decoration)
if (this.renderedDecorations.has(decoration)) {
this.removeDecorationClasslist(decoration)
}
}
/**
* unNegate doesn't mean "explicit apply"
*/
public unNegate(decoration: Decoration): void {
// a non-self owned composite wouldn't have been negated to begin with
if (!this.selfOwned) {
return
}
if (this.negatedDecorations.delete(decoration)) {
if (this.negatedDecorations.size === 0 && this.targetedDecorations.size === 0 && this.parent) {
return this.parentOwn()
}
// currently not present
if (!this.renderedDecorations.has(decoration) &&
// **and** either parent or itself has it applied
(this.parent.renderedDecorations.has(decoration) || decoration.appliedTargets.has(this.target))) {
this.recursiveRefresh(this, false, ChangeReason.TargetDecoration, decoration)
}
}
}
private selfOwn(reason: ChangeReason, decoration: Decoration) {
if (this.selfOwned) {
throw new Error(`DecorationComposite is already self owned`)
}
const parent = this.parent
this.selfOwned = true
this.compositeCssClasslist.classlist = []
this.renderedDecorations = new Map()
this.targetedDecorations = new Set()
this.negatedDecorations = new Set()
// first process all the inherited decorations
for (const [inheritedDecoration] of parent.renderedDecorations) {
// fate of the decoration (second arg) will be decided in `#recursiveRefresh`
if (inheritedDecoration !== decoration) {
this.processCompositeAlteration(ChangeReason.TargetDecoration, inheritedDecoration)
}
}
// perhaps negation is why this composite branched off
if (reason === ChangeReason.UnTargetDecoration &&
// parent had it
this.parent.renderedDecorations.has(decoration) &&
// this one won't
!this.renderedDecorations.has(decoration)) {
// announce the change
this.notifyClasslistChange(false)
}
// then move on to main business
this.recursiveRefresh(this, true, reason, decoration)
}
private parentOwn(newParent?: DecorationComposite, reason?: ChangeReason, decoration?: Decoration) {
this.selfOwned = false
this.targetedDecorations = void 0
this.negatedDecorations = void 0
if (newParent && this.parent !== newParent) {
if (this.parent) {
this.parent.linkedComposites.delete(this)
}
newParent.linkedComposites.add(this)
this.parent = newParent
}
this.recursiveRefresh(this, true, reason, decoration)
}
private processCompositeAlteration(reason: ChangeReason, decoration: Decoration): boolean {
if (!this.selfOwned) {
throw new Error(`DecorationComposite is not self owned`)
}
if (reason === ChangeReason.UnTargetDecoration) {
const disposable = this.renderedDecorations.get(decoration)
if (disposable) {
const applicationMode = decoration.appliedTargets.get(this.target)
const applicableToSelf = applicationMode && (applicationMode === TargetMatchMode.Self || applicationMode === TargetMatchMode.SelfAndChildren)
const applicableToChildren = applicationMode && (applicationMode === TargetMatchMode.Children || applicationMode === TargetMatchMode.SelfAndChildren)
if (applicableToSelf && this.type === DecorationCompositeType.Applicable) { return false }
if (applicableToChildren && this.type === DecorationCompositeType.Inheritable) { return false }
this.removeDecorationClasslist(decoration, false)
if (disposable) {
disposable.dispose()
}
return this.renderedDecorations.delete(decoration)
}
return false
}
if (reason === ChangeReason.TargetDecoration) {
const negationMode = decoration.negatedTargets.get(this.target)
const negatedOnSelf = negationMode && (negationMode === TargetMatchMode.Self || negationMode === TargetMatchMode.SelfAndChildren)
const negatedOnChildren = negationMode && (negationMode === TargetMatchMode.Children || negationMode === TargetMatchMode.SelfAndChildren)
if (negatedOnSelf && this.type === DecorationCompositeType.Applicable) { return }
if (negatedOnChildren && this.type === DecorationCompositeType.Inheritable) { return }
if (!this.renderedDecorations.has(decoration)) {
const disposables = new DisposablesComposite()
disposables.add(decoration.onDidAddCSSClassname(this.handleDecorationDidAddClassname))
disposables.add(decoration.onDidRemoveCSSClassname(this.handleDecorationDidRemoveClassname))
disposables.add(decoration.onDidDisableDecoration(this.removeDecorationClasslist))
disposables.add(decoration.onDidEnableDecoration(this.mergeDecorationClasslist))
this.renderedDecorations.set(decoration, disposables)
if (!decoration.disabled) {
(this.compositeCssClasslist.classlist as string[]).push(...decoration.cssClasslist)
return true
}
return false
}
}
}
private recursiveRefresh(origin: DecorationComposite, updateReferences: boolean, reason?: ChangeReason, decoration?: Decoration, notifyListeners = true) {
// references changed
if (!this.selfOwned && updateReferences) {
this.renderedDecorations = this.parent.renderedDecorations
this.compositeCssClasslist.classlist = this.parent.compositeCssClasslist.classlist
}
if (this.selfOwned && updateReferences && origin !== this) {
// purge all the decorations (unless applicable)
for (const [renderedDecoration] of this.renderedDecorations) {
this.processCompositeAlteration(ChangeReason.UnTargetDecoration, renderedDecoration)
}
// then add all the inherited decorations (unless not applicable)
for (const [inheritedDecoration] of this.parent.renderedDecorations) {
this.processCompositeAlteration(ChangeReason.TargetDecoration, inheritedDecoration)
}
if (notifyListeners) {
this.notifyClasslistChange(false)
}
} else if (this.selfOwned && reason === ChangeReason.UnTargetDecoration && this.renderedDecorations.has(decoration)) {
this.processCompositeAlteration(reason, decoration)
if (notifyListeners) {
this.notifyClasslistChange(false)
}
} else if (this.selfOwned && reason === ChangeReason.TargetDecoration && this.processCompositeAlteration(reason, decoration) && notifyListeners) {
this.notifyClasslistChange(false)
} else if (!this.selfOwned && notifyListeners) {
this.notifyClasslistChange(false)
}
for (const linkedComposite of this.linkedComposites) {
linkedComposite.recursiveRefresh(origin, updateReferences, reason, decoration, notifyListeners)
}
}
private handleDecorationDidAddClassname = (decoration: Decoration, classname: string) => {
if (!this.selfOwned) { throw new Error(`[INTERNAL] A non-self owned composite must not be incharge of Decoration events`) }
(this.compositeCssClasslist.classlist as string[]).push(classname)
this.notifyClasslistChange()
}
private handleDecorationDidRemoveClassname = (decoration: Decoration, classname: string) => {
if (!this.selfOwned) { throw new Error(`[INTERNAL] A non-self owned composite must not be incharge of Decoration events`) }
const idx = this.compositeCssClasslist.classlist.indexOf(classname)
if (idx > -1) {
(this.compositeCssClasslist.classlist as string[]).splice(idx, 1)
this.notifyClasslistChange()
}
}
private mergeDecorationClasslist = (decoration: Decoration) => {
if (!this.selfOwned) { throw new Error(`[INTERNAL] A non-self owned composite must not be incharge of Decoration events`) }
(this.compositeCssClasslist.classlist as string[]).push(...decoration.cssClasslist)
this.notifyClasslistChange()
}
private removeDecorationClasslist(decoration: Decoration, notifyAll = true) {
if (!this.selfOwned) { throw new Error(`[INTERNAL] A non-self owned composite must not be incharge of Decoration events`) }
for (const classname of decoration.cssClasslist) {
const idx = this.compositeCssClasslist.classlist.indexOf(classname)
if (idx > -1) {
(this.compositeCssClasslist.classlist as string[]).splice(idx, 1)
}
}
if (notifyAll) {
this.notifyClasslistChange()
}
}
private notifyClasslistChange(recursive = true) {
// here it's important that we don't iterate directly over this.classlistChangeCallbacks, instead create a copy of them first
// if one of the callbacks alters the Set by adding/removing callback (inside another callback) it makes this loop infinite
for (const cb of [...this.classlistChangeCallbacks]) {
cb()
}
if (recursive) {
for (const linkedComposite of this.linkedComposites) {
if (!linkedComposite.selfOwned) {
linkedComposite.notifyClasslistChange()
}
}
}
}
}