Skip to content

Commit 021ea81

Browse files
ahtesham-quraishAhtesham Quraish
andauthored
feat: course card embedding plugin (#2717)
* feat: adding course plugin in tiptap --------- Co-authored-by: Ahtesham Quraish <[email protected]>
1 parent 78471c1 commit 021ea81

File tree

10 files changed

+310
-0
lines changed

10 files changed

+310
-0
lines changed

frontends/ol-components/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@dnd-kit/core": "^6.0.8",
1818
"@dnd-kit/sortable": "^10.0.0",
1919
"@dnd-kit/utilities": "^3.2.1",
20+
"@ebay/nice-modal-react": "^1.2.13",
2021
"@emotion/react": "^11.11.1",
2122
"@emotion/styled": "^11.11.0",
2223
"@floating-ui/react": "^0.27.16",
@@ -29,6 +30,7 @@
2930
"@radix-ui/react-popover": "^1.1.15",
3031
"@remixicon/react": "^4.2.0",
3132
"@testing-library/dom": "^10.4.0",
33+
"@tiptap/core": "^3.10.5",
3234
"@tiptap/extension-highlight": "^3.10.5",
3335
"@tiptap/extension-horizontal-rule": "^3.10.5",
3436
"@tiptap/extension-image": "^3.10.5",

frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import TiptapEditor, { MainToolbarContent } from "./TiptapEditor"
2727

2828
// --- Tiptap Node ---
2929
import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension"
30+
import { LearningResourceNode } from "./extensions/node/learning-resource-node/learning-resource-node"
3031
import { MediaEmbed } from "./components/tiptap-node/media-embed/media-embed-extension"
3132
import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
3233

@@ -177,6 +178,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
177178
},
178179
}),
179180
HorizontalRule,
181+
LearningResourceNode,
180182
TextAlign.configure({ types: ["heading", "paragraph"] }),
181183
TaskList,
182184
TaskItem.configure({ nested: true }),

frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { LinkPopover } from "./components/tiptap-ui/link-popover"
3535
import { MarkButton } from "./components/tiptap-ui/mark-button"
3636
import { TextAlignButton } from "./components/tiptap-ui/text-align-button"
3737
import { UndoRedoButton } from "./components/tiptap-ui/undo-redo-button"
38+
import { LearningResourceEmbedButton } from "./extensions/ui/learning-resource-button/learning-resource-button"
3839

3940
// --- Styles ---
4041
import "./styles/_keyframe-animations.scss"
@@ -117,6 +118,7 @@ export const MainToolbarContent = ({ editor }: TiptapEditorToolbarProps) => {
117118
<ImageUploadButton text="Add" />
118119
</ToolbarGroup>
119120
<ToolbarGroup>
121+
<LearningResourceEmbedButton editor={editor} text="Course" />
120122
<MediaEmbedButton editor={editor} text="Embed" />
121123
</ToolbarGroup>
122124
<Spacer />
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react"
2+
import { NodeViewWrapper } from "@tiptap/react"
3+
import { LearningResourceListCard, styled } from "ol-components"
4+
import { useLearningResourcesDetail } from "api/hooks/learningResources"
5+
import type { ReactNodeViewProps } from "@tiptap/react"
6+
7+
const StyledLearningResourceListCard = styled(LearningResourceListCard)({
8+
"&& a": {
9+
color: "inherit",
10+
textDecoration: "none",
11+
},
12+
"&& a span": {
13+
textDecoration: "none",
14+
},
15+
})
16+
17+
export const LearningResourceNodeView = ({ node }: ReactNodeViewProps) => {
18+
const resourceId = node.attrs.resourceId
19+
const href = node.attrs.href
20+
21+
const { data, isLoading } = useLearningResourcesDetail(resourceId)
22+
23+
return (
24+
<NodeViewWrapper className="learning-resource-node">
25+
<StyledLearningResourceListCard
26+
resource={data}
27+
href={href}
28+
isLoading={isLoading}
29+
/>
30+
</NodeViewWrapper>
31+
)
32+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Node, mergeAttributes, type CommandProps } from "@tiptap/core"
2+
import { ReactNodeViewRenderer } from "@tiptap/react"
3+
import { LearningResourceNodeView } from "./LearningResourceListCard"
4+
5+
declare module "@tiptap/core" {
6+
interface Commands<ReturnType> {
7+
learningResource: {
8+
insertLearningResource: (resourceId: number, href?: string) => ReturnType
9+
}
10+
}
11+
}
12+
export interface LearningResourceOptions {
13+
HTMLAttributes: Record<string, string | number | null | undefined>
14+
}
15+
16+
export const LearningResourceNode = Node.create<LearningResourceOptions>({
17+
name: "learningResource",
18+
19+
group: "block",
20+
atom: true,
21+
selectable: true,
22+
23+
addAttributes() {
24+
return {
25+
resourceId: {
26+
default: null,
27+
},
28+
href: {
29+
default: null,
30+
},
31+
}
32+
},
33+
34+
parseHTML() {
35+
return [{ tag: "learning-resource" }]
36+
},
37+
38+
renderHTML({ HTMLAttributes }) {
39+
return ["learning-resource", mergeAttributes(HTMLAttributes)]
40+
},
41+
42+
addCommands() {
43+
return {
44+
insertLearningResource:
45+
(resourceId: number, href?: string) =>
46+
({ commands }: CommandProps) => {
47+
return commands.insertContent({
48+
type: this.name,
49+
attrs: { resourceId, href },
50+
})
51+
},
52+
}
53+
},
54+
addNodeView() {
55+
return ReactNodeViewRenderer(LearningResourceNodeView)
56+
},
57+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from "react"
2+
3+
export const Icon: React.FC<React.SVGProps<SVGSVGElement>> = ({
4+
className,
5+
...props
6+
}) => (
7+
<svg
8+
viewBox="0 0 24 24"
9+
width="34"
10+
height="34"
11+
fill="none"
12+
stroke="currentColor"
13+
strokeWidth={1.8}
14+
strokeLinecap="round"
15+
strokeLinejoin="round"
16+
className={className ?? "tiptap-button-icon"}
17+
aria-hidden="true"
18+
{...props}
19+
>
20+
<title>Insert course</title>
21+
22+
{/* Rounded Book */}
23+
<rect x="4" y="3" width="16" height="18" rx="3" ry="3" />
24+
25+
{/* Content lines */}
26+
<line x1="8" y1="8" x2="14" y2="8" />
27+
<line x1="8" y1="11" x2="12" y2="11" />
28+
</svg>
29+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { useState, useCallback } from "react"
2+
import NiceModal, { muiDialogV5 } from "@ebay/nice-modal-react"
3+
import { TextField, Alert } from "@mitodl/smoot-design"
4+
import { FormDialog } from "ol-components"
5+
6+
const CourseUrlInputDialog = NiceModal.create(() => {
7+
const modal = NiceModal.useModal()
8+
const [url, setUrl] = useState("")
9+
const [error, setError] = useState<string | null>(null)
10+
11+
const handleSubmit = (e?: React.FormEvent) => {
12+
e?.preventDefault()
13+
14+
if (!url.trim()) {
15+
setError("URL is required")
16+
return
17+
}
18+
modal.resolve(url)
19+
modal.hide()
20+
}
21+
22+
const handleReset = useCallback(() => {
23+
setUrl("")
24+
setError(null)
25+
}, [setUrl, setError])
26+
27+
return (
28+
<FormDialog
29+
{...muiDialogV5(modal)}
30+
title="Course Media"
31+
confirmText="Insert"
32+
onSubmit={handleSubmit}
33+
onReset={handleReset}
34+
noValidate
35+
fullWidth
36+
>
37+
<TextField
38+
name="courseUrl"
39+
label="Course URL"
40+
placeholder="learn.mit.edu/search?resource=297..."
41+
value={url}
42+
error={!!error}
43+
errorText={error ?? ""}
44+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
45+
setError(null)
46+
setUrl(e.target.value)
47+
}}
48+
fullWidth
49+
/>
50+
51+
{error && <Alert severity="error">{error}</Alert>}
52+
</FormDialog>
53+
)
54+
})
55+
56+
export default CourseUrlInputDialog
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React, { forwardRef, useCallback } from "react"
2+
import type { Editor } from "@tiptap/core"
3+
import { Button } from "../../../components/tiptap-ui-primitive/button"
4+
import { Badge } from "../../../components/tiptap-ui-primitive/badge"
5+
import { parseShortcutKeys } from "../../../lib/tiptap-utils"
6+
import {
7+
useLearningResourceEmbed,
8+
LEARNING_RESOURCE_SHORTCUT_KEY,
9+
} from "./useLearningResourceEmbed"
10+
import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
11+
12+
export interface LearningResourceEmbedButtonProps {
13+
editor?: Editor
14+
text?: string
15+
showShortcut?: boolean
16+
icon?: React.FC<React.SVGProps<SVGSVGElement>>
17+
onClick?: (e: React.MouseEvent) => void
18+
}
19+
20+
function LearningResourceShortcutBadge() {
21+
return (
22+
<Badge>
23+
{parseShortcutKeys({ shortcutKeys: LEARNING_RESOURCE_SHORTCUT_KEY })}
24+
</Badge>
25+
)
26+
}
27+
28+
export const LearningResourceEmbedButton = forwardRef<
29+
HTMLButtonElement,
30+
LearningResourceEmbedButtonProps
31+
>(
32+
(
33+
{
34+
editor: providedEditor,
35+
text,
36+
showShortcut,
37+
icon: CustomIcon,
38+
onClick,
39+
...props
40+
},
41+
ref,
42+
) => {
43+
const { editor } = useTiptapEditor(providedEditor)
44+
45+
const {
46+
isVisible,
47+
canInsert,
48+
label,
49+
Icon: DefaultIcon,
50+
handleEmbed,
51+
} = useLearningResourceEmbed(editor)
52+
53+
const handleClick = useCallback(
54+
(event: React.MouseEvent<HTMLButtonElement>) => {
55+
onClick?.(event)
56+
if (event.defaultPrevented) return
57+
handleEmbed()
58+
},
59+
[handleEmbed, onClick],
60+
)
61+
62+
if (!isVisible) return null
63+
64+
const RenderIcon = CustomIcon ?? DefaultIcon
65+
66+
return (
67+
<Button
68+
type="button"
69+
data-style="ghost"
70+
disabled={!canInsert}
71+
aria-label={label}
72+
tooltip={label}
73+
onClick={handleClick}
74+
ref={ref}
75+
{...props}
76+
>
77+
<RenderIcon />
78+
{text && <span className="tiptap-button-text">{text}</span>}
79+
{showShortcut && <LearningResourceShortcutBadge />}
80+
</Button>
81+
)
82+
},
83+
)
84+
85+
LearningResourceEmbedButton.displayName = "LearningResourceEmbedButton"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { useCallback } from "react"
2+
import type { Editor } from "@tiptap/react"
3+
import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
4+
import NiceModal from "@ebay/nice-modal-react"
5+
import CourseUrlInputDialog from "./ResourceUrlInputDialog"
6+
import { Icon } from "./Icon" // you create this SVG
7+
8+
export const LEARNING_RESOURCE_SHORTCUT_KEY = "Mod+Shift+R"
9+
10+
export function useLearningResourceEmbed(editor?: Editor | null) {
11+
const resolved = useTiptapEditor(editor).editor
12+
13+
const isVisible = !!resolved
14+
const canInsert = resolved?.isEditable ?? false
15+
const label = "Insert Learning Resource"
16+
17+
const handleEmbed = useCallback(async () => {
18+
const url: string = await NiceModal.show(CourseUrlInputDialog)
19+
if (!url) return
20+
21+
// Extract `resource=123`
22+
const match = url.match(/resource=(\d+)/)
23+
if (!match) {
24+
alert("Invalid URL. Must contain ?resource=ID")
25+
return
26+
}
27+
28+
const resourceId = Number(match[1])
29+
30+
resolved?.commands.insertLearningResource(resourceId, url)
31+
}, [resolved])
32+
33+
return {
34+
editor: resolved,
35+
isVisible,
36+
canInsert,
37+
label,
38+
Icon,
39+
isActive: false,
40+
shortcutKeys: LEARNING_RESOURCE_SHORTCUT_KEY,
41+
handleEmbed,
42+
}
43+
}

yarn.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)