chore: add clipper extension package (#2281)
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user