├── .gitignore ├── images └── screenshot.png ├── src ├── utils │ ├── svg.ts │ ├── icons.ts │ └── codepen.ts ├── main.ts ├── graph │ ├── types.ts │ ├── renderers │ │ ├── edge.ts │ │ ├── pointer.ts │ │ ├── shadow-tree.ts │ │ └── node.ts │ ├── layout.ts │ └── renderer.ts ├── event-graph.ts ├── dom.ts ├── event-visualizer.ts └── event-steps.ts ├── snowpack.config.js ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── package.json ├── public └── index.html ├── README.md └── typings └── dagre-layout.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | lib/ 4 | 5 | *.log 6 | .DS_Store -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmdartus/event-visualizer/HEAD/images/screenshot.png -------------------------------------------------------------------------------- /src/utils/svg.ts: -------------------------------------------------------------------------------- 1 | export function createSvgElement( 2 | tagName: K 3 | ): SVGElementTagNameMap[K] { 4 | return document.createElementNS("http://www.w3.org/2000/svg", tagName); 5 | } 6 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import EventVisualizer from "./event-visualizer"; 2 | 3 | declare global { 4 | interface HTMLElementTagNameMap { 5 | "event-visualizer": EventVisualizer; 6 | } 7 | } 8 | 9 | export default EventVisualizer; 10 | -------------------------------------------------------------------------------- /snowpack.config.js: -------------------------------------------------------------------------------- 1 | // More details: https://www.snowpack.dev/reference/configuration 2 | 3 | /** @type {import("snowpack").SnowpackUserConfig } */ 4 | export default { 5 | mount: { 6 | public: { url: "/", static: true }, 7 | src: { url: "/" }, 8 | }, 9 | 10 | buildOptions: { 11 | out: "dist", 12 | }, 13 | 14 | optimize: { 15 | bundle: true, 16 | minify: true, 17 | target: "es2018", 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/graph/types.ts: -------------------------------------------------------------------------------- 1 | import rough from "roughjs"; 2 | import { graphlib, Node, GraphEdge as Edge } from "dagre"; 3 | 4 | import { TreeNode } from "../dom.js"; 5 | 6 | export type RoughSVG = ReturnType; 7 | 8 | export enum GraphEdgeType { 9 | Child, 10 | ShadowRoot, 11 | AssignedElement, 12 | } 13 | 14 | interface NodeData { 15 | treeNode: TreeNode; 16 | } 17 | 18 | export type Graph = graphlib.Graph; 19 | export type GraphNode = Node; 20 | export type GraphEdge = Edge; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Project options 4 | "target": "ESNext", 5 | "lib": ["ES2020", "dom"], 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "declaration": true, 9 | 10 | // Type check 11 | "strict": true, 12 | 13 | // Linter checks 14 | "noUnusedLocals": true, 15 | 16 | // Resolution 17 | "moduleResolution": "node", 18 | 19 | // Experimental 20 | "experimentalDecorators": true, 21 | }, 22 | "include": [ 23 | "src/", 24 | "typings/" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/icons.ts: -------------------------------------------------------------------------------- 1 | import { html } from "lit"; 2 | 3 | // prettier-ignore 4 | export const boxArrowUp = () => html` 5 | 6 | 7 | 8 | 9 | ` 10 | -------------------------------------------------------------------------------- /src/utils/codepen.ts: -------------------------------------------------------------------------------- 1 | import EventVisualizer from "../event-visualizer"; 2 | 3 | /** 4 | * Create a new Code Pen editor from a event visualizer element. 5 | * Details: https://blog.codepen.io/documentation/prefill/ 6 | */ 7 | export function openInCodePen(eventVisualizer: EventVisualizer) { 8 | const title = eventVisualizer.label; 9 | const serializedElement = eventVisualizer.outerHTML; 10 | 11 | let html = serializedElement; 12 | html += `\n `; 13 | 14 | const data = { 15 | title, 16 | html, 17 | layout: "left", 18 | editors: "100", // HTML opened, CSS and JS closed 19 | }; 20 | 21 | const form = document.createElement("form"); 22 | form.action = "https://codepen.io/pen/define"; 23 | form.method = "POST"; 24 | form.target = "_blank"; 25 | form.style.display = "none"; 26 | 27 | const input = document.createElement("input"); 28 | input.name = "data"; 29 | input.value = JSON.stringify(data); 30 | 31 | form.append(input); 32 | document.body.append(form); 33 | 34 | form.submit(); 35 | form.remove(); 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Pierre-Marie Dartus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14" 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Check format 26 | run: npm run format:check 27 | 28 | - name: Run build step 29 | run: npm run build 30 | env: 31 | NODE_ENV: production 32 | 33 | - name: Save build output 34 | uses: actions/upload-artifact@v2 35 | with: 36 | name: dist 37 | path: dist/ 38 | 39 | deploy: 40 | name: Deploy 41 | runs-on: ubuntu-latest 42 | 43 | needs: build 44 | if: github.ref == 'refs/heads/master' 45 | 46 | steps: 47 | - name: Download a build output 48 | uses: actions/download-artifact@v2 49 | with: 50 | name: dist 51 | path: dist/ 52 | 53 | - name: Deploy to GH pages 54 | if: 55 | uses: crazy-max/ghaction-github-pages@v2 56 | with: 57 | build_dir: dist 58 | target_branch: gh-pages 59 | jekyll: false 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /src/event-graph.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, PropertyValues } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import { ref, createRef } from "lit/directives/ref.js"; 4 | 5 | import { GraphRenderer } from "./graph/renderer.js"; 6 | import { DomTree, EventDispatchingStep } from "./dom.js"; 7 | 8 | @customElement("event-graph") 9 | export class EventGraph extends LitElement { 10 | @property() tree!: DomTree; 11 | @property() steps!: EventDispatchingStep[]; 12 | @property() activeStep!: number; 13 | 14 | private svgRoot = createRef(); 15 | private graphRender?: GraphRenderer; 16 | 17 | render() { 18 | return html``; 19 | } 20 | 21 | firstUpdated() { 22 | const root = this.svgRoot.value!; 23 | this.graphRender = new GraphRenderer({ 24 | root, 25 | }); 26 | } 27 | 28 | updated(props: PropertyValues) { 29 | if (props.has("tree")) { 30 | this.graphRender?.setTree(this.tree); 31 | } 32 | 33 | const step = this.steps[this.activeStep]; 34 | this.graphRender?.setStep(step); 35 | } 36 | 37 | static get styles() { 38 | return css` 39 | :host { 40 | display: flex; 41 | align-items: center; 42 | } 43 | 44 | svg { 45 | width: 100%; 46 | height: 100%; 47 | max-height: 500px; 48 | font-family: monospace; 49 | } 50 | 51 | ${GraphRenderer.styles} 52 | `; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pmdartus/event-visualizer", 3 | "version": "3.0.0", 4 | "description": "A visualization tool to better understand how events propagate in the shadow DOM", 5 | "keywords": [ 6 | "event", 7 | "shadow-dom", 8 | "web-component" 9 | ], 10 | "author": "Pierre-Marie Dartus ", 11 | "license": "MIT", 12 | "type": "module", 13 | "files": [ 14 | "lib", 15 | "dist" 16 | ], 17 | "module": "./lib/main.js", 18 | "exports": { 19 | "import": "./lib/main.js" 20 | }, 21 | "types": "./lib/main.d.ts", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/pmdartus/event-visualizer" 25 | }, 26 | "scripts": { 27 | "start": "snowpack dev", 28 | "build": "tsc && snowpack build", 29 | "format": "prettier --write '{public,src}/**/*.{js,ts,html,css,md}'", 30 | "format:check": "prettier --check '{public,src}/**/*.{js,ts,html,css,md}'" 31 | }, 32 | "dependencies": { 33 | "dagre": "^0.8.5", 34 | "lit": "^2.0.0-rc.1", 35 | "roughjs": "^4.3.1" 36 | }, 37 | "devDependencies": { 38 | "husky": "^4.3.8", 39 | "lint-staged": "^10.5.4", 40 | "prettier": "^2.2.1", 41 | "snowpack": "^3.0.13", 42 | "typescript": "^4.2.3" 43 | }, 44 | "husky": { 45 | "hooks": { 46 | "pre-commit": "lint-staged" 47 | } 48 | }, 49 | "lint-staged": { 50 | "{public,src}/**/*.{js,ts,html,css,md}": "prettier --write" 51 | }, 52 | "prettier": { 53 | "printWidth": 100 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/graph/renderers/edge.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | import { Graph, GraphNode, GraphEdge, GraphEdgeType, RoughSVG } from "../types.js"; 4 | 5 | const CURVE_TIGHTNESS = 0.8; 6 | 7 | function renderEdge({ 8 | edge, 9 | from, 10 | to, 11 | root, 12 | rc, 13 | }: { 14 | edge: GraphEdge; 15 | from: GraphNode; 16 | to: GraphNode; 17 | rc: RoughSVG; 18 | root: SVGElement; 19 | }) { 20 | const line = rc.curve( 21 | edge.points.map((point) => [point.x, point.y]), 22 | { 23 | curveTightness: CURVE_TIGHTNESS, 24 | } 25 | ); 26 | line.setAttribute("data-from-id", from.treeNode.id); 27 | line.setAttribute("data-to-id", to.treeNode.id); 28 | 29 | line.classList.add("edge"); 30 | if (edge.type === GraphEdgeType.Child) { 31 | line.classList.add("edge__child"); 32 | } else if (edge.type === GraphEdgeType.ShadowRoot) { 33 | line.classList.add("edge__shadow-root"); 34 | } else if (edge.type === GraphEdgeType.AssignedElement) { 35 | line.classList.add("edge__assigned-element"); 36 | } 37 | 38 | root.appendChild(line); 39 | } 40 | 41 | export function render({ graph, root, rc }: { graph: Graph; root: SVGSVGElement; rc: RoughSVG }) { 42 | for (const graphEdge of graph.edges()) { 43 | const from = graph.node(graphEdge.v); 44 | const to = graph.node(graphEdge.w); 45 | const edge = graph.edge(graphEdge); 46 | renderEdge({ from, to, edge, root, rc }); 47 | } 48 | } 49 | 50 | export const styles = css` 51 | .edge path { 52 | stroke: #505050; 53 | } 54 | 55 | .edge__shadow-root { 56 | stroke-dasharray: 8; 57 | } 58 | 59 | .edge__assigned-element { 60 | stroke-dasharray: 1, 4; 61 | stroke-linecap: round; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /src/graph/layout.ts: -------------------------------------------------------------------------------- 1 | import dagre, { graphlib } from "dagre"; 2 | 3 | import { DomTree, TreeNodeType } from "../dom.js"; 4 | import { Graph, GraphEdgeType } from "./types.js"; 5 | 6 | const ELEMENT_NODE_WIDTH = 50; 7 | const ELEMENT_SHADOW_ROOT_WIDTH = 110; 8 | const NODE_HEIGHT = 50; 9 | 10 | const HORIZONTAL_SPACING = 70; 11 | const VERTICAL_SPACING = 50; 12 | 13 | export function graphFromDomTree(tree: DomTree): Graph { 14 | const graph: Graph = new graphlib.Graph({}); 15 | 16 | graph.setGraph({ 17 | nodesep: HORIZONTAL_SPACING, 18 | ranksep: VERTICAL_SPACING, 19 | }); 20 | 21 | for (const treeNode of tree.nodes) { 22 | const { id, type, domNode } = treeNode; 23 | 24 | graph.setNode(id, { 25 | treeNode, 26 | width: type === TreeNodeType.Element ? ELEMENT_NODE_WIDTH : ELEMENT_SHADOW_ROOT_WIDTH, 27 | height: NODE_HEIGHT, 28 | }); 29 | 30 | for (const childDomNode of Array.from(domNode.children)) { 31 | const childTreeNode = tree.getTreeNodeByDomNode(childDomNode)!; 32 | 33 | graph.setEdge(id, childTreeNode.id, { 34 | type: GraphEdgeType.Child, 35 | }); 36 | } 37 | 38 | if (domNode instanceof HTMLSlotElement) { 39 | for (const assignedElementDomNode of domNode.assignedElements()) { 40 | const assignedElementTreeNode = tree.getTreeNodeByDomNode(assignedElementDomNode)!; 41 | 42 | graph.setEdge(id, assignedElementTreeNode.id, { 43 | type: GraphEdgeType.AssignedElement, 44 | }); 45 | } 46 | } 47 | 48 | if (domNode instanceof ShadowRoot) { 49 | const hostElementTreeNode = tree.getTreeNodeByDomNode(domNode.host)!; 50 | 51 | graph.setEdge(hostElementTreeNode.id, id, { 52 | type: GraphEdgeType.ShadowRoot, 53 | }); 54 | } 55 | } 56 | 57 | dagre.layout(graph); 58 | 59 | return graph; 60 | } 61 | -------------------------------------------------------------------------------- /src/graph/renderer.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | import rough from "roughjs"; 3 | 4 | import { DomTree, EventDispatchingStep } from "../dom.js"; 5 | 6 | import { graphFromDomTree } from "./layout.js"; 7 | import { Graph, RoughSVG } from "./types.js"; 8 | 9 | import { 10 | render as renderShadowTrees, 11 | styles as shadowTreeStyles, 12 | } from "./renderers/shadow-tree.js"; 13 | import { 14 | render as renderNodes, 15 | update as updateNodes, 16 | styles as nodeStyles, 17 | } from "./renderers/node.js"; 18 | import { render as renderEdges, styles as edgeStyles } from "./renderers/edge.js"; 19 | import { 20 | render as renderPointers, 21 | update as updatePointers, 22 | styles as pointerStyles, 23 | } from "./renderers/pointer.js"; 24 | 25 | // Horizontal padding is larger than vertical padding to fit the pointers on the side of the graph. 26 | const GRAPH_VERTICAL_PADDING = 20; 27 | const GRAPH_HORIZONTAL_PADDING = 50; 28 | 29 | function updateViewBox({ root }: { root: SVGSVGElement; graph: Graph }): void { 30 | const { width, height } = root.getBBox(); 31 | root.setAttribute( 32 | "viewBox", 33 | [ 34 | -GRAPH_HORIZONTAL_PADDING, 35 | -GRAPH_VERTICAL_PADDING, 36 | width + 2 * GRAPH_HORIZONTAL_PADDING, 37 | height + 2 * GRAPH_VERTICAL_PADDING, 38 | ].join(" ") 39 | ); 40 | } 41 | 42 | export class GraphRenderer { 43 | private root: SVGSVGElement; 44 | private rc: RoughSVG; 45 | private graph?: Graph; 46 | 47 | constructor({ root }: { root: SVGSVGElement }) { 48 | this.root = root; 49 | this.rc = rough.svg(root); 50 | } 51 | 52 | setTree(tree: DomTree) { 53 | this.root.innerHTML = ""; 54 | 55 | this.graph = graphFromDomTree(tree); 56 | 57 | const config = { 58 | tree, 59 | graph: this.graph, 60 | root: this.root, 61 | rc: this.rc, 62 | }; 63 | 64 | renderShadowTrees(config); 65 | renderEdges(config); 66 | renderNodes(config); 67 | 68 | updateViewBox(config); 69 | 70 | renderPointers(config); 71 | } 72 | 73 | setStep(step: EventDispatchingStep) { 74 | if (!this.graph) { 75 | return; 76 | } 77 | 78 | const config = { 79 | step, 80 | graph: this.graph, 81 | root: this.root, 82 | }; 83 | 84 | updateNodes(config); 85 | updatePointers(config); 86 | } 87 | 88 | static styles = css` 89 | ${shadowTreeStyles} 90 | ${nodeStyles} 91 | ${edgeStyles} 92 | ${pointerStyles} 93 | `; 94 | } 95 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Event Visualizer Embedded 8 | 9 | 14 | 15 | 16 | 17 | 27 | 28 | 29 | 30 | 41 | 42 | 43 | 48 | 61 | 62 | 63 | 64 | 75 | 76 | 77 | 78 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/graph/renderers/pointer.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | import { EventDispatchingStep } from "../../dom.js"; 4 | import { createSvgElement } from "../../utils/svg.js"; 5 | 6 | import { Graph, RoughSVG } from "../types.js"; 7 | 8 | const POINTERS = ["target", "event"]; 9 | 10 | const POINTER_WIDTH = 50; 11 | const POINTER_HEIGHT = 20; 12 | const POINTER_PADDING = 5; 13 | 14 | function renderPointer({ pointer, root, rc }: { pointer: string; root: SVGElement; rc: RoughSVG }) { 15 | const pointerElm = rc.polygon( 16 | [ 17 | [-POINTER_WIDTH, -POINTER_HEIGHT / 2], 18 | [0, -POINTER_HEIGHT / 2], 19 | [0 + 5, 0], 20 | [0, POINTER_HEIGHT / 2], 21 | [-POINTER_WIDTH, POINTER_HEIGHT / 2], 22 | ], 23 | { 24 | fill: "white", 25 | fillStyle: "solid", 26 | } 27 | ); 28 | pointerElm.setAttribute("class", `pointer pointer__${pointer}`); 29 | 30 | root.appendChild(pointerElm); 31 | 32 | const labelElm = createSvgElement("text"); 33 | labelElm.textContent = pointer; 34 | labelElm.setAttribute("x", String(-POINTER_WIDTH + Math.floor(POINTER_PADDING / 2))); 35 | labelElm.setAttribute("y", String(0)); 36 | labelElm.setAttribute("dominant-baseline", "central"); 37 | 38 | pointerElm.appendChild(labelElm); 39 | } 40 | 41 | export function render({ graph, root, rc }: { graph: Graph; root: SVGSVGElement; rc: RoughSVG }) { 42 | for (const pointer of POINTERS) { 43 | renderPointer({ pointer, root, rc }); 44 | } 45 | } 46 | 47 | export function update({ 48 | step, 49 | graph, 50 | root, 51 | }: { 52 | step: EventDispatchingStep; 53 | graph: Graph; 54 | root: SVGSVGElement; 55 | }) { 56 | const { target, currentTarget } = step; 57 | 58 | const currentTargetNode = graph 59 | .nodes() 60 | .map((nodeId) => graph.node(nodeId)!) 61 | .find((node) => node.treeNode === currentTarget)!; 62 | const targetNode = graph 63 | .nodes() 64 | .map((nodeId) => graph.node(nodeId)!) 65 | .find((node) => node.treeNode === target)!; 66 | 67 | const eventPointerElm: SVGElement = root.querySelector(`.pointer__event`)!; 68 | const targetPointerElm: SVGElement = root.querySelector(`.pointer__target`)!; 69 | 70 | const pointerVerticalOffset = currentTargetNode === targetNode ? currentTargetNode.height / 4 : 0; 71 | 72 | eventPointerElm.style.transform = `translate(${ 73 | currentTargetNode.x - currentTargetNode.width / 2 74 | }px, ${currentTargetNode.y - pointerVerticalOffset}px)`; 75 | 76 | targetPointerElm.style.transform = `translate(${targetNode.x - targetNode.width / 2}px, ${ 77 | targetNode.y + pointerVerticalOffset 78 | }px)`; 79 | } 80 | 81 | export const styles = css` 82 | .pointer { 83 | transition: transform 0.5s; 84 | } 85 | 86 | .pointer__event > path { 87 | fill: var(--current-target-color); 88 | stroke: var(--current-target-alt-color); 89 | } 90 | 91 | .pointer__target > path { 92 | fill: var(--target-color); 93 | stroke: var(--target-alt-color); 94 | } 95 | `; 96 | -------------------------------------------------------------------------------- /src/graph/renderers/shadow-tree.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | import { DomTree, ShadowRootTreeNode, TreeNodeType } from "../../dom.js"; 4 | 5 | import { Graph, RoughSVG } from "../types.js"; 6 | 7 | interface BoundingBox { 8 | x: number; 9 | y: number; 10 | width: number; 11 | height: number; 12 | } 13 | 14 | const SHADOW_TREE_PADDING = 10; 15 | 16 | function isNodeContained(node: Node, root: ShadowRoot): boolean { 17 | if (node === root) { 18 | return false; 19 | } 20 | 21 | let currentRoot = node.getRootNode(); 22 | while (currentRoot instanceof ShadowRoot) { 23 | if (currentRoot === root) { 24 | return true; 25 | } 26 | 27 | currentRoot = currentRoot.host.getRootNode(); 28 | } 29 | 30 | return false; 31 | } 32 | 33 | export function render({ 34 | tree, 35 | graph, 36 | root, 37 | rc, 38 | }: { 39 | tree: DomTree; 40 | graph: Graph; 41 | root: SVGSVGElement; 42 | rc: RoughSVG; 43 | }) { 44 | const shadowTrees = new Map(); 45 | 46 | const computeShadowTreeBoundingBox = (rootTreeNode: ShadowRootTreeNode): BoundingBox => { 47 | let boundingBox = shadowTrees.get(rootTreeNode); 48 | 49 | if (!boundingBox) { 50 | const containedNodes = tree.nodes.filter((treeNode) => 51 | isNodeContained(treeNode.domNode, rootTreeNode.domNode) 52 | ); 53 | 54 | let minX = Infinity, 55 | minY = Infinity; 56 | let maxX = -Infinity, 57 | maxY = -Infinity; 58 | 59 | for (const containedNode of [rootTreeNode, ...containedNodes]) { 60 | let nodeBoundingBox: BoundingBox = graph.node(containedNode.id); 61 | 62 | if (rootTreeNode !== containedNode && containedNode.type === TreeNodeType.ShadowRoot) { 63 | nodeBoundingBox = computeShadowTreeBoundingBox(containedNode); 64 | } 65 | 66 | minX = Math.min(minX, nodeBoundingBox.x - nodeBoundingBox.width / 2); 67 | minY = Math.min(minY, nodeBoundingBox.y - nodeBoundingBox.height / 2); 68 | maxX = Math.max(maxX, nodeBoundingBox.x + nodeBoundingBox.width / 2); 69 | maxY = Math.max(maxY, nodeBoundingBox.y + nodeBoundingBox.height / 2); 70 | } 71 | 72 | const width = maxX - minX; 73 | const height = maxY - minY; 74 | 75 | boundingBox = { 76 | x: minX + width / 2, 77 | y: minY + height / 2, 78 | width: width + 2 * SHADOW_TREE_PADDING, 79 | height: height + 2 * SHADOW_TREE_PADDING, 80 | }; 81 | 82 | shadowTrees.set(rootTreeNode, boundingBox); 83 | } 84 | 85 | return boundingBox; 86 | }; 87 | 88 | for (const treeNode of tree.nodes) { 89 | if (treeNode.type === TreeNodeType.ShadowRoot) { 90 | const boundingBox = computeShadowTreeBoundingBox(treeNode); 91 | 92 | const rect = rc.rectangle( 93 | boundingBox.x - boundingBox.width / 2, 94 | boundingBox.y - boundingBox.height / 2, 95 | boundingBox.width, 96 | boundingBox.height, 97 | { 98 | fill: "white", 99 | fillStyle: "solid", 100 | } 101 | ); 102 | rect.classList.add("shadow-tree"); 103 | root.appendChild(rect); 104 | } 105 | } 106 | } 107 | 108 | export const styles = css` 109 | .shadow-tree > path { 110 | fill: rgb(71 85 105 / 8%); 111 | stroke: rgb(71 85 105 / 50%); 112 | } 113 | `; 114 | -------------------------------------------------------------------------------- /src/graph/renderers/node.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | import { TreeNodeType, EventDispatchingStep } from "../../dom.js"; 4 | import { createSvgElement } from "../../utils/svg.js"; 5 | 6 | import { Graph, GraphNode, RoughSVG } from "../types.js"; 7 | 8 | const NODE_LABEL_SIZE = 21; 9 | 10 | function renderNode({ node, rc, root }: { node: GraphNode; rc: RoughSVG; root: SVGElement }) { 11 | const { treeNode } = node; 12 | 13 | const rect = rc.rectangle( 14 | node.x - node.width / 2, 15 | node.y - node.height / 2, 16 | node.width, 17 | node.height, 18 | { 19 | fill: "white", 20 | fillStyle: "solid", 21 | } 22 | ); 23 | rect.setAttribute("data-graph-id", treeNode.id); 24 | rect.setAttribute( 25 | "class", 26 | `node node__${treeNode.type === TreeNodeType.Element ? "element" : "shadow-root"}` 27 | ); 28 | 29 | const text = createSvgElement("text"); 30 | text.setAttribute("x", String(node.x)); 31 | text.setAttribute("y", String(node.y)); 32 | text.setAttribute("dominant-baseline", "central"); 33 | text.setAttribute("text-anchor", "middle"); 34 | 35 | if (treeNode.type === TreeNodeType.Element) { 36 | text.textContent = `<${treeNode.name}>`; 37 | } else { 38 | // The dy attribute defines the offset from the previous element. To center both lines in the 39 | // middle of the element, the first is shift upward by half a line height relative 40 | // to the parent . The second is shift by a line height down relative to the 41 | // first . 42 | text.innerHTML = 43 | `Shadow Root` + 44 | `(${ 45 | treeNode.mode === "open" ? "🔓 open" : "🔒 closed" 46 | })`; 47 | } 48 | 49 | renderNodeLabel({ 50 | node, 51 | rc, 52 | root: rect, 53 | }); 54 | 55 | root.append(rect); 56 | rect.append(text); 57 | } 58 | 59 | function renderNodeLabel({ node, root, rc }: { node: GraphNode; rc: RoughSVG; root: SVGElement }) { 60 | const { treeNode } = node; 61 | 62 | if (!treeNode.label) { 63 | return; 64 | } 65 | 66 | const labelContainer = rc.rectangle( 67 | node.x + node.width / 2 - NODE_LABEL_SIZE / 2, 68 | node.y - node.height / 2 - NODE_LABEL_SIZE / 2, 69 | NODE_LABEL_SIZE, 70 | NODE_LABEL_SIZE, 71 | { 72 | fill: "white", 73 | fillStyle: "solid", 74 | } 75 | ); 76 | labelContainer.setAttribute("class", "node-label"); 77 | 78 | const labelText = createSvgElement("text"); 79 | labelText.textContent = treeNode.label; 80 | labelText.setAttribute("x", String(node.x + node.width / 2)); 81 | labelText.setAttribute("y", String(node.y - node.height / 2)); 82 | labelText.setAttribute("dominant-baseline", "central"); 83 | labelText.setAttribute("text-anchor", "middle"); 84 | 85 | root.append(labelContainer); 86 | labelContainer.append(labelText); 87 | } 88 | 89 | export function render({ graph, root, rc }: { graph: Graph; root: SVGSVGElement; rc: RoughSVG }) { 90 | for (const id of graph.nodes()) { 91 | const node = graph.node(id); 92 | renderNode({ node, root, rc }); 93 | } 94 | } 95 | 96 | export function update({ 97 | step, 98 | root, 99 | }: { 100 | step: EventDispatchingStep; 101 | graph: Graph; 102 | root: SVGSVGElement; 103 | }) { 104 | const { composedPath } = step; 105 | 106 | for (const svgNode of Array.from(root.querySelectorAll(".node"))) { 107 | const nodeId = svgNode.getAttribute("data-graph-id"); 108 | const isInComposedPath = composedPath.some((treeNode) => treeNode.id === nodeId); 109 | 110 | if (isInComposedPath) { 111 | svgNode.classList.add("node__composed-path"); 112 | } else { 113 | svgNode.classList.remove("node__composed-path"); 114 | } 115 | } 116 | } 117 | 118 | export const styles = css` 119 | .node.node__composed-path > path { 120 | fill: var(--composed-path-color); 121 | stroke: var(--composed-path-alt-color); 122 | stroke-width: 1.5; 123 | } 124 | 125 | .node-label > path { 126 | fill: #a9d2f7; 127 | } 128 | `; 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event visualizer 2 | 3 | A visualization tool to better understand how events propagate in the shadow DOM. 4 | 5 | ![Build status](https://github.com/pmdartus/event-visualizer/actions/workflows/ci.yml/badge.svg) 6 | ![NPM](https://img.shields.io/npm/v/@pmdartus/event-visualizer) 7 | 8 | ## Overview 9 | 10 | The way DOM events propagate in the shadow DOM is not intuitive for developers onboarding with web components. Event configuration, DOM structure and `closed` vs. `opened` shadow trees are many factors influencing event propagation. 11 | 12 | This project is an attempt to bring clarity to this subject by offering a visual playground explaining how events propagates step-by-step in the shadow DOM. 13 | 14 | [![Event visualizer screenshot](images/screenshot.png)](https://codepen.io/pmdartus/pen/GRrWxQY?editors=1000) 15 | 16 | **Try it out:** [Demo / Playground](https://codepen.io/pmdartus/pen/GRrWxQY?editors=1000) 17 | 18 | ## Installation 19 | 20 | This package can be consumed as an NPM package. 21 | 22 | ```sh 23 | $ npm install --save @pmdartus/event-visualizer 24 | ``` 25 | 26 | Alternatively for drop-in consumption this package can directly be loaded from [Skypack](https://www.skypack.dev/) CDN. 27 | 28 | ```html 29 | 30 | ``` 31 | 32 | ## `` 33 | 34 | ### Properties / Attributes 35 | 36 | | Property | Attribute | Type | Default | Description | 37 | | --------------- | ---------------- | --------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | 38 | | `label` | `label` | `string` | `"Event propagation"` | The label name. | 39 | | `eventBubbles` | `event-bubbles` | `boolean` | `false` | Indicates wether the dispatched event should [bubbles](https://developer.mozilla.org/en-US/docs/Web/API/Event/bubbles) or not. | 40 | | `eventComposed` | `event-composed` | `boolean` | `false` | Indicates wether the dispatched event should be [composed](https://developer.mozilla.org/en-US/docs/Web/API/Event/composed) or not. | 41 | 42 | ### Slots 43 | 44 | | Name | Description | 45 | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | 46 | | default | Accepts a single `