Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit 0add791

Browse files
authored
feat: link embed (#81)
1 parent 93130b6 commit 0add791

19 files changed

Lines changed: 625 additions & 171 deletions

File tree

‎components/editor/dictionary.tsx‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import useI18n from 'libs/web/hooks/use-i18n'
2+
import { useMemo } from 'react'
3+
4+
export const useDictionary = () => {
5+
const { t } = useI18n()
6+
7+
const dictionary = useMemo(
8+
() => ({
9+
addColumnAfter: t('Insert column after'),
10+
addColumnBefore: t('Insert column before'),
11+
addRowAfter: t('Insert row after'),
12+
addRowBefore: t('Insert row before'),
13+
alignCenter: t('Align center'),
14+
alignLeft: t('Align left'),
15+
alignRight: t('Align right'),
16+
bulletList: t('Bulleted list'),
17+
checkboxList: t('Todo list'),
18+
codeBlock: t('Code block'),
19+
codeCopied: t('Copied to clipboard'),
20+
codeInline: t('Code'),
21+
createLink: t('Create link'),
22+
createLinkError: t('Sorry, an error occurred creating the link'),
23+
createNewDoc: t('Create a new note'),
24+
deleteColumn: t('Delete column'),
25+
deleteRow: t('Delete row'),
26+
deleteTable: t('Delete table'),
27+
deleteImage: t('Delete image'),
28+
alignImageLeft: t('Float left half width'),
29+
alignImageRight: t('Float right half width'),
30+
alignImageDefault: t('Center large'),
31+
em: t('Italic'),
32+
embedInvalidLink: t('Sorry, that link won’t work for this embed type'),
33+
findOrCreateDoc: t('Find or create a note…'),
34+
h1: t('Big heading'),
35+
h2: t('Medium heading'),
36+
h3: t('Small heading'),
37+
heading: t('Heading'),
38+
hr: t('Divider'),
39+
image: t('Image'),
40+
imageUploadError: t('Sorry, an error occurred uploading the image'),
41+
info: t('Info'),
42+
infoNotice: t('Info notice'),
43+
link: t('Link'),
44+
linkCopied: t('Link copied to clipboard'),
45+
mark: t('Highlight'),
46+
newLineEmpty: t("Type '/' to insert…"),
47+
newLineWithSlash: t('Keep typing to filter…'),
48+
noResults: t('No results'),
49+
openLink: t('Open link'),
50+
orderedList: t('Ordered list'),
51+
pageBreak: t('Page break'),
52+
pasteLink: t('Paste a link…'),
53+
pasteLinkWithTitle: (title: string): string =>
54+
t(`Paste a {{title}} link…`, { title }),
55+
placeholder: t('Placeholder'),
56+
quote: t('Quote'),
57+
removeLink: t('Remove link'),
58+
searchOrPasteLink: t('Search or paste a link…'),
59+
strikethrough: t('Strikethrough'),
60+
strong: t('Bold'),
61+
subheading: t('Subheading'),
62+
table: t('Table'),
63+
tip: t('Tip'),
64+
tipNotice: t('Tip notice'),
65+
warning: t('Warning'),
66+
warningNotice: t('Warning notice'),
67+
}),
68+
[t]
69+
)
70+
71+
return dictionary
72+
}

‎components/editor/editor.tsx‎

Lines changed: 8 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
1+
import { FC, useEffect, useState } from 'react'
22
import { use100vh } from 'react-div-100vh'
33
import MarkdownEditor, { Props } from 'rich-markdown-editor'
44
import { useEditorTheme } from './theme'
55
import useMounted from 'libs/web/hooks/use-mounted'
6-
import useI18n from 'libs/web/hooks/use-i18n'
76
import Tooltip from './tooltip'
87
import extensions from './extensions'
98
import EditorState from 'libs/web/state/editor'
109
import { useToast } from 'libs/web/hooks/use-toast'
10+
import { useDictionary } from './dictionary'
11+
import { useEmbeds } from './embeds'
1112

1213
export type EditorProps = Pick<Props, 'readOnly'>
1314

@@ -18,88 +19,18 @@ const Editor: FC<EditorProps> = ({ readOnly }) => {
1819
onClickLink,
1920
onUploadImage,
2021
onHoverLink,
21-
onNoteChange,
22+
onEditorChange,
2223
backlinks,
2324
editorEl,
2425
note,
2526
} = EditorState.useContainer()
2627
const height = use100vh()
2728
const mounted = useMounted()
28-
const { t } = useI18n()
2929
const editorTheme = useEditorTheme()
3030
const [hasMinHeight, setHasMinHeight] = useState(true)
3131
const toast = useToast()
32-
33-
const dictionary = useMemo(
34-
() => ({
35-
addColumnAfter: t('Insert column after'),
36-
addColumnBefore: t('Insert column before'),
37-
addRowAfter: t('Insert row after'),
38-
addRowBefore: t('Insert row before'),
39-
alignCenter: t('Align center'),
40-
alignLeft: t('Align left'),
41-
alignRight: t('Align right'),
42-
bulletList: t('Bulleted list'),
43-
checkboxList: t('Todo list'),
44-
codeBlock: t('Code block'),
45-
codeCopied: t('Copied to clipboard'),
46-
codeInline: t('Code'),
47-
createLink: t('Create link'),
48-
createLinkError: t('Sorry, an error occurred creating the link'),
49-
createNewDoc: t('Create a new note'),
50-
deleteColumn: t('Delete column'),
51-
deleteRow: t('Delete row'),
52-
deleteTable: t('Delete table'),
53-
deleteImage: t('Delete image'),
54-
alignImageLeft: t('Float left half width'),
55-
alignImageRight: t('Float right half width'),
56-
alignImageDefault: t('Center large'),
57-
em: t('Italic'),
58-
embedInvalidLink: t('Sorry, that link won’t work for this embed type'),
59-
findOrCreateDoc: t('Find or create a note…'),
60-
h1: t('Big heading'),
61-
h2: t('Medium heading'),
62-
h3: t('Small heading'),
63-
heading: t('Heading'),
64-
hr: t('Divider'),
65-
image: t('Image'),
66-
imageUploadError: t('Sorry, an error occurred uploading the image'),
67-
info: t('Info'),
68-
infoNotice: t('Info notice'),
69-
link: t('Link'),
70-
linkCopied: t('Link copied to clipboard'),
71-
mark: t('Highlight'),
72-
newLineEmpty: t("Type '/' to insert…"),
73-
newLineWithSlash: t('Keep typing to filter…'),
74-
noResults: t('No results'),
75-
openLink: t('Open link'),
76-
orderedList: t('Ordered list'),
77-
pageBreak: t('Page break'),
78-
pasteLink: t('Paste a link…'),
79-
pasteLinkWithTitle: (title: string): string =>
80-
t(`Paste a {{title}} link…`, { title }),
81-
placeholder: t('Placeholder'),
82-
quote: t('Quote'),
83-
removeLink: t('Remove link'),
84-
searchOrPasteLink: t('Search or paste a link…'),
85-
strikethrough: t('Strikethrough'),
86-
strong: t('Bold'),
87-
subheading: t('Subheading'),
88-
table: t('Table'),
89-
tip: t('Tip'),
90-
tipNotice: t('Tip notice'),
91-
warning: t('Warning'),
92-
warningNotice: t('Warning notice'),
93-
}),
94-
[t]
95-
)
96-
97-
const onEditorChange = useCallback(
98-
(value: () => string): void => {
99-
onNoteChange.callback({ content: value() })
100-
},
101-
[onNoteChange]
102-
)
32+
const dictionary = useDictionary()
33+
const embeds = useEmbeds()
10334

10435
useEffect(() => {
10536
setHasMinHeight((backlinks?.length ?? 0) <= 0)
@@ -124,6 +55,7 @@ const Editor: FC<EditorProps> = ({ readOnly }) => {
12455
tooltip={Tooltip}
12556
extensions={extensions}
12657
className="px-4 md:px-0"
58+
embeds={embeds}
12759
/>
12860
<style jsx global>{`
12961
.ProseMirror ul {
@@ -150,7 +82,7 @@ const Editor: FC<EditorProps> = ({ readOnly }) => {
15082
.ProseMirror h3 {
15183
font-size: 1.5em;
15284
}
153-
.ProseMirror a {
85+
.ProseMirror a:not(.bookmark) {
15486
text-decoration: underline;
15587
}
15688
`}</style>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Skeleton } from '@material-ui/lab'
2+
import useFetcher from 'libs/web/api/fetcher'
3+
import { decode } from 'qss'
4+
import { FC, useEffect, useState } from 'react'
5+
import { Metadata } from 'unfurl.js/dist/types'
6+
import { EmbedProps } from '.'
7+
8+
export const Bookmark: FC<EmbedProps> = ({ attrs: { href } }) => {
9+
const { request } = useFetcher()
10+
const [data, setData] = useState<Metadata>()
11+
12+
useEffect(() => {
13+
request<undefined, Metadata>({
14+
url: href,
15+
method: 'GET',
16+
}).then((data) => {
17+
setData(data)
18+
})
19+
}, [href, request])
20+
21+
const image = data?.open_graph?.images?.[0].url ?? data?.favicon
22+
const title = data?.open_graph?.title ?? data?.title
23+
const description = data?.open_graph?.description ?? data?.description
24+
const url =
25+
data?.open_graph?.url ??
26+
decode<{ url: string }>(href.replace(/.*\?/, '')).url
27+
28+
if (!data) {
29+
return <Skeleton variant="rect" height={128}></Skeleton>
30+
}
31+
32+
return (
33+
<a
34+
className="bookmark overflow-hidden border-gray-200 border rounded flex h-32 !no-underline hover:bg-blue-50"
35+
href={url}
36+
target="_blank"
37+
rel="noreferrer"
38+
>
39+
<div className="flex-1 p-2 overflow-hidden">
40+
<div className="mb-2 block text-gray-800 overflow-ellipsis overflow-hidden h-6">
41+
{title}
42+
</div>
43+
<div className="text-sm overflow-ellipsis overflow-hidden h-10 text-gray-400 mb-2">
44+
{description}
45+
</div>
46+
<div className="text-sm overflow-ellipsis overflow-hidden h-5">
47+
{url}
48+
</div>
49+
</div>
50+
{!!image && (
51+
<div className="md:w-48 flex w-0">
52+
<img className="m-auto object-cover h-full" src={image} alt={title} />
53+
</div>
54+
)}
55+
</a>
56+
)
57+
}

‎components/editor/embeds/embed.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Skeleton } from '@material-ui/lab'
2+
import useFetcher from 'libs/web/api/fetcher'
3+
import { FC, useEffect, useState } from 'react'
4+
import { Metadata } from 'unfurl.js/dist/types'
5+
import { EmbedProps } from '.'
6+
import InnerHTML from 'dangerously-set-html-content'
7+
import { decode } from 'qss'
8+
9+
export const Embed: FC<EmbedProps> = ({ attrs: { href } }) => {
10+
const { request } = useFetcher()
11+
const [data, setData] = useState<Metadata>()
12+
13+
useEffect(() => {
14+
request<undefined, Metadata>({
15+
url: href,
16+
method: 'GET',
17+
}).then((data) => {
18+
setData(data)
19+
})
20+
}, [href, request])
21+
22+
if (!data) {
23+
return <Skeleton variant="rect" height={128}></Skeleton>
24+
}
25+
26+
const html = (data?.oEmbed as any)?.html
27+
28+
if (html) {
29+
return <InnerHTML html={html} />
30+
}
31+
32+
const url =
33+
data?.open_graph?.url ??
34+
decode<{ url: string }>(href.replace(/.*\?/, '')).url
35+
36+
return <iframe className="w-full h-96" src={url} allowFullScreen />
37+
}

‎components/editor/embeds/index.tsx‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import CsrfTokenState from 'libs/web/state/csrf-token'
2+
import { useCallback } from 'react'
3+
import { Props } from 'rich-markdown-editor'
4+
import { Bookmark } from './bookmark'
5+
import { Embed } from './embed'
6+
7+
export type EmbedProps = {
8+
attrs: {
9+
href: string
10+
matches: string[]
11+
}
12+
}
13+
14+
export const useEmbeds = () => {
15+
const csrfToken = CsrfTokenState.useContainer()
16+
17+
const createEmbedComponent = useCallback(
18+
(Component) => {
19+
return (props: EmbedProps) => {
20+
return (
21+
<CsrfTokenState.Provider initialState={csrfToken}>
22+
<Component {...props} />
23+
</CsrfTokenState.Provider>
24+
)
25+
}
26+
},
27+
[csrfToken]
28+
)
29+
30+
return [
31+
{
32+
title: 'Bookmark',
33+
matcher: (url) => url.match(/^\/api\/extract\?type=bookmark/),
34+
component: createEmbedComponent(Bookmark),
35+
},
36+
{
37+
title: 'Embed',
38+
matcher: (url) => url.match(/^\/api\/extract\?type=embed/),
39+
component: createEmbedComponent(Embed),
40+
},
41+
] as Props['embeds']
42+
}

‎components/icon-button.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
LinkIcon,
1616
ArrowSmLeftIcon,
1717
ArrowSmRightIcon,
18+
ExternalLinkIcon,
19+
BookmarkAltIcon,
20+
PuzzleIcon,
1821
} from '@heroicons/react/outline'
1922

2023
export const ICONS = {
@@ -32,6 +35,9 @@ export const ICONS = {
3235
Link: LinkIcon,
3336
ArrowSmLeft: ArrowSmLeftIcon,
3437
ArrowSmRight: ArrowSmRightIcon,
38+
ExternalLink: ExternalLinkIcon,
39+
BookmarkAlt: BookmarkAltIcon,
40+
Puzzle: PuzzleIcon,
3541
}
3642

3743
const IconButton = forwardRef<

‎components/layout/layout-main.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { SwipeableDrawer } from '@material-ui/core'
1515
import SidebarMenu from 'components/portal/sidebar-menu/sidebar-menu'
1616
import { NoteModel } from 'libs/shared/note'
1717
import PreviewModal from 'components/portal/preview-modal'
18+
import LinkToolbar from 'components/portal/link-toolbar/link-toolbar'
1819

1920
const MainWrapper: FC = ({ children }) => {
2021
const {
@@ -103,6 +104,7 @@ const LayoutMain: FC<{
103104
</SearchState.Provider>
104105
<ShareModal />
105106
<PreviewModal />
107+
<LinkToolbar />
106108
<SidebarMenu />
107109
</NoteState.Provider>
108110
</NoteTreeState.Provider>

0 commit comments

Comments
 (0)