chore: add clipper extension package (#2281)

This commit is contained in:
Aman Harwara
2023-04-11 22:14:02 +05:30
committed by GitHub
parent 0b0466c9fa
commit 4f5e634685
214 changed files with 3163 additions and 355 deletions

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { confirmDialog } from '@standardnotes/ui-services'
import { BlocksEditorComposer } from '../SuperEditor/BlocksEditorComposer'
import { BlocksEditor } from '../SuperEditor/BlocksEditor'
import { SNNote } from '@standardnotes/snjs'
import { NoteSyncController } from '@/Controllers/NoteSyncController'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import { LinkingController } from '@/Controllers/LinkingController'
import Button from '../Button/Button'
import Spinner from '../Spinner/Spinner'
const ClippedNoteView = ({
note,
linkingController,
clearClip,
isFirefoxPopup,
}: {
note: SNNote
linkingController: LinkingController
clearClip: () => void
isFirefoxPopup: boolean
}) => {
const application = useApplication()
const syncController = useRef(new NoteSyncController(application, note))
useEffect(() => {
const currentController = syncController.current
return () => {
currentController.deinit()
}
}, [])
const [title, setTitle] = useState(() => note.title)
useEffect(() => {
void syncController.current.saveAndAwaitLocalPropagation({
title,
isUserModified: true,
dontGeneratePreviews: true,
})
}, [application.items, title])
const handleChange = useCallback(async (value: string, preview: string) => {
void syncController.current.saveAndAwaitLocalPropagation({
text: value,
isUserModified: true,
previews: {
previewPlain: preview,
previewHtml: undefined,
},
})
}, [])
const [isDiscarding, setIsDiscarding] = useState(false)
const discardNote = useCallback(async () => {
if (
await confirmDialog({
text: 'Are you sure you want to discard this clip?',
confirmButtonText: 'Discard',
confirmButtonStyle: 'danger',
})
) {
setIsDiscarding(true)
application.mutator
.deleteItem(note)
.then(() => {
if (isFirefoxPopup) {
window.close()
}
clearClip()
})
.catch(console.error)
.finally(() => setIsDiscarding(false))
}
}, [application.mutator, clearClip, isFirefoxPopup, note])
return (
<div className="">
<div className="border-b border-border p-3">
<div className="mb-3 flex w-full items-center gap-3">
{!isFirefoxPopup && (
<Button className="flex items-center justify-center" fullWidth onClick={clearClip} disabled={isDiscarding}>
<Icon type="arrow-left" className="mr-2" />
Back
</Button>
)}
<Button
className="flex items-center justify-center"
fullWidth
primary
colorStyle="danger"
onClick={discardNote}
disabled={isDiscarding}
>
{isDiscarding ? (
<Spinner className="h-6 w-6 text-danger-contrast" />
) : (
<>
<Icon type="trash-filled" className="mr-2" />
Discard
</>
)}
</Button>
</div>
<input
className="w-full text-base font-semibold"
type="text"
value={title}
onChange={(event) => {
setTitle(event.target.value)
}}
/>
<LinkedItemBubblesContainer linkingController={linkingController} item={note} hideToggle />
</div>
<div className="p-3">
<BlocksEditorComposer initialValue={note.text}>
<BlocksEditor onChange={handleChange}></BlocksEditor>
</BlocksEditorComposer>
</div>
</div>
)
}
export default ClippedNoteView

View File

@@ -0,0 +1,327 @@
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { SNLogoFull } from '@standardnotes/icons'
import { useCallback, useEffect, useState } from 'react'
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
import MenuPaneSelector from '../AccountMenu/MenuPaneSelector'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import Menu from '../Menu/Menu'
import MenuItem from '../Menu/MenuItem'
import { storage as extensionStorage, windows } from 'webextension-polyfill'
import sendMessageToActiveTab from '@standardnotes/clipper/src/utils/sendMessageToActiveTab'
import { ClipPayload, RuntimeMessageTypes } from '@standardnotes/clipper/src/types/message'
import { confirmDialog } from '@standardnotes/ui-services'
import {
ApplicationEvent,
ContentType,
FeatureIdentifier,
FeatureStatus,
NoteContent,
NoteType,
SNNote,
} from '@standardnotes/snjs'
import { addToast, ToastType } from '@standardnotes/toast'
import { getSuperJSONFromClipPayload } from './getSuperJSONFromClipHTML'
import ClippedNoteView from './ClippedNoteView'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import Button from '../Button/Button'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
import { useStateRef } from '@/Hooks/useStateRef'
const Header = () => (
<div className="flex items-center border-b border-border p-1 px-3 py-2 text-base font-semibold text-info-contrast">
<SNLogoFull className="h-7" />
</div>
)
const ClipperView = ({
viewControllerManager,
applicationGroup,
}: {
viewControllerManager: ViewControllerManager
applicationGroup: ApplicationGroup
}) => {
const application = useApplication()
const [currentWindow, setCurrentWindow] = useState<Awaited<ReturnType<typeof windows.getCurrent>>>()
useEffect(() => {
windows
.getCurrent({
populate: true,
})
.then((window) => {
setCurrentWindow(window)
})
.catch(console.error)
}, [])
const isFirefoxPopup = !!currentWindow && currentWindow.type === 'popup' && currentWindow.incognito === false
const [user, setUser] = useState(() => application.getUser())
const [isEntitledToExtension, setIsEntitled] = useState(
() => application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled,
)
const isEntitledRef = useStateRef(isEntitledToExtension)
const hasSubscription = application.hasValidSubscription()
useEffect(() => {
return application.addEventObserver(async (event) => {
switch (event) {
case ApplicationEvent.SignedIn:
case ApplicationEvent.SignedOut:
case ApplicationEvent.UserRolesChanged:
setUser(application.getUser())
setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled)
break
case ApplicationEvent.FeaturesUpdated:
setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled)
break
}
})
}, [application])
const [menuPane, setMenuPane] = useState<AccountMenuPane>()
const activateRegisterPane = useCallback(() => {
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
const activateSignInPane = useCallback(() => {
setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane])
const showSignOutConfirmation = useCallback(async () => {
if (
await confirmDialog({
title: 'Sign Out',
text: 'Are you sure you want to sign out?',
confirmButtonText: 'Sign Out',
confirmButtonStyle: 'danger',
cancelButtonText: 'Cancel',
})
) {
await application.user.signOut()
}
}, [application.user])
const [hasSelection, setHasSelection] = useState(false)
useEffect(() => {
if (!user) {
return
}
try {
const checkIfPageHasSelection = async () => {
setHasSelection(Boolean(await sendMessageToActiveTab(RuntimeMessageTypes.HasSelection)))
}
void checkIfPageHasSelection()
} catch (error) {
console.error(error)
}
}, [user])
const [clipPayload, setClipPayload] = useState<ClipPayload>()
useEffect(() => {
const getClipFromStorage = async () => {
const result = await extensionStorage.local.get('clip')
if (!result.clip) {
return
}
setClipPayload(result.clip)
void extensionStorage.local.remove('clip')
}
void getClipFromStorage()
}, [])
const clearClip = useCallback(() => {
setClipPayload(undefined)
}, [])
const [clippedNote, setClippedNote] = useState<SNNote>()
useEffect(() => {
if (!isEntitledRef.current) {
return
}
async function createNoteFromClip() {
if (!clipPayload) {
setClippedNote(undefined)
return
}
if (!clipPayload.content) {
addToast({
type: ToastType.Error,
message: 'No content to clip',
})
return
}
const editorStateJSON = await getSuperJSONFromClipPayload(clipPayload)
const note = application.items.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
title: clipPayload.title,
text: editorStateJSON,
editorIdentifier: FeatureIdentifier.SuperEditor,
noteType: NoteType.Super,
references: [],
})
void application.items.insertItem(note).then((note) => {
setClippedNote(note as SNNote)
addToast({
type: ToastType.Success,
message: 'Note clipped successfully',
})
void application.sync.sync()
})
}
void createNoteFromClip()
}, [application.items, application.sync, clipPayload, isEntitledRef])
const upgradePlan = useCallback(async () => {
if (hasSubscription) {
await openSubscriptionDashboard(application)
} else {
await application.openPurchaseFlow()
}
window.close()
}, [application, hasSubscription])
if (user && !isEntitledToExtension) {
return (
<>
<Header />
<div className="px-3 py-3">
<div
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
aria-hidden={true}
>
<Icon className={`h-12 w-12 ${PremiumFeatureIconClass}`} size={'custom'} type={PremiumFeatureIconName} />
</div>
<div className="mb-1 text-center text-lg font-bold">Enable Advanced Features</div>
<div className="mb-3 text-center">
To take advantage of <span className="font-semibold">Web Clipper</span> and other advanced features, upgrade
your current plan.
</div>
<Button className="mb-2" fullWidth primary onClick={upgradePlan}>
Upgrade
</Button>
<Button fullWidth onClick={showSignOutConfirmation}>
Sign out
</Button>
</div>
</>
)
}
if (clippedNote) {
return (
<>
<Header />
<ClippedNoteView
note={clippedNote}
key={clippedNote.uuid}
linkingController={viewControllerManager.linkingController}
clearClip={clearClip}
isFirefoxPopup={isFirefoxPopup}
/>
</>
)
}
if (!user) {
return (
<>
<Header />
{menuPane ? (
<div className="py-1">
<MenuPaneSelector
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={applicationGroup}
menuPane={menuPane}
setMenuPane={setMenuPane}
closeMenu={() => setMenuPane(undefined)}
/>
</div>
) : (
<Menu a11yLabel="User account menu" isOpen={true}>
<MenuItem onClick={activateRegisterPane}>
<Icon type="user" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
Create free account
</MenuItem>
<MenuItem onClick={activateSignInPane}>
<Icon type="signIn" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
Sign in
</MenuItem>
</Menu>
)}
</>
)
}
return (
<>
<Header />
<div>
<Menu a11yLabel="Extension menu" isOpen={true} className="pb-1">
<MenuItem
onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetFullPage)
if (!payload) {
return
}
setClipPayload(payload)
}}
>
Clip full page
</MenuItem>
<MenuItem
onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetArticle)
if (!payload) {
return
}
setClipPayload(payload)
}}
>
Clip article
</MenuItem>
<MenuItem
disabled={!hasSelection}
onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetSelection)
if (!payload) {
return
}
setClipPayload(payload)
}}
>
Clip current selection
</MenuItem>
<MenuItem
onClick={async () => {
void sendMessageToActiveTab(RuntimeMessageTypes.StartNodeSelection)
window.close()
}}
>
Select elements to clip
</MenuItem>
<div className="border-t border-border px-3 pt-3 pb-1 text-base text-foreground">
<div>You're signed in as:</div>
<div className="wrap my-0.5 font-bold">{user.email}</div>
<span className="text-neutral">{application.getHost()}</span>
</div>
<MenuItem onClick={showSignOutConfirmation}>
<Icon type="signOut" className="mr-2 h-6 w-6 text-neutral" />
Sign out
</MenuItem>
</Menu>
</div>
</>
)
}
export default ClipperView

View File

@@ -0,0 +1,59 @@
import { $createParagraphNode, $getRoot, $insertNodes, LexicalNode } from 'lexical'
import { $generateNodesFromDOM } from '../SuperEditor/Lexical/Utils/generateNodesFromDOM'
import { createHeadlessEditor } from '@lexical/headless'
import { BlockEditorNodes } from '../SuperEditor/Lexical/Nodes/AllNodes'
import BlocksEditorTheme from '../SuperEditor/Lexical/Theme/Theme'
import { ClipPayload } from '@standardnotes/clipper/src/types/message'
export const getSuperJSONFromClipPayload = async (clipPayload: ClipPayload) => {
const editor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...BlockEditorNodes],
})
await new Promise<void>((resolve) => {
editor.update(() => {
const parser = new DOMParser()
const clipSourceDOM = parser.parseFromString(
`<p>Clip source: <a href="${clipPayload.url}">${clipPayload.url}</a></p>`,
'text/html',
)
const clipSourceParagraphNode = $generateNodesFromDOM(editor, clipSourceDOM).concat(
$createParagraphNode(),
$createParagraphNode(),
)
$getRoot().select()
$insertNodes(clipSourceParagraphNode)
const dom = parser.parseFromString(clipPayload.content, 'text/html')
const generatedNodes = $generateNodesFromDOM(editor, dom)
const nodesToInsert: LexicalNode[] = []
generatedNodes.forEach((node) => {
const type = node.getType()
// Wrap text & link nodes with paragraph since they can't
// be top-level nodes in Super
if (type === 'text' || type === 'link') {
const paragraphNode = $createParagraphNode()
paragraphNode.append(node)
nodesToInsert.push(paragraphNode)
return
} else {
nodesToInsert.push(node)
}
nodesToInsert.push($createParagraphNode())
})
$getRoot().selectEnd()
$insertNodes(nodesToInsert.concat($createParagraphNode()))
resolve()
})
})
return JSON.stringify(editor.getEditorState().toJSON())
}