diff --git a/packages/debugger/src/locator/element-overlay.tsx b/packages/debugger/src/locator/DOMLocator/element-overlay.tsx similarity index 100% rename from packages/debugger/src/locator/element-overlay.tsx rename to packages/debugger/src/locator/DOMLocator/element-overlay.tsx diff --git a/packages/debugger/src/locator/find-components.ts b/packages/debugger/src/locator/DOMLocator/find-components.ts similarity index 78% rename from packages/debugger/src/locator/find-components.ts rename to packages/debugger/src/locator/DOMLocator/find-components.ts index c8cf52fc..6c77665a 100644 --- a/packages/debugger/src/locator/find-components.ts +++ b/packages/debugger/src/locator/DOMLocator/find-components.ts @@ -1,5 +1,7 @@ import {isWindows} from '@solid-primitives/platform' -import {LOCATION_ATTRIBUTE_NAME, NodeID, WINDOW_PROJECTPATH_PROPERTY} from '../types' +import {LOCATION_ATTRIBUTE_NAME, NodeID, WINDOW_PROJECTPATH_PROPERTY} from '../../types' +import {SourceElementType, SourceLocation} from '../types' +import {parseLocationString} from '../utils' export type LocationAttr = `${string}:${number}:${number}` @@ -12,15 +14,9 @@ export type LocatorComponent = { export type TargetIDE = 'vscode' | 'webstorm' | 'atom' | 'vscode-insiders' -export type SourceLocation = { - file: string - line: number - column: number -} - export type SourceCodeData = SourceLocation & { projectPath: string - element: HTMLElement | string | undefined + element: SourceElementType } export type TargetURLFunction = (data: SourceCodeData) => string | void @@ -66,24 +62,6 @@ export function getSourceCodeData( return {...parsed, projectPath, element} } -/** - * Validates and parses a location string to a {@link SourceLocation} object - */ -export function parseLocationString(location: string): SourceLocation | undefined { - // eslint-disable-next-line prefer-const - let [filePath, line, column] = location.split(':') as [string, string | number, string | number] - if ( - filePath && - line && - column && - typeof filePath === 'string' && - !isNaN((line = Number(line))) && - !isNaN((column = Number(column))) - ) { - return {file: filePath, line, column} - } -} - export function openSourceCode(target: TargetIDE | TargetURLFunction, data: SourceCodeData): void { const url = getTargetURL(target, data) if (typeof url === 'string') window.open(url, '_blank') diff --git a/packages/debugger/src/locator/DOMLocator/index.ts b/packages/debugger/src/locator/DOMLocator/index.ts new file mode 100644 index 00000000..da7b6020 --- /dev/null +++ b/packages/debugger/src/locator/DOMLocator/index.ts @@ -0,0 +1,157 @@ +import {makeHoverElementListener} from '@solid-devtools/shared/primitives' +import {warn} from '@solid-devtools/shared/utils' +import {makeEventListener} from '@solid-primitives/event-listener' +import {createKeyHold} from '@solid-primitives/keyboard' +import {scheduleIdle} from '@solid-primitives/scheduled' +import {defer} from '@solid-primitives/utils' +import {createEffect, createMemo, createSignal, onCleanup} from 'solid-js' +import * as registry from '../../main/component-registry' +import {ObjectType, getObjectById} from '../../main/id' +import {NodeID} from '../../main/types' +import {HighlightElementPayload, LocatorFactory, SourceElementType, SourceLocation} from '../types' +import {createElementsOverlay} from './element-overlay' +import { + LocatorComponent, + TargetIDE, + TargetURLFunction, + getLocationAttr, + getProjectPath, + getSourceCodeData, + openSourceCode, +} from './find-components' +import {LocatorOptions} from './types' + +export function createDOMLocatorFactory(options: LocatorOptions): LocatorFactory { + return props => { + const [enabledByPressingSignal, setEnabledByPressingSignal] = createSignal( + (): boolean => false, + ) + props.setLocatorEnabledSignal(createMemo(() => enabledByPressingSignal()())) + + const [hoverTarget, setHoverTarget] = createSignal(null) + const [devtoolsTarget, setDevtoolsTarget] = createSignal(null) + + const [highlightedComponents, setHighlightedComponents] = createSignal( + [], + ) + + const calcHighlightedComponents = ( + target: HTMLElement | HighlightElementPayload, + ): LocatorComponent[] => { + if (!target) return [] + + // target is an elementId + if ('type' in target && target.type === 'element') { + const element = getObjectById(target.id, ObjectType.Element) + if (!(element instanceof HTMLElement)) return [] + target = element + } + + // target is an element + if (target instanceof HTMLElement) { + const comp = registry.findComponent(target) + if (!comp) return [] + return [ + { + location: getLocationAttr(target), + element: target, + id: comp.id, + name: comp.name, + }, + ] + } + + // target is a component or an element of a component (in DOM walker mode) + const comp = registry.getComponent(target.id) + if (!comp) return [] + return comp.elements.map(element => ({ + element, + id: comp.id, + name: comp.name, + })) + } + + createEffect( + defer( + () => hoverTarget() ?? devtoolsTarget(), + scheduleIdle(target => + setHighlightedComponents(() => calcHighlightedComponents(target)), + ), + ), + ) + + createElementsOverlay(highlightedComponents) + + // notify of component hovered by using the debugger + createEffect((prev: NodeID | undefined) => { + const target = hoverTarget() + const comp = target && registry.findComponent(target) + if (prev) props.emit('HoveredComponent', {nodeId: prev, state: false}) + if (comp) { + const {id} = comp + props.emit('HoveredComponent', {nodeId: id, state: true}) + return id + } + }) + + let targetIDE: TargetIDE | TargetURLFunction | undefined + + createEffect(() => { + if (!props.locatorEnabled()) return + + // set hovered element as target + makeHoverElementListener(el => setHoverTarget(el)) + onCleanup(() => setHoverTarget(null)) + + // go to selected component source code on click + makeEventListener( + window, + 'click', + e => { + const {target} = e + if (!(target instanceof HTMLElement)) return + const highlighted = highlightedComponents() + const comp = + highlighted.find(({element}) => target.contains(element)) ?? highlighted[0] + if (!comp) return + const sourceCodeData = + comp.location && getSourceCodeData(comp.location, comp.element) + + // intercept on-page components clicks and send them to the devtools overlay + props.onComponentClick(comp.id, () => { + if (!targetIDE || !sourceCodeData) return + e.preventDefault() + e.stopPropagation() + openSourceCode(targetIDE, sourceCodeData) + }) + }, + true, + ) + }) + + if (options.targetIDE) targetIDE = options.targetIDE + if (options.key !== false) { + const isHoldingKey = createKeyHold(options.key ?? 'Alt', {preventDefault: true}) + setEnabledByPressingSignal(() => isHoldingKey) + } + + return { + setDevtoolsHighlightTarget(target: HighlightElementPayload) { + setDevtoolsTarget(target) + }, + openElementSourceCode( + location: SourceLocation, + element: SourceElementType, + ) { + if (!targetIDE) return warn('Please set `targetIDE` it in useLocator options.') + const projectPath = getProjectPath() + if (!projectPath) return warn('projectPath is not set.') + openSourceCode(targetIDE, { + ...location, + projectPath, + element, + }) + }, + } + } +} diff --git a/packages/debugger/src/locator/test/index.test.ts b/packages/debugger/src/locator/DOMLocator/test/index.test.ts similarity index 91% rename from packages/debugger/src/locator/test/index.test.ts rename to packages/debugger/src/locator/DOMLocator/test/index.test.ts index fcd62011..a9a21ea0 100644 --- a/packages/debugger/src/locator/test/index.test.ts +++ b/packages/debugger/src/locator/DOMLocator/test/index.test.ts @@ -8,7 +8,7 @@ vi.mock('@solid-primitives/platform', () => ({ }, })) -const fetchFunction = async () => (await import('../find-components')).parseLocationString +const fetchFunction = async () => (await import('../../utils')).parseLocationString describe('locator attribute pasting', () => { beforeEach(() => { diff --git a/packages/debugger/src/locator/DOMLocator/types.ts b/packages/debugger/src/locator/DOMLocator/types.ts new file mode 100644 index 00000000..c5023c7c --- /dev/null +++ b/packages/debugger/src/locator/DOMLocator/types.ts @@ -0,0 +1,18 @@ +import type {KbdKey} from '@solid-primitives/keyboard' +import type {TargetIDE, TargetURLFunction} from './find-components' + +export type {LocationAttr, LocatorComponent, TargetIDE, TargetURLFunction} from './find-components' + +export type LocatorOptions = { + /** Choose in which IDE the component source code should be revealed. */ + targetIDE?: false | TargetIDE | TargetURLFunction + /** + * Holding which key should enable the locator overlay? + * @default 'Alt' + */ + key?: false | KbdKey +} + +// used by the transform +export const WINDOW_PROJECTPATH_PROPERTY = '$sdt_projectPath' +export const LOCATION_ATTRIBUTE_NAME = 'data-source-loc' diff --git a/packages/debugger/src/locator/index.ts b/packages/debugger/src/locator/index.ts index d1b5cd3b..a5c5223e 100644 --- a/packages/debugger/src/locator/index.ts +++ b/packages/debugger/src/locator/index.ts @@ -1,188 +1 @@ -import {makeHoverElementListener} from '@solid-devtools/shared/primitives' -import {warn} from '@solid-devtools/shared/utils' -import {EmitterEmit} from '@solid-primitives/event-bus' -import {makeEventListener} from '@solid-primitives/event-listener' -import {createKeyHold} from '@solid-primitives/keyboard' -import {scheduleIdle} from '@solid-primitives/scheduled' -import {defer} from '@solid-primitives/utils' -import { - Accessor, - createEffect, - createMemo, - createSignal, - getOwner, - onCleanup, - runWithOwner, -} from 'solid-js' -import type {Debugger} from '../main' -import * as registry from '../main/component-registry' -import {ObjectType, getObjectById} from '../main/id' -import SolidAPI from '../main/solid-api' -import {NodeID} from '../main/types' -import {createElementsOverlay} from './element-overlay' -import { - LocatorComponent, - SourceCodeData, - SourceLocation, - TargetIDE, - TargetURLFunction, - getLocationAttr, - getProjectPath, - getSourceCodeData, - openSourceCode, -} from './find-components' -import {HighlightElementPayload, LocatorOptions} from './types' - -export {parseLocationString} from './find-components' - -export function createLocator(props: { - emit: EmitterEmit - locatorEnabled: Accessor - setLocatorEnabledSignal(signal: Accessor): void - onComponentClick(componentId: NodeID, next: VoidFunction): void -}) { - const [enabledByPressingSignal, setEnabledByPressingSignal] = createSignal((): boolean => false) - props.setLocatorEnabledSignal(createMemo(() => enabledByPressingSignal()())) - - const [hoverTarget, setHoverTarget] = createSignal(null) - const [devtoolsTarget, setDevtoolsTarget] = createSignal(null) - - const [highlightedComponents, setHighlightedComponents] = createSignal([]) - - const calcHighlightedComponents = ( - target: HTMLElement | HighlightElementPayload, - ): LocatorComponent[] => { - if (!target) return [] - - // target is an elementId - if ('type' in target && target.type === 'element') { - const element = getObjectById(target.id, ObjectType.Element) - if (!(element instanceof HTMLElement)) return [] - target = element - } - - // target is an element - if (target instanceof HTMLElement) { - const comp = registry.findComponent(target) - if (!comp) return [] - return [ - { - location: getLocationAttr(target), - element: target, - id: comp.id, - name: comp.name, - }, - ] - } - - // target is a component or an element of a component (in DOM walker mode) - const comp = registry.getComponent(target.id) - if (!comp) return [] - return comp.elements.map(element => ({ - element, - id: comp.id, - name: comp.name, - })) - } - - createEffect( - defer( - () => hoverTarget() ?? devtoolsTarget(), - scheduleIdle(target => - setHighlightedComponents(() => calcHighlightedComponents(target)), - ), - ), - ) - - createElementsOverlay(highlightedComponents) - - // notify of component hovered by using the debugger - createEffect((prev: NodeID | undefined) => { - const target = hoverTarget() - const comp = target && registry.findComponent(target) - if (prev) props.emit('HoveredComponent', {nodeId: prev, state: false}) - if (comp) { - const {id} = comp - props.emit('HoveredComponent', {nodeId: id, state: true}) - return id - } - }) - - let targetIDE: TargetIDE | TargetURLFunction | undefined - - createEffect(() => { - if (!props.locatorEnabled()) return - - // set hovered element as target - makeHoverElementListener(el => setHoverTarget(el)) - onCleanup(() => setHoverTarget(null)) - - // go to selected component source code on click - makeEventListener( - window, - 'click', - e => { - const {target} = e - if (!(target instanceof HTMLElement)) return - const highlighted = highlightedComponents() - const comp = - highlighted.find(({element}) => target.contains(element)) ?? highlighted[0] - if (!comp) return - const sourceCodeData = - comp.location && getSourceCodeData(comp.location, comp.element) - - // intercept on-page components clicks and send them to the devtools overlay - props.onComponentClick(comp.id, () => { - if (!targetIDE || !sourceCodeData) return - e.preventDefault() - e.stopPropagation() - openSourceCode(targetIDE, sourceCodeData) - }) - }, - true, - ) - }) - - let locatorUsed = false - const owner = getOwner()! - /** - * User function to enable user locator features. Such as element hover and go to source. - * - * Can bu used only once. - * - * @param options {@link LocatorOptions} for the locator. - */ - function useLocator(options: LocatorOptions): void { - runWithOwner(owner, () => { - if (locatorUsed) return warn('useLocator can be called only once.') - locatorUsed = true - if (options.targetIDE) targetIDE = options.targetIDE - if (options.key !== false) { - const isHoldingKey = createKeyHold(options.key ?? 'Alt', {preventDefault: true}) - setEnabledByPressingSignal(() => isHoldingKey) - } - }) - } - - // Enable the locator when the options were passed by the vite plugin - if (SolidAPI.locatorOptions) { - useLocator(SolidAPI.locatorOptions) - } - - return { - useLocator, - setDevtoolsHighlightTarget(target: HighlightElementPayload) { - setDevtoolsTarget(target) - }, - openElementSourceCode(location: SourceLocation, element: SourceCodeData['element']) { - if (!targetIDE) return warn('Please set `targetIDE` it in useLocator options.') - const projectPath = getProjectPath() - if (!projectPath) return warn('projectPath is not set.') - openSourceCode(targetIDE, { - ...location, - projectPath, - element, - }) - }, - } -} +export {parseLocationString} from './utils' diff --git a/packages/debugger/src/locator/types.ts b/packages/debugger/src/locator/types.ts index 8bf002dd..62108478 100644 --- a/packages/debugger/src/locator/types.ts +++ b/packages/debugger/src/locator/types.ts @@ -1,31 +1,35 @@ -import type {ToDyscriminatedUnion} from '@solid-devtools/shared/utils' -import type {KbdKey} from '@solid-primitives/keyboard' -import type {NodeID} from '../main/types' -import type {TargetIDE, TargetURLFunction} from './find-components' +import {ToDyscriminatedUnion} from '@solid-devtools/shared/utils' +import {EmitterEmit} from '@solid-primitives/event-bus' +import {Accessor} from 'solid-js' +import {Debugger, NodeID} from '../types' -export type { - LocationAttr, - LocatorComponent, - SourceLocation, - TargetIDE, - TargetURLFunction, -} from './find-components' - -export type LocatorOptions = { - /** Choose in which IDE the component source code should be revealed. */ - targetIDE?: false | TargetIDE | TargetURLFunction - /** - * Holding which key should enable the locator overlay? - * @default 'Alt' - */ - key?: false | KbdKey +export type SourceLocation = { + file: string + line: number + column: number } +export type SourceElementType = ElementType | string | undefined + export type HighlightElementPayload = ToDyscriminatedUnion<{ node: {id: NodeID} element: {id: NodeID} }> | null -// used by the transform -export const WINDOW_PROJECTPATH_PROPERTY = '$sdt_projectPath' -export const LOCATION_ATTRIBUTE_NAME = 'data-source-loc' +export interface Locator { + setDevtoolsHighlightTarget(target: HighlightElementPayload): void + openElementSourceCode(location: SourceLocation, element: SourceElementType): void +} + +export interface CreateLocatorProps { + emit: EmitterEmit + locatorEnabled: Accessor + setLocatorEnabledSignal(signal: Accessor): void + onComponentClick(componentId: NodeID, next: VoidFunction): void +} + +export type LocatorFactory = (props: CreateLocatorProps) => Locator + +// TODO: rename or remove? +// this is used externally so any change is a change in API +export type {LocatorOptions} from './DOMLocator/types' diff --git a/packages/debugger/src/locator/utils.ts b/packages/debugger/src/locator/utils.ts new file mode 100644 index 00000000..d3393d7e --- /dev/null +++ b/packages/debugger/src/locator/utils.ts @@ -0,0 +1,19 @@ +import {SourceLocation} from './types' + +/** + * Validates and parses a location string to a {@link SourceLocation} object + */ +export function parseLocationString(location: string): SourceLocation | undefined { + // eslint-disable-next-line prefer-const + let [filePath, line, column] = location.split(':') as [string, string | number, string | number] + if ( + filePath && + line && + column && + typeof filePath === 'string' && + !isNaN((line = Number(line))) && + !isNaN((column = Number(column))) + ) { + return {file: filePath, line, column} + } +} diff --git a/packages/debugger/src/main/index.ts b/packages/debugger/src/main/index.ts index 6245ba1d..0222b5f6 100644 --- a/packages/debugger/src/main/index.ts +++ b/packages/debugger/src/main/index.ts @@ -1,12 +1,22 @@ +import {warn} from '@solid-devtools/shared/utils' import {createEventBus, createGlobalEmitter, GlobalEmitter} from '@solid-primitives/event-bus' import {createStaticStore} from '@solid-primitives/static-store' import {defer} from '@solid-primitives/utils' -import {batch, createComputed, createEffect, createMemo, createSignal} from 'solid-js' +import { + batch, + createComputed, + createEffect, + createMemo, + createSignal, + getOwner, + runWithOwner, +} from 'solid-js' import {createDependencyGraph, DGraphUpdate} from '../dependency' import {createInspector, InspectorUpdate, ToggleInspectedValueData} from '../inspector' -import {createLocator} from '../locator' -import {HighlightElementPayload} from '../locator/types' +import {createDOMLocatorFactory} from '../locator/DOMLocator' +import {HighlightElementPayload, Locator, LocatorFactory} from '../locator/types' import {createStructure, StructureUpdates} from '../structure' +import {LocatorOptions} from '../types' import {DebuggerModule, DEFAULT_MAIN_VIEW, DevtoolsMainView, TreeWalkerMode} from './constants' import {getObjectById, getSdtId, ObjectType} from './id' import {createInternalRoot} from './roots' @@ -207,29 +217,13 @@ const plugin = createInternalRoot(() => { inspectedState, }) - // - // Locator - // - const locator = createLocator({ - emit: hub.output.emit, - locatorEnabled, - setLocatorEnabledSignal: signal => toggleModules('locatorKeyPressSignal', () => signal), - onComponentClick(componentId, next) { - modules.debugger ? hub.output.emit('InspectedComponent', componentId) : next() - }, - }) - + let locator: Locator | undefined // Opens the source code of the inspected component function openInspectedNodeLocation() { const details = inspector.getLastDetails() - details?.location && locator.openElementSourceCode(details.location, details.name) + details?.location && locator?.openElementSourceCode(details.location, details.name) } - // send the state of the client locator mode - createEffect( - defer(modules.locatorKeyPressSignal, state => hub.output.emit('LocatorModeChange', state)), - ) - hub.input.listen(e => { switch (e.name) { case 'ResetState': { @@ -238,12 +232,12 @@ const plugin = createInternalRoot(() => { resetInspectedNode() currentView = DEFAULT_MAIN_VIEW structure.resetTreeWalkerMode() - locator.setDevtoolsHighlightTarget(null) + locator?.setDevtoolsHighlightTarget(null) }) break } case 'HighlightElementChange': - return locator.setDevtoolsHighlightTarget(e.details) + return locator?.setDevtoolsHighlightTarget(e.details) case 'InspectNode': return setInspectedNode(e.details) case 'InspectValue': @@ -275,10 +269,62 @@ const plugin = createInternalRoot(() => { } } + let locatorUsed = false + const owner = getOwner()! + + /** + * User function to create custom locators. If you're targeting solid-js/web, use `useLocator()` instead. + * + * Can be used only once. + * Automatically called if locatorOptions were provided by vite. + * + * @param createLocator {@link LocatorFactory} factory function for the locator object. + */ + function useCustomLocator(createLocator: LocatorFactory) { + runWithOwner(owner, () => { + if (locatorUsed) return warn('useLocator can be called only once.') + locatorUsed = true + + locator = createLocator({ + emit: hub.output.emit, + locatorEnabled, + setLocatorEnabledSignal: signal => + toggleModules('locatorKeyPressSignal', () => signal), + onComponentClick(componentId, next) { + modules.debugger ? hub.output.emit('InspectedComponent', componentId) : next() + }, + }) + // send the state of the client locator mode + createEffect( + defer(modules.locatorKeyPressSignal, state => + hub.output.emit('LocatorModeChange', state), + ), + ) + }) + } + + /** + * User function to enable user locator features. Such as element hover and go to source. + * + * Can be used only once. + * Automatically called if locatorOptions were provided by vite. + * + * @param options {@link LocatorOptions} for the locator. + */ + function useLocator(locatorOptions: LocatorOptions) { + useCustomLocator(createDOMLocatorFactory(locatorOptions)) + } + + // Enable the locator when the options were passed by the vite plugin + if (SolidApi.locatorOptions) { + useLocator(SolidApi.locatorOptions) + } + return { useDebugger, - useLocator: locator.useLocator, + useLocator, + useCustomLocator, } }) -export const {useDebugger, useLocator} = plugin +export const {useDebugger, useLocator, useCustomLocator} = plugin diff --git a/packages/debugger/src/types.ts b/packages/debugger/src/types.ts index a2e57ded..c717ead1 100644 --- a/packages/debugger/src/types.ts +++ b/packages/debugger/src/types.ts @@ -1,5 +1,6 @@ export type {DGraphUpdate, SerializedDGraph} from './dependency' export * from './inspector/types' +export * from './locator/DOMLocator/types' export * from './locator/types' export type {Debugger} from './main' export * from './main/constants'