Merge pull request #2814 from omnivore-app/feat/extension-create-labels

Allow creating labels from the extension
This commit is contained in:
Jackson Harper
2023-09-30 14:07:04 +08:00
committed by GitHub
18 changed files with 1422 additions and 309 deletions

View File

@ -160,3 +160,9 @@ dependencies {
apollo {
packageName.set 'app.omnivore.omnivore.graphql.generated'
}
task printVersion {
doLast {
println "omnivoreVersion: ${android.defaultConfig.versionName}"
}
}

View File

@ -194,12 +194,13 @@ export const Button = styled('button', {
},
},
link: {
color: '$grayText',
border: 'none',
bg: 'transparent',
'&:hover': {
opacity: 0.8,
},
fontSize: '14px',
fontWeight: 'regular',
fontFamily: '$display',
color: '$thLibraryMenuUnselected',
cursor: 'pointer',
},
circularIcon: {
mx: '$1',

View File

@ -25,16 +25,21 @@ export interface FormInputProps {
}
export const FormInput = styled('input', {
border: 'none',
border: '1px solid $textNonessential',
width: '100%',
bg: 'transparent',
fontSize: '16px',
fontFamily: 'inter',
fontWeight: 'normal',
lineHeight: '1.35',
borderRadius: '5px',
textIndent: '8px',
marginBottom: '2px',
height: '38px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
border: '1px solid transparent',
outline: '2px solid $omnivoreCtaYellow',
},
})
@ -63,6 +68,10 @@ export const BorderedFormInput = styled(FormInput, {
borderColor: '#d9d9d9',
borderRadius: '6px',
transition: 'all .2s',
'&:focus': {
border: '1px solid transparent',
outline: '2px solid $omnivoreCtaYellow',
},
})
export function GeneralFormInput(props: FormInputProps): JSX.Element {
@ -170,7 +179,7 @@ export function GeneralFormInput(props: FormInputProps): JSX.Element {
required={input.required}
css={{
border: '1px solid $textNonessential',
borderRadius: '8px',
borderRadius: '5px',
width: '100%',
bg: 'transparent',
fontSize: '16px',
@ -179,8 +188,8 @@ export function GeneralFormInput(props: FormInputProps): JSX.Element {
height: '38px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
border: '1px solid transparent',
outline: '2px solid $omnivoreCtaYellow',
},
}}
name={input.name}

View File

@ -146,7 +146,6 @@ const textVariants = {
},
navLink: {
m: 0,
fontSize: '$1',
fontWeight: 400,
color: '$graySolid',
cursor: 'pointer',

View File

@ -106,6 +106,10 @@ export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
cursor: 'pointer',
mouseEvents: 'all',
}}
onClick={(event) => {
router.push('/settings/account')
event.preventDefault()
}}
>
<Avatar
imageURL={viewerData.me.profile.pictureUrl}

View File

@ -1,4 +1,4 @@
import { Box, VStack } from '../elements/LayoutPrimitives'
import { Box, HStack, VStack } from '../elements/LayoutPrimitives'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { SettingsHeader } from '../patterns/SettingsHeader'
import { navigationCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
@ -13,6 +13,7 @@ import { PageMetaData } from '../patterns/PageMetaData'
import { HEADER_HEIGHT } from './homeFeed/HeaderSpacer'
import { deinitAnalytics } from '../../lib/analytics'
import { logout } from '../../lib/logout'
import { SettingsMenu } from './SettingsMenu'
type SettingsLayoutProps = {
title?: string
@ -51,7 +52,10 @@ export function SettingsLayout(props: SettingsLayoutProps): JSX.Element {
height: HEADER_HEIGHT,
}}
></Box>
{props.children}
<HStack css={{ width: '100%', height: '100%' }}>
<SettingsMenu />
{props.children}
</HStack>
<Box css={{ height: '120px', width: '100%' }} />
</VStack>
{showLogoutConfirmation ? (

View File

@ -0,0 +1,231 @@
import { useMemo } from 'react'
import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives'
import { LIBRARY_LEFT_MENU_WIDTH } from './homeFeed/LibraryFilterMenu'
import { LogoBox } from '../elements/LogoBox'
import Link from 'next/link'
import { styled, theme } from '../tokens/stitches.config'
import { Button } from '../elements/Button'
import { ArrowSquareUpRight } from 'phosphor-react'
import { useRouter } from 'next/router'
const HorizontalDivider = styled(SpanBox, {
width: '100%',
height: '1px',
my: '25px',
background: `${theme.colors.grayLine.toString()}`,
})
const StyledLink = styled(SpanBox, {
pl: '25px',
ml: '10px',
mb: '10px',
display: 'flex',
alignItems: 'center',
gap: '2px',
'&:hover': {
textDecoration: 'underline',
},
width: 'calc(100% - 10px)',
maxWidth: '100%',
height: '32px',
fontSize: '14px',
fontWeight: 'regular',
fontFamily: '$display',
color: '$thLibraryMenuUnselected',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
})
export function SettingsMenu(): JSX.Element {
const section1 = [
{ name: 'Account', destination: '/settings/account' },
{ name: 'API Keys', destination: '/settings/api' },
{ name: 'Emails', destination: '/settings/emails' },
{ name: 'Feeds', destination: '/settings/feeds' },
{ name: 'Subscriptions', destination: '/settings/subscriptions' },
{ name: 'Labels', destination: '/settings/labels' },
]
const section2 = [
{ name: 'Integrations', destination: '/settings/integrations' },
{ name: 'Install', destination: '/settings/installation' },
]
return (
<>
<Box
css={{
left: '0px',
top: '0px',
position: 'fixed',
bg: '$thLeftMenuBackground',
height: '100%',
width: LIBRARY_LEFT_MENU_WIDTH,
overflowY: 'auto',
overflowX: 'hidden',
'&::-webkit-scrollbar': {
display: 'none',
},
'@mdDown': {
visibility: 'hidden',
width: '100%',
transition: 'visibility 0s, top 150ms',
},
zIndex: 3,
}}
>
<Box
css={{
width: '100%',
px: '25px',
pb: '50px',
pt: '4.5px',
lineHeight: '1',
}}
>
<LogoBox />
</Box>
<VStack
css={{
gap: '10px',
width: '100%',
}}
distribution="start"
alignment="start"
>
{section1.map((item) => {
return <SettingsButton key={item.name} {...item} />
})}
<HorizontalDivider />
{section2.map((item) => {
return <SettingsButton key={item.name} {...item} />
})}
<HorizontalDivider />
<StyledLink>
<Button
style="link"
onClick={(event) => {
if (window.Intercom) {
window.Intercom('show')
}
event.preventDefault()
}}
>
Feedback
</Button>
</StyledLink>
<StyledLink
css={{
'> a': {
backgroundColor: 'transparent',
textDecoration: 'none',
},
}}
>
<a
href="https://docs.omnivore.app"
target="_blank"
rel="noreferrer"
>
<HStack
distribution="start"
alignment="center"
css={{
gap: '5px',
color: '$thLibraryMenuUnselected',
'&:hover': {
textDecoration: 'underline',
},
}}
>
Documentation
<ArrowSquareUpRight size={12} />
</HStack>
</a>
</StyledLink>
</VStack>
</Box>
{/* This spacer pushes library content to the right of
the fixed left side menu. */}
<Box
css={{
minWidth: LIBRARY_LEFT_MENU_WIDTH,
height: '100%',
bg: '$thBackground',
'@mdDown': {
display: 'none',
},
}}
></Box>
</>
)
}
type SettingsButtonProps = {
name: string
destination: string
}
function SettingsButton(props: SettingsButtonProps): JSX.Element {
const router = useRouter()
const selected = useMemo(() => {
if (router && router.isReady) {
return router.asPath.endsWith(props.destination)
}
return false
}, [props, router])
return (
<Link href={props.destination} passHref title={props.name}>
<SpanBox
css={{
mx: '10px',
pl: '25px',
display: 'flex',
alignItems: 'center',
gap: '2px',
width: 'calc(100% - 20px)',
maxWidth: '100%',
height: '32px',
fontSize: '14px',
fontWeight: 'regular',
fontFamily: '$display',
verticalAlign: 'middle',
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: selected ? '$thLibrarySelectionColor' : 'unset',
color: selected
? '$thLibraryMenuSecondary'
: '$thLibraryMenuUnselected',
'&:hover': {
textDecoration: 'underline',
backgroundColor: selected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
'&:active': {
backgroundColor: selected
? '$thLibrarySelectionColor'
: '$thBackground4',
},
}}
>
{props.name}
</SpanBox>
</Link>
)
}

View File

@ -0,0 +1,37 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { State } from '../fragments/articleFragment'
export type UpdateUserInput = {
name: string
bio: string
}
export async function updateUserMutation(
input: UpdateUserInput
): Promise<string | undefined> {
const mutation = gql`
mutation UpdateUser($input: UpdateUserInput!) {
updateUser(input: $input) {
... on UpdateUserSuccess {
user {
name
}
}
... on UpdateUserError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, {
input,
})
const output = data as any
return output.updateUser.user.name
} catch (err) {
return undefined
}
}

View File

@ -0,0 +1,40 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
import { State } from '../fragments/articleFragment'
export type UpdateUserProfileInput = {
userId: string
username: string
}
export async function updateUserProfileMutation(
input: UpdateUserProfileInput
): Promise<string | undefined> {
const mutation = gql`
mutation UpdateUserProfile($input: UpdateUserProfileInput!) {
updateUserProfile(input: $input) {
... on UpdateUserProfileSuccess {
user {
profile {
username
}
}
}
... on UpdateUserProfileError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, {
input,
})
const output = data as any
console.log('output: ', output)
return output.updateUserProfile.user.profile.username
} catch (err) {
return undefined
}
}

View File

@ -7,6 +7,7 @@ type ValidateUsernameInput = {
}
type ValidateUsernameResponse = {
isLoading: boolean
isUsernameValid: boolean
usernameErrorMessage?: string
}
@ -20,12 +21,19 @@ export function useValidateUsernameQuery({
}
`
const { data } = useSWR([query, username], makePublicGqlFetcher({ username }))
// Don't fetch if username is empty
const { data, error, isValidating } = useSWR(
username ? [query, username] : null,
makePublicGqlFetcher({ username })
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isUsernameValid = (data as any)?.validateUsername ?? false
if (isUsernameValid) {
return { isUsernameValid }
return {
isUsernameValid,
isLoading: !data && !error,
}
}
// Try to figure out why the username is invalid
@ -33,12 +41,14 @@ export function useValidateUsernameQuery({
if (usernameErrorMessage) {
return {
isUsernameValid: false,
isLoading: !data && !error,
usernameErrorMessage,
}
}
return {
isUsernameValid: false,
isLoading: !data && !error,
usernameErrorMessage: 'This username is not available',
}
}
@ -48,8 +58,8 @@ function validationErrorMessage(username: string): string | undefined {
return undefined
}
if (username.length < 3) {
return 'Username should contain at least three characters'
if (username.length < 4) {
return 'Username should contain at least four characters'
}
if (username.length > 15) {

View File

@ -0,0 +1,257 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { FormInput } from '../../components/elements/FormElements'
import { StyledText } from '../../components/elements/StyledText'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Toaster } from 'react-hot-toast'
import {
Box,
SpanBox,
VStack,
} from '../../components/elements/LayoutPrimitives'
import { Button } from '../../components/elements/Button'
import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery'
import { updateUserMutation } from '../../lib/networking/mutations/updateUserMutation'
import { updateUserProfileMutation } from '../../lib/networking/mutations/updateUserProfileMutation'
import { styled } from '../../components/tokens/stitches.config'
const StyledLabel = styled('label', {
fontWeight: 600,
fontSize: '16px',
})
export default function Account(): JSX.Element {
const { viewerData } = useGetViewerQuery()
const [name, setName] = useState('')
const [username, setUsername] = useState('')
const [nameUpdating, setNameUpdating] = useState(false)
const [usernameUpdating, setUsernameUpdating] = useState(false)
const [debouncedUsername, setDebouncedUsername] = useState('')
const { usernameErrorMessage, isLoading: isUsernameValidationLoading } =
useValidateUsernameQuery({
username: debouncedUsername,
})
const usernameEdited = useMemo(() => {
return username !== viewerData?.me?.profile.username
}, [username, viewerData])
const usernameError = useMemo(() => {
return (
usernameEdited &&
username.length > 0 &&
usernameErrorMessage &&
!isUsernameValidationLoading
)
}, [
usernameEdited,
username,
usernameErrorMessage,
isUsernameValidationLoading,
])
useEffect(() => {
if (viewerData?.me?.profile.username) {
setUsername(viewerData?.me?.profile.username)
}
}, [viewerData?.me?.profile.username])
useEffect(() => {
if (viewerData?.me?.name) {
setName(viewerData?.me?.name)
}
}, [viewerData?.me?.name])
const handleUsernameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
setUsername(event.target.value)
setTimeout(() => {
if (event.target.value) {
setDebouncedUsername(event.target.value)
}
}, 2000)
event.preventDefault()
},
[]
)
const handleUpdateName = useCallback(() => {
setNameUpdating(true)
;(async () => {
const updatedName = await updateUserMutation({ name, bio: '' })
if (updatedName) {
setName(updatedName)
showSuccessToast('Name updated')
} else {
showErrorToast('Error updating name')
}
setNameUpdating(false)
})()
}, [name, nameUpdating, setName, setNameUpdating])
const handleUpdateUsername = useCallback(() => {
setUsernameUpdating(true)
const userId = viewerData?.me?.id
if (!userId) {
showErrorToast('Error updating user info')
return
}
;(async () => {
const updatedUsername = await updateUserProfileMutation({
userId,
username,
})
if (updatedUsername) {
setUsername(updatedUsername)
setDebouncedUsername(updatedUsername)
showSuccessToast('Username updated')
} else {
showErrorToast('Error updating username')
}
setUsernameUpdating(false)
})()
}, [
username,
usernameUpdating,
setUsername,
setUsernameUpdating,
viewerData?.me,
])
applyStoredTheme(false)
return (
<SettingsLayout>
<Toaster
containerStyle={{
top: '5rem',
}}
/>
<VStack
css={{ width: '100%', height: '100%' }}
distribution="start"
alignment="center"
>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
gap: '25px',
minWidth: '300px',
maxWidth: '865px',
}}
>
<Box>
<StyledText style="fixedHeadline" css={{ my: '6px' }}>
Account Details
</StyledText>
</Box>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '5px',
borderRadius: '5px',
}}
distribution="start"
alignment="start"
>
<form
onSubmit={(event) => {
handleUpdateName()
event.preventDefault()
}}
>
<StyledLabel>Name</StyledLabel>
<FormInput
type={'text'}
value={name}
tabIndex={1}
placeholder={'Name'}
disabled={nameUpdating}
onChange={(event) => {
setName(event.target.value)
event.preventDefault()
}}
/>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
Your name is displayed on your profile and is used when
communicating with you.
</StyledText>
<Button type="submit" style="ctaDarkYellow">
Update Name
</Button>
</form>
</VStack>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '5px',
borderRadius: '5px',
}}
>
<StyledLabel>Username</StyledLabel>
<form
onSubmit={(event) => {
handleUpdateUsername()
event.preventDefault()
}}
>
<FormInput
type={'text'}
placeholder={'Username'}
value={username}
disabled={usernameUpdating}
onChange={(event) => {
handleUsernameChange(event)
event.preventDefault()
}}
/>
<SpanBox>
<StyledText
style="caption"
css={{
m: 0,
minHeight: '20px',
color: usernameError ? '$error' : 'unset',
alignSelf: 'flex-start',
}}
>
{usernameError && !isUsernameValidationLoading && (
<>{usernameErrorMessage}</>
)}
{usernameEdited &&
!usernameError &&
!isUsernameValidationLoading && <>Username is available.</>}
</StyledText>
</SpanBox>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
Your username must be unique among all users. It can only
contain letters, numbers, and the underscore character.
</StyledText>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
* Changing your username may break some links from external
apps.
</StyledText>
<Button style="ctaDarkYellow">Update Username</Button>
</form>
</VStack>
</VStack>
</VStack>
</SettingsLayout>
)
}

View File

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "process.env.EXTENSION_NAME",
"short_name": "process.env.EXTENSION_NAME",
"version": "2.4.4",
"version": "2.6.1",
"description": "Save PDFs and Articles to your Omnivore library",
"author": "Omnivore Media, Inc",
"default_locale": "en",
@ -11,7 +11,7 @@
"url": "https://omnivore.app/"
},
"homepage_url": "https://omnivore.app/",
"content_security_policy": "default-src 'none'; child-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; prefetch-src 'none'; worker-src 'none'; connect-src https://storage.googleapis.com/ process.env.OMNIVORE_GRAPHQL_URL blob:; frame-src 'none'; font-src 'none'; img-src data:; script-src 'self'; script-src-elem 'self'; script-src-attr 'none'; style-src 'self'; style-src-elem 'self'; style-src-attr 'none'; base-uri 'none'; form-action 'none'; block-all-mixed-content; upgrade-insecure-requests; report-uri https://api.jeurissen.co/reports/csp/webext/omnivore/",
"content_security_policy": "default-src 'none'; child-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; worker-src 'none'; connect-src https://storage.googleapis.com/ process.env.OMNIVORE_GRAPHQL_URL blob:; frame-src 'none'; font-src 'none'; img-src data:; script-src 'self'; script-src-elem 'self'; script-src-attr 'none'; style-src 'self'; style-src-elem 'self'; style-src-attr 'none'; base-uri 'none'; form-action 'none'; block-all-mixed-content; upgrade-insecure-requests; report-uri https://api.jeurissen.co/reports/csp/webext/omnivore/",
"icons": {
"16": "/images/extension/icon-16.png",
"24": "/images/extension/icon-24.png",

View File

@ -54,6 +54,7 @@ async function updateLabelsCache(apiUrl, tab) {
console.log(!data.labels, data.labels['errorCodes'], !data.labels['labels'])
return []
}
await setStorage({
labels: data.labels.labels,
labelsLastUpdated: new Date().toISOString(),
@ -97,13 +98,15 @@ async function updatePageTitle(apiUrl, pageId, title) {
return data.updatePage.updatePage
}
async function setLabels(apiUrl, pageId, labelIds) {
async function setLabels(apiUrl, pageId, labels) {
const mutation = JSON.stringify({
query: `mutation SetLabels($input: SetLabelsInput!) {
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
id
name
color
}
}
... on SetLabelsError {
@ -115,7 +118,7 @@ async function setLabels(apiUrl, pageId, labelIds) {
variables: {
input: {
pageId,
labelIds,
labels,
},
},
})
@ -129,9 +132,34 @@ async function setLabels(apiUrl, pageId, labelIds) {
console.log('GQL Error setting labels:', data)
throw new Error('Error setting labels.')
}
await appendLabelsToCache(data.setLabels.labels)
return data.setLabels.labels
}
async function appendLabelsToCache(labels) {
const cachedLabels = await getStorageItem('labels')
if (cachedLabels) {
labels.forEach((l) => {
const existing = cachedLabels.find((cached) => cached.name === l.name)
if (!existing) {
cachedLabels.unshift(l)
}
})
await setStorage({
labels: cachedLabels,
labelsLastUpdated: new Date().toISOString(),
})
} else {
await setStorage({
labels: labels,
labelsLastUpdated: new Date().toISOString(),
})
}
}
async function addNote(apiUrl, pageId, noteId, shortId, note) {
const query = JSON.stringify({
query: `query GetArticle(

View File

@ -14,11 +14,51 @@
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
class TaskQueue {
constructor() {
this.queue = []
this.isRunning = false
this.isReady = false
}
enqueue(task) {
this.queue.push(task)
// Only run the next task if the queue is ready
if (this.isReady) {
this.runNext()
}
}
async runNext() {
if (this.isRunning || this.queue.length === 0 || !this.isReady) return
this.isRunning = true
const task = this.queue.shift()
try {
await task()
} catch (err) {
console.error('Task failed:', err)
} finally {
this.isRunning = false
if (this.isReady) {
this.runNext()
}
}
}
setReady() {
this.isReady = true
this.runNext()
}
}
let authToken = undefined
const queue = new TaskQueue()
const omnivoreURL = process.env.OMNIVORE_URL
const omnivoreGraphqlURL = process.env.OMNIVORE_GRAPHQL_URL
let pendingRequests = []
let completedRequests = {}
function getCurrentTab() {
@ -135,7 +175,6 @@ async function savePdfFile(
contentType,
contentObjUrl
)
console.log(' uploadFileResult: ', uploadFileResult)
URL.revokeObjectURL(contentObjUrl)
if (uploadFileResult && uploadRequestResult.createdPageId) {
@ -255,7 +294,7 @@ async function saveApiRequest(currentTab, query, field, input) {
console.log('error saving: ', err)
}
processPendingRequests(currentTab.id)
queue.setReady()
}
function updateClientStatus(tabId, target, status, message) {
@ -312,12 +351,18 @@ async function setLabelsRequest(tabId, request, completedResponse) {
return setLabels(
omnivoreGraphqlURL + 'graphql',
completedResponse.responseId,
request.labelIds
request.labels
)
.then(() => {
updateClientStatus(tabId, 'labels', 'success', 'Labels updated.')
return true
})
.then(() => {
browserApi.tabs.sendMessage(tabId, {
action: ACTIONS.LabelCacheUpdated,
payload: {},
})
})
.catch(() => {
updateClientStatus(tabId, 'labels', 'failure', 'Error updating labels.')
return true
@ -351,48 +396,49 @@ async function deleteRequest(tabId, request, completedResponse) {
})
}
async function processPendingRequests(tabId) {
const tabRequests = pendingRequests.filter((pr) => pr.tabId === tabId)
tabRequests.forEach(async (pr) => {
let handled = false
const completed = completedRequests[pr.clientRequestId]
if (completed) {
switch (pr.type) {
case 'EDIT_TITLE':
handled = await editTitleRequest(tabId, pr, completed)
break
case 'ADD_NOTE':
handled = await addNoteRequest(tabId, pr, completed)
break
case 'SET_LABELS':
handled = await setLabelsRequest(tabId, pr, completed)
break
case 'ARCHIVE':
handled = await archiveRequest(tabId, pr, completed)
break
case 'DELETE':
handled = await deleteRequest(tabId, pr, completed)
break
}
}
if (handled) {
const idx = pendingRequests.findIndex((opr) => pr.id === opr.id)
if (idx > -1) {
pendingRequests.splice(idx, 1)
}
}
})
// TODO: need to handle clearing completedRequests also
async function processEditTitleRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
handled = await editTitleRequest(tabId, pr, completed)
console.log('processEditTitleRequest: ', handled)
return handled
}
async function saveArticle(tab) {
async function processAddNoteRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await addNoteRequest(tabId, pr, completed)
console.log('processAddNoteRequest: ', handled)
return handled
}
async function processSetLabelsRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await setLabelsRequest(tabId, pr, completed)
console.log('processSetLabelsRequest: ', handled)
return handled
}
async function processArchiveRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await archiveRequest(tabId, pr, completed)
console.log('processArchiveRequest: ', handled)
return handled
}
async function processDeleteRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await deleteRequest(tabId, pr, completed)
console.log('processDeleteRequest: ', handled)
return handled
}
async function saveArticle(tab, createHighlight) {
browserApi.tabs.sendMessage(
tab.id,
{
action: ACTIONS.GetContent,
payload: {
createHighlight: createHighlight,
},
},
async (response) => {
if (!response || typeof response !== 'object') {
@ -521,7 +567,8 @@ async function clearPreviousIntervalTimer(tabId) {
clearTimeout(intervalTimeoutId)
}
function onExtensionClick(tabId) {
function extensionSaveCurrentPage(tabId, createHighlight) {
createHighlight = createHighlight ? true : false
/* clear any previous timers on each click */
clearPreviousIntervalTimer(tabId)
@ -544,7 +591,7 @@ function onExtensionClick(tabId) {
if (onSuccess && typeof onSuccess === 'function') {
onSuccess()
}
await saveArticle(tab)
await saveArticle(tab, createHighlight)
try {
await updateLabelsCache(omnivoreGraphqlURL + 'graphql', tab)
browserApi.tabs.sendMessage(tab.id, {
@ -577,7 +624,7 @@ function onExtensionClick(tabId) {
* post timeout, we proceed to save as some sites (people.com) take a
* long time to reach complete state and remain in interactive state.
*/
await saveArticle(tab)
await saveArticle(tab, createHighlight)
})
},
(intervalId, timeoutId) => {
@ -597,13 +644,12 @@ function checkAuthOnFirstClickPostInstall(tabId) {
function handleActionClick() {
executeAction(function (currentTab) {
onExtensionClick(currentTab.id)
extensionSaveCurrentPage(currentTab.id)
})
}
function executeAction(action) {
getCurrentTab().then((currentTab) => {
console.log('currentTab: ', currentTab)
browserApi.tabs.sendMessage(
currentTab.id,
{
@ -685,65 +731,65 @@ function init() {
}
if (request.action === ACTIONS.EditTitle) {
pendingRequests.push({
id: uuidv4(),
type: 'EDIT_TITLE',
tabId: sender.tab.id,
title: request.payload.title,
clientRequestId: request.payload.ctx.requestId,
})
processPendingRequests(sender.tab.id)
queue.enqueue(() =>
processEditTitleRequest(sender.tab.id, {
id: uuidv4(),
type: 'EDIT_TITLE',
tabId: sender.tab.id,
title: request.payload.title,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.Archive) {
pendingRequests.push({
id: uuidv4(),
type: 'ARCHIVE',
tabId: sender.tab.id,
clientRequestId: request.payload.ctx.requestId,
})
processPendingRequests(sender.tab.id)
queue.enqueue(() =>
processArchiveRequest(sender.tab.id, {
id: uuidv4(),
type: 'ARCHIVE',
tabId: sender.tab.id,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.Delete) {
pendingRequests.push({
type: 'DELETE',
tabId: sender.tab.id,
clientRequestId: request.payload.ctx.requestId,
})
processPendingRequests(sender.tab.id)
queue.enqueue(() =>
processDeleteRequest(sender.tab.id, {
type: 'DELETE',
tabId: sender.tab.id,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.AddNote) {
pendingRequests.push({
id: uuidv4(),
type: 'ADD_NOTE',
tabId: sender.tab.id,
note: request.payload.note,
clientRequestId: request.payload.ctx.requestId,
})
processPendingRequests(sender.tab.id)
queue.enqueue(() =>
processAddNoteRequest(sender.tab.id, {
id: uuidv4(),
type: 'ADD_NOTE',
tabId: sender.tab.id,
note: request.payload.note,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.SetLabels) {
pendingRequests.push({
id: uuidv4(),
type: 'SET_LABELS',
tabId: sender.tab.id,
labelIds: request.payload.labelIds,
clientRequestId: request.payload.ctx.requestId,
})
processPendingRequests(sender.tab.id)
queue.enqueue(() =>
processSetLabelsRequest(sender.tab.id, {
id: uuidv4(),
type: 'SET_LABELS',
tabId: sender.tab.id,
labels: request.payload.labels,
clientRequestId: request.payload.ctx.requestId,
})
)
}
})
browserApi.contextMenus.create({
id: 'save-selection',
id: 'save-link-selection',
title: 'Save this link to Omnivore',
contexts: ['link'],
onclick: async function (obj) {
@ -752,6 +798,28 @@ function init() {
})
},
})
browserApi.contextMenus.create({
id: 'save-page-selection',
title: 'Save this page to Omnivore',
contexts: ['page'],
onclick: async function (obj) {
executeAction(function (currentTab) {
extensionSaveCurrentPage(currentTab.id)
})
},
})
browserApi.contextMenus.create({
id: 'save-text-selection',
title: 'Create Highlight and Save to Omnivore',
contexts: ['selection'],
onclick: async function (obj) {
executeAction(function (currentTab) {
extensionSaveCurrentPage(currentTab.id, true)
})
},
})
}
init()

View File

@ -23,7 +23,8 @@
browserApi.runtime.onMessage.addListener(
({ action, payload }, sender, sendResponse) => {
if (action === ACTIONS.GetContent) {
prepareContent().then((pageContent) => {
const createHighlight = payload && payload.createHighlight
prepareContent(createHighlight).then((pageContent) => {
sendResponse({
type: pageContent.type,
doc: pageContent.content || '',

View File

@ -5,37 +5,38 @@
ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS
*/
'use strict';
'use strict'
;(function () {
const iframes = {}
(function () {
const iframes = {};
browserApi.runtime.onMessage.addListener(
({ action, payload }, sender, sendResponse) => {
if (action !== ACTIONS.AddIframeContent) return
const { url, content } = payload
iframes[url] = content
sendResponse({})
}
)
browserApi.runtime.onMessage.addListener(({ action, payload }, sender, sendResponse) => {
if (action !== ACTIONS.AddIframeContent) return;
const { url, content } = payload;
iframes[url] = content;
sendResponse({});
});
async function grabPdfContent () {
const fileExtension = window.location.pathname.slice(-4).toLowerCase();
const hasPdfExtension = fileExtension === '.pdf';
async function grabPdfContent() {
const fileExtension = window.location.pathname.slice(-4).toLowerCase()
const hasPdfExtension = fileExtension === '.pdf'
const pdfContentTypes = [
'application/acrobat',
'application/pdf',
'application/x-pdf',
'applications/vnd.pdf',
'text/pdf',
'text/x-pdf'
];
const isPdfContent = pdfContentTypes.indexOf(document.contentType) !== -1;
'text/x-pdf',
]
const isPdfContent = pdfContentTypes.indexOf(document.contentType) !== -1
if (!hasPdfExtension && !isPdfContent) {
return Promise.resolve(null);
return Promise.resolve(null)
}
const embedEl = document.querySelector('embed');
const embedEl = document.querySelector('embed')
if (embedEl && embedEl.type !== 'application/pdf') {
return Promise.resolve(null);
return Promise.resolve(null)
}
if (ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS && embedEl.src) {
@ -43,115 +44,120 @@
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const xhr = new XMLHttpRequest()
// load `document` from `cache`
xhr.open('GET', '', true);
xhr.responseType = 'blob';
xhr.open('GET', '', true)
xhr.responseType = 'blob'
xhr.onload = function (e) {
if (this.status === 200) {
resolve({ type: 'pdf', uploadContentObjUrl: URL.createObjectURL(this.response) })
resolve({
type: 'pdf',
uploadContentObjUrl: URL.createObjectURL(this.response),
})
} else {
reject(e);
reject(e)
}
};
xhr.send();
});
}
xhr.send()
})
}
function prepareContentPostItem (itemEl) {
const lowerTagName = itemEl.tagName.toLowerCase();
function prepareContentPostItem(itemEl) {
const lowerTagName = itemEl.tagName.toLowerCase()
if (lowerTagName === 'iframe') {
const frameHtml = iframes[itemEl.src];
if (!frameHtml) return;
const frameHtml = iframes[itemEl.src]
if (!frameHtml) return
const containerEl = document.createElement('div');
containerEl.className = 'omnivore-instagram-embed';
containerEl.innerHTML = frameHtml;
const containerEl = document.createElement('div')
containerEl.className = 'omnivore-instagram-embed'
containerEl.innerHTML = frameHtml
const parentEl = itemEl.parentNode;
if (!parentEl) return;
const parentEl = itemEl.parentNode
if (!parentEl) return
parentEl.replaceChild(containerEl, itemEl);
parentEl.replaceChild(containerEl, itemEl)
return;
return
}
if (lowerTagName === 'img' || lowerTagName === 'image') {
// Removing blurred images since they are mostly the copies of lazy loaded ones
const style = window.getComputedStyle(itemEl);
const filter = style.getPropertyValue('filter');
if (filter.indexOf('blur(') === -1) return;
itemEl.remove();
return;
const style = window.getComputedStyle(itemEl)
const filter = style.getPropertyValue('filter')
if (filter.indexOf('blur(') === -1) return
itemEl.remove()
return
}
const style = window.getComputedStyle(itemEl);
const backgroundImage = style.getPropertyValue('background-image');
const style = window.getComputedStyle(itemEl)
const backgroundImage = style.getPropertyValue('background-image')
// convert all nodes with background image to img nodes
const noBackgroundImage = !backgroundImage || backgroundImage === 'none';
if (!noBackgroundImage) return;
const noBackgroundImage = !backgroundImage || backgroundImage === 'none'
if (!noBackgroundImage) return
const filter = style.getPropertyValue('filter');
const filter = style.getPropertyValue('filter')
// avoiding image nodes with a blur effect creation
if (filter && filter.indexOf('blur(') !== -1) {
itemEl.remove();
return;
itemEl.remove()
return
}
// Replacing element only of there are no content inside, b/c might remove important div with content.
// Article example: http://www.josiahzayner.com/2017/01/genetic-designer-part-i.html
// DIV with class "content-inner" has `url("https://resources.blogblog.com/blogblog/data/1kt/travel/bg_container.png")` background image.
if (itemEl.src) return;
if (itemEl.innerHTML.length > 24) return;
if (itemEl.src) return
if (itemEl.innerHTML.length > 24) return
const BI_SRC_REGEXP = /url\("(.+?)"\)/gi;
const matchedSRC = BI_SRC_REGEXP.exec(backgroundImage);
const BI_SRC_REGEXP = /url\("(.+?)"\)/gi
const matchedSRC = BI_SRC_REGEXP.exec(backgroundImage)
// Using "g" flag with a regex we have to manually break down lastIndex to zero after every usage
// More details here: https://stackoverflow.com/questions/1520800/why-does-a-regexp-with-global-flag-give-wrong-results
BI_SRC_REGEXP.lastIndex = 0;
BI_SRC_REGEXP.lastIndex = 0
const targetSrc = matchedSRC && matchedSRC[1];
if (!targetSrc) return;
const targetSrc = matchedSRC && matchedSRC[1]
if (!targetSrc) return
const imgEl = document.createElement('img');
imgEl.src = targetSrc;
const parentEl = itemEl.parentNode;
if (!parentEl) return;
const imgEl = document.createElement('img')
imgEl.src = targetSrc
const parentEl = itemEl.parentNode
if (!parentEl) return
parentEl.replaceChild(imgEl, itemEl);
parentEl.replaceChild(imgEl, itemEl)
}
function prepareContentPostScroll () {
const contentCopyEl = document.createElement('div');
contentCopyEl.style.position = 'absolute';
contentCopyEl.style.left = '-2000px';
contentCopyEl.style.zIndex = '-2000';
contentCopyEl.innerHTML = document.body.innerHTML;
function prepareContentPostScroll() {
const contentCopyEl = document.createElement('div')
contentCopyEl.style.position = 'absolute'
contentCopyEl.style.left = '-2000px'
contentCopyEl.style.zIndex = '-2000'
contentCopyEl.innerHTML = document.body.innerHTML
// Appending copy of the content to the DOM to enable computed styles capturing ability
// Without adding that copy to the DOM the `window.getComputedStyle` method will always return undefined.
document.documentElement.appendChild(contentCopyEl);
document.documentElement.appendChild(contentCopyEl)
Array.from(contentCopyEl.getElementsByTagName('*')).forEach(prepareContentPostItem);
Array.from(contentCopyEl.getElementsByTagName('*')).forEach(
prepareContentPostItem
)
/*
* Grab head and body separately as using clone on entire document into a div
* removes the head and body tags while grabbing html in them. Instead we
* capture them separately and concatenate them here with head and body tags
* preserved.
*/
const contentCopyHtml = `<html><head>${document.head.innerHTML}</head><body>${contentCopyEl.innerHTML}</body></html>`;
* Grab head and body separately as using clone on entire document into a div
* removes the head and body tags while grabbing html in them. Instead we
* capture them separately and concatenate them here with head and body tags
* preserved.
*/
const contentCopyHtml = `<html><head>${document.head.innerHTML}</head><body>${contentCopyEl.innerHTML}</body></html>`
// Cleaning up the copy element
contentCopyEl.remove();
return contentCopyHtml;
contentCopyEl.remove()
return contentCopyHtml
}
function createBackdrop () {
const backdropEl = document.createElement('div');
backdropEl.className = 'webext-omnivore-backdrop';
function createBackdrop() {
const backdropEl = document.createElement('div')
backdropEl.className = 'webext-omnivore-backdrop'
backdropEl.style.cssText = `all: initial !important;
position: fixed !important;
top: 0 !important;
@ -164,74 +170,171 @@
transition: opacity 0.3s !important;
-webkit-backdrop-filter: blur(4px) !important;
backdrop-filter: blur(4px) !important;
`;
return backdropEl;
`
return backdropEl
}
function clearExistingBackdrops () {
const backdropCol = document.querySelectorAll('.webext-omnivore-backdrop');
const getQuoteText = (containerNode) => {
const nonParagraphTagsRegEx =
/^(a|b|basefont|bdo|big|em|font|i|s|small|span|strike|strong|su[bp]|tt|u|code|mark)$/i
let textResult = ''
let newParagraph = false
const getTextNodes = (node) => {
let isPre = false
const nodeElement =
node instanceof HTMLElement ? node : node.parentElement
if (nodeElement) {
isPre = window
.getComputedStyle(nodeElement)
.whiteSpace.startsWith('pre')
}
if (node.nodeType == 3) {
const text = isPre ? node.nodeValue : node.nodeValue.replace(/\n/g, '')
textResult += text
} else if (node != containerNode) {
if (!nonParagraphTagsRegEx.test(node.tagName)) {
textResult += '\n\n'
}
}
const children = node.childNodes
children.forEach(function (child) {
getTextNodes(child)
})
}
getTextNodes(containerNode)
return textResult.trim()
}
const markHighlightSelection = () => {
// First remove any previous markers, this would only normally happen during debugging
try {
const markers = window.document.querySelectorAll(
`span[data-omnivore-highlight-start="true"],
span[data-omnivore-highlight-end="true"]`
)
for (let i = 0; i < markers.length; i++) {
markers[i].remove()
}
} catch (error) {
console.log('remove marker error: ', error)
// This should be OK
}
try {
const sel = window.getSelection()
if (sel.rangeCount) {
const range = sel.getRangeAt(0)
const endMarker = document.createElement('span')
const startMarker = document.createElement('span')
endMarker.setAttribute('data-omnivore-highlight-end', 'true')
startMarker.setAttribute('data-omnivore-highlight-start', 'true')
var container = document.createElement('div')
for (var i = 0, len = sel.rangeCount; i < len; ++i) {
container.appendChild(sel.getRangeAt(i).cloneContents())
}
const endRange = range.cloneRange()
endRange.collapse(false)
endRange.insertNode(endMarker)
range.insertNode(startMarker)
return {
highlightHTML: container.innerHTML,
highlightText: getQuoteText(container),
}
}
} catch (error) {
console.log('get text error', error)
}
return null
}
function clearExistingBackdrops() {
const backdropCol = document.querySelectorAll('.webext-omnivore-backdrop')
for (let i = 0; i < backdropCol.length; i++) {
const backdropEl = backdropCol[i];
backdropEl.style.setProperty('opacity', '0', 'important');
const backdropEl = backdropCol[i]
backdropEl.style.setProperty('opacity', '0', 'important')
}
setTimeout(() => {
for (let i = 0; i < backdropCol.length; i++) {
backdropCol[i].remove();
backdropCol[i].remove()
}
}, 0.5e3);
}, 0.5e3)
}
async function prepareContent () {
const pdfContent = await grabPdfContent();
async function prepareContent(createHighlight) {
const pdfContent = await grabPdfContent()
if (pdfContent) {
return pdfContent
}
const url = window.location.href;
const url = window.location.href
try {
if (handleBackendUrl(url)) {
if (!createHighlight && handleBackendUrl(url)) {
return { type: 'url' }
}
} catch {
console.log('error checking url')
}
async function scrollPage (url) {
const scrollingEl = (document.scrollingElement || document.body);
const lastScrollPos = scrollingEl.scrollTop;
const currentScrollHeight = scrollingEl.scrollHeight;
console.log('get content: ', createHighlight)
if (createHighlight) {
console.log('creating highlight while saving')
const highlightSelection = markHighlightSelection()
console.log('highlightSelection', highlightSelection)
}
async function scrollPage(url) {
const scrollingEl = document.scrollingElement || document.body
const lastScrollPos = scrollingEl.scrollTop
const currentScrollHeight = scrollingEl.scrollHeight
/* add blurred overlay while scrolling */
clearExistingBackdrops();
clearExistingBackdrops()
const backdropEl = createBackdrop();
document.body.appendChild(backdropEl);
const backdropEl = createBackdrop()
document.body.appendChild(backdropEl)
/*
* check below compares scrollTop against initial page height to handle
* pages with infinite scroll else we shall be infinitely scrolling here.
* stop scrolling if the url has changed in the meantime.
*/
while (scrollingEl.scrollTop <= (currentScrollHeight - 500) && window.location.href === url) {
const prevScrollTop = scrollingEl.scrollTop;
scrollingEl.scrollTop += 500;
while (
scrollingEl.scrollTop <= currentScrollHeight - 500 &&
window.location.href === url
) {
const prevScrollTop = scrollingEl.scrollTop
scrollingEl.scrollTop += 500
/* sleep upon scrolling position change for event loop to handle events from scroll */
await (new Promise((resolve) => { setTimeout(resolve, 10); }));
await new Promise((resolve) => {
setTimeout(resolve, 10)
})
if (scrollingEl.scrollTop === prevScrollTop) {
/* break out scroll loop if we are not able to scroll for any reason */
// console.log('breaking out scroll loop', scrollingEl.scrollTop, currentScrollHeight);
break;
break
}
}
scrollingEl.scrollTop = lastScrollPos;
scrollingEl.scrollTop = lastScrollPos
/* sleep upon scrolling position change for event loop to handle events from scroll */
await (new Promise((resolve) => { setTimeout(resolve, 10); }));
await new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
await scrollPage(url);
await scrollPage(url)
clearExistingBackdrops();
return { type: 'html', content: prepareContentPostScroll() };
clearExistingBackdrops()
return { type: 'html', content: prepareContentPostScroll() }
}
window.prepareContent = prepareContent;
})();
window.prepareContent = prepareContent
})()

View File

@ -192,8 +192,18 @@
function updateLabelsFromCache(payload) {
;(async () => {
await getStorageItem('labels').then((cachedLabels) => {
if (labels) {
const selectedLabels = labels.filter((l) => l.selected)
selectedLabels.forEach((l) => {
const cached = cachedLabels.find((cached) => cached.name == l.name)
if (cached) {
cached.selected = true
} else {
cachedLabels.push(l)
}
})
}
labels = cachedLabels
console.log(' == updated labels', cachedLabels)
})
})()
}
@ -279,7 +289,12 @@
}
function toggleRow(rowId) {
console.log('currentToastEl: ', currentToastEl)
if (!currentToastEl) {
// its possible this was called after closing the extension
// so just return
return
}
const container = currentToastEl.shadowRoot.querySelector(rowId)
const initialState = container?.getAttribute('data-state')
const rows = currentToastEl.shadowRoot.querySelectorAll(
@ -361,7 +376,7 @@
})
}
function createLabelRow(label, idx) {
function createLabelRow(label) {
const element = document.createElement('button')
const dot = document.createElement('span')
dot.style = 'width:10px;height:10px;border-radius:1000px;'
@ -384,9 +399,8 @@
element.appendChild(check)
element.onclick = labelClick
element.onkeydown = labelKeyDown
element.onkeydown = labelEditorKeyDownHandler
element.setAttribute('data-label-id', label.id)
element.setAttribute('data-label-idx', idx)
element.setAttribute(
'data-label-selected',
label['selected'] ? 'on' : 'off'
@ -421,68 +435,117 @@
if (label) {
label.selected = toggledValue
}
const labelList = event.target.form.querySelector('#label-list')
const labelInput = event.target.form.querySelector(
'#omnivore-edit-label-input'
)
if (toggledValue) {
addLabel(labelList, labelInput, label.name)
} else {
removeLabel(labelList, label.id)
}
}
function labelKeyDown(event) {
function backspaceOnLastItem(labelsList, labelsInput) {
// Get the last <li> item before the <li><input item
const lastItem =
labelsInput.closest('#label-entry-item').previousElementSibling
if (lastItem) {
const backspaced = lastItem.getAttribute('data-label-backspaced')
if (backspaced) {
removeLabel(
labelsInput.closest('#label-list'),
lastItem.getAttribute('data-label-id')
)
} else {
lastItem.setAttribute('data-label-backspaced', 'on')
}
}
}
function labelEditorClickHandler(event) {
const input = event.target.querySelector('#omnivore-edit-label-input')
if (input && event.target != input) {
input.focus()
return
}
}
function clearBackspacedLabels(form) {
const selected = form.querySelectorAll('.label[data-label-backspaced="on"]')
selected.forEach((node) => {
node.removeAttribute('data-label-backspaced')
})
}
function labelEditorKeyDownHandler(event) {
event.cancelBubble = true
if (event.stopPropogation) {
event.stopPropogation()
}
// If any labels have been backspaced into (so they have the selected outline), clear their state
if (event.target.form && event.key.toLowerCase() !== 'backspace') {
clearBackspacedLabels(event.target.form)
}
switch (event.key.toLowerCase()) {
case 'arrowup': {
if (
event.target ==
event.target.form.querySelector('#omnivore-edit-label-text')
) {
if (event.target.id == 'omnivore-edit-label-input') {
return
}
const idx = event.target.getAttribute('data-label-idx')
let prevIdx = idx && Number(idx) != NaN ? Number(idx) - 1 : 0
if (
event.target ==
event.target.form.querySelector('#omnivore-save-button')
) {
// Focus the last label index
const maxItemIdx = Math.max(
...Array.from(
event.target.form.querySelectorAll(`button[data-label-idx]`)
).map((b) => Number(b.getAttribute('data-label-idx')))
)
if (maxItemIdx != NaN) {
prevIdx = maxItemIdx
}
if (!event.target.getAttribute('data-label-id')) {
return
}
const prev = event.target.form.querySelector(
`button[data-label-idx='${prevIdx}']`
)
if (prev) {
let prev = event.target.previousElementSibling
if (prev && prev.getAttribute('data-label-id')) {
prev.focus()
} else {
// Focus the text area
event.target.form.querySelector('#omnivore-edit-label-text')?.focus()
event.target.form.querySelector('#omnivore-edit-label-input')?.focus()
}
event.preventDefault()
break
}
case 'arrowdown': {
const idx = event.target.getAttribute('data-label-idx')
const nextIdx = idx && Number(idx) != NaN ? Number(idx) + 1 : 0
const next = event.target.form.querySelector(
`button[data-label-idx='${nextIdx}']`
)
if (next) {
next.focus()
let next = undefined
if (event.target.id == 'omnivore-edit-label-input') {
idx = event.target.getAttribute('data-label-id')
next = event.target
.closest('#omnivore-edit-labels-form')
.querySelector('#omnivore-edit-labels-list')
.querySelector('[data-label-id]')
} else {
// Focus the save button
event.target.form.querySelector('.omnivore-save-button')?.focus()
next = event.target.nextElementSibling
}
if (next && next.getAttribute('data-label-id')) {
next.focus()
}
event.preventDefault()
break
}
case 'backspace': {
if (
event.target.id == 'omnivore-edit-label-input' &&
event.target.value.length == 0
) {
const labelList = event.target.form.querySelector('#label-list')
backspaceOnLastItem(labelList, event.target)
}
break
}
case 'enter': {
if (event.target.id == 'omnivore-edit-label-input') {
if (event.target.value) {
const labelList = event.target.form.querySelector('#label-list')
addLabel(labelList, event.target, event.target.value)
}
event.preventDefault()
return
}
const labelId = event.target.getAttribute('data-label-id')
toggleLabel(event, labelId)
event.preventDefault()
@ -491,7 +554,15 @@
}
}
function addNote() {
function noteCacheKey() {
return document.location
? `cached-note-${document.location.href}`
: undefined
}
async function addNote() {
const cachedNoteKey = noteCacheKey()
cancelAutoDismiss()
toggleRow('#omnivore-add-note-row')
@ -500,7 +571,24 @@
)
if (noteArea) {
noteArea.focus()
if (cachedNoteKey) {
const existingNote = await getStorageItem(cachedNoteKey)
noteArea.value = existingNote
}
if (noteArea.value) {
noteArea.select()
} else {
noteArea.focus()
}
noteArea.addEventListener('input', (event) => {
;(async () => {
const note = {}
note[cachedNoteKey] = event.target.value
await setStorage(note)
})()
})
noteArea.onkeydown = (e) => {
e.cancelBubble = true
@ -529,7 +617,6 @@
currentToastEl.shadowRoot.querySelector(
'#omnivore-add-note-form'
).onsubmit = (event) => {
console.log('submitting form: ', event)
updateStatusBox('#omnivore-add-note-status', 'loading', 'Adding note...')
browserApi.runtime.sendMessage({
@ -541,7 +628,9 @@
})
event.preventDefault()
event.stopPropogation()
if (event.stopPropogation) {
event.stopPropogation()
}
}
}
@ -585,6 +674,148 @@
}
}
function getRandomColor() {
const colors = [
'#FF5D99',
'#7CFF7B',
'#FFD234',
'#7BE4FF',
'#CE88EF',
'#EF8C43',
]
const randomIndex = Math.floor(Math.random() * colors.length)
return colors[randomIndex]
}
function getTempUUID() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
)
}
function addLabel(labelList, labelInput, labelValue) {
// first check if the label is already entered:
const existingLabel = labels.find((l) => l.name === labelValue)
const labelEntryItem = labelList.querySelector('#label-entry-item')
const inputItem = labelEntryItem.querySelector('#omnivore-edit-label-input')
// Handle case where label is already selected
if (
existingLabel &&
labelList.querySelector(`[data-label-id='${existingLabel.id}']`)
) {
const labelItem = labelList.querySelector(
`[data-label-id='${existingLabel.id}']`
)
labelItem.setAttribute('data-item-highlighted', 'on')
setTimeout(() => {
labelItem.style.borderColor = 'rgb(222, 222, 222)'
}, 500)
if (inputItem) {
inputItem.value = ''
inputItem.focus()
updateLabels(undefined)
}
return
}
const labelColor = existingLabel ? existingLabel.color : getRandomColor()
const labelElem = document.createElement('li')
labelElem.classList.add('label')
labelElem.innerHTML = `
<span style="width: 10px; height: 10px; border-radius: 1000px; background-color: ${labelColor};"></span>
${labelValue}
<button class="label-remove-button">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="#6A6968" viewBox="0 0 256 256">
<rect width="256" height="256" fill="none"></rect><line x1="200" y1="56" x2="56" y2="200" stroke="#6A6968" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"></line>
<line x1="200" y1="200" x2="56" y2="56" stroke="#6A6968" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"></line>
</svg>
</button>
`
labelList.insertBefore(labelElem, labelEntryItem)
labelInput.value = ''
const form = labelList.closest('#omnivore-edit-labels-form')
if (existingLabel) {
const element = form.querySelector(
`[data-label-id='${existingLabel.id}']`
)
existingLabel.selected = true
element.setAttribute('data-label-selected', 'on')
labelElem.setAttribute('data-label-id', existingLabel.id)
} else {
// insert a toggle row at the top
const rowList = form.querySelector('#omnivore-edit-labels-list')
const newLabel = {
id: getTempUUID(),
color: labelColor,
name: labelValue,
temporary: true,
selected: true,
}
labels.push(newLabel)
labelElem.setAttribute('data-label-id', newLabel.id)
// Now prepend a label in the rows at the bottom
const rowHtml = createLabelRow(newLabel)
const firstRow = rowList.querySelector('button[data-label-id]')
rowHtml.setAttribute('data-label-selected', 'on')
rowList.insertBefore(rowHtml, firstRow)
}
if (inputItem) {
inputItem.focus()
updateLabels(undefined)
}
syncLabelChanges()
}
function removeLabel(labelList, labelID) {
const form = labelList.closest('#omnivore-edit-labels-form')
const element = labelList.querySelector(`[data-label-id='${labelID}']`)
if (element) {
element.remove()
}
const rowElement = form.querySelector(`[data-label-id='${labelID}']`)
if (rowElement) {
rowElement.setAttribute('data-label-selected', 'off')
}
syncLabelChanges()
}
function syncLabelChanges() {
updateStatusBox(
'#omnivore-edit-labels-status',
'loading',
'Updating Labels...',
undefined
)
const setLabels = labels
.filter((l) => l['selected'])
.map((l) => {
return {
name: l.name,
color: l.color,
}
})
browserApi.runtime.sendMessage({
action: ACTIONS.SetLabels,
payload: {
ctx: ctx,
labels: setLabels,
},
})
}
async function editLabels() {
cancelAutoDismiss()
@ -594,46 +825,36 @@
toggleRow('#omnivore-edit-labels-row')
currentToastEl.shadowRoot
.querySelector('#omnivore-edit-label-text')
.querySelector('#omnivore-edit-label-input')
?.focus()
const list = currentToastEl.shadowRoot.querySelector(
'#omnivore-edit-labels-list'
)
currentToastEl.shadowRoot
.querySelector('#omnivore-edit-label-text')
.addEventListener('input', function () {
updateLabels(this.value)
})
currentToastEl.shadowRoot.querySelector(
'#omnivore-edit-label-text'
).onkeydown = labelKeyDown
'#omnivore-edit-label-input'
).onkeydown = labelEditorKeyDownHandler
currentToastEl.shadowRoot.querySelector(
'#omnivore-edit-label-editor'
).onclick = labelEditorClickHandler
currentToastEl.shadowRoot
.querySelector('#omnivore-edit-label-input')
.addEventListener('input', (event) => {
updateLabels(event.target.value)
})
if (list) {
list.innerHTML = ''
labels.forEach(function (label, idx) {
const rowHtml = createLabelRow(label, idx)
list.appendChild(rowHtml)
})
}
currentToastEl.shadowRoot.querySelector(
'#omnivore-edit-labels-form'
).onsubmit = (event) => {
event.preventDefault()
const statusBox = currentToastEl.shadowRoot.querySelector(
'#omnivore-edit-labels-status'
)
statusBox.innerText = 'Updating labels...'
const labelIds = labels.filter((l) => l['selected']).map((l) => l.id)
browserApi.runtime.sendMessage({
action: ACTIONS.SetLabels,
payload: {
ctx: ctx,
labelIds: labelIds,
},
})
labels
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
)
.forEach(function (label, idx) {
const rowHtml = createLabelRow(label)
list.appendChild(rowHtml)
})
}
}
@ -648,15 +869,22 @@
.filter(
(l) => l.name.toLowerCase().indexOf(filterValue.toLowerCase()) > -1
)
.forEach(function (label, idx) {
const rowHtml = createLabelRow(label, idx)
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
)
.forEach(function (label) {
const rowHtml = createLabelRow(label)
list.appendChild(rowHtml)
})
} else {
labels.forEach(function (label, idx) {
const rowHtml = createLabelRow(label, idx)
list.appendChild(rowHtml)
})
labels
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
)
.forEach(function (label) {
const rowHtml = createLabelRow(label)
list.appendChild(rowHtml)
})
}
}
}

View File

@ -68,6 +68,11 @@
line-height: 20px;
text-align: center;
}
#omnivore-toast-container #omnivore-edit-labels-status {
height: 22px;
padding-top: 10px;
}
#omnivore-toast-button-row {
gap: 5px;
align-items: center;
@ -228,7 +233,7 @@
max-height: 200px;
gap: 5px;
color: #3B3A38;
margin-top: 15px;
margin-top: 0px;
margin-bottom: 10px;
}
@ -331,6 +336,86 @@
}
}
</style>
<style>
.label-editor {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: inline-block;
background-color: var(--colors-thBackground2);
border: 1px solid transparent;
border-radius: 6px;
padding: 5px;
line-height: 2;
cursor: text;
font-size: 12px;
width: calc(100% - 10px);
min-height: 28px;
}
.label-list {
flex-wrap: wrap;
justify-content: start;
display: flex;
width: 100%;
align-items: center;
padding: 0px;
box-sizing: border-box;
height: auto;
flex: 0 0 auto;
overflow-y: auto;
margin: 0;
list-style: none;
gap: 5px;
}
#omnivore-toast-container .omnivore-toast-func-row .label-remove-button {
display: flex;
align-items: center;
justify-content: center;
color: transparent;
border: none;
background-color: transparent;
cursor: pointer;
padding: 0px;
}
#omnivore-edit-labels-row .label-input {
box-sizing: content-box;
font-size: 16px;
min-width: 2px;
border: none;
outline: none;
padding: 0px;
height: 28px;
}
.label {
display: inline-table;
padding: 1px;
padding-left: 7px;
padding-right: 7px;
border-radius: 5px;
display: flex;
align-items: center;
gap: 5px;
border: 1px solid rgb(222, 222, 222);
background-color: rgb(249, 249, 249);
}
#omnivore-toast-container .omnivore-toast-func-row .label button {
height: unset;
}
.label[data-label-backspaced="on"] {
border: 1px solid rgb(255, 234, 159);
}
.label[data-item-highlighted="on"] {
border: 1px solid black;
transition: border-color 0.25s linear;
}
</style>
<div id="omnivore-toast-container">
<div id="omnivore-toast-button-row">
@ -421,14 +506,16 @@
</form>
</div>
<div id="omnivore-edit-labels-row" class="omnivore-toast-func-row" data-state="closed">
<span id="omnivore-edit-labels-status" class="omnivore-toast-func-status"></span>
<form id="omnivore-edit-labels-form">
<input type="text" id="omnivore-edit-label-text" placeholder="Filter for labels" tabindex="0"> </input>
<div id="omnivore-edit-labels-list">
<div id="omnivore-edit-label-editor" class="label-editor">
<ul id="label-list" class="label-list">
<li id="label-entry-item">
<input type="text" id="omnivore-edit-label-input" placeholder="Add a label..." maxlength="48" class="label-input">
</li>
</ul>
</div>
<button class="omnivore-save-button">Save</button>
<span id="omnivore-edit-labels-status" class="omnivore-toast-func-status"></span>
<div id="omnivore-edit-labels-list"></div>
</form>
</div>