Skip to content

Commit

Permalink
Merge branch 'refs/heads/pf/tr/wires-snippets' into TEMP/fronts-PLUS-…
Browse files Browse the repository at this point in the history
…newswires-snippets

# Conflicts:
#	client/src/app.tsx
  • Loading branch information
twrichards committed Nov 1, 2024
2 parents 4fd35f0 + 68daa99 commit 69b44b9
Show file tree
Hide file tree
Showing 8 changed files with 488 additions and 38 deletions.
28 changes: 28 additions & 0 deletions bootstrapping-lambda/local/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ <h4>presented after 1 second</h4>
and Pinboard detecting that and still taking over the element
</p>
</ul>
<div style="padding: 20px; background-color: aliceblue">
<h3>Pinboard selection target</h3>
<div
data-pinboard-selection-target
data-usage-note="This is a sample usage note, copyright Pinboard sandbox."
>
<p>
This is a target for the <strong>Pinboard</strong> library to render
the selection interface into. It will be hidden by the library when
not in use. This is a target for the <em>Pinboard</em> library to
render the selection interface into. It will be hidden by the library
when not in use. This is a target for the
<strong>Pinboard</strong> library to render the selection interface
into. It will be hidden by the library when not in use.
</p>
<p>
This is a target for the <em>Pinboard</em> library to render the
selection interface into. It will be hidden by the library when not in
use. This is a target for the <strong>Pinboard</strong> library to
render the selection interface into. It will be hidden by the library
when not in use. This is a target for the <em>Pinboard</em> library to
render the selection interface into. It will be hidden by the library
when not in use.
</p>
</div>
</div>

<hr />
</body>
<script>
setTimeout(
Expand Down
4 changes: 3 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"preact": "10.15.1",
"react-draggable": "^4.4.5",
"react-joyride": "^2.5.3",
"react-shadow": "^19.0.2"
"react-shadow": "^19.0.2",
"sanitize-html": "^2.13.1"
},
"devDependencies": {
"@babel/core": "^7.17.4",
Expand All @@ -41,6 +42,7 @@
"@svgr/webpack": "^6.2.1",
"@types/react": "16.9.56",
"@types/react-dom": "16.9.9",
"@types/sanitize-html": "^2",
"@types/webpack-env": "^1.16.3",
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
"babel-loader": "^8.2.3",
Expand Down
2 changes: 2 additions & 0 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
SUGGEST_ALTERNATE_CROP_QUERY_SELECTOR,
SuggestAlternateCrops,
} from "./fronts/suggestAlternateCrops";
import { NewswiresIntegration } from "./newswires/newswiresIntegration";

const PRESELECT_PINBOARD_HTML_TAG = "pinboard-preselect";
const PRESET_UNREAD_NOTIFICATIONS_COUNT_HTML_TAG = "pinboard-bubble-preset";
Expand Down Expand Up @@ -487,6 +488,7 @@ export const PinBoardApp = ({
expand={() => setIsExpanded(true)}
/>
))}
<NewswiresIntegration />
</TourStateProvider>
</GlobalStateProvider>
</ApolloProvider>
Expand Down
218 changes: 218 additions & 0 deletions client/src/newswires/newswiresIntegration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { debounce } from "../util";
import React from "react";
import { css, Global } from "@emotion/react";
import { useGlobalStateContext } from "../globalState";
import { pinboard, pinMetal } from "../../colours";
import { textSans } from "../../fontNormaliser";
import { space } from "@guardian/source-foundations";
import ReactDOM from "react-dom";
import PinIcon from "../../icons/pin-icon.svg";
import root from "react-shadow/emotion";
import { boxShadow } from "../styling";

const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]";

interface ButtonPosition {
top: number;
left: number;
unRoundedCorner: "bottom-left" | "top-right" | "top-left" | "bottom-right";
}

export const NewswiresIntegration = () => {
const { setPayloadToBeSent, setIsExpanded } = useGlobalStateContext();

const [state, setState] = useState<{
selectedHTML: string;
containerElement: HTMLElement;
firstButtonPosition: ButtonPosition;
lastButtonPosition: ButtonPosition;
} | null>(null);

const handleSelectionChange = () => {
const selection = window.getSelection();
const maybeOriginalTargetEl: HTMLElement | null = document.querySelector(
SELECTION_TARGET_DATA_ATTR
);
if (
selection &&
selection.rangeCount > 0 &&
selection.toString().length > 0 &&
maybeOriginalTargetEl
) {
const clonedContents = selection.getRangeAt(0).cloneContents();
const maybeClonedTargetEl = clonedContents.querySelector(
SELECTION_TARGET_DATA_ATTR
);
const parentRect = maybeOriginalTargetEl.getBoundingClientRect();
const selectionRects = Array.from(
selection.getRangeAt(0).getClientRects()
);
const firstRect = selectionRects[0];
const lastRect = selectionRects[selectionRects.length - 1];
const firstButtonCoords = {
top: firstRect.y - parentRect.y,
left: firstRect.x - parentRect.x,
};
const firstButtonPosition: ButtonPosition = {
...firstButtonCoords,
unRoundedCorner: `bottom-${
firstButtonCoords.left > parentRect.width / 2 ? "right" : "left"
}`,
};
const lastButtonCoords = {
top: lastRect.y - parentRect.y + lastRect.height,
left: lastRect.x - parentRect.x + lastRect.width - 1,
};
const lastButtonPosition: ButtonPosition = {
...lastButtonCoords,
unRoundedCorner: `top-${
lastButtonCoords.left > parentRect.width / 2 ? "right" : "left"
}`,
};

if (maybeClonedTargetEl) {
console.log(
"selection contains whole target element; contents:",
maybeClonedTargetEl.innerHTML
);
setState({
selectedHTML: maybeClonedTargetEl.innerHTML,
containerElement: maybeOriginalTargetEl,
firstButtonPosition,
lastButtonPosition,
});
} else if (
maybeOriginalTargetEl?.contains(selection.anchorNode) &&
maybeOriginalTargetEl?.contains(selection.focusNode)
) {
const tempEl = document.createElement("div");
tempEl.appendChild(clonedContents);
console.log(
"selection is within target element; contents:",
tempEl.innerHTML
);
setState({
selectedHTML: tempEl.innerHTML,
containerElement: maybeOriginalTargetEl,
firstButtonPosition,
lastButtonPosition,
});
//TODO might need to clean up tempEl to avoid memory leak?
}
}
};

const debouncedSelectionHandler = useMemo(
() => () => {
setState(null); // clear selection to hide buttons
debounce(handleSelectionChange, 500)();
},
[handleSelectionChange]
);

useEffect(() => {
document.addEventListener("selectionchange", debouncedSelectionHandler);
/**
* todos:
* [ ] limit to newswires domain
* [x] add selection listener -- addEventListener("selectionchange", (event) => {});
* [x] debounce handler
* [x] check parent node of selection is newswires body text el (maybe add data attribute to body text el)
* - (find first shared parent of anchorNode and focusNode, make sure we're not sharing bits of text outside of the target)
* [x] extract HTML from selection (see chat thread)
* [x] render button when there's a selection
* [x] do things with pinboard
*/
return () =>
document.removeEventListener(
"selectionchange",
debouncedSelectionHandler
);
}, []);

const addSelectionToPinboard = useCallback(() => {
if (state) {
setPayloadToBeSent({
type: "newswires-snippet",
payload: {
embeddableHtml: state.selectedHTML,
embeddableUrl: window.location.href,
maybeUsageNote: state.containerElement.dataset.usageNote,
},
});
setIsExpanded(true);
}
}, [state, setPayloadToBeSent]);

return (
<>
<Global
styles={css`
${SELECTION_TARGET_DATA_ATTR} {
position: relative;
}
${SELECTION_TARGET_DATA_ATTR}::selection, ${SELECTION_TARGET_DATA_ATTR} ::selection {
background-color: ${pinboard[500]};
color: ${pinMetal};
}
`}
/>
{state &&
ReactDOM.createPortal(
<root.div>
{[state.firstButtonPosition, state.lastButtonPosition].map(
(buttonCoords, index) => (
<button
key={index}
css={css`
position: absolute;
top: ${buttonCoords.top}px;
left: ${buttonCoords.left}px;
transform: translate(
${
buttonCoords.unRoundedCorner.includes("left")
? "0"
: "-100%"
},${
buttonCoords.unRoundedCorner.includes("bottom")
? "-100%"
: "0"
}
);
display: flex;
align-items: center;
background-color: ${pinboard[500]};
${textSans.xsmall({ fontWeight: "bold" })};
box-shadow: ${boxShadow};
border: none;
border-radius: 100px;
border-${buttonCoords.unRoundedCorner}-radius: 0;
padding: 0 ${space[2]}px 0 ${space[3]}px;
line-height: 2;
cursor: pointer;
color: ${pinMetal};
text-wrap: nowrap;
`}
onClick={addSelectionToPinboard}
>
Add selection to{" "}
<PinIcon
css={css`
height: 18px;
margin-left: ${space[1]}px;
path {
stroke: ${pinMetal};
stroke-width: 1px;
}
`}
/>
</button>
)
)}
</root.div>,
state.containerElement
)}
</>
);
};
70 changes: 62 additions & 8 deletions client/src/payloadDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useContext } from "react";
import React, { useContext, useMemo } from "react";
import { css } from "@emotion/react";
import sanitizeHtml from "sanitize-html";
import { PayloadAndType } from "./types/PayloadAndType";
import { neutral, palette, space } from "@guardian/source-foundations";
import { brand, neutral, palette, space } from "@guardian/source-foundations";
import { GridStaticImageDisplay } from "./grid/gridStaticImageDisplay";
import { GridDynamicSearchDisplay } from "./grid/gridDynamicSearchDisplay";
import { TelemetryContext, PINBOARD_TELEMETRY_TYPE } from "./types/Telemetry";
Expand All @@ -24,6 +25,13 @@ export const PayloadDisplay = ({
}: PayloadDisplayProps) => {
const { payload } = payloadAndType;
const sendTelemetryEvent = useContext(TelemetryContext);

const safeSnippetHtml = useMemo(() => {
return payloadAndType.type === "newswires-snippet"
? sanitizeHtml(payloadAndType.payload.embeddableHtml)
: undefined;
}, [payloadAndType]);

return (
<div
css={css`
Expand Down Expand Up @@ -58,12 +66,27 @@ export const PayloadDisplay = ({
`}
draggable={!shouldNotBeClickable}
onDragStart={(event) => {
event.dataTransfer.setData("URL", payload.embeddableUrl);
event.dataTransfer.setData(
// prevent grid from accepting these as drops, as per https://github.com/guardian/grid/commit/4b72d93eedcbacb4f90680764d468781a72507f5#diff-771b9da876348ce4b4e057e2d8253324c30a8f3db4e434d49b3ce70dbbdb0775R138-R140
"application/vnd.mediaservice.kahuna.image",
"true"
);
if (payloadAndType.type === "newswires-snippet") {
// event.dataTransfer.setData("text/plain", "This is text to drag");

event.dataTransfer.setData(
"text/html",
// TODO consider also add a gu-note for who shared it and when
`${sanitizeHtml(
payloadAndType.payload.embeddableHtml
)}<br/><gu-note>${
payloadAndType.payload.maybeUsageNote ||
"NO USAGE NOTE IN THE WIRE"
}</gu-note>`
);
} else {
event.dataTransfer.setData("URL", payload.embeddableUrl);
event.dataTransfer.setData(
// prevent grid from accepting these as drops, as per https://github.com/guardian/grid/commit/4b72d93eedcbacb4f90680764d468781a72507f5#diff-771b9da876348ce4b4e057e2d8253324c30a8f3db4e434d49b3ce70dbbdb0775R138-R140
"application/vnd.mediaservice.kahuna.image",
"true"
);
}
sendTelemetryEvent?.(PINBOARD_TELEMETRY_TYPE.DRAG_FROM_PINBOARD, {
assetType: payloadAndType?.type,
...(tab && { tab }),
Expand Down Expand Up @@ -105,6 +128,37 @@ export const PayloadDisplay = ({
payload={payloadAndType.payload}
/>
)}
{payloadAndType.type === "newswires-snippet" && (
<div
css={css`
font-size: 0.8rem;
display: flex;
flex-direction: column;
gap: 2px;
width: 192px;
`}
>
<strong>Newswires snippet:</strong>
<blockquote
css={css`
font-size: 0.8rem;
overflow-y: auto;
margin: 0;
padding: 0 0 0 ${space[1]}px;
border-left: 4px solid ${brand[800]};
background-color: ${neutral[100]};
max-height: 175px;
overflow-y: auto;
`}
>
<div
dangerouslySetInnerHTML={{
__html: safeSnippetHtml || "",
}}
/>
</blockquote>
</div>
)}

{clearPayloadToBeSent && (
<FloatingClearButton clear={clearPayloadToBeSent} />
Expand Down
Loading

0 comments on commit 69b44b9

Please sign in to comment.