UNPKG

aspen-decorations

Version:

Complex styling for react-aspen w/ inheritance and negations

380 lines (328 loc) 14.9 kB
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() } } } } }