mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #2623 from omnivore-app/web/feat-export-highlights-and-notes
Add ability to export highlights and notes from notebook
This commit is contained in:
commit
8e2e36aa62
9 changed files with 152 additions and 73 deletions
46
packages/web/components/elements/icons/ExportIcon.tsx
Normal file
46
packages/web/components/elements/icons/ExportIcon.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* eslint-disable functional/no-class */
|
||||
/* eslint-disable functional/no-this-expression */
|
||||
import { IconProps } from './IconProps'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export class ExportIcon extends React.Component<IconProps> {
|
||||
render() {
|
||||
const size = (this.props.size || 26).toString()
|
||||
const color = (this.props.color || '#2A2A2A').toString()
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 26 26"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M9.19727 9.54199H8.1556C7.60306 9.54199 7.07316 9.76149 6.68246 10.1522C6.29176 10.5429 6.07227 11.0728 6.07227 11.6253V19.9587C6.07227 20.5112 6.29176 21.0411 6.68246 21.4318C7.07316 21.8225 7.60306 22.042 8.1556 22.042H18.5723C19.1248 22.042 19.6547 21.8225 20.0454 21.4318C20.4361 21.0411 20.6556 20.5112 20.6556 19.9587V11.6253C20.6556 11.0728 20.4361 10.5429 20.0454 10.1522C19.6547 9.76149 19.1248 9.54199 18.5723 9.54199H17.5306"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.3643 14.7503V3.29199"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.2393 6.41699L13.3643 3.29199L16.4893 6.41699"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable react/no-children-prop */
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import type { Highlight } from '../../lib/networking/fragments/highlightFragment'
|
||||
import { LabelChip } from '../elements/LabelChip'
|
||||
import {
|
||||
|
|
@ -14,7 +14,6 @@ import { HighlightViewNote } from './HighlightNotes'
|
|||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { isDarkTheme } from '../../lib/themeUpdater'
|
||||
import { HighlightsMenu } from '../templates/homeFeed/HighlightItem'
|
||||
import { ReadableItem } from '../../lib/networking/queries/useGetLibraryItemsQuery'
|
||||
import { UserBasicData } from '../../lib/networking/queries/useGetViewerQuery'
|
||||
import {
|
||||
|
|
@ -25,7 +24,6 @@ import {
|
|||
useHover,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react'
|
||||
import { LibraryHoverActions } from './LibraryCards/LibraryHoverActions'
|
||||
import { HighlightHoverActions } from './HighlightHoverActions'
|
||||
|
||||
type HighlightViewProps = {
|
||||
|
|
@ -54,7 +52,6 @@ const StyledQuote = styled(Blockquote, {
|
|||
export function HighlightView(props: HighlightViewProps): JSX.Element {
|
||||
const isDark = isDarkTheme()
|
||||
const [noteMode, setNoteMode] = useState<'preview' | 'edit'>('preview')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
|
|
@ -73,7 +70,7 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
|
|||
const hover = useHover(context)
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([hover])
|
||||
const highlightAlpha = isDark ? 1.0 : 0.35
|
||||
const highlightAlpha = isDark ? 0.5 : 0.35
|
||||
|
||||
return (
|
||||
<VStack
|
||||
|
|
@ -112,13 +109,19 @@ export function HighlightView(props: HighlightViewProps): JSX.Element {
|
|||
<StyledQuote>
|
||||
<SpanBox
|
||||
css={{
|
||||
'*': {
|
||||
m: '0px',
|
||||
'> *': {
|
||||
display: 'inline',
|
||||
padding: '2px',
|
||||
backgroundColor: `rgba(var(--colors-highlightBackground), ${highlightAlpha})`,
|
||||
boxShadow: `3px 0 0 rgba(var(--colors-highlightBackground), ${highlightAlpha}), -3px 0 0 rgba(var(--colors-highlightBackground), ${highlightAlpha})`,
|
||||
boxDecorationBreak: 'clone',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
'> ul': {
|
||||
display: 'block',
|
||||
boxShadow: 'unset',
|
||||
backgroundColor: 'unset',
|
||||
},
|
||||
fontSize: '15px',
|
||||
lineHeight: 1.5,
|
||||
color: '$thTextSubtle2',
|
||||
|
|
|
|||
|
|
@ -776,7 +776,11 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
}}
|
||||
>
|
||||
<>
|
||||
<NotebookHeader setShowNotebook={props.setShowHighlightsModal} />
|
||||
<NotebookHeader
|
||||
viewer={props.viewer}
|
||||
item={props.item}
|
||||
setShowNotebook={props.setShowHighlightsModal}
|
||||
/>
|
||||
<NotebookContent
|
||||
viewer={props.viewer}
|
||||
item={props.item}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { StyledText } from '../../elements/StyledText'
|
|||
import { theme } from '../../tokens/stitches.config'
|
||||
import type { Highlight } from '../../../lib/networking/fragments/highlightFragment'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { CaretDown, CaretRight } from 'phosphor-react'
|
||||
import { updateHighlightMutation } from '../../../lib/networking/mutations/updateHighlightMutation'
|
||||
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
||||
import { diff_match_patch } from 'diff-match-patch'
|
||||
|
|
@ -382,47 +381,3 @@ export function NotebookContent(props: NotebookContentProps): JSX.Element {
|
|||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
type SectionTitleProps = {
|
||||
title: string
|
||||
selected: boolean
|
||||
setSelected: (set: boolean) => void
|
||||
}
|
||||
|
||||
function SectionTitle(props: SectionTitleProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
style="plainIcon"
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
color: props.selected ? '$thTextContrast' : '$thTextSubtle',
|
||||
borderBottom: props.selected
|
||||
? '1px solid $thTextContrast'
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
onClick={(event) => {
|
||||
props.setSelected(true)
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
m: '0px',
|
||||
pt: '2px',
|
||||
pb: '2px',
|
||||
px: '5px',
|
||||
fontFamily: '$inter',
|
||||
fontWeight: '500',
|
||||
fontSize: '13px',
|
||||
color: '$thNotebookSubtle',
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</StyledText>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,47 @@
|
|||
import { useCallback } from 'react'
|
||||
import { Highlight } from '../../../lib/networking/fragments/highlightFragment'
|
||||
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
|
||||
import {
|
||||
UserBasicData,
|
||||
useGetViewerQuery,
|
||||
} from '../../../lib/networking/queries/useGetViewerQuery'
|
||||
import { CloseButton } from '../../elements/CloseButton'
|
||||
import { Dropdown, DropdownOption } from '../../elements/DropdownElements'
|
||||
import { HStack } from '../../elements/LayoutPrimitives'
|
||||
import { MenuTrigger } from '../../elements/MenuTrigger'
|
||||
import { StyledText } from '../../elements/StyledText'
|
||||
import { NotebookModal } from './NotebookModal'
|
||||
import { Sidebar } from 'phosphor-react'
|
||||
import { theme } from '../../tokens/stitches.config'
|
||||
import { Button } from '../../elements/Button'
|
||||
import { ExportIcon } from '../../elements/icons/ExportIcon'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { UserBasicData } from '../../../lib/networking/queries/useGetViewerQuery'
|
||||
import { ReadableItem } from '../../../lib/networking/queries/useGetLibraryItemsQuery'
|
||||
import { useGetArticleQuery } from '../../../lib/networking/queries/useGetArticleQuery'
|
||||
import { highlightsAsMarkdown } from '../homeFeed/HighlightItem'
|
||||
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
|
||||
|
||||
type NotebookHeaderProps = {
|
||||
viewer: UserBasicData
|
||||
item: ReadableItem
|
||||
|
||||
setShowNotebook: (set: boolean) => void
|
||||
}
|
||||
|
||||
export const NotebookHeader = (props: NotebookHeaderProps) => {
|
||||
const handleClose = useCallback(() => {
|
||||
props.setShowNotebook(false)
|
||||
}, [props])
|
||||
const { articleData } = useGetArticleQuery({
|
||||
slug: props.item.slug,
|
||||
username: props.viewer.profile.username,
|
||||
includeFriendsHighlights: false,
|
||||
})
|
||||
|
||||
const exportHighlights = useCallback(() => {
|
||||
if (articleData?.article.article.highlights) {
|
||||
const markdown = highlightsAsMarkdown(
|
||||
articleData?.article.article.highlights
|
||||
)
|
||||
if (markdown.length > 1) {
|
||||
;(async () => {
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
showSuccessToast('Highlights and notes copied')
|
||||
})()
|
||||
} else {
|
||||
showSuccessToast('Nothing to export')
|
||||
}
|
||||
} else {
|
||||
showErrorToast('Could not copy highlights')
|
||||
}
|
||||
}, [articleData])
|
||||
|
||||
return (
|
||||
<HStack
|
||||
|
|
@ -68,6 +87,19 @@ export const NotebookHeader = (props: NotebookHeaderProps) => {
|
|||
title="Delete Article Note"
|
||||
/>
|
||||
</Dropdown> */}
|
||||
|
||||
<Button
|
||||
style="plainIcon"
|
||||
onClick={(event) => {
|
||||
exportHighlights()
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ExportIcon
|
||||
size={25}
|
||||
color={theme.colors.thNotebookSubtle.toString()}
|
||||
/>
|
||||
</Button>
|
||||
<Button style="plainIcon" onClick={() => props.setShowNotebook(false)}>
|
||||
<Sidebar size={25} color={theme.colors.thNotebookSubtle.toString()} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ export const NotebookPresenter = (props: NotebookPresenterProps) => {
|
|||
}}
|
||||
>
|
||||
<>
|
||||
<NotebookHeader setShowNotebook={props.setOpen} />
|
||||
<NotebookHeader
|
||||
viewer={props.viewer}
|
||||
item={props.item}
|
||||
setShowNotebook={props.setOpen}
|
||||
/>
|
||||
<NotebookContent
|
||||
viewer={props.viewer}
|
||||
item={props.item}
|
||||
|
|
|
|||
|
|
@ -508,7 +508,11 @@ export default function PdfArticleContainer(
|
|||
}}
|
||||
>
|
||||
<>
|
||||
<NotebookHeader setShowNotebook={props.setShowHighlightsModal} />
|
||||
<NotebookHeader
|
||||
viewer={props.viewer}
|
||||
item={props.article}
|
||||
setShowNotebook={props.setShowHighlightsModal}
|
||||
/>
|
||||
<NotebookContent
|
||||
viewer={props.viewer}
|
||||
item={props.article}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { Box, VStack } from '../../elements/LayoutPrimitives'
|
||||
|
||||
import { styled, theme } from '../../tokens/stitches.config'
|
||||
import { getHighlightLocation } from '../article/Notebook'
|
||||
|
||||
type HighlightsMenuProps = {
|
||||
viewer: UserBasicData
|
||||
|
|
@ -131,6 +132,36 @@ export function HighlightsMenu(props: HighlightsMenuProps): JSX.Element {
|
|||
)
|
||||
}
|
||||
|
||||
const sortHighlights = (highlights: Highlight[]) => {
|
||||
const sorted = (a: number, b: number) => {
|
||||
if (a < b) {
|
||||
return -1
|
||||
}
|
||||
if (a > b) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
return (highlights ?? [])
|
||||
.filter((h) => h.type === 'HIGHLIGHT')
|
||||
.sort((a: Highlight, b: Highlight) => {
|
||||
if (a.highlightPositionPercent && b.highlightPositionPercent) {
|
||||
return sorted(a.highlightPositionPercent, b.highlightPositionPercent)
|
||||
}
|
||||
// We do this in a try/catch because it might be an invalid diff
|
||||
// With PDF it will definitely be an invalid diff.
|
||||
try {
|
||||
const aPos = getHighlightLocation(a.patch)
|
||||
const bPos = getHighlightLocation(b.patch)
|
||||
if (aPos && bPos) {
|
||||
return sorted(aPos, bPos)
|
||||
}
|
||||
} catch {}
|
||||
return a.createdAt.localeCompare(b.createdAt)
|
||||
})
|
||||
}
|
||||
|
||||
export function highlightAsMarkdown(highlight: Highlight) {
|
||||
let buffer = `> ${highlight.quote}`
|
||||
if (highlight.annotation) {
|
||||
|
|
@ -143,14 +174,14 @@ export function highlightAsMarkdown(highlight: Highlight) {
|
|||
export function highlightsAsMarkdown(highlights: Highlight[]) {
|
||||
const noteMD = highlights.find((h) => h.type == 'NOTE')
|
||||
|
||||
const highlightMD = highlights
|
||||
const highlightMD = sortHighlights(highlights)
|
||||
.filter((h) => h.type == 'HIGHLIGHT')
|
||||
.map((highlight) => {
|
||||
return highlightAsMarkdown(highlight)
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
if (noteMD) {
|
||||
if (noteMD?.annotation) {
|
||||
return `${noteMD.annotation}\n\n${highlightMD}`
|
||||
}
|
||||
return highlightMD
|
||||
|
|
|
|||
|
|
@ -280,8 +280,8 @@ const darkThemeSpec = {
|
|||
thLibrarySelectionColor: '#3D3D3D',
|
||||
|
||||
thNotebookSubtle: '#898989',
|
||||
thNotebookBorder: '#898989',
|
||||
thNotebookBackground: '#3B3938',
|
||||
thNotebookBorder: '#3D3D3D',
|
||||
thNotebookBackground: '#2F2F2F',
|
||||
thNotebookTextBackground: '#3D3D3D',
|
||||
thNotebookHighContrast: '#2A2A2A',
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue