Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(widget): You can copy metadata fields to clipboard #217

Merged
merged 7 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/widget/src/assets/copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { svg } from 'lit';

export const copyToClipboardButton = (
textToCopy: string,
onCopy: (newText: string, x: number, y: number) => void,
) => svg`
<svg @click='${(event: PointerEvent) => copyToClipboard(event, textToCopy, onCopy)}'
class='copy-to-clipboard-button clickable' width='12' height='14' viewBox='0 0 12 14' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path class='copy-to-clipboard-button-path' fill='#989898' fill-rule='evenodd' clip-rule='evenodd' d='M7 3H2C1.44772 3 1 3.44772 1 4V12C1 12.5523 1.44772 13 2 13H7C7.55228 13 8 12.5523 8 12V4C8 3.44772 7.55228 3 7 3ZM2 2C0.895431 2 0 2.89543 0 4V12C0 13.1046 0.89543 14 2 14H7C8.10457 14 9 13.1046 9 12V4C9 2.89543 8.10457 2 7 2H2ZM10 1H5C4.44772 1 4 1.44772 4 2V10C4 10.5523 4.44772 11 5 11H10C10.5523 11 11 10.5523 11 10V2C11 1.44772 10.5523 1 10 1ZM5 0C3.89543 0 3 0.895431 3 2V10C3 11.1046 3.89543 12 5 12H10C11.1046 12 12 11.1046 12 10V2C12 0.895431 11.1046 0 10 0H5Z'/>
</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);
});
};
1 change: 1 addition & 0 deletions packages/widget/src/assets/index.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion packages/widget/src/detail-navigation-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const backButton = (
<svg class='docmaps-timeline-back clickable' width='36' height='36' viewBox='0 0 36 36' fill='none' xmlns='http://www.w3.org/2000/svg'
@click='${() => updateSelectedNode(previousNode)}'>
<circle cx='17.5' cy='17.5' r='17' stroke='#474747'/>
<path transform='scale(-1,1) translate(-35,0)' d='M22.5 17.134C23.1667 17.5189 23.1667 18.4811 22.5 18.866L10.5 25.7942C9.83333 26.1791 9 25.698 9 24.9282L9 11.0718C9 10.302 9.83333 9.82087 10.5 10.2058L22.5 17.134Z' fill='#474747'/>
<path fill='#474747' transform='scale(-1,1) translate(-35,0)' d='M22.5 17.134C23.1667 17.5189 23.1667 18.4811 22.5 18.866L10.5 25.7942C9.83333 26.1791 9 25.698 9 24.9282L9 11.0718C9 10.302 9.83333 9.82087 10.5 10.2058L22.5 17.134Z'/>
<path transform='scale(-1,1) translate(-35,0)' d='M24 10L24 26' stroke='#474747' stroke-width='2' stroke-linecap='round'/>
</svg>`;
};
Expand Down
81 changes: 49 additions & 32 deletions packages/widget/src/detail-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -17,14 +17,17 @@ 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;
const textColor = opts.detailViewTextColor || opts.textColor;

const fieldsToDisplay: MetadataTuple[] = getMetadataFieldsToDisplay(selectedNode);
const detailBody: HTMLTemplateResult =
fieldsToDisplay.length > 0 ? createMetadataGrid(fieldsToDisplay) : emptyMetadataMessage();
fieldsToDisplay.length > 0
? renderMetadataGrid(fieldsToDisplay, updateDetailTooltip)
: emptyMetadataMessage();

return html`
<div class="detail-timeline no-select">
Expand All @@ -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` <div class="metadata-grid">${gridItems}</div>`;
};

function displayMetadataKey(
function renderMetadataKey(
key: MetadataKey,
value: MetadataValue,
index: number,
Expand All @@ -70,45 +77,50 @@ function displayMetadataKey(
return html` <div class="metadata-grid-item key">${key}</div>`;
}

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` <a href="${value}" target="_blank" class="metadata-grid-item value metadata-link">
${value}
</a>`;
}

if (key === 'content' && Array.isArray(value)) {
// display as list of clickable links
return html` ${value.map(
(val) =>
html` <a
href="${val}"
target="_blank"
class="metadata-grid-item value content metadata-link"
>
${val}
</a>`,
)}`;
const template = html` <a href="${value}" target="_blank" class="metadata-link">${value}</a>`;
return copyableMetadataValue(template, value, updateDetailTooltip);
}

if (Array.isArray(value)) {
// display as list
return html`${value.map(
(val) => html` <div class="metadata-grid-item value content">${val}</div>`,
)}`;
// Display as a list of clickable links.
return html` ${value.map((val) => {
const template = html` <a href="${val}" target="_blank" class="content metadata-link"
>${val}</a
>`;
return copyableMetadataValue(template, val, updateDetailTooltip);
})}`;
}

// Display as single value
return html` <div class="metadata-grid-item value">${value}</div>`;
return copyableMetadataValue(html` <span>${value}</span>`, value, updateDetailTooltip);
}

function copyableMetadataValue(
template: HTMLTemplateResult,
value: string,
onCopy: (newText: string, x: number, y: number) => void,
): HTMLTemplateResult {
return html`
<div class="metadata-grid-item value">${template} ${copyToClipboardButton(value, onCopy)}</div>
`;
}

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 => {
Expand All @@ -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';
Expand Down
35 changes: 22 additions & 13 deletions packages/widget/src/display-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -57,14 +57,16 @@ 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)),
...normalizeDisplayObject(b),
};
}

// 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;
Expand All @@ -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: {
Expand Down
48 changes: 41 additions & 7 deletions packages/widget/src/docmaps-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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` <div id="${GRAPH_CANVAS_ID}"></div>`;
Expand All @@ -65,6 +84,23 @@ export class DocmapsWidget extends LitElement {
<span>DOCMAP</span>
</div>
${d3Canvas} ${content}
<div id="graph-tooltip" class="tooltip" style="opacity:0;"></div>
${this.renderDetailTooltip()}
</div>
`;
}

private renderDetailTooltip() {
const { text, x, y } = this.detailTooltip;
return html`
<div
id="detail-tooltip"
class="tooltip"
style="opacity: ${text ? 1 : 0}; left: ${x}px; top: ${y}px; visibility: ${text
? 'visible'
: 'hidden'}"
>
${text}
</div>
`;
}
Expand All @@ -87,8 +123,6 @@ export class DocmapsWidget extends LitElement {
};

private graphView() {
const tooltip = html` <div id="tooltip" class="tooltip" style="opacity:0;"></div>`;

if (this.graph) {
if (this.#hasRenderedOnce) {
// There is a canvas for D3 to draw in! We can render the graph now
Expand All @@ -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() {
Expand All @@ -120,6 +153,7 @@ export class DocmapsWidget extends LitElement {
this.graph.nodes,
this.showDetailViewForNode,
this.closeDetailView,
this.updateDetailTooltip,
);
}
}
Expand Down
Loading