diff --git a/packages/widget/src/assets/copy-to-clipboard.ts b/packages/widget/src/assets/copy-to-clipboard.ts new file mode 100644 index 00000000..83fce8e3 --- /dev/null +++ b/packages/widget/src/assets/copy-to-clipboard.ts @@ -0,0 +1,30 @@ +import { svg } from 'lit'; + +export const copyToClipboardButton = ( + textToCopy: string, + onCopy: (newText: string, x: number, y: number) => void, +) => svg` + + + +`; + +// Be aware: the copy-to-clipboard functionality is not tested. Sadly, it is basically impossible to test clipboard +// functionality in Playwright. +const copyToClipboard = ( + event: PointerEvent, + textToCopy: string, + onCopy: (newText: string, x: number, y: number) => void, +) => { + navigator.clipboard + .writeText(textToCopy) + .then(() => { + const x: number = event.pageX; + const y: number = event.pageY; + onCopy('Copied!', x, y); + }) + .catch((err) => { + console.log('Failed to copy to clipboard\n\n', err); + }); +}; diff --git a/packages/widget/src/assets/index.ts b/packages/widget/src/assets/index.ts index 947fd7af..9c1dba2f 100644 --- a/packages/widget/src/assets/index.ts +++ b/packages/widget/src/assets/index.ts @@ -1,4 +1,5 @@ // All files which will be accessible when the widget is installed via npm are declared here +export * from './copy-to-clipboard'; export * from './close-details-button'; export * from './logo-small'; export * from './logo-large'; diff --git a/packages/widget/src/detail-navigation-header.ts b/packages/widget/src/detail-navigation-header.ts index c3d6b4a8..4b05c149 100644 --- a/packages/widget/src/detail-navigation-header.ts +++ b/packages/widget/src/detail-navigation-header.ts @@ -17,7 +17,7 @@ const backButton = ( - + `; }; diff --git a/packages/widget/src/detail-view.ts b/packages/widget/src/detail-view.ts index 197b227f..4b40317d 100644 --- a/packages/widget/src/detail-view.ts +++ b/packages/widget/src/detail-view.ts @@ -6,7 +6,7 @@ import { TYPE_DISPLAY_OPTIONS, } from './display-object.ts'; import { renderDetailNavigationHeader } from './detail-navigation-header'; -import { closeDetailsButton } from './assets'; +import { closeDetailsButton, copyToClipboardButton } from './assets'; type MetadataKey = string; type MetadataValue = string | string[]; @@ -17,6 +17,7 @@ export function renderDetailsView( allNodes: DisplayObject[], updateSelectedNode: (node: DisplayObject) => void, closeDetailsView: () => void, + updateDetailTooltip: (newText: string, x: number, y: number) => void, ): HTMLTemplateResult { const opts = TYPE_DISPLAY_OPTIONS[selectedNode.type]; const backgroundColor = opts.detailViewBackgroundColor || opts.backgroundColor; @@ -24,7 +25,9 @@ export function renderDetailsView( const fieldsToDisplay: MetadataTuple[] = getMetadataFieldsToDisplay(selectedNode); const detailBody: HTMLTemplateResult = - fieldsToDisplay.length > 0 ? createMetadataGrid(fieldsToDisplay) : emptyMetadataMessage(); + fieldsToDisplay.length > 0 + ? renderMetadataGrid(fieldsToDisplay, updateDetailTooltip) + : emptyMetadataMessage(); return html`
@@ -42,16 +45,20 @@ export function renderDetailsView( `; } -const createMetadataGrid = ( +// Renders the metadata grid, which is a 2-column grid of key-value pairs. +// The key is displayed in the left column, and the value is displayed in the right column. If the value is an array, it +// is displayed as multiple rows, with the key cell spanning all those rows. +const renderMetadataGrid = ( metadataEntries: [MetadataKey, MetadataValue][], + updateDetailTooltip: (newText: string, x: number, y: number) => void, ): HTMLTemplateResult => { const gridItems: HTMLTemplateResult[] = metadataEntries.map(([key, value], index) => - createGridItem(key, value, index), + createGridItem(key, value, index, updateDetailTooltip), ); return html`
${gridItems}
`; }; -function displayMetadataKey( +function renderMetadataKey( key: MetadataKey, value: MetadataValue, index: number, @@ -70,45 +77,50 @@ function displayMetadataKey( return html`
${key}
`; } -function displayMetadataValue(key: MetadataKey, value: MetadataValue): HTMLTemplateResult { - if (key === 'url') { +function displayMetadataValue( + key: MetadataKey, + value: MetadataValue, + updateDetailTooltip: (newText: string, x: number, y: number) => void, +): HTMLTemplateResult { + if (key === 'url' && typeof value === 'string') { // display as clickable link - return html` - ${value} - `; - } - - if (key === 'content' && Array.isArray(value)) { - // display as list of clickable links - return html` ${value.map( - (val) => - html` - ${val} - `, - )}`; + const template = html` ${value}`; + return copyableMetadataValue(template, value, updateDetailTooltip); } if (Array.isArray(value)) { - // display as list - return html`${value.map( - (val) => html`
${val}
`, - )}`; + // Display as a list of clickable links. + return html` ${value.map((val) => { + const template = html` ${val}`; + return copyableMetadataValue(template, val, updateDetailTooltip); + })}`; } // Display as single value - return html`
${value}
`; + return copyableMetadataValue(html` ${value}`, value, updateDetailTooltip); +} + +function copyableMetadataValue( + template: HTMLTemplateResult, + value: string, + onCopy: (newText: string, x: number, y: number) => void, +): HTMLTemplateResult { + return html` +
${template} ${copyToClipboardButton(value, onCopy)}
+ `; } const createGridItem = ( key: MetadataKey, value: MetadataValue, index: number, + updateDetailTooltip: (newText: string, x: number, y: number) => void, ): HTMLTemplateResult => { - return html` ${displayMetadataKey(key, value, index)} ${displayMetadataValue(key, value)} `; + return html` + ${renderMetadataKey(key, value, index)} ${displayMetadataValue(key, value, updateDetailTooltip)} + `; }; const emptyMetadataMessage = (): HTMLTemplateResult => { @@ -124,10 +136,15 @@ const getMetadataFieldsToDisplay = (node: DisplayObject): MetadataTuple[] => { // then keep only the fields that should be displayed: return Object.entries(normalizedNode) .filter(([key, value]) => isDisplayObjectMetadataField(key) && value) - .filter(isMetadataTuple); + .filter(valueIsAStringOrStringArray); }; -const isMetadataTuple = (tuple: [string, any]): tuple is MetadataTuple => { +// This type guard asserts that the tuple's value is a string or string array. We need to narrow this down so that we +// can handle "content" differently from other fields, making its key cell span multiple rows. +// +// It's worth noting that if we ever add a non-string or non-string-array field to DisplayObjectMetadata, this method +// will need to be updated, because right now it will filter those fields out and keep them from being displayed. +const valueIsAStringOrStringArray = (tuple: [string, any]): tuple is MetadataTuple => { const [_, value] = tuple; const isString = typeof value === 'string'; diff --git a/packages/widget/src/display-object.ts b/packages/widget/src/display-object.ts index b739f24a..ee48f7c7 100644 --- a/packages/widget/src/display-object.ts +++ b/packages/widget/src/display-object.ts @@ -15,16 +15,16 @@ export interface DisplayObjectMetadata { actors?: string; } -// DisplayObjects are the widget's internal representation of a node from the graph view. -// They roughly correspond to a ThingT in the Docmap spec, but with only the fields that we want to display. +// A DisplayObject is the widget's internal representation of a node in the graph view. +// This type roughly corresponds to ThingT in the @docmaps/sdk, but with only the fields that we want to display. export interface DisplayObject extends DisplayObjectMetadata { nodeId: string; // Used internally to construct graph relationships, never rendered type: string; } -// The following 3 statements allow us to use FieldsToDisplay both as a type and as something we can -// check against at runtime. We could also use io-ts for this, but that felt like overkill since this -// is the only place in the widget where we do something like this. +// The following 3 statements allow us to use FieldsToDisplay both as a type and as something we can check against at +// runtime. We could use io-ts for this, but that felt like overkill since this is the only place in the widget where we +// do something like this. export type DisplayObjectMetadataField = keyof DisplayObjectMetadata; const DisplayObjectMetadataPrototype: { [K in DisplayObjectMetadataField]: null } = { doi: null, @@ -39,8 +39,8 @@ export function isDisplayObjectMetadataField(key: string): key is DisplayObjectM return key in DisplayObjectMetadataPrototype; } -// Returns a new DisplayObject which has no fields set to the value undefined, -// meaning the new Display Object can be merged with another DisplayObject via destructuring. +// Returns a new DisplayObject which has no fields set to the value undefined, meaning the new Display Object can be +// merged with another DisplayObject via destructuring. // // Also puts the fields in the order in which they should be displayed. export function normalizeDisplayObject(displayObject: DisplayObject): DisplayObject { @@ -57,6 +57,8 @@ export function normalizeDisplayObject(displayObject: DisplayObject): DisplayObj }; } +// Lets you combine two display objects into one, with the fields of the second object taking precedence. +// only the first object can be undefined. export function mergeDisplayObjects(a: DisplayObject | undefined, b: DisplayObject): DisplayObject { return { ...(a && normalizeDisplayObject(a)), @@ -64,7 +66,7 @@ export function mergeDisplayObjects(a: DisplayObject | undefined, b: DisplayObje }; } -// DisplayObjectEdges are the widget's internal representation of an edge connecting two DisplayObjects. +// DisplayObjectEdges are the widget's internal representation of a connection between two DisplayObjects. export type DisplayObjectEdge = { sourceId: string; targetId: string; @@ -75,16 +77,23 @@ export type DisplayObjectGraph = { edges: DisplayObjectEdge[]; }; -// The appearance of a DisplayObject in the graph view and in the detail view is determined by its 'type' field. -// The following constants define the possible values of the 'type' field and the appearance that corresponds with each value. +// The appearance of a DisplayObject in the graph view and in the detail view is determined by its 'type' field. The +// following constants define the possible values of the 'type' field and the styling that corresponds with each value. export type TypeDisplayOption = { + // Abbreviation representing the type, e.g. 'R' for 'Review' shortLabel: string; + // Full, human-readable name of the type, e.g. 'Review' longLabel: string; + // Background color of the node in the graph view backgroundColor: string; + // Text color of the node in the graph view textColor: string; - dottedBorder?: boolean; // whether the node should be rendered with a dotted border - detailViewBackgroundColor?: string; // if this is not set, backgroundColor will be used - detailViewTextColor?: string; // if this is not set, textColor will be used + // whether the node should be rendered with a dotted border in the graph view + dottedBorder?: boolean; + // Background color of the detail view header. if this is not set, backgroundColor will be used + detailViewBackgroundColor?: string; + // Text color of the detail view header. if this is not set, textColor will be used + detailViewTextColor?: string; }; export const TYPE_DISPLAY_OPTIONS: { diff --git a/packages/widget/src/docmaps-widget.ts b/packages/widget/src/docmaps-widget.ts index 7c6e1ec8..8c63c6ed 100644 --- a/packages/widget/src/docmaps-widget.ts +++ b/packages/widget/src/docmaps-widget.ts @@ -24,6 +24,13 @@ export class DocmapsWidget extends LitElement { @state() graph?: DisplayObjectGraph; + @state() + detailTooltip: { text: string; x: number; y: number } = { + text: '', + x: 0, + y: 0, + }; + // We keep track of this because we have to render once before drawing the graph // so that D3 has a canvas to draw into. #hasRenderedOnce: boolean = false; @@ -50,9 +57,21 @@ export class DocmapsWidget extends LitElement { }; // Method to clear the selected node and go back to the graph view - private closeDetailView() { + private closeDetailView = () => { this.selectedNode = undefined; - } + }; + + private updateDetailTooltip = (newText: string, x: number, y: number) => { + // Show the tooltip with the provided text and position + this.detailTooltip = { + text: newText, + x: x, + y: y, + }; + + // Set a timeout to hide the tooltip after 3 seconds + setTimeout(() => (this.detailTooltip = { ...this.detailTooltip, text: '' }), 2000); + }; render(): HTMLTemplateResult { const d3Canvas: HTMLTemplateResult = html`
`; @@ -65,6 +84,23 @@ export class DocmapsWidget extends LitElement { DOCMAP
${d3Canvas} ${content} +
+ ${this.renderDetailTooltip()} + + `; + } + + private renderDetailTooltip() { + const { text, x, y } = this.detailTooltip; + return html` +
+ ${text}
`; } @@ -87,8 +123,6 @@ export class DocmapsWidget extends LitElement { }; private graphView() { - const tooltip = html`
`; - if (this.graph) { if (this.#hasRenderedOnce) { // There is a canvas for D3 to draw in! We can render the graph now @@ -98,11 +132,10 @@ export class DocmapsWidget extends LitElement { return html` ${this.#docmapFetchingTask?.render({ complete: this.onFetchComplete, error: (e) => noDocmapFoundScreen(e, this.doi), - })} - ${tooltip}`; + })}`; } - return tooltip; + return nothing; } private detailView() { @@ -120,6 +153,7 @@ export class DocmapsWidget extends LitElement { this.graph.nodes, this.showDetailViewForNode, this.closeDetailView, + this.updateDetailTooltip, ); } } diff --git a/packages/widget/src/graph-view.ts b/packages/widget/src/graph-view.ts index 47667119..e64c8b74 100644 --- a/packages/widget/src/graph-view.ts +++ b/packages/widget/src/graph-view.ts @@ -276,6 +276,7 @@ export const clearGraph = (shadowRoot: ShadowRoot | null) => { const getNodeX = (d: D3Node) => d.x ?? 0; const getNodeY = (d: D3Node) => d.y ?? 0; + export const setUpTooltips = ( selection: d3.Selection, shadowRoot: ShadowRoot | null, @@ -284,7 +285,7 @@ export const setUpTooltips = ( return; } - const tooltip = d3.select(shadowRoot.querySelector('#tooltip')); + const tooltip = d3.select(shadowRoot.querySelector('#graph-tooltip')); selection .on('mouseover', function (event, d) { @@ -292,7 +293,7 @@ export const setUpTooltips = ( .html(() => TYPE_DISPLAY_OPTIONS[d.type].longLabel) .style('visibility', 'visible') .style('opacity', 1) - .style('left', `${event.pageX + 5}px`) // Position the tooltip at the mouse location + .style('left', `${event.pageX}px`) // Position the tooltip at the mouse location .style('top', `${event.pageY - 28}px`); }) .on('mouseout', () => { @@ -306,8 +307,27 @@ export const setupInteractivity = ( shadowRoot: ShadowRoot | null, onNodeClick: (node: DisplayObject) => void, ) => { - nodeElements.on('click', (_event, d: D3Node) => onNodeClick(d)); - labels.on('click', (_event, d: D3Node) => onNodeClick(d)); + if (!shadowRoot) { + return; + } + + const tooltip = d3.select(shadowRoot.querySelector('#graph-tooltip')); + + nodeElements.on('click', (_event, d: D3Node) => { + // Hide the tooltip first + tooltip.style('visibility', 'hidden').style('opacity', 0); + + // Then handle the node click + onNodeClick(d); + }); + + labels.on('click', (_event, d: D3Node) => { + // Hide the tooltip first + tooltip.style('visibility', 'hidden').style('opacity', 0); + + // Then handle the node click + onNodeClick(d); + }); setUpTooltips(nodeElements, shadowRoot); setUpTooltips(labels, shadowRoot); diff --git a/packages/widget/src/styles.ts b/packages/widget/src/styles.ts index 2bf9696f..779a6d99 100644 --- a/packages/widget/src/styles.ts +++ b/packages/widget/src/styles.ts @@ -22,7 +22,7 @@ export const customCss: CSSResult = css` -ms-user-select: none; /* IE10+/Edge */ user-select: none; /* Standard */ } - + .not-found-message { margin-top: 121px; font-family: 'IBM Plex Mono', monospace; @@ -31,7 +31,7 @@ export const customCss: CSSResult = css` text-align: center; color: #777777; } - + .not-found-message p { margin: 0; } @@ -144,10 +144,14 @@ export const customCss: CSSResult = css` .metadata-grid-item.value { font-weight: 300; - padding-right: 46px; + padding-right: 25px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; } - .metadata-grid-item.value.content { + .metadata-grid-item.value .content { font-size: 12px; font-style: italic; font-weight: 300; @@ -157,6 +161,7 @@ export const customCss: CSSResult = css` } .metadata-link { + max-width: 89%; color: inherit; text-decoration: underline; cursor: pointer; @@ -166,6 +171,11 @@ export const customCss: CSSResult = css` color: #C1C1C1; } + + .copy-to-clipboard-button:hover .copy-to-clipboard-button-path { + fill: #474747; + } + .detail-header .close-button { margin-right: 25px; display: flex; @@ -187,7 +197,7 @@ export const customCss: CSSResult = css` display: inline-block; margin: 7px 13px 6px 11px } - + .not-found-screen { display: flex; flex-direction: column; diff --git a/packages/widget/test/integration/docmaps-widget.test.ts b/packages/widget/test/integration/docmaps-widget.test.ts index a2fba0ff..23f46ffe 100644 --- a/packages/widget/test/integration/docmaps-widget.test.ts +++ b/packages/widget/test/integration/docmaps-widget.test.ts @@ -90,15 +90,21 @@ test('Tooltips appear on mouseover', async ({ page, browserName }) => { docmapWithMultipleSteps, ); - await assertTooltipAppearsOnHover(widget, widget.locator('.node').first(), 'Preprint'); - await assertTooltipAppearsOnHover(widget, widget.locator('.node').nth(3), 'Reply'); + await assertGraphTooltipAppearsOnHover(widget, widget.locator('.node').first(), 'Preprint'); + await assertGraphTooltipAppearsOnHover(widget, widget.locator('.node').nth(3), 'Reply'); if (browserName !== 'webkit') { // TODO for some reason this test fails on webkit even though the functionality does work on Safari. // This behavior is not important enough to spend time debugging right now. - await assertTooltipAppearsOnHover(widget, widget.locator('.label').first(), 'Preprint'); - await assertTooltipAppearsOnHover(widget, widget.locator('.label').nth(3), 'Reply'); + await assertGraphTooltipAppearsOnHover(widget, widget.locator('.label').first(), 'Preprint'); + await assertGraphTooltipAppearsOnHover(widget, widget.locator('.label').nth(3), 'Reply'); } + + // tooltips should still appear after the details pane is opened and then closed + const nodeToClick = widget.locator('.node').first(); + await nodeToClick.click({ force: true }); + await widget.locator('.close-button').click({ force: true }); + await assertGraphTooltipAppearsOnHover(widget, widget.locator('.node').nth(3), 'Reply'); }); test(`Can display details view for a Preprint with every field`, async ({ page }) => { @@ -363,14 +369,14 @@ test('When the docmap cannot be found, the empty screen is shown', async ({ page await expect(widget).toContainText('No data found for DOI unknown-doi'); }); -async function assertTooltipAppearsOnHover( +async function assertGraphTooltipAppearsOnHover( widget: Locator, thingToHoverOver: Locator, expectedTooltipText: string, ) { await thingToHoverOver.hover({ trial: false, force: true }); - const tooltip = widget.locator('#tooltip'); + const tooltip = widget.locator('#graph-tooltip'); await expect(tooltip).toBeVisible(); await expect(tooltip).toHaveText(expectedTooltipText);