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

newswires snippets #332

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
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
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
2 changes: 2 additions & 0 deletions client/fontNormaliser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export const agateSans = agateSansFont(
);
export const textSans = pixelSizedFont(sourceFoundations.textSans);

export const bodyFont = pixelSizedFont(sourceFoundations.body);

const isAgateLoaded = () => {
let foundAgate = false;
document.fonts.forEach((font) => {
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
5 changes: 5 additions & 0 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ import {
SUGGEST_ALTERNATE_CROP_QUERY_SELECTOR,
SuggestAlternateCrops,
} from "./fronts/suggestAlternateCrops";
import {
isNewswiresDomain,
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 @@ -509,6 +513,7 @@ export const PinBoardApp = ({
expand={() => setIsExpanded(true)}
/>
))}
{isNewswiresDomain && <NewswiresIntegration />}
</TourStateProvider>
</GlobalStateProvider>
</ApolloProvider>
Expand Down
214 changes: 214 additions & 0 deletions client/src/newswires/newswiresIntegration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
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 isNewswiresDomain = [
"https://pinboard.local.dev-gutools.co.uk", // local testing in Pinboard's local sandbox
"https://newswires.local.dev-gutools.co.uk",
"https://newswires.code.dev-gutools.co.uk",
"https://newswires.gutools.co.uk",
].includes(window.location.hostname);

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);
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
)}
</>
);
};
Loading
Loading