From 4a7a5961800395298bcea82e69503afd2767bf2c Mon Sep 17 00:00:00 2001 From: Pete F Date: Mon, 21 Oct 2024 13:17:31 +0100 Subject: [PATCH 01/15] Remove export from non-imported types --- client/src/types/PayloadAndType.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/client/src/types/PayloadAndType.ts b/client/src/types/PayloadAndType.ts index 633a3c4a..ba57b864 100644 --- a/client/src/types/PayloadAndType.ts +++ b/client/src/types/PayloadAndType.ts @@ -1,19 +1,17 @@ import * as Sentry from "@sentry/react"; -export const sources = ["grid", "mam"] as const; -export const sourceTypes = ["crop", "original", "search", "video"] as const; +const sources = ["grid", "mam"] as const; +const sourceTypes = ["crop", "original", "search", "video"] as const; -export type Source = (typeof sources)[number]; -export const isSource = (source: unknown): source is Source => +type Source = (typeof sources)[number]; +const isSource = (source: unknown): source is Source => sources.includes(source as Source); -export type SourceType = (typeof sourceTypes)[number]; -export const isSourceType = (sourceType: unknown): sourceType is SourceType => +type SourceType = (typeof sourceTypes)[number]; +const isSourceType = (sourceType: unknown): sourceType is SourceType => sourceTypes.includes(sourceType as SourceType); export type PayloadType = `${Source}-${SourceType}`; // TODO improve this type as it enumerates all the combinations, e.g. mam-original which is not valid -export const isPayloadType = ( - payloadType: string -): payloadType is PayloadType => { +const isPayloadType = (payloadType: string): payloadType is PayloadType => { const parts = payloadType.split("-"); return parts.length === 2 && isSource(parts[0]) && isSourceType(parts[1]); }; @@ -36,11 +34,11 @@ export interface PayloadWithApiUrl extends PayloadCommon { apiUrl: string; } -export type Payload = +type Payload = | PayloadWithThumbnail | PayloadWithApiUrl | PayloadWithExternalUrl; -export const isPayload = (maybePayload: unknown): maybePayload is Payload => { +const isPayload = (maybePayload: unknown): maybePayload is Payload => { return ( typeof maybePayload === "object" && maybePayload !== null && From ca269b9f54b6498e445616f012f53cd2b09a5fee Mon Sep 17 00:00:00 2001 From: Pete F Date: Mon, 21 Oct 2024 13:18:21 +0100 Subject: [PATCH 02/15] Stash: send snippet to pinboard --- bootstrapping-lambda/local/index.html | 26 +++++ client/src/app.tsx | 2 + client/src/newswires/newswiresIntegration.tsx | 102 ++++++++++++++++++ client/src/payloadDisplay.tsx | 10 ++ client/src/types/PayloadAndType.ts | 23 +++- client/src/util.ts | 15 +++ 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 client/src/newswires/newswiresIntegration.tsx diff --git a/bootstrapping-lambda/local/index.html b/bootstrapping-lambda/local/index.html index 7ba61983..4d5bda1b 100644 --- a/bootstrapping-lambda/local/index.html +++ b/bootstrapping-lambda/local/index.html @@ -21,6 +21,32 @@ +
+

not inside target

+

Not inside target

+
+

Pinboard selection target

+

+ This is a target for the Pinboard library to render the selection + interface into. It will be hidden by the library when not in use. This + is a target for the Pinboard library to render the selection interface + into. It will be hidden by the library when not in use. This is a target + for the Pinboard library to render the selection interface into. It will + be hidden by the library when not in use. This is a target for the + Pinboard library to render the selection interface into. It will be + hidden by the library when not in use. This is a target for the Pinboard + library to render the selection interface into. It will be hidden by the + library when not in use. This is a target for the Pinboard library to + render the selection interface into. It will be hidden by the library + when not in use. +

+
+

not inside target

+ +
Expand pinboard via query param ?expandPinboard=true diff --git a/client/src/app.tsx b/client/src/app.tsx index bb1d9d88..d74ad398 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -58,6 +58,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"; @@ -509,6 +510,7 @@ export const PinBoardApp = ({ expand={() => setIsExpanded(true)} /> ))} + diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx new file mode 100644 index 00000000..dc785b3a --- /dev/null +++ b/client/src/newswires/newswiresIntegration.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { debounce } from "../util"; +import React from "react"; +import { css } from "@emotion/react"; +import { useGlobalStateContext } from "../globalState"; + +const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]"; + +export const NewswiresIntegration = () => { + const { setPayloadToBeSent } = useGlobalStateContext(); + const [selectedHTML, setSelectedHTML] = useState(null); + + const handleSelectionChange = () => { + const selection = window.getSelection(); + if (selection) { + const maybeOriginalTargetEl = document.querySelector( + SELECTION_TARGET_DATA_ATTR + ); + try { + const clonedContents = selection.getRangeAt(0).cloneContents(); + const maybeClonedTargetEl = clonedContents.querySelector( + SELECTION_TARGET_DATA_ATTR + ); + if (maybeClonedTargetEl) { + console.log( + "selection contains whole target element; contents:", + maybeClonedTargetEl.innerHTML + ); + setSelectedHTML(maybeClonedTargetEl.innerHTML); + } 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 + ); + setSelectedHTML(tempEl.innerHTML); + } + } + } catch (e) { + console.error("Error cloning selection contents", e); + } + } + }; + + const debouncedSelectionHandler = useMemo( + () => debounce(handleSelectionChange, 750), + [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) + * [ ] render button when there's a selection + * [ ] do things with pinboard + */ + return () => + document.removeEventListener( + "selectionchange", + debouncedSelectionHandler + ); + }, []); + + const addSelectionToPinboard = useCallback(() => { + if (selectedHTML) { + setPayloadToBeSent({ + type: "newswires-snippet", + payload: { + embeddableHtml: selectedHTML, + embeddableUrl: window.location.href, + }, + }); + } + }, [selectedHTML, setPayloadToBeSent]); + + return ( +
+ {selectedHTML && ( +
+ +
+ )} +
+ ); +}; diff --git a/client/src/payloadDisplay.tsx b/client/src/payloadDisplay.tsx index 1d37f3e1..5290ec01 100644 --- a/client/src/payloadDisplay.tsx +++ b/client/src/payloadDisplay.tsx @@ -105,6 +105,16 @@ export const PayloadDisplay = ({ payload={payloadAndType.payload} /> )} + {payloadAndType.type === "newswires-snippet" && ( + + {payloadAndType.payload.embeddableHtml} + + )} {clearPayloadToBeSent && ( diff --git a/client/src/types/PayloadAndType.ts b/client/src/types/PayloadAndType.ts index ba57b864..a7523e9d 100644 --- a/client/src/types/PayloadAndType.ts +++ b/client/src/types/PayloadAndType.ts @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/react"; -const sources = ["grid", "mam"] as const; -const sourceTypes = ["crop", "original", "search", "video"] as const; +const sources = ["grid", "mam", "newswires"] as const; +const sourceTypes = ["crop", "original", "search", "video", "snippet"] as const; type Source = (typeof sources)[number]; const isSource = (source: unknown): source is Source => @@ -34,16 +34,23 @@ export interface PayloadWithApiUrl extends PayloadCommon { apiUrl: string; } +export interface PayloadWithSnippet extends PayloadCommon { + embeddableHtml: string; +} + type Payload = | PayloadWithThumbnail | PayloadWithApiUrl - | PayloadWithExternalUrl; + | PayloadWithExternalUrl + | PayloadWithSnippet; const isPayload = (maybePayload: unknown): maybePayload is Payload => { return ( typeof maybePayload === "object" && maybePayload !== null && "embeddableUrl" in maybePayload && - ("thumbnail" in maybePayload || "apiUrl" in maybePayload) + ("thumbnail" in maybePayload || + "apiUrl" in maybePayload || + "embeddableHtml" in maybePayload) ); }; @@ -62,10 +69,16 @@ export type MamVideoPayload = { payload: PayloadWithExternalUrl; }; +export type NewswiresSnippetPayload = { + type: "newswires-snippet"; + payload: PayloadWithSnippet; +}; + export type PayloadAndType = | StaticGridPayload | DynamicGridPayload - | MamVideoPayload; + | MamVideoPayload + | NewswiresSnippetPayload; export const buildPayloadAndType = ( type: string, diff --git a/client/src/util.ts b/client/src/util.ts index 2650e8e8..7caa38f4 100644 --- a/client/src/util.ts +++ b/client/src/util.ts @@ -90,3 +90,18 @@ export const readAndThenSilentlyDropQueryParamFromURL = (param: string) => { ); return value; }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- suitably generic function +export const debounce = void>( + f: F, + delay: number +): ((...args: Parameters) => void) => { + let waiting: ReturnType | undefined; + + return (...args: Parameters) => { + if (waiting !== undefined) { + clearTimeout(waiting); + } + waiting = setTimeout(() => f(...args), delay); + }; +}; From a1244c0a743412b437b3d0e7232f5e6b6896ca09 Mon Sep 17 00:00:00 2001 From: Pete F Date: Tue, 22 Oct 2024 17:29:29 +0100 Subject: [PATCH 03/15] WIP --- client/package.json | 4 +- client/src/newswires/newswiresIntegration.tsx | 110 +++++++++------ client/src/payloadDisplay.tsx | 62 ++++++-- client/src/types/PayloadAndType.ts | 29 ++-- yarn.lock | 132 ++++++++++++++++-- 5 files changed, 261 insertions(+), 76 deletions(-) diff --git a/client/package.json b/client/package.json index fd5546a8..41416c80 100644 --- a/client/package.json +++ b/client/package.json @@ -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", @@ -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", diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index dc785b3a..974be931 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -1,48 +1,52 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { debounce } from "../util"; import React from "react"; -import { css } from "@emotion/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"; const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]"; export const NewswiresIntegration = () => { - const { setPayloadToBeSent } = useGlobalStateContext(); + const { setPayloadToBeSent, setIsExpanded } = useGlobalStateContext(); const [selectedHTML, setSelectedHTML] = useState(null); + const [mountPoint, setMountPoint] = useState(null); + const [buttonCoords, setButtonCoords] = useState({ x: 0, y: 0 }); const handleSelectionChange = () => { const selection = window.getSelection(); - if (selection) { + if (selection && selection.rangeCount > 0) { const maybeOriginalTargetEl = document.querySelector( SELECTION_TARGET_DATA_ATTR ); - try { - const clonedContents = selection.getRangeAt(0).cloneContents(); - const maybeClonedTargetEl = clonedContents.querySelector( - SELECTION_TARGET_DATA_ATTR + + setMountPoint(maybeOriginalTargetEl); + const clonedContents = selection.getRangeAt(0).cloneContents(); + const maybeClonedTargetEl = clonedContents.querySelector( + SELECTION_TARGET_DATA_ATTR + ); + if (maybeClonedTargetEl) { + console.log( + "selection contains whole target element; contents:", + maybeClonedTargetEl.innerHTML ); - if (maybeClonedTargetEl) { - console.log( - "selection contains whole target element; contents:", - maybeClonedTargetEl.innerHTML - ); - setSelectedHTML(maybeClonedTargetEl.innerHTML); - } 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 - ); - setSelectedHTML(tempEl.innerHTML); - } - } - } catch (e) { - console.error("Error cloning selection contents", e); + setSelectedHTML(maybeClonedTargetEl.innerHTML); + setSelectionFocusNode(selectionFocusNode); // todo: set coords instead, based on selection + } 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 + ); + setSelectedHTML(tempEl.innerHTML); + setSelectionFocusNode(selection.focusNode); } } }; @@ -81,22 +85,44 @@ export const NewswiresIntegration = () => { embeddableUrl: window.location.href, }, }); + setIsExpanded(true); } }, [selectedHTML, setPayloadToBeSent]); return ( -
- {selectedHTML && ( -
- -
- )} -
+ <> + + {selectedHTML && + mountPoint && + ReactDOM.createPortal( +
+ +
, + mountPoint + )} + ); }; diff --git a/client/src/payloadDisplay.tsx b/client/src/payloadDisplay.tsx index 5290ec01..0a2c0b3f 100644 --- a/client/src/payloadDisplay.tsx +++ b/client/src/payloadDisplay.tsx @@ -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"; @@ -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 (
{ - 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/plain", + sanitizeHtml(payloadAndType.payload.embeddableHtml) + ); + } 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 }), @@ -106,14 +123,35 @@ export const PayloadDisplay = ({ /> )} {payloadAndType.type === "newswires-snippet" && ( - - {payloadAndType.payload.embeddableHtml} - + Newswires snippet: +
+
+
+
)} {clearPayloadToBeSent && ( diff --git a/client/src/types/PayloadAndType.ts b/client/src/types/PayloadAndType.ts index a7523e9d..a898f09c 100644 --- a/client/src/types/PayloadAndType.ts +++ b/client/src/types/PayloadAndType.ts @@ -1,19 +1,16 @@ import * as Sentry from "@sentry/react"; -const sources = ["grid", "mam", "newswires"] as const; -const sourceTypes = ["crop", "original", "search", "video", "snippet"] as const; - -type Source = (typeof sources)[number]; -const isSource = (source: unknown): source is Source => - sources.includes(source as Source); -type SourceType = (typeof sourceTypes)[number]; -const isSourceType = (sourceType: unknown): sourceType is SourceType => - sourceTypes.includes(sourceType as SourceType); - -export type PayloadType = `${Source}-${SourceType}`; // TODO improve this type as it enumerates all the combinations, e.g. mam-original which is not valid +const payloadTypes = [ + "grid-crop", + "grid-original", + "grid-search", + "mam-video", + "newswires-snippet", +] as const; + +export type PayloadType = (typeof payloadTypes)[number]; const isPayloadType = (payloadType: string): payloadType is PayloadType => { - const parts = payloadType.split("-"); - return parts.length === 2 && isSource(parts[0]) && isSourceType(parts[1]); + return payloadTypes.includes(payloadType as PayloadType); }; interface PayloadCommon { @@ -99,6 +96,12 @@ export const buildPayloadAndType = ( "externalUrl" in payload ) { return { type, payload }; + } else if ( + type === "newswires-snippet" && + "embeddableHtml" in payload && + "embeddableUrl" in payload + ) { + return { type, payload }; } }; diff --git a/yarn.lock b/yarn.lock index 55422e33..760bd5d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7313,6 +7313,15 @@ __metadata: languageName: node linkType: hard +"@types/sanitize-html@npm:^2": + version: 2.13.0 + resolution: "@types/sanitize-html@npm:2.13.0" + dependencies: + htmlparser2: "npm:^8.0.0" + checksum: 10c0/c6614b38f67dd6fb3a94c9163a55fa43b9aa81a845fe9584d9ffbd5da0e00e0ac8162ede9f4bde095840b2ef9db12265f5fcc40b707f48a420405c6aa7c3ff51 + languageName: node + linkType: hard + "@types/send@npm:*": version: 0.17.1 resolution: "@types/send@npm:0.17.1" @@ -9469,6 +9478,7 @@ __metadata: "@svgr/webpack": "npm:^6.2.1" "@types/react": "npm:16.9.56" "@types/react-dom": "npm:16.9.9" + "@types/sanitize-html": "npm:^2" "@types/webpack-env": "npm:^1.16.3" "@types/webscopeio__react-textarea-autocomplete": "npm:^4.7.2" "@webscopeio/react-textarea-autocomplete": "npm:4.9.2" @@ -9484,6 +9494,7 @@ __metadata: react-draggable: "npm:^4.4.5" react-joyride: "npm:^2.5.3" react-shadow: "npm:^19.0.2" + sanitize-html: "npm:^2.13.1" webpack: "npm:^5.76.0" webpack-bundle-analyzer: "npm:^4.5.0" webpack-cli: "npm:^4.9.2" @@ -10473,10 +10484,21 @@ __metadata: languageName: node linkType: hard -"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0": - version: 2.2.0 - resolution: "domelementtype@npm:2.2.0" - checksum: 10c0/0e3824e21fb9ff2cda9579ad04ef0068c58cc1746cf723560e1b4cb73ccae324062d468b25a576948459df7dd99e42d8a100b7fcfc6e05c8eefa2e6fed3f8f7d +"dom-serializer@npm:^2.0.0": + version: 2.0.0 + resolution: "dom-serializer@npm:2.0.0" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.2" + entities: "npm:^4.2.0" + checksum: 10c0/d5ae2b7110ca3746b3643d3ef60ef823f5f078667baf530cec096433f1627ec4b6fa8c072f09d079d7cda915fd2c7bc1b7b935681e9b09e591e1e15f4040b8e2 + languageName: node + linkType: hard + +"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 languageName: node linkType: hard @@ -10498,6 +10520,15 @@ __metadata: languageName: node linkType: hard +"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": + version: 5.0.3 + resolution: "domhandler@npm:5.0.3" + dependencies: + domelementtype: "npm:^2.3.0" + checksum: 10c0/bba1e5932b3e196ad6862286d76adc89a0dbf0c773e5ced1eb01f9af930c50093a084eff14b8de5ea60b895c56a04d5de8bbc4930c5543d029091916770b2d2a + languageName: node + linkType: hard + "domutils@npm:^2.5.2, domutils@npm:^2.8.0": version: 2.8.0 resolution: "domutils@npm:2.8.0" @@ -10509,6 +10540,17 @@ __metadata: languageName: node linkType: hard +"domutils@npm:^3.0.1": + version: 3.1.0 + resolution: "domutils@npm:3.1.0" + dependencies: + dom-serializer: "npm:^2.0.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + checksum: 10c0/342d64cf4d07b8a0573fb51e0a6312a88fb520c7fefd751870bf72fa5fc0f2e0cb9a3958a573610b1d608c6e2a69b8e9b4b40f0bfb8f87a71bce4f180cca1887 + languageName: node + linkType: hard + "dot-case@npm:^3.0.4": version: 3.0.4 resolution: "dot-case@npm:3.0.4" @@ -10745,6 +10787,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.2.0, entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -12694,6 +12743,18 @@ __metadata: languageName: node linkType: hard +"htmlparser2@npm:^8.0.0": + version: 8.0.2 + resolution: "htmlparser2@npm:8.0.2" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.0.1" + entities: "npm:^4.4.0" + checksum: 10c0/609cca85886d0bf2c9a5db8c6926a89f3764596877492e2caa7a25a789af4065bc6ee2cdc81807fe6b1d03a87bf8a373b5a754528a4cc05146b713c20575aab4 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -13419,6 +13480,13 @@ __metadata: languageName: node linkType: hard +"is-plain-object@npm:^5.0.0": + version: 5.0.0 + resolution: "is-plain-object@npm:5.0.0" + checksum: 10c0/893e42bad832aae3511c71fd61c0bf61aa3a6d853061c62a307261842727d0d25f761ce9379f7ba7226d6179db2a3157efa918e7fe26360f3bf0842d9f28942c + languageName: node + linkType: hard + "is-potential-custom-element-name@npm:^1.0.1": version: 1.0.1 resolution: "is-potential-custom-element-name@npm:1.0.1" @@ -15698,6 +15766,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.7": + version: 3.3.8 + resolution: "nanoid@npm:3.3.8" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/4b1bb29f6cfebf3be3bc4ad1f1296fb0a10a3043a79f34fbffe75d1621b4318319211cd420549459018ea3592f0d2f159247a6f874911d6d26eaaadda2478120 + languageName: node + linkType: hard + "nanomatch@npm:^1.2.9": version: 1.2.13 resolution: "nanomatch@npm:1.2.13" @@ -16304,6 +16381,13 @@ __metadata: languageName: node linkType: hard +"parse-srcset@npm:^1.0.2": + version: 1.0.2 + resolution: "parse-srcset@npm:1.0.2" + checksum: 10c0/2f268e3d110d4c53d06ed2a8e8ee61a7da0cee13bf150819a6da066a8ca9b8d15b5600d6e6cae8be940e2edc50ee7c1e1052934d6ec858324065ecef848f0497 + languageName: node + linkType: hard + "parse-url@npm:^9.2.0": version: 9.2.0 resolution: "parse-url@npm:9.2.0" @@ -16476,10 +16560,10 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1": - version: 1.0.1 - resolution: "picocolors@npm:1.0.1" - checksum: 10c0/c63cdad2bf812ef0d66c8db29583802355d4ca67b9285d846f390cc15c2f6ccb94e8cb7eb6a6e97fc5990a6d3ad4ae42d86c84d3146e667c739a4234ed50d400 +"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 languageName: node linkType: hard @@ -16570,6 +16654,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.3.11": + version: 8.4.49 + resolution: "postcss@npm:8.4.49" + dependencies: + nanoid: "npm:^3.3.7" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/f1b3f17aaf36d136f59ec373459f18129908235e65dbdc3aee5eef8eba0756106f52de5ec4682e29a2eab53eb25170e7e871b3e4b52a8f1de3d344a514306be3 + languageName: node + linkType: hard + "postgres@npm:^3.2.4": version: 3.2.4 resolution: "postgres@npm:3.2.4" @@ -17557,6 +17652,20 @@ __metadata: languageName: node linkType: hard +"sanitize-html@npm:^2.13.1": + version: 2.13.1 + resolution: "sanitize-html@npm:2.13.1" + dependencies: + deepmerge: "npm:^4.2.2" + escape-string-regexp: "npm:^4.0.0" + htmlparser2: "npm:^8.0.0" + is-plain-object: "npm:^5.0.0" + parse-srcset: "npm:^1.0.2" + postcss: "npm:^8.3.11" + checksum: 10c0/306c811a254e48eb45e9c523fb91cced893d77786323a64fb47f4bb3f1237b4d29e3722c0723c329e6cb6ac674ae903e961d446c3636b9db5961c83b2c0995fe + languageName: node + linkType: hard + "sax@npm:1.2.1": version: 1.2.1 resolution: "sax@npm:1.2.1" @@ -18089,6 +18198,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + "source-map-resolve@npm:^0.5.0": version: 0.5.3 resolution: "source-map-resolve@npm:0.5.3" From 4515c925f133b29b1b99cf858ecba5910c7ae9ac Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Wed, 23 Oct 2024 09:57:04 +0100 Subject: [PATCH 04/15] display selection buttons at each end of the selected text --- client/src/newswires/newswiresIntegration.tsx | 130 +++++++++++++----- 1 file changed, 93 insertions(+), 37 deletions(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index 974be931..7ed3476a 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -10,31 +10,56 @@ import ReactDOM from "react-dom"; const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]"; +interface ButtonPosition { + top: number; + left: number; +} + export const NewswiresIntegration = () => { const { setPayloadToBeSent, setIsExpanded } = useGlobalStateContext(); - const [selectedHTML, setSelectedHTML] = useState(null); - const [mountPoint, setMountPoint] = useState(null); - const [buttonCoords, setButtonCoords] = useState({ x: 0, y: 0 }); + + const [state, setState] = useState<{ + selectedHTML: string; + mountPoint: HTMLElement; + firstButtonPosition: ButtonPosition; + lastButtonPosition: ButtonPosition; + } | null>(null); const handleSelectionChange = () => { const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const maybeOriginalTargetEl = document.querySelector( - SELECTION_TARGET_DATA_ATTR - ); - - setMountPoint(maybeOriginalTargetEl); + const maybeOriginalTargetEl: HTMLElement | null = document.querySelector( + SELECTION_TARGET_DATA_ATTR + ); + if (selection && selection.rangeCount > 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 newFirstButtonCoords = { + top: firstRect.y - parentRect.y, + left: firstRect.x - parentRect.x, + }; + const newLastButtonCoords = { + top: lastRect.y - parentRect.y + lastRect.height, + left: lastRect.x - parentRect.x + lastRect.width, + }; if (maybeClonedTargetEl) { console.log( "selection contains whole target element; contents:", maybeClonedTargetEl.innerHTML ); - setSelectedHTML(maybeClonedTargetEl.innerHTML); - setSelectionFocusNode(selectionFocusNode); // todo: set coords instead, based on selection + setState({ + selectedHTML: maybeClonedTargetEl.innerHTML, + mountPoint: maybeOriginalTargetEl, + firstButtonPosition: newFirstButtonCoords, + lastButtonPosition: newLastButtonCoords, + }); } else if ( maybeOriginalTargetEl?.contains(selection.anchorNode) && maybeOriginalTargetEl?.contains(selection.focusNode) @@ -45,14 +70,22 @@ export const NewswiresIntegration = () => { "selection is within target element; contents:", tempEl.innerHTML ); - setSelectedHTML(tempEl.innerHTML); - setSelectionFocusNode(selection.focusNode); + setState({ + selectedHTML: tempEl.innerHTML, + mountPoint: maybeOriginalTargetEl, + firstButtonPosition: newFirstButtonCoords, + lastButtonPosition: newLastButtonCoords, + }); + //TODO might need to clean up tempEl } } }; const debouncedSelectionHandler = useMemo( - () => debounce(handleSelectionChange, 750), + () => () => { + setState(null); // clear selection to hide buttons + debounce(handleSelectionChange, 750)(); + }, [handleSelectionChange] ); @@ -77,51 +110,74 @@ export const NewswiresIntegration = () => { }, []); const addSelectionToPinboard = useCallback(() => { - if (selectedHTML) { + if (state) { setPayloadToBeSent({ type: "newswires-snippet", payload: { - embeddableHtml: selectedHTML, + embeddableHtml: state.selectedHTML, embeddableUrl: window.location.href, }, }); setIsExpanded(true); } - }, [selectedHTML, setPayloadToBeSent]); + }, [state, setPayloadToBeSent]); return ( <> - {selectedHTML && - mountPoint && + {state && ReactDOM.createPortal(
- + {[state.firstButtonPosition, state.lastButtonPosition].map( + (buttonCoords, index) => ( + + ) + )}
, - mountPoint + state.mountPoint )} ); From ad075e862be39b1f57c6270971f229a910b6408a Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Wed, 23 Oct 2024 20:48:51 +0100 Subject: [PATCH 05/15] [newswires] ignore empty selections --- client/src/newswires/newswiresIntegration.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index 7ed3476a..cb6ad632 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -30,7 +30,12 @@ export const NewswiresIntegration = () => { const maybeOriginalTargetEl: HTMLElement | null = document.querySelector( SELECTION_TARGET_DATA_ATTR ); - if (selection && selection.rangeCount > 0 && maybeOriginalTargetEl) { + if ( + selection && + selection.rangeCount > 0 && + selection.toString().length > 0 && + maybeOriginalTargetEl + ) { const clonedContents = selection.getRangeAt(0).cloneContents(); const maybeClonedTargetEl = clonedContents.querySelector( SELECTION_TARGET_DATA_ATTR From 376fb8e1bd43eaa98c9e2fe1612d446687c917ff Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Wed, 23 Oct 2024 20:49:35 +0100 Subject: [PATCH 06/15] [newswires] improve button formatting --- client/src/newswires/newswiresIntegration.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index cb6ad632..355e2c45 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -7,6 +7,7 @@ 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"; const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]"; @@ -161,7 +162,7 @@ export const NewswiresIntegration = () => { display: flex; align-items: center; background-color: ${pinboard[500]}; - ${textSans.xsmall()}; + ${textSans.xsmall({ fontWeight: "bold" })}; border: none; border-radius: 100px; border-${ @@ -177,7 +178,17 @@ export const NewswiresIntegration = () => { `} onClick={addSelectionToPinboard} > - Add selection to pinboard + Add selection to{" "} + ) )} From 2ea5fb9fcdb7eefc835c8b2ea26bc40bb0c885ad Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Wed, 23 Oct 2024 20:53:02 +0100 Subject: [PATCH 07/15] [newswires] use shadow-DOM to isolate styles --- client/src/newswires/newswiresIntegration.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index 355e2c45..303ca82a 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -8,6 +8,7 @@ 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"; const SELECTION_TARGET_DATA_ATTR = "[data-pinboard-selection-target]"; @@ -143,7 +144,7 @@ export const NewswiresIntegration = () => { /> {state && ReactDOM.createPortal( -
+ {[state.firstButtonPosition, state.lastButtonPosition].map( (buttonCoords, index) => ( ) )} -
, + , state.mountPoint )} From 17c196f0f605835378b29cae0121d16ef648bd6f Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Wed, 23 Oct 2024 20:55:06 +0100 Subject: [PATCH 08/15] [newswires] speed-up debounce on selection change --- client/src/newswires/newswiresIntegration.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index 303ca82a..a2820150 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -91,7 +91,7 @@ export const NewswiresIntegration = () => { const debouncedSelectionHandler = useMemo( () => () => { setState(null); // clear selection to hide buttons - debounce(handleSelectionChange, 750)(); + debounce(handleSelectionChange, 500)(); }, [handleSelectionChange] ); From 3da8728ba1a38eefe470981641b1ea9aa633ba7f Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Thu, 24 Oct 2024 08:53:34 +0100 Subject: [PATCH 09/15] [newswires] fix selection highlighting and improve distinction between add buttons and the selection itself --- client/src/newswires/newswiresIntegration.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index a2820150..53be7641 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -9,6 +9,7 @@ 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]"; @@ -54,7 +55,7 @@ export const NewswiresIntegration = () => { }; const newLastButtonCoords = { top: lastRect.y - parentRect.y + lastRect.height, - left: lastRect.x - parentRect.x + lastRect.width, + left: lastRect.x - parentRect.x + lastRect.width - 1, }; if (maybeClonedTargetEl) { console.log( @@ -136,7 +137,7 @@ export const NewswiresIntegration = () => { ${SELECTION_TARGET_DATA_ATTR} { position: relative; } - ${SELECTION_TARGET_DATA_ATTR}::selection { + ${SELECTION_TARGET_DATA_ATTR}::selection, ${SELECTION_TARGET_DATA_ATTR} ::selection { background-color: ${pinboard[500]}; color: ${pinMetal}; } @@ -164,6 +165,7 @@ export const NewswiresIntegration = () => { align-items: center; background-color: ${pinboard[500]}; ${textSans.xsmall({ fontWeight: "bold" })}; + box-shadow: ${boxShadow}; border: none; border-radius: 100px; border-${ @@ -175,7 +177,7 @@ export const NewswiresIntegration = () => { line-height: 2; cursor: pointer; color: ${pinMetal}; - text-wrap: nowrap; + text-wrap: nowrap; `} onClick={addSelectionToPinboard} > From 227241d7ec356df397b5ff8140dacbd7ee23a9b9 Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Thu, 24 Oct 2024 09:11:33 +0100 Subject: [PATCH 10/15] [newswires] set drag'n'drop MIME type to `text/html` not `text/plain` --- client/src/payloadDisplay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/payloadDisplay.tsx b/client/src/payloadDisplay.tsx index 0a2c0b3f..82d05f0b 100644 --- a/client/src/payloadDisplay.tsx +++ b/client/src/payloadDisplay.tsx @@ -70,7 +70,7 @@ export const PayloadDisplay = ({ // event.dataTransfer.setData("text/plain", "This is text to drag"); event.dataTransfer.setData( - "text/plain", + "text/html", sanitizeHtml(payloadAndType.payload.embeddableHtml) ); } else { From 1f58cef15d6753af3b1f22548a649ff0475af3bc Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Thu, 24 Oct 2024 09:54:51 +0100 Subject: [PATCH 11/15] [newswires] add `gu-note` when dropping snippet which includes any usage note (or fallback message) --- client/src/newswires/newswiresIntegration.tsx | 9 +++++---- client/src/payloadDisplay.tsx | 8 +++++++- client/src/types/PayloadAndType.ts | 1 + 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/client/src/newswires/newswiresIntegration.tsx b/client/src/newswires/newswiresIntegration.tsx index 53be7641..e791ef99 100644 --- a/client/src/newswires/newswiresIntegration.tsx +++ b/client/src/newswires/newswiresIntegration.tsx @@ -23,7 +23,7 @@ export const NewswiresIntegration = () => { const [state, setState] = useState<{ selectedHTML: string; - mountPoint: HTMLElement; + containerElement: HTMLElement; firstButtonPosition: ButtonPosition; lastButtonPosition: ButtonPosition; } | null>(null); @@ -64,7 +64,7 @@ export const NewswiresIntegration = () => { ); setState({ selectedHTML: maybeClonedTargetEl.innerHTML, - mountPoint: maybeOriginalTargetEl, + containerElement: maybeOriginalTargetEl, firstButtonPosition: newFirstButtonCoords, lastButtonPosition: newLastButtonCoords, }); @@ -80,7 +80,7 @@ export const NewswiresIntegration = () => { ); setState({ selectedHTML: tempEl.innerHTML, - mountPoint: maybeOriginalTargetEl, + containerElement: maybeOriginalTargetEl, firstButtonPosition: newFirstButtonCoords, lastButtonPosition: newLastButtonCoords, }); @@ -124,6 +124,7 @@ export const NewswiresIntegration = () => { payload: { embeddableHtml: state.selectedHTML, embeddableUrl: window.location.href, + maybeUsageNote: state.containerElement.dataset.usageNote, }, }); setIsExpanded(true); @@ -196,7 +197,7 @@ export const NewswiresIntegration = () => { ) )} , - state.mountPoint + state.containerElement )} ); diff --git a/client/src/payloadDisplay.tsx b/client/src/payloadDisplay.tsx index 82d05f0b..07632732 100644 --- a/client/src/payloadDisplay.tsx +++ b/client/src/payloadDisplay.tsx @@ -71,7 +71,13 @@ export const PayloadDisplay = ({ event.dataTransfer.setData( "text/html", - sanitizeHtml(payloadAndType.payload.embeddableHtml) + // TODO consider also add a gu-note for who shared it and when + `${sanitizeHtml( + payloadAndType.payload.embeddableHtml + )}
${ + payloadAndType.payload.maybeUsageNote || + "NO USAGE NOTE IN THE WIRE" + }` ); } else { event.dataTransfer.setData("URL", payload.embeddableUrl); diff --git a/client/src/types/PayloadAndType.ts b/client/src/types/PayloadAndType.ts index a898f09c..95aaef89 100644 --- a/client/src/types/PayloadAndType.ts +++ b/client/src/types/PayloadAndType.ts @@ -33,6 +33,7 @@ export interface PayloadWithApiUrl extends PayloadCommon { export interface PayloadWithSnippet extends PayloadCommon { embeddableHtml: string; + maybeUsageNote: string | undefined; } type Payload = From 86f779ebc6af682dc6adaaf1adc284fd91942a48 Mon Sep 17 00:00:00 2001 From: Tom Richards Date: Mon, 28 Oct 2024 10:17:45 +0000 Subject: [PATCH 12/15] [newswires] ensure 'add selection' buttons don't ever burst out of container, by detecting left vs right and shifting the position accordingly --- bootstrapping-lambda/local/index.html | 54 ++++++++++--------- client/src/newswires/newswiresIntegration.tsx | 50 ++++++++++------- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/bootstrapping-lambda/local/index.html b/bootstrapping-lambda/local/index.html index 4d5bda1b..c85862bd 100644 --- a/bootstrapping-lambda/local/index.html +++ b/bootstrapping-lambda/local/index.html @@ -21,32 +21,6 @@ -
-

not inside target

-

Not inside target

-
-

Pinboard selection target

-

- This is a target for the Pinboard library to render the selection - interface into. It will be hidden by the library when not in use. This - is a target for the Pinboard library to render the selection interface - into. It will be hidden by the library when not in use. This is a target - for the Pinboard library to render the selection interface into. It will - be hidden by the library when not in use. This is a target for the - Pinboard library to render the selection interface into. It will be - hidden by the library when not in use. This is a target for the Pinboard - library to render the selection interface into. It will be hidden by the - library when not in use. This is a target for the Pinboard library to - render the selection interface into. It will be hidden by the library - when not in use. -

-
-

not inside target

- -
Expand pinboard via query param ?expandPinboard=true @@ -175,6 +149,34 @@

presented after 1 second

and Pinboard detecting that and still taking over the element

+
+

Pinboard selection target

+
+

+ This is a target for the Pinboard library to render + the selection interface into. It will be hidden by the library when + not in use. This is a target for the Pinboard library to + render the selection interface into. It will be hidden by the library + when not in use. This is a target for the + Pinboard library to render the selection interface + into. It will be hidden by the library when not in use. +

+

+ This is a target for the Pinboard library to render the + selection interface into. It will be hidden by the library when not in + use. This is a target for the Pinboard library to + render the selection interface into. It will be hidden by the library + when not in use. This is a target for the Pinboard library to + render the selection interface into. It will be hidden by the library + when not in use. +

+
+
+ +