feat(clipper): Added "Clip as screenshot" switch to allow clipping content as a screenshot and save it to Files

This commit is contained in:
Aman Harwara
2023-05-05 17:03:13 +05:30
parent 74f8f9fafe
commit 0d3b7f7d94
10 changed files with 96 additions and 20 deletions

Binary file not shown.

View File

@@ -32,6 +32,7 @@
"webpack": "*" "webpack": "*"
}, },
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.2" "@mozilla/readability": "^0.4.2",
"html-to-image": "^1.11.11"
} }
} }

View File

@@ -1,9 +1,9 @@
import { runtime, action, browserAction, windows, storage } from 'webextension-polyfill' import { runtime, action, browserAction, windows, storage } from 'webextension-polyfill'
import { RuntimeMessage, RuntimeMessageTypes } from '../types/message' import { ClipPayload, RuntimeMessage, RuntimeMessageTypes } from '../types/message'
const isFirefox = navigator.userAgent.indexOf('Firefox/') !== -1 const isFirefox = navigator.userAgent.indexOf('Firefox/') !== -1
const openPopupAndClipSelection = async (payload: { title: string; content: string }) => { const openPopupAndClipSelection = async (payload: ClipPayload) => {
await storage.local.set({ clip: payload }) await storage.local.set({ clip: payload })
if (isFirefox) { if (isFirefox) {

View File

@@ -1,8 +1,10 @@
import { runtime } from 'webextension-polyfill' import { runtime } from 'webextension-polyfill'
import { Readability } from '@mozilla/readability' import { Readability } from '@mozilla/readability'
import { RuntimeMessage, RuntimeMessageTypes } from '../types/message' import { RuntimeMessage, RuntimeMessageTypes } from '../types/message'
import { toPng } from 'html-to-image'
let isSelectingNodeForClipping = false let isSelectingNodeForClipping = false
let isScreenshotMode = false
runtime.onMessage.addListener(async (message: RuntimeMessage) => { runtime.onMessage.addListener(async (message: RuntimeMessage) => {
switch (message.type) { switch (message.type) {
@@ -10,6 +12,10 @@ runtime.onMessage.addListener(async (message: RuntimeMessage) => {
isSelectingNodeForClipping = true isSelectingNodeForClipping = true
return return
} }
case RuntimeMessageTypes.ToggleScreenshotMode: {
isScreenshotMode = message.enabled
return
}
case RuntimeMessageTypes.HasSelection: { case RuntimeMessageTypes.HasSelection: {
const selection = window.getSelection() const selection = window.getSelection()
@@ -40,6 +46,11 @@ runtime.onMessage.addListener(async (message: RuntimeMessage) => {
return { title: document.title, content: result.innerHTML, url: window.location.href } return { title: document.title, content: result.innerHTML, url: window.location.href }
} }
case RuntimeMessageTypes.GetFullPage: { case RuntimeMessageTypes.GetFullPage: {
if (isScreenshotMode) {
const content = await toPng(document.body)
return { title: document.title, content: content, url: window.location.href, isScreenshot: true }
}
return { title: document.title, content: document.body.innerHTML, url: window.location.href } return { title: document.title, content: document.body.innerHTML, url: window.location.href }
} }
case RuntimeMessageTypes.GetArticle: { case RuntimeMessageTypes.GetArticle: {
@@ -90,7 +101,7 @@ const disableNodeSelection = () => {
nodeOverlayElement.style.visibility = 'hidden' nodeOverlayElement.style.visibility = 'hidden'
} }
window.addEventListener('click', (event) => { window.addEventListener('click', async (event) => {
if (!isSelectingNodeForClipping) { if (!isSelectingNodeForClipping) {
return return
} }
@@ -102,10 +113,10 @@ window.addEventListener('click', (event) => {
return return
} }
const title = document.title const title = document.title
const content = target.outerHTML const content = isScreenshotMode ? await toPng(target) : target.outerHTML
void runtime.sendMessage({ void runtime.sendMessage({
type: RuntimeMessageTypes.OpenPopupWithSelection, type: RuntimeMessageTypes.OpenPopupWithSelection,
payload: { title, content, url: window.location.href }, payload: { title, content, url: window.location.href, isScreenshot: isScreenshotMode },
} as RuntimeMessage) } as RuntimeMessage)
}) })

View File

@@ -5,6 +5,7 @@ export const RuntimeMessageTypes = {
GetFullPage: 'get-full-page', GetFullPage: 'get-full-page',
OpenPopupWithSelection: 'open-popup-with-selection', OpenPopupWithSelection: 'open-popup-with-selection',
StartNodeSelection: 'start-node-selection', StartNodeSelection: 'start-node-selection',
ToggleScreenshotMode: 'toggle-screenshot-mode',
} as const } as const
export type RuntimeMessageType = typeof RuntimeMessageTypes[keyof typeof RuntimeMessageTypes] export type RuntimeMessageType = typeof RuntimeMessageTypes[keyof typeof RuntimeMessageTypes]
@@ -15,6 +16,7 @@ export type ClipPayload = {
title: string title: string
content: string content: string
url: string url: string
isScreenshot?: boolean
} }
export type RuntimeMessageReturnTypes = { export type RuntimeMessageReturnTypes = {
@@ -24,6 +26,7 @@ export type RuntimeMessageReturnTypes = {
[RuntimeMessageTypes.GetFullPage]: ClipPayload [RuntimeMessageTypes.GetFullPage]: ClipPayload
[RuntimeMessageTypes.OpenPopupWithSelection]: void [RuntimeMessageTypes.OpenPopupWithSelection]: void
[RuntimeMessageTypes.StartNodeSelection]: void [RuntimeMessageTypes.StartNodeSelection]: void
[RuntimeMessageTypes.ToggleScreenshotMode]: void
} }
export type RuntimeMessage = export type RuntimeMessage =
@@ -32,5 +35,9 @@ export type RuntimeMessage =
payload: ClipPayload payload: ClipPayload
} }
| { | {
type: Exclude<RuntimeMessageType, MessagesWithClipPayload> type: typeof RuntimeMessageTypes.ToggleScreenshotMode
enabled: boolean
}
| {
type: Exclude<RuntimeMessageType, MessagesWithClipPayload | typeof RuntimeMessageTypes.ToggleScreenshotMode>
} }

View File

@@ -1,14 +1,14 @@
import { tabs } from 'webextension-polyfill' import { tabs } from 'webextension-polyfill'
import { RuntimeMessageReturnTypes, RuntimeMessageType } from '../types/message' import { RuntimeMessage, RuntimeMessageReturnTypes } from '../types/message'
export default async function sendMessageToActiveTab<T extends RuntimeMessageType>( export default async function sendMessageToActiveTab<T extends RuntimeMessage>(
type: T, message: T,
): Promise<RuntimeMessageReturnTypes[T] | undefined> { ): Promise<RuntimeMessageReturnTypes[T['type']] | undefined> {
const [activeTab] = await tabs.query({ active: true, currentWindow: true, windowType: 'normal' }) const [activeTab] = await tabs.query({ active: true, currentWindow: true, windowType: 'normal' })
if (!activeTab || !activeTab.id) { if (!activeTab || !activeTab.id) {
return return
} }
return await tabs.sendMessage(activeTab.id, { type }) return await tabs.sendMessage(activeTab.id, message)
} }

View File

@@ -200,6 +200,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
applicationGroup={mainApplicationGroup} applicationGroup={mainApplicationGroup}
/> />
<ToastContainer /> <ToastContainer />
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
{renderChallenges()} {renderChallenges()}
</FileDragNDropProvider> </FileDragNDropProvider>
</LinkingControllerProvider> </LinkingControllerProvider>

View File

@@ -36,6 +36,7 @@ import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
import ItemSelectionDropdown from '../ItemSelectionDropdown/ItemSelectionDropdown' import ItemSelectionDropdown from '../ItemSelectionDropdown/ItemSelectionDropdown'
import LinkedItemBubble from '../LinkedItems/LinkedItemBubble' import LinkedItemBubble from '../LinkedItems/LinkedItemBubble'
import StyledTooltip from '../StyledTooltip/StyledTooltip' import StyledTooltip from '../StyledTooltip/StyledTooltip'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
const Header = () => ( const Header = () => (
<div className="flex items-center border-b border-border p-1 px-3 py-2 text-base font-semibold text-info-contrast"> <div className="flex items-center border-b border-border p-1 px-3 py-2 text-base font-semibold text-info-contrast">
@@ -135,6 +136,14 @@ const ClipperView = ({
} }
}, [application.user]) }, [application.user])
const [isScreenshotMode, setIsScreenshotMode] = useState(false)
useEffect(() => {
void sendMessageToActiveTab({
type: RuntimeMessageTypes.ToggleScreenshotMode,
enabled: isScreenshotMode,
})
}, [isScreenshotMode])
const [hasSelection, setHasSelection] = useState(false) const [hasSelection, setHasSelection] = useState(false)
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
@@ -143,7 +152,7 @@ const ClipperView = ({
try { try {
const checkIfPageHasSelection = async () => { const checkIfPageHasSelection = async () => {
setHasSelection(Boolean(await sendMessageToActiveTab(RuntimeMessageTypes.HasSelection))) setHasSelection(Boolean(await sendMessageToActiveTab({ type: RuntimeMessageTypes.HasSelection })))
} }
void checkIfPageHasSelection() void checkIfPageHasSelection()
@@ -188,6 +197,17 @@ const ClipperView = ({
}) })
return return
} }
if (clipPayload.isScreenshot) {
const blob = await fetch(clipPayload.content).then((response) => response.blob())
const file = new File([blob], `${clipPayload.title} - ${clipPayload.url}.png`, {
type: 'image/png',
})
viewControllerManager.filesController.uploadNewFile(file).catch(console.error)
return
}
const editorStateJSON = await getSuperJSONFromClipPayload(clipPayload) const editorStateJSON = await getSuperJSONFromClipPayload(clipPayload)
@@ -216,7 +236,15 @@ const ClipperView = ({
} }
void createNoteFromClip() void createNoteFromClip()
}, [application.items, application.linkingController, application.sync, clipPayload, defaultTag, isEntitledRef]) }, [
application.items,
application.linkingController,
application.sync,
clipPayload,
defaultTag,
isEntitledRef,
viewControllerManager.filesController,
])
const upgradePlan = useCallback(async () => { const upgradePlan = useCallback(async () => {
if (hasSubscription) { if (hasSubscription) {
@@ -307,18 +335,25 @@ const ClipperView = ({
<Menu a11yLabel="Extension menu" isOpen={true} className="pb-1"> <Menu a11yLabel="Extension menu" isOpen={true} className="pb-1">
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetFullPage) const payload = await sendMessageToActiveTab({ type: RuntimeMessageTypes.GetFullPage })
if (!payload) { if (!payload) {
return return
} }
setClipPayload(payload) setClipPayload(payload)
if (isScreenshotMode) {
addToast({
type: ToastType.Regular,
message: 'Capturing full page...',
})
}
}} }}
> >
Clip full page Clip full page
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={isScreenshotMode}
onClick={async () => { onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetArticle) const payload = await sendMessageToActiveTab({ type: RuntimeMessageTypes.GetArticle })
if (!payload) { if (!payload) {
return return
} }
@@ -328,25 +363,34 @@ const ClipperView = ({
Clip article Clip article
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!hasSelection} disabled={!hasSelection || isScreenshotMode}
onClick={async () => { onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetSelection) const payload = await sendMessageToActiveTab({ type: RuntimeMessageTypes.GetSelection })
if (!payload) { if (!payload) {
return return
} }
setClipPayload(payload) setClipPayload(payload)
}} }}
> >
Clip current selection Clip text selection
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={async () => { onClick={async () => {
void sendMessageToActiveTab(RuntimeMessageTypes.StartNodeSelection) void sendMessageToActiveTab({ type: RuntimeMessageTypes.StartNodeSelection })
window.close() window.close()
}} }}
> >
Select elements to clip Select elements to clip
</MenuItem> </MenuItem>
<MenuSwitchButtonItem
checked={isScreenshotMode}
onChange={function (checked: boolean): void {
setIsScreenshotMode(checked)
}}
className="flex-row-reverse gap-2"
>
Clip as screenshot
</MenuSwitchButtonItem>
<div className="border-t border-border px-3 py-3 text-base text-foreground"> <div className="border-t border-border px-3 py-3 text-base text-foreground">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="font-medium">Default tag:</div> <div className="font-medium">Default tag:</div>

View File

@@ -29,6 +29,10 @@ export const getSuperJSONFromClipPayload = async (clipPayload: ClipPayload) => {
$getRoot().select() $getRoot().select()
$insertNodes(clipSourceParagraphNode) $insertNodes(clipSourceParagraphNode)
if (typeof clipPayload.content !== 'string') {
throw new Error('Clip payload content is not a string')
}
const dom = parser.parseFromString(clipPayload.content, 'text/html') const dom = parser.parseFromString(clipPayload.content, 'text/html')
const generatedNodes = $generateNodesFromDOM(editor, dom) const generatedNodes = $generateNodesFromDOM(editor, dom)
const nodesToInsert: LexicalNode[] = [] const nodesToInsert: LexicalNode[] = []

View File

@@ -4709,6 +4709,7 @@ __metadata:
copy-webpack-plugin: 11.0.0 copy-webpack-plugin: 11.0.0
eslint: ^8.29.0 eslint: ^8.29.0
eslint-config-prettier: ^8.5.0 eslint-config-prettier: ^8.5.0
html-to-image: ^1.11.11
ts-loader: ^9.4.2 ts-loader: ^9.4.2
typescript: "*" typescript: "*"
web-ext: ^7.5.0 web-ext: ^7.5.0
@@ -13712,6 +13713,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"html-to-image@npm:^1.11.11":
version: 1.11.11
resolution: "html-to-image@npm:1.11.11"
checksum: b453beca72a697bf06fae4945e5460d1d9b1751e8569a0d721dda9485df1dde093938cc9bd9172b8df5fc23133a53a4d619777b3d22f7211cd8a67e3197ab4e8
languageName: node
linkType: hard
"htmlparser2@npm:^8.0.1": "htmlparser2@npm:^8.0.1":
version: 8.0.1 version: 8.0.1
resolution: "htmlparser2@npm:8.0.1" resolution: "htmlparser2@npm:8.0.1"