From 80c7d0b91a33f024319b667c4c8ca8c310cf8232 Mon Sep 17 00:00:00 2001 From: Marshal Thompson Date: Mon, 10 Oct 2022 08:49:10 -0500 Subject: [PATCH] feat: Shadow DOM --- src/engine.ts | 338 +++++++++++++++++++++++++++++++++-------------- src/globals.d.ts | 7 + 2 files changed, 248 insertions(+), 97 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index f13d078..12c7307 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -79,6 +79,7 @@ type LayoutState = { const INSTANCE_SYMBOL: unique symbol = Symbol('CQ_INSTANCE'); const STYLESHEET_SYMBOL: unique symbol = Symbol('CQ_STYLESHEET'); +const SHADOW_SYMBOL: unique symbol = Symbol('CQ_SHADOW'); const SUPPORTS_SMALL_VIEWPORT_UNITS = CSS.supports('width: 1svh'); const VERTICAL_WRITING_MODES = new Set([ 'vertical-lr', @@ -179,7 +180,7 @@ export function initializePolyfill() { const dummyElement = document.createElement(`cq-polyfill-${PER_RUN_UID}`); const globalStyleElement = document.createElement('style'); - const mutationObserver = new MutationObserver(mutations => { + const createMutationObserver = (): MutationObserver => new MutationObserver(mutations => { for (const entry of mutations) { for (const node of entry.removedNodes) { const instance = getInstance(node); @@ -210,6 +211,7 @@ export function initializePolyfill() { scheduleUpdate(); } }); + const mutationObserver = createMutationObserver(); mutationObserver.observe(documentElement, { childList: true, subtree: true, @@ -217,6 +219,42 @@ export function initializePolyfill() { attributeOldValue: true, }); + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function (options) { + const shadow = originalAttachShadow.apply(this, [options]); + getOrCreateInstance(shadow); + return shadow; + } + + const originalReplaceSync = CSSStyleSheet.prototype.replaceSync; + if (originalReplaceSync) { + CSSStyleSheet.prototype.replaceSync = function (options) { + const result = transpileStyleSheet( + options, + undefined + ); + setDescriptorsForStyleSheet(this, result.descriptors); + const replaceSync = originalReplaceSync.apply(this, [result.source]); + return replaceSync; + } + } + + const originalReplace = CSSStyleSheet.prototype.replace; + if (originalReplace) { + CSSStyleSheet.prototype.replace = function (options) { + const result = transpileStyleSheet( + options, + undefined + ); + setDescriptorsForStyleSheet(this, result.descriptors); + const replace = originalReplace.apply(this, [result.source]); + return replace; + } + } + + // TODO: implement monkeypatch for CSSStyleSheet.prototype.insertRule and CSSStyleSheet.prototype.deleteRule + // as they currently are not handled. + const resizeObserver = new ResizeObserver(entries => { for (const entry of entries) { const instance = getOrCreateInstance(entry.target); @@ -360,7 +398,7 @@ export function initializePolyfill() { function getOrCreateInstance(node: Node): Instance { let instance = getInstance(node); if (!instance) { - let innerController: NodeController; + let innerController: NodeController; let stateProvider: LayoutStateProvider | null = null; let alwaysObserveSize = false; @@ -381,6 +419,10 @@ export function initializePolyfill() { ...options, }), }); + } else if (node instanceof ShadowRoot) { + innerController = new ShadowRootController(node, createMutationObserver()); + const computeState = createStateComputer(node); + stateProvider = parentState => computeState(parentState, cacheKey); } else if (node instanceof HTMLStyleElement) { innerController = new StyleElementController(node, { registerStyleSheet: options => @@ -517,6 +559,11 @@ export function initializePolyfill() { for (const child of node.childNodes) { getOrCreateInstance(child).update(currentState); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shadow = (node as any)[SHADOW_SYMBOL]; + if (shadow) { + getOrCreateInstance(shadow).update(currentState); + } }, resize() { @@ -525,9 +572,15 @@ export function initializePolyfill() { mutate() { cacheKey = Symbol(); + innerController.mutated(); for (const child of node.childNodes) { getOrCreateInstance(child).mutate(); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shadow = (node as any)[SHADOW_SYMBOL]; + if (shadow) { + getOrCreateInstance(shadow).mutate(); + } }, }; @@ -560,6 +613,10 @@ class NodeController { updated() { // Handler implemented by subclasses } + + mutated() { + // Handler implemented by subclasses + } } class LinkElementController extends NodeController { @@ -644,6 +701,70 @@ class StyleElementController extends NodeController { } } +class ShadowRootController extends NodeController { + private controller: AbortController | null = null; + private mo: MutationObserver | null = null; + private host: HTMLElement | null = null; + + constructor(node: ShadowRoot, mo: MutationObserver) { + super(node); + this.mo = mo; + this.host = node.host as HTMLElement; + } + + connected(): void { + this.mo?.observe(this.node, { + childList: true, + subtree: true, + attributes: true, + attributeOldValue: true, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.host as any)[SHADOW_SYMBOL] = this.node; + } + + updated() { + this.processCssRules(); + } + + mutated() { + this.processCssRules(); + } + + disconnected(): void { + this.controller?.abort(); + this.controller = null; + this.mo?.disconnect(); + } + + private processCssRules(): void { + let setProp = false; + if (this.host) { + for (const adoptedStyleSheet of this.node.adoptedStyleSheets) { + for (const rule of adoptedStyleSheet.cssRules) { + if (rule instanceof CSSStyleRule) { + const value = rule.style.getPropertyValue(CUSTOM_PROPERTY_TYPE).trim(); + if (value) { + const selector: string = rule.selectorText.replace(':host', this.host.localName); + // handle parentheses on :host([attribute]) + const mutatedSelector = !selector.includes(':') ? selector.replace('(', '').replace(')', '') : selector; + // if match apply rule + if (this.host.matches(mutatedSelector)) { + this.host.style.setProperty(CUSTOM_PROPERTY_TYPE, value); + setProp = true; + break; + } + } + } + } + } + if (!setProp && this.host.style.getPropertyValue(CUSTOM_PROPERTY_TYPE)) { + this.host.style.removeProperty(CUSTOM_PROPERTY_TYPE); + } + } + } +} + class GlobalStyleElementController extends NodeController { connected(): void { const style = `* { ${CUSTOM_PROPERTY_TYPE}: cq-normal; ${CUSTOM_PROPERTY_NAME}: cq-none; }`; @@ -813,113 +934,136 @@ function computeDimensionSum( return names.reduce((value, name) => value + computeDimension(read, name), 0); } -function createStateComputer(element: Element) { - const styles = window.getComputedStyle(element); - +function createStateComputer(shadow: ShadowRoot): (parentState: LayoutState, cacheKey: symbol) => LayoutState; +function createStateComputer(element: Element): (parentState: LayoutState, cacheKey: symbol) => LayoutState; +function createStateComputer(shadowOrElement: ShadowRoot | Element) { + const isElement = shadowOrElement instanceof Element; + let conditions: ContainerConditionEntry[]; + let context: TreeContext; + let displayFlags: DisplayFlags; + let isQueryContainer: boolean; // eslint-disable-next-line @typescript-eslint/no-unused-vars return memoizeAndReuse((parentState: LayoutState, cacheKey: symbol) => { - const {context: parentContext, conditions: parentConditions} = parentState; - - const readProperty = (name: string) => styles.getPropertyValue(name); - const layoutData = computeLayoutData(readProperty); - const context: TreeContext = { - ...parentContext, - writingAxis: layoutData.writingAxis, - }; + const { context: parentContext, conditions: parentConditions } = parentState; + if (isElement) { + const styles = window.getComputedStyle(shadowOrElement); + const readProperty = (name: string) => styles.getPropertyValue(name); + const layoutData = computeLayoutData(readProperty); + context = { + ...parentContext, + writingAxis: layoutData.writingAxis, + }; - let conditions = parentConditions; - let isQueryContainer = false; - let displayFlags = layoutData.displayFlags; - if ((parentState.displayFlags & DisplayFlags.Enabled) === 0) { - displayFlags = 0; - } + conditions = parentConditions; + isQueryContainer = false; + displayFlags = layoutData.displayFlags; + if ((parentState.displayFlags & DisplayFlags.Enabled) === 0) { + displayFlags = 0; + } - const {containerType, containerNames} = layoutData; - if (containerType > 0) { - const isValidContainer = - containerType > 0 && - (displayFlags & DisplayFlags.EligibleForSizeContainment) === + const { containerType, containerNames } = layoutData; + if (containerType > 0) { + const isValidContainer = + containerType > 0 && + (displayFlags & DisplayFlags.EligibleForSizeContainment) === DisplayFlags.EligibleForSizeContainment; - const parentConditionMap = new Map( - parentConditions.map(entry => [entry[0].value, entry[1]]) - ); - - conditions = []; - isQueryContainer = true; - - if (isValidContainer) { - const sizeData = computeSizeData(readProperty); - context.fontSize = sizeData.fontSize; - - const sizeFeatures = computeSizeFeatures(layoutData, sizeData); - const queryContext = { - sizeFeatures, - treeContext: context, - }; - - const computeQueryCondition = (query: ContainerQueryDescriptor) => { - const {rule} = query; - const name = rule.name; - const result = - name == null || containerNames.has(name) - ? evaluateContainerCondition(rule, queryContext) - : null; - - if (result == null) { - const condition = - parentConditionMap.get(query) ?? QueryContainerFlags.None; - return ( - (condition && QueryContainerFlags.Condition) === - QueryContainerFlags.Condition - ); - } + const parentConditionMap = new Map( + parentConditions.map(entry => [entry[0].value, entry[1]]) + ); + + conditions = []; + isQueryContainer = true; + + if (isValidContainer) { + const sizeData = computeSizeData(readProperty); + context.fontSize = sizeData.fontSize; + + const sizeFeatures = computeSizeFeatures(layoutData, sizeData); + const queryContext = { + sizeFeatures, + treeContext: context, + }; + + const computeQueryCondition = (query: ContainerQueryDescriptor) => { + const { rule } = query; + const name = rule.name; + const result = + name == null || containerNames.has(name) + ? evaluateContainerCondition(rule, queryContext) + : null; + + if (result == null) { + const condition = + parentConditionMap.get(query) ?? QueryContainerFlags.None; + return ( + (condition && QueryContainerFlags.Condition) === + QueryContainerFlags.Condition + ); + } - return result === true; - }; - - const computeQueryState = ( - conditionMap: Map, - query: ContainerQueryDescriptor - ): QueryContainerFlags => { - let state = conditionMap.get(query); - if (state == null) { - const condition = computeQueryCondition(query); - const container = - condition === true && - (query.parent == null || - (computeQueryState(conditionMap, query.parent) & - QueryContainerFlags.Condition) === + return result === true; + }; + + const computeQueryState = ( + conditionMap: Map, + query: ContainerQueryDescriptor + ): QueryContainerFlags => { + let state = conditionMap.get(query); + if (state == null) { + const condition = computeQueryCondition(query); + const container = + condition === true && + (query.parent == null || + (computeQueryState(conditionMap, query.parent) & + QueryContainerFlags.Condition) === QueryContainerFlags.Condition); - state = - (condition ? QueryContainerFlags.Condition : 0) | - (container ? QueryContainerFlags.Container : 0); - conditionMap.set(query, state); + state = + (condition ? QueryContainerFlags.Condition : 0) | + (container ? QueryContainerFlags.Container : 0); + conditionMap.set(query, state); + } + + return state; + }; + + const newConditionMap: Map< + ContainerQueryDescriptor, + QueryContainerFlags + > = new Map(); + for (const entry of parentConditions) { + conditions.push([ + entry[0], + computeQueryState(newConditionMap, entry[0].value), + ]); } - return state; - }; - - const newConditionMap: Map< - ContainerQueryDescriptor, - QueryContainerFlags - > = new Map(); - for (const entry of parentConditions) { - conditions.push([ - entry[0], - computeQueryState(newConditionMap, entry[0].value), - ]); + context.cqw = + sizeFeatures.width != null + ? sizeFeatures.width / 100 + : parentContext.cqw; + context.cqh = + sizeFeatures.height != null + ? sizeFeatures.height / 100 + : parentContext.cqh; + } + } + } else { + const rootQueryDescriptors: ContainerConditionEntry[] = []; + for (const adoptedStyleSheet of shadowOrElement.adoptedStyleSheets) { + if (adoptedStyleSheet) { + for (const query of getDescriptorsForStyleSheet(adoptedStyleSheet)) { + rootQueryDescriptors.push([ + new Reference(query), + QueryContainerFlags.None, + ]); + } } - - context.cqw = - sizeFeatures.width != null - ? sizeFeatures.width / 100 - : parentContext.cqw; - context.cqh = - sizeFeatures.height != null - ? sizeFeatures.height / 100 - : parentContext.cqh; } + conditions = rootQueryDescriptors; + context = { ...parentContext }; + displayFlags = parentState.displayFlags; + isQueryContainer = parentState.isQueryContainer; } return { diff --git a/src/globals.d.ts b/src/globals.d.ts index 9fed1c5..2844291 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -13,3 +13,10 @@ declare const IS_WPT_BUILD: boolean; declare const PACKAGE_VERSION: string; +interface ShadowRoot { + adoptedStyleSheets: CSSStyleSheet[]; +} +interface StyleSheet { + replace(text: string): Promise; + replaceSync(text: string): void; +}