diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index 681e873b7..a2dca9ae5 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -160,3 +160,9 @@ dependencies { apollo { packageName.set 'app.omnivore.omnivore.graphql.generated' } + +task printVersion { + doLast { + println "omnivoreVersion: ${android.defaultConfig.versionName}" + } +} diff --git a/packages/web/components/elements/Button.tsx b/packages/web/components/elements/Button.tsx index 831fc58cc..2939850cf 100644 --- a/packages/web/components/elements/Button.tsx +++ b/packages/web/components/elements/Button.tsx @@ -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', diff --git a/packages/web/components/elements/FormElements.tsx b/packages/web/components/elements/FormElements.tsx index 995e7f759..1c00bded2 100644 --- a/packages/web/components/elements/FormElements.tsx +++ b/packages/web/components/elements/FormElements.tsx @@ -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} diff --git a/packages/web/components/elements/StyledText.tsx b/packages/web/components/elements/StyledText.tsx index 0d480266b..0a47e4747 100644 --- a/packages/web/components/elements/StyledText.tsx +++ b/packages/web/components/elements/StyledText.tsx @@ -146,7 +146,6 @@ const textVariants = { }, navLink: { m: 0, - fontSize: '$1', fontWeight: 400, color: '$graySolid', cursor: 'pointer', diff --git a/packages/web/components/templates/PrimaryDropdown.tsx b/packages/web/components/templates/PrimaryDropdown.tsx index 431076b85..5d46f9932 100644 --- a/packages/web/components/templates/PrimaryDropdown.tsx +++ b/packages/web/components/templates/PrimaryDropdown.tsx @@ -106,6 +106,10 @@ export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element { cursor: 'pointer', mouseEvents: 'all', }} + onClick={(event) => { + router.push('/settings/account') + event.preventDefault() + }} > - {props.children} + + + {props.children} + {showLogoutConfirmation ? ( diff --git a/packages/web/components/templates/SettingsMenu.tsx b/packages/web/components/templates/SettingsMenu.tsx new file mode 100644 index 000000000..48cb8a772 --- /dev/null +++ b/packages/web/components/templates/SettingsMenu.tsx @@ -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 ( + <> + + + + + + + {section1.map((item) => { + return + })} + + {section2.map((item) => { + return + })} + + + + + a': { + backgroundColor: 'transparent', + textDecoration: 'none', + }, + }} + > + + + Documentation + + + + + + + {/* This spacer pushes library content to the right of + the fixed left side menu. */} + + + ) +} + +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 ( + + + {props.name} + + + ) +} diff --git a/packages/web/lib/networking/mutations/updateUserMutation.ts b/packages/web/lib/networking/mutations/updateUserMutation.ts new file mode 100644 index 000000000..c94a4c30c --- /dev/null +++ b/packages/web/lib/networking/mutations/updateUserMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/mutations/updateUserProfileMutation.ts b/packages/web/lib/networking/mutations/updateUserProfileMutation.ts new file mode 100644 index 000000000..3fd33d6d0 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateUserProfileMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/queries/useValidateUsernameQuery.tsx b/packages/web/lib/networking/queries/useValidateUsernameQuery.tsx index c40dd9fc0..467d737fc 100644 --- a/packages/web/lib/networking/queries/useValidateUsernameQuery.tsx +++ b/packages/web/lib/networking/queries/useValidateUsernameQuery.tsx @@ -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) { diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx new file mode 100644 index 000000000..3e34236aa --- /dev/null +++ b/packages/web/pages/settings/account.tsx @@ -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): 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 ( + + + + + + + + Account Details + + + +
{ + handleUpdateName() + event.preventDefault() + }} + > + Name + { + setName(event.target.value) + event.preventDefault() + }} + /> + + Your name is displayed on your profile and is used when + communicating with you. + + + +
+ + + Username +
{ + handleUpdateUsername() + event.preventDefault() + }} + > + { + handleUsernameChange(event) + event.preventDefault() + }} + /> + + + {usernameError && !isUsernameValidationLoading && ( + <>{usernameErrorMessage} + )} + {usernameEdited && + !usernameError && + !isUsernameValidationLoading && <>Username is available.} + + + + Your username must be unique among all users. It can only + contain letters, numbers, and the underscore character. + + + * Changing your username may break some links from external + apps. + + + +
+
+
+
+ ) +} diff --git a/pkg/extension/src/manifest.json b/pkg/extension/src/manifest.json index c82046e48..1739f5f59 100644 --- a/pkg/extension/src/manifest.json +++ b/pkg/extension/src/manifest.json @@ -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", diff --git a/pkg/extension/src/scripts/api.js b/pkg/extension/src/scripts/api.js index 2bab74be8..48465ca9d 100644 --- a/pkg/extension/src/scripts/api.js +++ b/pkg/extension/src/scripts/api.js @@ -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( diff --git a/pkg/extension/src/scripts/background.js b/pkg/extension/src/scripts/background.js index 3bb5fd6fb..0fccd47c8 100644 --- a/pkg/extension/src/scripts/background.js +++ b/pkg/extension/src/scripts/background.js @@ -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() diff --git a/pkg/extension/src/scripts/content/content-listener-script.js b/pkg/extension/src/scripts/content/content-listener-script.js index b5c6d9cee..d86bb3ed8 100644 --- a/pkg/extension/src/scripts/content/content-listener-script.js +++ b/pkg/extension/src/scripts/content/content-listener-script.js @@ -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 || '', diff --git a/pkg/extension/src/scripts/content/prepare-content.js b/pkg/extension/src/scripts/content/prepare-content.js index 59208ebed..01dc089fa 100644 --- a/pkg/extension/src/scripts/content/prepare-content.js +++ b/pkg/extension/src/scripts/content/prepare-content.js @@ -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 = `${document.head.innerHTML}${contentCopyEl.innerHTML}`; + * 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 = `${document.head.innerHTML}${contentCopyEl.innerHTML}` // 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 +})() diff --git a/pkg/extension/src/scripts/content/toast.js b/pkg/extension/src/scripts/content/toast.js index 1751b497f..8c1d6f9fd 100644 --- a/pkg/extension/src/scripts/content/toast.js +++ b/pkg/extension/src/scripts/content/toast.js @@ -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
  • item before the
  • { + 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 = ` + + ${labelValue} + + ` + + 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) + }) } } } diff --git a/pkg/extension/src/views/toast.html b/pkg/extension/src/views/toast.html index 31bf5c65b..3130869be 100644 --- a/pkg/extension/src/views/toast.html +++ b/pkg/extension/src/views/toast.html @@ -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 @@ } } + +
    @@ -421,14 +506,16 @@
    - -
    - -
    - +
    +
      +
    • + +
    • +
    - + +