tracker: add use el / sprite map support, change graphql relay plugin

This commit is contained in:
nick-delirium 2024-12-18 14:04:19 +01:00
parent d7f810809e
commit f791d06ecd
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
19 changed files with 427 additions and 59 deletions

View file

@ -35,9 +35,9 @@ export default class GQLDetails extends React.PureComponent {
</div>
</div>
<div style={{ height: 'calc(100vh - 364px)', overflowY: 'auto' }}>
<div style={{ height: 'calc(100vh - 264px)', overflowY: 'auto' }} className={'border-t border-t-gray-light mt-2 py-2'}>
<div>
<div className="flex justify-between items-start mt-6 mb-2">
<div className="flex justify-between items-start mb-2">
<h5 className="mt-1 mr-1">{'Variables'}</h5>
</div>
<div className={dataClass}>

View file

@ -37,6 +37,7 @@ const IMG = 'img';
const MEDIA = 'media';
const OTHER = 'other';
const WS = 'websocket';
const GRAPHQL = 'graphql';
const TYPE_TO_TAB = {
[ResourceType.XHR]: XHR,
@ -47,9 +48,10 @@ const TYPE_TO_TAB = {
[ResourceType.MEDIA]: MEDIA,
[ResourceType.WS]: WS,
[ResourceType.OTHER]: OTHER,
[ResourceType.GRAPHQL]: GRAPHQL,
};
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS] as const;
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS, GRAPHQL] as const;
export const NETWORK_TABS = TAP_KEYS.map((tab) => ({
text: tab === 'xhr' ? 'Fetch/XHR' : tab,
key: tab,

View file

@ -42,6 +42,21 @@ export default class MessageLoader {
this.session = session
}
/**
* TODO: has to be moved out of messageLoader logic somehow
* */
spriteMapSvg: SVGElement | null = null;
potentialSpriteMap: Record<string, any> = {};
domParser: DOMParser | null = null;
createSpriteMap = () => {
if (!this.spriteMapSvg) {
this.domParser = new DOMParser();
this.spriteMapSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
this.spriteMapSvg.setAttribute("style", "display: none;");
this.spriteMapSvg.setAttribute("id", "reconstructed-sprite");
}
}
createNewParser(
shouldDecrypt = true,
onMessagesDone: (msgs: PlayerMsg[], file?: string) => void,
@ -78,7 +93,22 @@ export default class MessageLoader {
let artificialStartTime = Infinity;
let startTimeSet = false;
msgs.forEach((msg) => {
msgs.forEach((msg, i) => {
if (msg.tp === MType.SetNodeAttribute) {
if (msg.value.includes('_$OPENREPLAY_SPRITE$_')) {
this.createSpriteMap()
if (!this.domParser) {
return console.error('DOM parser is not initialized?');
}
handleSprites(
this.potentialSpriteMap,
this.domParser,
msg,
this.spriteMapSvg!,
i
);
}
}
if (msg.tp === MType.Redux || msg.tp === MType.ReduxDeprecated) {
if ('actionTime' in msg && msg.actionTime) {
msg.time = msg.actionTime - this.session.startedAt;
@ -293,6 +323,10 @@ export default class MessageLoader {
await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]);
this.messageManager.onFileReadSuccess();
// no sprites for mobile
if (this.spriteMapSvg && 'injectSpriteMap' in this.messageManager) {
this.messageManager.injectSpriteMap(this.spriteMapSvg);
}
};
loadEFSMobs = async () => {
@ -420,5 +454,28 @@ function findBrokenNodes(nodes: any[]) {
return result;
}
function handleSprites(potentialSpriteMap: Record<string, any>, parser: DOMParser, msg: Record<string, any>, spriteMapSvg: SVGElement, i: number) {
const [_, dataUrl] = msg.value.split('_$OPENREPLAY_SPRITE$_');
const potentialSprite = potentialSpriteMap[dataUrl];
if (potentialSprite) {
msg.value = potentialSprite;
} else {
const svgText = atob(dataUrl.split(",")[1]);
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
const originalSvg = svgDoc.querySelector("svg");
if (originalSvg) {
const symbol = document.createElementNS("http://www.w3.org/2000/svg", "symbol");
const symbolId = `symbol-${msg.id || 'ind-' + i}`; // Generate an ID if missing
symbol.setAttribute("id", symbolId);
symbol.setAttribute("viewBox", originalSvg.getAttribute("viewBox") || "0 0 24 24");
symbol.innerHTML = originalSvg.innerHTML;
spriteMapSvg.appendChild(symbol);
msg.value = `#${symbolId}`;
potentialSpriteMap[dataUrl] = `#${symbolId}`;
}
}
}
// @ts-ignore
window.searchOrphans = (msgs) => findBrokenNodes(msgs.filter(m => [8,9,10,70].includes(m.tp)));

View file

@ -8,7 +8,7 @@ import ListWalker from '../common/ListWalker';
import MouseMoveManager from './managers/MouseMoveManager';
import ActivityManager from './managers/ActivityManager';
import TabClosingManager from "./managers/TabClosingManager";
import TabClosingManager from './managers/TabClosingManager';
import { MouseThrashing, MType } from './messages';
import type { Message, MouseClick } from './messages';
@ -52,7 +52,7 @@ export interface State extends ScreenState {
};
tabNames: {
[tabId: string]: string;
}
};
domContentLoadedTime?: { time: number; value: number };
domBuildingTime?: number;
@ -99,7 +99,7 @@ export default class MessageManager {
closedTabs: [],
sessionStart: 0,
tabNames: {},
};
};
private clickManager: ListWalker<MouseClick> = new ListWalker();
private mouseThrashingManager: ListWalker<MouseThrashing> = new ListWalker();
@ -128,7 +128,9 @@ export default class MessageManager {
this.mouseMoveManager = new MouseMoveManager(screen);
this.sessionStart = this.session.startedAt;
state.update({ sessionStart: this.sessionStart });
this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live
this.activityManager = new ActivityManager(
this.session.duration.milliseconds
); // only if not-live
}
public getListsFullState = () => {
@ -139,12 +141,18 @@ export default class MessageManager {
return Object.values(this.tabs)[0].getListsFullState();
};
public injectSpriteMap = (spriteEl: SVGElement) => {
Object.values(this.tabs).forEach((tab) => {
tab.injectSpriteMap(spriteEl)
})
};
public setSession = (session: SessionFilesInfo) => {
this.session = session;
this.sessionStart = this.session.startedAt;
this.state.update({ sessionStart: this.sessionStart });
Object.values(this.tabs).forEach((tab) => tab.setSession(session));
}
};
public updateLists(lists: RawList) {
Object.keys(this.tabs).forEach((tab) => {
@ -198,26 +206,26 @@ export default class MessageManager {
* Scan tab managers for last message ts
* */
public createTabCloseEvents = () => {
const lastMsgArr: [string, number][] = []
const lastMsgArr: [string, number][] = [];
if (this.tabsAmount === 1) {
return this.tabCloseManager.append({
tabId: Object.keys(this.tabs)[0],
time: this.session.durationMs - 100
})
time: this.session.durationMs - 100,
});
}
for (const [tabId, tab] of Object.entries(this.tabs)) {
const { lastMessageTs } = tab
const { lastMessageTs } = tab;
if (lastMessageTs && tabId) {
lastMsgArr.push([tabId, lastMessageTs])
lastMsgArr.push([tabId, lastMessageTs]);
}
}
lastMsgArr.sort((a, b) => a[1] - b[1])
lastMsgArr.sort((a, b) => a[1] - b[1]);
lastMsgArr.forEach(([tabId, lastMessageTs]) => {
this.tabCloseManager.append({ tabId, time: lastMessageTs })
})
}
this.tabCloseManager.append({ tabId, time: lastMessageTs });
});
};
public startLoading = () => {
this.waitingForFiles = true;
@ -238,15 +246,15 @@ export default class MessageManager {
// usually means waiting for messages from live session
if (Object.keys(this.tabs).length === 0) return;
this.activeTabManager.moveReady(t).then(async (tabId) => {
const closeMessage = await this.tabCloseManager.moveReady(t)
const closeMessage = await this.tabCloseManager.moveReady(t);
if (closeMessage) {
const closedTabs = this.tabCloseManager.closedTabs
const closedTabs = this.tabCloseManager.closedTabs;
if (closedTabs.size === this.tabsAmount) {
if (this.session.durationMs - t < 250) {
this.state.update({ closedTabs: Array.from(closedTabs) })
this.state.update({ closedTabs: Array.from(closedTabs) });
}
} else {
this.state.update({ closedTabs: Array.from(closedTabs) })
this.state.update({ closedTabs: Array.from(closedTabs) });
}
}
// Moving mouse and setting :hover classes on ready view
@ -261,7 +269,8 @@ export default class MessageManager {
this.screen.cursor.shake();
}
if (!this.activeTab) {
this.activeTab = this.state.get().currentTab ?? Object.keys(this.tabs)[0];
this.activeTab =
this.state.get().currentTab ?? Object.keys(this.tabs)[0];
}
if (tabId) {
@ -291,8 +300,7 @@ export default class MessageManager {
});
if (
this.waitingForFiles ||
(this.lastMessageTime <= t &&
t < this.session.durationMs)
(this.lastMessageTime <= t && t < this.session.durationMs)
) {
this.setMessagesLoading(true);
}
@ -318,7 +326,12 @@ export default class MessageManager {
if (msg.tp === 9999) return;
if (!this.tabs[msg.tabId]) {
this.tabsAmount++;
this.state.update({ tabStates: { ...this.state.get().tabStates, [msg.tabId]: TabSessionManager.INITIAL_STATE } });
this.state.update({
tabStates: {
...this.state.get().tabStates,
[msg.tabId]: TabSessionManager.INITIAL_STATE,
},
});
this.tabs[msg.tabId] = new TabSessionManager(
this.session,
this.state,
@ -368,7 +381,11 @@ export default class MessageManager {
switch (msg.tp) {
case MType.CreateDocument:
if (!this.firstVisualEventSet) {
this.activeTabManager.unshift({ tp: MType.TabChange, tabId: msg.tabId, time: 0 });
this.activeTabManager.unshift({
tp: MType.TabChange,
tabId: msg.tabId,
time: 0,
});
this.state.update({
firstVisualEvent: msg.time,
currentTab: msg.tabId,
@ -387,9 +404,11 @@ export default class MessageManager {
this.updateChangeEvents();
}
this.screen.display(!messagesLoading);
const cssLoading = Object.values(this.state.get().tabStates).some((tab) => tab.cssLoading);
const isReady = !messagesLoading && !cssLoading
this.state.update({ messagesLoading, ready: isReady});
const cssLoading = Object.values(this.state.get().tabStates).some(
(tab) => tab.cssLoading
);
const isReady = !messagesLoading && !cssLoading;
this.state.update({ messagesLoading, ready: isReady });
};
decodeMessage(msg: Message) {

View file

@ -181,7 +181,7 @@ export default class Screen {
getElementFromInternalPoint({ x, y }: Point): Element | null {
// elementFromPoint && elementFromPoints require viewpoint-related coordinates,
// not document-related
// not document-related
return this.document?.elementFromPoint(x, y) || null;
}

View file

@ -121,6 +121,10 @@ export default class TabSessionManager {
return this.pagesManager.getNode(id);
};
public injectSpriteMap = (spriteMapEl: SVGElement) => {
this.pagesManager.injectSpriteMap(spriteMapEl);
}
public updateLists(lists: Partial<InitialLists>) {
Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => {
const currentList = this.lists.lists[key];

View file

@ -7,7 +7,7 @@ import ListWalker from '../../../common/ListWalker';
import StylesManager from './StylesManager';
import FocusManager from './FocusManager';
import SelectionManager from './SelectionManager';
import type { StyleElement } from './VirtualDOM';
import { StyleElement, VSpriteMap } from "./VirtualDOM";
import {
OnloadStyleSheet,
VDocument,
@ -157,6 +157,12 @@ export default class DOMManager extends ListWalker<Message> {
return;
}
const parent = this.vElements.get(parentID) || this.olVRoots.get(parentID);
if ('tagName' in child && child.tagName === 'BODY') {
const spriteMap = new VSpriteMap('svg', true, Number.MAX_SAFE_INTEGER - 100, Number.MAX_SAFE_INTEGER - 100);
spriteMap.node.setAttribute('id', 'OPENREPLAY_SPRITES_MAP');
spriteMap.node.setAttribute('style', 'display: none;');
child.insertChildAt(spriteMap, Number.MAX_SAFE_INTEGER - 100);
}
if (!parent) {
logger.error(
`${id} Insert error. Parent vNode ${parentID} not found`,

View file

@ -53,7 +53,7 @@ export abstract class VNode<T extends Node = Node> {
public abstract applyChanges(): void
}
type VChild = VElement | VText
type VChild = VElement | VText | VSpriteMap
abstract class VParent<T extends Node = Node> extends VNode<T>{
/**
*/
@ -140,6 +140,44 @@ export class VShadowRoot extends VParent<ShadowRoot> {
export type VRoot = VDocument | VShadowRoot
export class VSpriteMap extends VParent<Element> {
parentNode: VParent | null =
null; /** Should be modified only by he parent itself */
private newAttributes: Map<string, string | false> = new Map();
constructor(
readonly tagName: string,
readonly isSVG = true,
public readonly index: number,
private readonly nodeId: number
) {
super();
this.createNode();
}
protected createNode() {
try {
const element = document.createElementNS(
'http://www.w3.org/2000/svg',
this.tagName
);
element.dataset['openreplayId'] = this.nodeId.toString();
return element;
} catch (e) {
console.error(
'Openreplay: Player received invalid html tag',
this.tagName,
e
);
return document.createElement(this.tagName.replace(/[^a-z]/gi, ''));
}
}
applyChanges() {
// this is a hack to prevent the sprite map from being removed from the DOM
return null;
}
}
export class VElement extends VParent<Element> {
parentNode: VParent | null = null /** Should be modified only by he parent itself */
private newAttributes: Map<string, string | false> = new Map()

View file

@ -79,15 +79,41 @@ export default class PagesManager extends ListWalker<DOMManager> {
return this.currentPage?.getNode(id);
}
spriteMapEl: SVGElement | null = null;
injectSpriteMap = (spriteEl: SVGElement) => {
this.spriteMapEl = spriteEl;
this.refreshSprites();
};
refreshSprites = () => {
const int = setInterval(() => {
const potential = this.screen.document?.body.querySelector(
'#OPENREPLAY_SPRITES_MAP'
);
if (potential) {
potential.innerHTML = this.spriteMapEl!.innerHTML;
clearInterval(int);
}
}, 250);
}
moveReady(t: number): Promise<void> {
const requiredPage = this.moveGetLast(t);
let changed = false;
if (requiredPage != null) {
this.currentPage?.clearSelectionManager();
this.currentPage = requiredPage;
this.currentPage.reset(); // Otherwise it won't apply create_document
changed = true;
}
if (this.currentPage != null) {
return this.currentPage.moveReady(t);
return this.currentPage.moveReady(t).then(() => {
if (changed && this.spriteMapEl) {
setTimeout(() => {
this.refreshSprites();
}, 0)
}
})
}
return Promise.resolve();
}

View file

@ -105,13 +105,26 @@ export interface IResourceRequest extends IResource {
decodedBodySize?: number,
}
const getGraphqlReqName = (resource: IResource) => {
try {
if (!resource.request) return getResourceName(resource.url)
const req = JSON.parse(resource.request)
const body = JSON.parse(req.body)
return /query (\w+)/.exec(body.query)?.[1]
} catch (e) {
return getResourceName(resource.url)
}
}
export const Resource = (resource: IResource) => ({
...resource,
name: getResourceName(resource.url),
isRed: !resource.success || resource.error, //|| resource.score >= RED_BOUND,
isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
})
export const Resource = (resource: IResource) => {
const name = resource.type === 'graphql' ? getGraphqlReqName(resource) : getResourceName(resource.url)
return {
...resource,
name,
isRed: !resource.success || resource.error, //|| resource.score >= RED_BOUND,
isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
}
}
export function getResourceFromResourceTiming(msg: ResourceTiming, sessStart: number) {

View file

@ -30,16 +30,50 @@ export const recordGraphQL = tracker.use(createGraphqlMiddleware());
### Relay
If you're using [Relay network tools](https://github.com/relay-tools/react-relay-network-modern),
you can simply [create a middleware](https://github.com/relay-tools/react-relay-network-modern/tree/master?tab=readme-ov-file#example-of-injecting-networklayer-with-middlewares-on-the-client-side)
you can simply [create a middleware](https://github.com/relay-tools/react-relay-network-modern/tree/master?tab=readme-ov-file#example-of-injecting-networklayer-with-middlewares-on-the-client-side) (async based); otherwise this will require wrapping fetch function with Observable.
```js
import { createRelayMiddleware } from '@openreplay/tracker-graphql';
import { Observable } from 'relay-runtime';
const trackerMiddleware = tracker.use(createRelayMiddleware());
const withTracker = tracker.use(createRelayMiddleware())
function createFetchObservable(operation, variables) {
return Observable.create(sink => {
fetch(`YOUR URL`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: operation.text, variables }),
})
.then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
})
.then(data => {
sink.next(data);
sink.complete();
})
.catch(error => {
sink.error(error);
})
});
}
const network = Network.create(withTracker(createFetchObservable));
const environment = new Environment({
network,
store: new Store(new RecordSource()),
});
```
```js
import { createRelayToolsMiddleware } from '@openreplay/tracker-graphql';
const trackerMiddleware = tracker.use(createRelayToolsMiddleware());
const network = new RelayNetworkLayer([
// your middleware
// ,
trackerMiddleware,
]);
```

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker-graphql",
"description": "Tracker plugin for GraphQL requests recording",
"version": "4.1.0",
"version": "4.2.0",
"keywords": [
"graphql",
"logging",
@ -31,7 +31,6 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@apollo/client": "^3.9.5",
"@types/zen-observable": "^0.8.7",
"zen-observable": "^0.10.0"
}

View file

@ -1,10 +1,11 @@
import createTrackerLink from './apolloMiddleware.js';
import createRelayMiddleware from './relayMiddleware.js';
import { createRelayObserver, createRelayMiddleware } from './relayMiddleware.js';
import createGraphqlMiddleware from './graphqlMiddleware.js';
import { Sanitizer } from './types.js';
export {
createTrackerLink,
createRelayObserver,
createRelayMiddleware,
createGraphqlMiddleware,
Sanitizer,

View file

@ -1,6 +1,95 @@
import { App, Messages } from '@openreplay/tracker';
import type { Middleware, RelayRequest } from './relaytypes';
import { Sanitizer } from './types';
import Observable from 'zen-observable';
interface GraphQLOperation {
name: string;
operationKind: string;
text?: string | null;
}
interface GraphQLVariables {
[key: string]: any;
}
interface GraphQLCacheConfig {
[key: string]: any;
}
interface FetchFunction {
(
operation: GraphQLOperation,
variables: GraphQLVariables,
cacheConfig: GraphQLCacheConfig,
uploadables?: any
): Observable<unknown>;
}
function safeStringify(value: unknown) {
try {
return JSON.stringify(value);
} catch {
// If we cant stringify (e.g., cyclic object), return a placeholder
return '"[unserializable]"';
}
}
function createRelayObserver(sanitizer?: Sanitizer<Record<string, any>>) {
return (app: App | null) => {
return (originalFetch: FetchFunction) => (operation: GraphQLOperation, variables: GraphQLVariables, cacheConfig: GraphQLCacheConfig, uploadables?: any): Observable<any> => {
const startTime = Date.now();
const observable = originalFetch(operation, variables, cacheConfig, uploadables);
if (!app || !app.active()) {
return observable;
}
return new Observable(observer =>
observable.subscribe({
next: (data: any) => {
const duration = Date.now() - startTime;
const opName = operation.name;
const opKind = operation.operationKind;
const vars = JSON.stringify(sanitizer ? sanitizer(variables) : variables);
if (data.errors && data.errors.length > 0) {
const opResp = safeStringify(sanitizer ? sanitizer(data.errors) : data.errors);
app.send(Messages.GraphQL(
opKind,
`ERROR: ${opName}`,
vars,
opResp,
duration
));
} else {
const opResp = safeStringify(sanitizer ? sanitizer(data) : data);
app.send(Messages.GraphQL(
opKind,
opName,
vars,
opResp,
duration
));
}
observer.next(data);
},
error: err => {
const duration = Date.now() - startTime;
const opName = 'ERROR: ' + operation.name;
const opKind = operation.operationKind;
const vars = safeStringify(sanitizer ? sanitizer(variables) : variables);
const opResp = safeStringify(err);
app.send(Messages.GraphQL(opKind, opName, vars, opResp, duration));
observer.error(err);
},
complete: () => {
observer.complete();
}
})
)
}
}
}
const createRelayMiddleware = (sanitizer?: Sanitizer<Record<string, any>>) => {
return (app: App | null): Middleware => {
@ -52,4 +141,4 @@ function getMessage(
return Messages.GraphQL(opKind, opName, vars, opResp, duration);
}
export default createRelayMiddleware;
export { createRelayMiddleware, createRelayObserver };

View file

@ -3,11 +3,12 @@
"noImplicitThis": true,
"strictNullChecks": true,
"alwaysStrict": true,
"target": "es6",
"module": "es6",
"target": "es2020",
"module": "ESNext",
"moduleResolution": "node",
"declaration": true,
"outDir": "./lib",
"allowSyntheticDefaultImports": true
}
},
"include": ["src/**/*.ts"],
}

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "15.0.3",
"version": "15.0.4",
"keywords": [
"logging",
"replay"

View file

@ -4,7 +4,13 @@ export function isNode(sth: any): sth is Node {
}
export function isSVGElement(node: Element): node is SVGElement {
return node.namespaceURI === 'http://www.w3.org/2000/svg'
return (
node.namespaceURI === 'http://www.w3.org/2000/svg' || node.localName === 'svg'
)
}
export function isUseElement(node: Element): node is SVGUseElement {
return node.localName === 'use'
}
export function isElementNode(node: Node): node is Element {

View file

@ -9,6 +9,7 @@ import {
MoveNode,
RemoveNode,
UnbindNodes,
SetNodeAttribute,
} from '../messages.gen.js'
import App from '../index.js'
import {
@ -16,10 +17,67 @@ import {
isTextNode,
isElementNode,
isSVGElement,
isUseElement,
hasTag,
isCommentNode,
} from '../guards.js'
const iconCache = {}
const domParser = new DOMParser()
async function parseUseEl(useElement: SVGUseElement, mode: 'inline' | 'dataurl') {
try {
const href = useElement.getAttribute('xlink:href') || useElement.getAttribute('href')
if (!href) {
console.debug('Openreplay: xlink:href or href not found on <use>.')
return
}
const [url, symbolId] = href.split('#')
if (!url || !symbolId) {
console.debug('Openreplay: Invalid xlink:href or href found on <use>.')
return
}
if (iconCache[symbolId]) {
return iconCache[symbolId]
}
const response = await fetch(url)
const svgText = await response.text()
const svgDoc = domParser.parseFromString(svgText, 'image/svg+xml')
const symbol = svgDoc.getElementById(symbolId)
if (!symbol) {
console.debug('Openreplay: Symbol not found in SVG.')
return
}
if (mode === 'inline') {
const res = { paths: symbol.innerHTML, vbox: symbol.getAttribute('viewBox') || '0 0 24 24' }
iconCache[symbolId] = res
return res
} else if (mode === 'dataurl') {
const inlineSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
${symbol.innerHTML}
</svg>
`
const encodedSvg = btoa(inlineSvg)
const dataUrl = `data:image/svg+xml;base64,${encodedSvg}`
iconCache[symbolId] = dataUrl
return dataUrl
} else {
console.debug(`Openreplay: Unknown mode: ${mode}. Use "inline" or "dataurl".`)
}
} catch (error) {
console.error('Openreplay: Error processing <use> element:', error)
}
}
function isIgnored(node: Node): boolean {
if (isCommentNode(node)) {
return true
@ -146,8 +204,8 @@ export default abstract class Observer {
{
acceptNode: (node) =>
isIgnored(node) || this.app.nodes.getID(node) === undefined
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
},
// @ts-ignore
false,
@ -178,13 +236,28 @@ export default abstract class Observer {
}
if (value === null) {
this.app.send(RemoveNodeAttribute(id, name))
} else if (name === 'href') {
if (value.length > 1e5) {
}
if (isUseElement(node) && name === 'href') {
parseUseEl(node, 'dataurl')
.then((dataUrl) => {
if (dataUrl) {
this.app.send(SetNodeAttribute(id, name, `_$OPENREPLAY_SPRITE$_${dataUrl}`))
}
})
.catch((e: any) => {
console.error('Openreplay: Error parsing <use> element:', e)
})
return
}
if (name === 'href') {
if (value!.length > 1e5) {
value = ''
}
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
this.app.send(SetNodeAttributeURLBased(id, name, value!, this.app.getBaseHref()))
} else {
this.app.attributeSender.sendSetAttribute(id, name, value)
this.app.attributeSender.sendSetAttribute(id, name, value!)
}
return
}