+
+ # {getSortIcon()}
-
-
+
type
+
name
+
+
element
+
-
-
-
-
-
-
- # {getSortIcon()}
-
-
-
type
-
name
-
-
element
-
+
+ {buffer.current.length === 0 ? (
+
+
-
-
- {buffer.current.length === 0 ? (
-
-
-
- ) : (
-
- {({ width, height }) => (
-
- {EventRecord}
-
- )}
-
+ ) : (
+
+ {({ width, height }) => (
+
+ {EventRecord}
+
)}
-
-
+
+ )}
-
+
);
}
diff --git a/src/components/Embed.js b/src/components/Embed.js
index 58d0fd4c..d699adf6 100644
--- a/src/components/Embed.js
+++ b/src/components/Embed.js
@@ -5,24 +5,7 @@ import Embedded from './Embedded';
import { SyncIcon, XIcon } from '@primer/octicons-react';
import { defaultPanes } from '../constants';
-
-function TabButton({ children, active, onClick, disabled }) {
- return (
-
- );
-}
+import TabButton from './TabButton';
const possiblePanes = ['markup', 'preview', 'query', 'result'];
diff --git a/src/components/Loader.js b/src/components/Loader.js
index 3e1e6f3a..6369949c 100644
--- a/src/components/Loader.js
+++ b/src/components/Loader.js
@@ -6,7 +6,7 @@ function Loader({ loading }) {

diff --git a/src/components/Playground.js b/src/components/Playground.js
index d5cb7f26..1759b352 100644
--- a/src/components/Playground.js
+++ b/src/components/Playground.js
@@ -2,24 +2,17 @@ import React from 'react';
import { useParams } from 'react-router-dom';
import Preview from './Preview';
import MarkupEditor from './MarkupEditor';
-import Result from './Result';
-import Query from './Query';
import usePlayground from '../hooks/usePlayground';
import Layout from './Layout';
import Loader from './Loader';
-
-function Paper({ children }) {
- return (
-
- {children}
-
- );
-}
+import PlaygroundPanels from './PlaygroundPanels';
+import { usePreviewEvents } from '../context/PreviewEvents';
function Playground() {
const { gistId, gistVersion } = useParams();
const [state, dispatch] = usePlayground({ gistId, gistVersion });
- const { markup, query, result, status, dirty, settings } = state;
+ const { markup, result, status, dirty, settings } = state;
+ const { previewRef } = usePreviewEvents();
const isLoading = status === 'loading';
@@ -39,32 +32,24 @@ function Playground() {
isLoading ? 'opacity-0' : 'opacity-100',
].join(' ')}
>
-
+
-
-
-
-
-
-
-
-
-
+
+
);
diff --git a/src/components/PlaygroundPanels.js b/src/components/PlaygroundPanels.js
new file mode 100644
index 00000000..c3bf9ac5
--- /dev/null
+++ b/src/components/PlaygroundPanels.js
@@ -0,0 +1,63 @@
+import React, { Suspense } from 'react';
+import { useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import usePlayground from '../hooks/usePlayground';
+
+import Query from './Query';
+import Result from './Result';
+import TabButton from './TabButton';
+
+const panels = ['Query', 'Events'];
+
+const DomEvents = React.lazy(() => import('./DomEvents'));
+
+function Paper({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function PlaygroundPanels() {
+ const { gistId, gistVersion } = useParams();
+ const [state, dispatch] = usePlayground({ gistId, gistVersion });
+ const { query, result } = state;
+ const [panel, setPanel] = useState(panels[0]);
+
+ return (
+ <>
+
+ {panels.map((panelName) => (
+
+
+ setPanel(panelName)}
+ active={panelName === panel}
+ >
+ {panelName}
+
+
+
+ ))}
+
+
+ {panel === panels[0] && (
+
+
+
+
+
+
+
+
+
+ )}
+ {panel === panels[1] && }
+
+ >
+ );
+}
+
+export default PlaygroundPanels;
diff --git a/src/components/TabButton.js b/src/components/TabButton.js
new file mode 100644
index 00000000..04d52d3f
--- /dev/null
+++ b/src/components/TabButton.js
@@ -0,0 +1,21 @@
+import React from 'react';
+
+function TabButton({ children, active, onClick, disabled }) {
+ return (
+
+ );
+}
+
+export default TabButton;
diff --git a/src/context/PreviewEvents.js b/src/context/PreviewEvents.js
new file mode 100644
index 00000000..0ca7ffc6
--- /dev/null
+++ b/src/context/PreviewEvents.js
@@ -0,0 +1,113 @@
+import React, {
+ useCallback,
+ useRef,
+ useState,
+ useContext,
+ createContext,
+} from 'react';
+import throttle from 'lodash.throttle';
+
+import { addLoggingEvents } from '../lib/domEvents';
+
+const PreviewEventsContext = createContext();
+const PreviewEventsActionsContext = createContext();
+
+export function PreviewEventsProvider({ children }) {
+ const buffer = useRef([]);
+ const previewRef = useRef();
+ const sortDirection = useRef('asc');
+ const [appendMode, setAppendMode] = useState('bottom');
+ const [eventCount, setEventCount] = useState(0);
+ const [eventListeners, setEventListeners] = useState([]);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const flush = useCallback(
+ throttle(() => setEventCount(buffer.current.length), 16, {
+ leading: false,
+ }),
+ [setEventCount],
+ );
+
+ const changeSortDirection = () => {
+ const newDirection = sortDirection.current === 'desc' ? 'asc' : 'desc';
+ buffer.current = buffer.current.reverse();
+ setAppendMode(newDirection === 'desc' ? 'top' : 'bottom');
+ sortDirection.current = newDirection;
+ };
+
+ const reset = () => {
+ buffer.current = [];
+ setEventCount(0);
+ };
+
+ const setPreviewRef = useCallback((node) => {
+ if (node) {
+ previewRef.current = node;
+ const eventListeners = addLoggingEvents(node, (event) => {
+ const log = {
+ id: buffer.current.length + 1,
+ type: event.event.EventType,
+ name: event.event.name,
+ element: event.target.tagName,
+ selector: event.target.toString(),
+ };
+ if (sortDirection.current === 'desc') {
+ buffer.current.splice(0, 0, log);
+ } else {
+ buffer.current.push(log);
+ }
+
+ setTimeout(flush, 0);
+ });
+ setEventListeners(eventListeners);
+ } else if (previewRef.current) {
+ eventListeners.forEach((event) =>
+ previewRef.current.removeEventListener(event.name, event.listener),
+ );
+ previewRef.current = null;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function usePreviewEvents() {
+ const context = useContext(PreviewEventsContext);
+
+ if (!context) {
+ throw new Error(
+ `usePreviewEvents must be used within PreviewEventsProvider`,
+ );
+ }
+
+ return context;
+}
+
+export function usePreviewEventsActions() {
+ const context = useContext(PreviewEventsActionsContext);
+
+ if (!context) {
+ throw new Error(
+ `usePreviewEventsActions must be used within PreviewEventsProvider`,
+ );
+ }
+
+ return context;
+}
diff --git a/src/lib/domEvents.js b/src/lib/domEvents.js
new file mode 100644
index 00000000..ac5b83e2
--- /dev/null
+++ b/src/lib/domEvents.js
@@ -0,0 +1,65 @@
+import { eventMap } from '@testing-library/dom/dist/event-map';
+
+export function addLoggingEvents(node, log) {
+ function createEventLogger(eventType) {
+ return function logEvent(event) {
+ if (event.target === event.currentTarget) {
+ return;
+ }
+
+ log({
+ event: eventType,
+ target: getElementData(event.target),
+ });
+ };
+ }
+ const eventListeners = [];
+ Object.keys(eventMap).forEach((name) => {
+ eventListeners.push({
+ name: name.toLowerCase(),
+ listener: node.addEventListener(
+ name.toLowerCase(),
+ createEventLogger({ name, ...eventMap[name] }),
+ true,
+ ),
+ });
+ });
+
+ return eventListeners;
+}
+
+export function getElementData(element) {
+ const value =
+ element.tagName === 'SELECT' && element.multiple
+ ? element.selectedOptions.length > 0
+ ? JSON.stringify(
+ Array.from(element.selectedOptions).map((o) => o.value),
+ )
+ : null
+ : element.value;
+
+ const hasChecked = element.type === 'checkbox' || element.type === 'radio';
+
+ return {
+ tagName: element.tagName.toLowerCase(),
+ id: element.id || null,
+ name: element.name || null,
+ htmlFor: element.htmlFor || null,
+ value: value || null,
+ checked: hasChecked ? !!element.checked : null,
+ toString: targetToString,
+ };
+}
+
+export function targetToString() {
+ return [
+ this.tagName.toLowerCase(),
+ this.id && `#${this.id}`,
+ this.name && `[name="${this.name}"]`,
+ this.htmlFor && `[for="${this.htmlFor}"]`,
+ this.value && `[value="${this.value}"]`,
+ this.checked !== null && `[checked=${this.checked}]`,
+ ]
+ .filter(Boolean)
+ .join('');
+}