UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

338 lines (283 loc) • 10.3 kB
import { assertExists, getOwnProperty, objectMapValues, uniqueId } from '@tldraw/utils' import { FontEmbedder } from './FontEmbedder' import { ReadonlyStyles, Styles, cssRules } from './cssRules' import { elementStyle, getComputedStyle, getRenderedChildNodes, getRenderedChildren, } from './domUtils' import { resourceToDataUrl } from './fetchCache' import { parseCssValueUrls, shouldIncludeCssProperty } from './parseCss' const NO_STYLES = {} as const interface ElementStyleInfo { self: Styles before: Styles | undefined after: Styles | undefined } export class StyleEmbedder { constructor(private readonly root: Element) {} private readonly styles = new Map<Element, ElementStyleInfo>() readonly fonts = new FontEmbedder() readRootElementStyles(rootElement: Element) { // when reading a root, we always apply _all_ the styles, even if they match the defaults this.readElementStyles(rootElement, { shouldRespectDefaults: false, shouldSkipInheritedParentStyles: false, }) const children = Array.from(getRenderedChildren(rootElement)) while (children.length) { const child = children.pop()! children.push(...getRenderedChildren(child)) // when reading children, we don't apply styles that match the defaults for that // element, or that would be inherited from the parent this.readElementStyles(child, { shouldRespectDefaults: true, shouldSkipInheritedParentStyles: true, }) } } private readElementStyles( element: Element, { shouldRespectDefaults = true, shouldSkipInheritedParentStyles = true } ) { const defaultStyles = shouldRespectDefaults ? getDefaultStylesForTagName(element.tagName.toLowerCase()) : NO_STYLES const parentStyles = Object.assign({}, NO_STYLES) as Styles if (shouldSkipInheritedParentStyles) { let el = element.parentElement // Keep going up the tree to find all the relevant styles while (el) { const currentStyles = this.styles.get(el)?.self for (const style in currentStyles) { if (!parentStyles[style]) { parentStyles[style] = currentStyles[style] } } el = el.parentElement } } const info: ElementStyleInfo = { self: styleFromElement(element, { defaultStyles, parentStyles }), before: styleFromPseudoElement(element, '::before'), after: styleFromPseudoElement(element, '::after'), } this.styles.set(element, info) } fetchResources() { const promises: Promise<void>[] = [] for (const info of this.styles.values()) { for (const styles of objectMapValues(info)) { if (!styles) continue for (const [property, value] of Object.entries(styles)) { if (!value) continue if (property === 'font-family') { this.fonts.onFontFamilyValue(value) } const urlMatches = parseCssValueUrls(value) if (urlMatches.length === 0) continue promises.push( ...urlMatches.map(async ({ url, original }) => { const dataUrl = (await resourceToDataUrl(url)) ?? 'data:' styles[property] = value.replace(original, `url("${dataUrl}")`) }) ) } } } return Promise.all(promises) } // custom elements are tricky. if we serialize the dom as-is, the custom elements wont have // their shadow-dom contents serialized. after we've read all the styles, we need to unwrap the // contents of each custom elements shadow dom directly into the parent element itself. unwrapCustomElements() { const visited = new Set<Node>() const visit = (element: Element, clonedParent: Element | null) => { if (visited.has(element)) return visited.add(element) const shadowRoot = element.shadowRoot if (shadowRoot) { const clonedCustomEl = document.createElement('div') this.styles.set(clonedCustomEl, this.styles.get(element)!) clonedCustomEl.setAttribute('data-tl-custom-element', element.tagName) ;(clonedParent ?? element.parentElement!).appendChild(clonedCustomEl) for (const child of shadowRoot.childNodes) { if (child instanceof Element) { visit(child, clonedCustomEl) } else { clonedCustomEl.appendChild(child.cloneNode(true)) } } element.remove() } else if (clonedParent) { if (element.tagName.toLowerCase() === 'style') { // we don't clone style tags at that would break the style scoping. instead we // rely on the computed styles we've already read return } const clonedEl = element.cloneNode(false) as Element this.styles.set(clonedEl, this.styles.get(element)!) clonedParent.appendChild(clonedEl) for (const child of getRenderedChildNodes(element)) { if (child instanceof Element) { visit(child, clonedEl) } else { clonedEl.appendChild(child.cloneNode(true)) } } } } for (const element of this.styles.keys()) { visit(element, null) } } embedStyles(): string { let css = '' for (const [element, info] of this.styles) { if (info.after || info.before) { const className = `pseudo-${uniqueId()}` element.classList.add(className) if (info.before) { css += `.${className}::before {${formatCss(info.before)}}\n` } if (info.after) { css += `.${className}::after {${formatCss(info.after)}}\n` } } const style = elementStyle(element) for (const [property, value] of Object.entries(info.self)) { if (!value) continue style.setProperty(property, value) } // in HTML, font-kerning: auto is equivalent to font-kerning: normal. But in SVG, it's // none. We set it to normal here to match the HTML behavior, as otherwise this can // cause rendering differences. if (style.fontKerning === 'auto') { style.fontKerning = 'normal' } } return css } async getFontFaceCss() { return await this.fonts.createCss() } dispose() { destroyDefaultStyleFrame() } } interface ReadStyleOpts { defaultStyles: ReadonlyStyles parentStyles: ReadonlyStyles } function styleFromElement(element: Element, { defaultStyles, parentStyles }: ReadStyleOpts) { // `computedStyleMap` produces a more accurate representation of the styles, but it's not // supported in firefox at the time of writing. So we fall back to `getComputedStyle` if it's // not available. if (element.computedStyleMap) { return styleFromComputedStyleMap(element.computedStyleMap(), { defaultStyles, parentStyles }) } return styleFromComputedStyle(getComputedStyle(element), { defaultStyles, parentStyles }) } function styleFromPseudoElement(element: Element, pseudo: string) { // the equivalent of `computedStyleMap` for pseudo-elements isn't even fully specced out yet, so // for those we have to use `getComputedStyle` in all browsers. const style = getComputedStyle(element, pseudo) const content = style.getPropertyValue('content') if (content === '' || content === 'none') { return undefined } return styleFromComputedStyle(style, { defaultStyles: NO_STYLES, parentStyles: NO_STYLES }) } function styleFromComputedStyleMap( style: StylePropertyMapReadOnly, { defaultStyles, parentStyles }: ReadStyleOpts ) { const styles: Record<string, string> = {} const currentColor = style.get('color')?.toString() || '' const ruleOptions = { currentColor, parentStyles, defaultStyles, getStyle: (property: string) => style.get(property)?.toString() ?? '', } for (const property of style.keys()) { if (!shouldIncludeCssProperty(property)) continue const value = style.get(property)!.toString() if (defaultStyles[property] === value) continue const rule = getOwnProperty(cssRules, property) if (rule && rule(value, property, ruleOptions)) continue styles[property] = value } return styles } function styleFromComputedStyle( style: CSSStyleDeclaration, { defaultStyles, parentStyles }: ReadStyleOpts ) { const styles: Record<string, string> = {} const currentColor = style.color const ruleOptions = { currentColor, parentStyles, defaultStyles, getStyle: (property: string) => style.getPropertyValue(property), } for (const property in style) { if (!shouldIncludeCssProperty(property)) continue const value = style.getPropertyValue(property) if (defaultStyles[property] === value) continue const rule = getOwnProperty(cssRules, property) if (rule && rule(value, property, ruleOptions)) continue styles[property] = value } return styles } function formatCss(style: ReadonlyStyles) { let cssText = '' for (const [property, value] of Object.entries(style)) { cssText += `${property}: ${value};` } return cssText } // when we're figuring out the default values for a tag, we need read them from a separate document // so they're not affected by the current document's styles let defaultStyleFrame: | { iframe: HTMLIFrameElement; foreignObject: SVGForeignObjectElement; document: Document } | undefined const defaultStylesByTagName: Record<string, ReadonlyStyles> = {} function getDefaultStyleFrame() { if (!defaultStyleFrame) { const frame = document.createElement('iframe') frame.style.display = 'none' document.body.appendChild(frame) const frameDocument = assertExists(frame.contentDocument, 'frame must have a document') const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject') svg.appendChild(foreignObject) frameDocument.body.appendChild(svg) defaultStyleFrame = { iframe: frame, foreignObject, document: frameDocument } } return defaultStyleFrame } function destroyDefaultStyleFrame() { if (defaultStyleFrame) { document.body.removeChild(defaultStyleFrame.iframe) defaultStyleFrame = undefined } } const defaultStyleReadOptions: ReadStyleOpts = { defaultStyles: NO_STYLES, parentStyles: NO_STYLES } function getDefaultStylesForTagName(tagName: string) { let existing = defaultStylesByTagName[tagName] if (!existing) { const { foreignObject, document } = getDefaultStyleFrame() const element = document.createElement(tagName) foreignObject.appendChild(element) existing = element.computedStyleMap ? styleFromComputedStyleMap(element.computedStyleMap(), defaultStyleReadOptions) : styleFromComputedStyle(getComputedStyle(element), defaultStyleReadOptions) foreignObject.removeChild(element) defaultStylesByTagName[tagName] = existing } return existing }