Skip to content

Commit 69b44b9

Browse files
committed
Merge branch 'refs/heads/pf/tr/wires-snippets' into TEMP/fronts-PLUS-newswires-snippets
# Conflicts: # client/src/app.tsx
2 parents 4fd35f0 + 68daa99 commit 69b44b9

File tree

8 files changed

+488
-38
lines changed

8 files changed

+488
-38
lines changed

bootstrapping-lambda/local/index.html

+28
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@ <h4>presented after 1 second</h4>
149149
and Pinboard detecting that and still taking over the element
150150
</p>
151151
</ul>
152+
<div style="padding: 20px; background-color: aliceblue">
153+
<h3>Pinboard selection target</h3>
154+
<div
155+
data-pinboard-selection-target
156+
data-usage-note="This is a sample usage note, copyright Pinboard sandbox."
157+
>
158+
<p>
159+
This is a target for the <strong>Pinboard</strong> library to render
160+
the selection interface into. It will be hidden by the library when
161+
not in use. This is a target for the <em>Pinboard</em> library to
162+
render the selection interface into. It will be hidden by the library
163+
when not in use. This is a target for the
164+
<strong>Pinboard</strong> library to render the selection interface
165+
into. It will be hidden by the library when not in use.
166+
</p>
167+
<p>
168+
This is a target for the <em>Pinboard</em> library to render the
169+
selection interface into. It will be hidden by the library when not in
170+
use. This is a target for the <strong>Pinboard</strong> library to
171+
render the selection interface into. It will be hidden by the library
172+
when not in use. This is a target for the <em>Pinboard</em> library to
173+
render the selection interface into. It will be hidden by the library
174+
when not in use.
175+
</p>
176+
</div>
177+
</div>
178+
179+
<hr />
152180
</body>
153181
<script>
154182
setTimeout(

client/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"preact": "10.15.1",
3131
"react-draggable": "^4.4.5",
3232
"react-joyride": "^2.5.3",
33-
"react-shadow": "^19.0.2"
33+
"react-shadow": "^19.0.2",
34+
"sanitize-html": "^2.13.1"
3435
},
3536
"devDependencies": {
3637
"@babel/core": "^7.17.4",
@@ -41,6 +42,7 @@
4142
"@svgr/webpack": "^6.2.1",
4243
"@types/react": "16.9.56",
4344
"@types/react-dom": "16.9.9",
45+
"@types/sanitize-html": "^2",
4446
"@types/webpack-env": "^1.16.3",
4547
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
4648
"babel-loader": "^8.2.3",

client/src/app.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
SUGGEST_ALTERNATE_CROP_QUERY_SELECTOR,
5454
SuggestAlternateCrops,
5555
} from "./fronts/suggestAlternateCrops";
56+
import { NewswiresIntegration } from "./newswires/newswiresIntegration";
5657

5758
const PRESELECT_PINBOARD_HTML_TAG = "pinboard-preselect";
5859
const PRESET_UNREAD_NOTIFICATIONS_COUNT_HTML_TAG = "pinboard-bubble-preset";
@@ -487,6 +488,7 @@ export const PinBoardApp = ({
487488
expand={() => setIsExpanded(true)}
488489
/>
489490
))}
491+
<NewswiresIntegration />
490492
</TourStateProvider>
491493
</GlobalStateProvider>
492494
</ApolloProvider>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import { debounce } from "../util";
3+
import React from "react";
4+
import { css, Global } from "@emotion/react";
5+
import { useGlobalStateContext } from "../globalState";
6+
import { pinboard, pinMetal } from "../../colours";
7+
import { textSans } from "../../fontNormaliser";
8+
import { space } from "@guardian/source-foundations";
9+
import ReactDOM from "react-dom";
10+
import PinIcon from "../../icons/pin-icon.svg";
11+
import root from "react-shadow/emotion";
12+
import { boxShadow } from "../styling";
13+
14+
const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]";
15+
16+
interface ButtonPosition {
17+
top: number;
18+
left: number;
19+
unRoundedCorner: "bottom-left" | "top-right" | "top-left" | "bottom-right";
20+
}
21+
22+
export const NewswiresIntegration = () => {
23+
const { setPayloadToBeSent, setIsExpanded } = useGlobalStateContext();
24+
25+
const [state, setState] = useState<{
26+
selectedHTML: string;
27+
containerElement: HTMLElement;
28+
firstButtonPosition: ButtonPosition;
29+
lastButtonPosition: ButtonPosition;
30+
} | null>(null);
31+
32+
const handleSelectionChange = () => {
33+
const selection = window.getSelection();
34+
const maybeOriginalTargetEl: HTMLElement | null = document.querySelector(
35+
SELECTION_TARGET_DATA_ATTR
36+
);
37+
if (
38+
selection &&
39+
selection.rangeCount > 0 &&
40+
selection.toString().length > 0 &&
41+
maybeOriginalTargetEl
42+
) {
43+
const clonedContents = selection.getRangeAt(0).cloneContents();
44+
const maybeClonedTargetEl = clonedContents.querySelector(
45+
SELECTION_TARGET_DATA_ATTR
46+
);
47+
const parentRect = maybeOriginalTargetEl.getBoundingClientRect();
48+
const selectionRects = Array.from(
49+
selection.getRangeAt(0).getClientRects()
50+
);
51+
const firstRect = selectionRects[0];
52+
const lastRect = selectionRects[selectionRects.length - 1];
53+
const firstButtonCoords = {
54+
top: firstRect.y - parentRect.y,
55+
left: firstRect.x - parentRect.x,
56+
};
57+
const firstButtonPosition: ButtonPosition = {
58+
...firstButtonCoords,
59+
unRoundedCorner: `bottom-${
60+
firstButtonCoords.left > parentRect.width / 2 ? "right" : "left"
61+
}`,
62+
};
63+
const lastButtonCoords = {
64+
top: lastRect.y - parentRect.y + lastRect.height,
65+
left: lastRect.x - parentRect.x + lastRect.width - 1,
66+
};
67+
const lastButtonPosition: ButtonPosition = {
68+
...lastButtonCoords,
69+
unRoundedCorner: `top-${
70+
lastButtonCoords.left > parentRect.width / 2 ? "right" : "left"
71+
}`,
72+
};
73+
74+
if (maybeClonedTargetEl) {
75+
console.log(
76+
"selection contains whole target element; contents:",
77+
maybeClonedTargetEl.innerHTML
78+
);
79+
setState({
80+
selectedHTML: maybeClonedTargetEl.innerHTML,
81+
containerElement: maybeOriginalTargetEl,
82+
firstButtonPosition,
83+
lastButtonPosition,
84+
});
85+
} else if (
86+
maybeOriginalTargetEl?.contains(selection.anchorNode) &&
87+
maybeOriginalTargetEl?.contains(selection.focusNode)
88+
) {
89+
const tempEl = document.createElement("div");
90+
tempEl.appendChild(clonedContents);
91+
console.log(
92+
"selection is within target element; contents:",
93+
tempEl.innerHTML
94+
);
95+
setState({
96+
selectedHTML: tempEl.innerHTML,
97+
containerElement: maybeOriginalTargetEl,
98+
firstButtonPosition,
99+
lastButtonPosition,
100+
});
101+
//TODO might need to clean up tempEl to avoid memory leak?
102+
}
103+
}
104+
};
105+
106+
const debouncedSelectionHandler = useMemo(
107+
() => () => {
108+
setState(null); // clear selection to hide buttons
109+
debounce(handleSelectionChange, 500)();
110+
},
111+
[handleSelectionChange]
112+
);
113+
114+
useEffect(() => {
115+
document.addEventListener("selectionchange", debouncedSelectionHandler);
116+
/**
117+
* todos:
118+
* [ ] limit to newswires domain
119+
* [x] add selection listener -- addEventListener("selectionchange", (event) => {});
120+
* [x] debounce handler
121+
* [x] check parent node of selection is newswires body text el (maybe add data attribute to body text el)
122+
* - (find first shared parent of anchorNode and focusNode, make sure we're not sharing bits of text outside of the target)
123+
* [x] extract HTML from selection (see chat thread)
124+
* [x] render button when there's a selection
125+
* [x] do things with pinboard
126+
*/
127+
return () =>
128+
document.removeEventListener(
129+
"selectionchange",
130+
debouncedSelectionHandler
131+
);
132+
}, []);
133+
134+
const addSelectionToPinboard = useCallback(() => {
135+
if (state) {
136+
setPayloadToBeSent({
137+
type: "newswires-snippet",
138+
payload: {
139+
embeddableHtml: state.selectedHTML,
140+
embeddableUrl: window.location.href,
141+
maybeUsageNote: state.containerElement.dataset.usageNote,
142+
},
143+
});
144+
setIsExpanded(true);
145+
}
146+
}, [state, setPayloadToBeSent]);
147+
148+
return (
149+
<>
150+
<Global
151+
styles={css`
152+
${SELECTION_TARGET_DATA_ATTR} {
153+
position: relative;
154+
}
155+
${SELECTION_TARGET_DATA_ATTR}::selection, ${SELECTION_TARGET_DATA_ATTR} ::selection {
156+
background-color: ${pinboard[500]};
157+
color: ${pinMetal};
158+
}
159+
`}
160+
/>
161+
{state &&
162+
ReactDOM.createPortal(
163+
<root.div>
164+
{[state.firstButtonPosition, state.lastButtonPosition].map(
165+
(buttonCoords, index) => (
166+
<button
167+
key={index}
168+
css={css`
169+
position: absolute;
170+
top: ${buttonCoords.top}px;
171+
left: ${buttonCoords.left}px;
172+
transform: translate(
173+
${
174+
buttonCoords.unRoundedCorner.includes("left")
175+
? "0"
176+
: "-100%"
177+
},${
178+
buttonCoords.unRoundedCorner.includes("bottom")
179+
? "-100%"
180+
: "0"
181+
}
182+
);
183+
display: flex;
184+
align-items: center;
185+
background-color: ${pinboard[500]};
186+
${textSans.xsmall({ fontWeight: "bold" })};
187+
box-shadow: ${boxShadow};
188+
border: none;
189+
border-radius: 100px;
190+
border-${buttonCoords.unRoundedCorner}-radius: 0;
191+
padding: 0 ${space[2]}px 0 ${space[3]}px;
192+
line-height: 2;
193+
cursor: pointer;
194+
color: ${pinMetal};
195+
text-wrap: nowrap;
196+
`}
197+
onClick={addSelectionToPinboard}
198+
>
199+
Add selection to{" "}
200+
<PinIcon
201+
css={css`
202+
height: 18px;
203+
margin-left: ${space[1]}px;
204+
path {
205+
stroke: ${pinMetal};
206+
stroke-width: 1px;
207+
}
208+
`}
209+
/>
210+
</button>
211+
)
212+
)}
213+
</root.div>,
214+
state.containerElement
215+
)}
216+
</>
217+
);
218+
};

client/src/payloadDisplay.tsx

+62-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React, { useContext } from "react";
1+
import React, { useContext, useMemo } from "react";
22
import { css } from "@emotion/react";
3+
import sanitizeHtml from "sanitize-html";
34
import { PayloadAndType } from "./types/PayloadAndType";
4-
import { neutral, palette, space } from "@guardian/source-foundations";
5+
import { brand, neutral, palette, space } from "@guardian/source-foundations";
56
import { GridStaticImageDisplay } from "./grid/gridStaticImageDisplay";
67
import { GridDynamicSearchDisplay } from "./grid/gridDynamicSearchDisplay";
78
import { TelemetryContext, PINBOARD_TELEMETRY_TYPE } from "./types/Telemetry";
@@ -24,6 +25,13 @@ export const PayloadDisplay = ({
2425
}: PayloadDisplayProps) => {
2526
const { payload } = payloadAndType;
2627
const sendTelemetryEvent = useContext(TelemetryContext);
28+
29+
const safeSnippetHtml = useMemo(() => {
30+
return payloadAndType.type === "newswires-snippet"
31+
? sanitizeHtml(payloadAndType.payload.embeddableHtml)
32+
: undefined;
33+
}, [payloadAndType]);
34+
2735
return (
2836
<div
2937
css={css`
@@ -58,12 +66,27 @@ export const PayloadDisplay = ({
5866
`}
5967
draggable={!shouldNotBeClickable}
6068
onDragStart={(event) => {
61-
event.dataTransfer.setData("URL", payload.embeddableUrl);
62-
event.dataTransfer.setData(
63-
// prevent grid from accepting these as drops, as per https://github.com/guardian/grid/commit/4b72d93eedcbacb4f90680764d468781a72507f5#diff-771b9da876348ce4b4e057e2d8253324c30a8f3db4e434d49b3ce70dbbdb0775R138-R140
64-
"application/vnd.mediaservice.kahuna.image",
65-
"true"
66-
);
69+
if (payloadAndType.type === "newswires-snippet") {
70+
// event.dataTransfer.setData("text/plain", "This is text to drag");
71+
72+
event.dataTransfer.setData(
73+
"text/html",
74+
// TODO consider also add a gu-note for who shared it and when
75+
`${sanitizeHtml(
76+
payloadAndType.payload.embeddableHtml
77+
)}<br/><gu-note>${
78+
payloadAndType.payload.maybeUsageNote ||
79+
"NO USAGE NOTE IN THE WIRE"
80+
}</gu-note>`
81+
);
82+
} else {
83+
event.dataTransfer.setData("URL", payload.embeddableUrl);
84+
event.dataTransfer.setData(
85+
// prevent grid from accepting these as drops, as per https://github.com/guardian/grid/commit/4b72d93eedcbacb4f90680764d468781a72507f5#diff-771b9da876348ce4b4e057e2d8253324c30a8f3db4e434d49b3ce70dbbdb0775R138-R140
86+
"application/vnd.mediaservice.kahuna.image",
87+
"true"
88+
);
89+
}
6790
sendTelemetryEvent?.(PINBOARD_TELEMETRY_TYPE.DRAG_FROM_PINBOARD, {
6891
assetType: payloadAndType?.type,
6992
...(tab && { tab }),
@@ -105,6 +128,37 @@ export const PayloadDisplay = ({
105128
payload={payloadAndType.payload}
106129
/>
107130
)}
131+
{payloadAndType.type === "newswires-snippet" && (
132+
<div
133+
css={css`
134+
font-size: 0.8rem;
135+
display: flex;
136+
flex-direction: column;
137+
gap: 2px;
138+
width: 192px;
139+
`}
140+
>
141+
<strong>Newswires snippet:</strong>
142+
<blockquote
143+
css={css`
144+
font-size: 0.8rem;
145+
overflow-y: auto;
146+
margin: 0;
147+
padding: 0 0 0 ${space[1]}px;
148+
border-left: 4px solid ${brand[800]};
149+
background-color: ${neutral[100]};
150+
max-height: 175px;
151+
overflow-y: auto;
152+
`}
153+
>
154+
<div
155+
dangerouslySetInnerHTML={{
156+
__html: safeSnippetHtml || "",
157+
}}
158+
/>
159+
</blockquote>
160+
</div>
161+
)}
108162

109163
{clearPayloadToBeSent && (
110164
<FloatingClearButton clear={clearPayloadToBeSent} />

0 commit comments

Comments
 (0)