Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontends/ol-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@remixicon/react": "^4.2.0",
"@testing-library/dom": "^10.4.0",
"@tiptap/core": "^3.11.0",
"@tiptap/extension-blockquote": "^3.11.0",
"@tiptap/extension-document": "^3.11.1",
"@tiptap/extension-heading": "^3.11.1",
"@tiptap/extension-highlight": "^3.11.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { Alert, Button, ButtonLink } from "@mitodl/smoot-design"
import Typography from "@mui/material/Typography"
import { useUserHasPermission, Permission } from "api/hooks/user"
import { BannerNode } from "./extensions/node/Banner/BannerNode"
import { Quote } from "./extensions/node/Quote/Quote"
import {
HEADER_HEIGHT,
HEADER_HEIGHT_MD,
Expand Down Expand Up @@ -274,6 +275,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
Subscript,
Selection,
Image,
Quote,
MediaEmbedNode,
DividerNode,
ArticleByLineInfoBarNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { UndoRedoButton } from "./vendor/components/tiptap-ui/undo-redo-button"
import { LearningResourceButton } from "./extensions/ui/LearningResource/LearningResourceButton"
import { Button } from "./vendor/components/tiptap-ui-primitive/button"
import { DividerButton } from "./extensions/ui/Divider/DividerButton"
import { QuoteButton } from "./extensions/ui/Quote/QuoteButton"
import {
DropdownMenu,
DropdownMenuTrigger,
Expand Down Expand Up @@ -135,6 +136,32 @@ const StyledEditorContent = styled(EditorContent, {
marginBottom: 0,
},
},
quote: {
backgroundColor: theme.custom.colors.lightGray1,
padding: "40px",
borderRadius: "8px",
marginBottom: "40px",
display: "block",
borderLeft: `2px solid ${theme.custom.colors.red}`,
"::before": {
content: '"“"', // opening inverted comma
position: "absolute",
left: "17px",
fontSize: "64px",
lineHeight: 1,
fontWeight: theme.typography.fontWeightRegular,
top: "-15px",
color: theme.custom.colors.red,
fontFamily: theme.typography.fontFamily,
},
p: {
position: "relative",
},
"p:last-child": {
marginBottom: 0,
marginTop: 0,
},
},
},
}))

Expand Down Expand Up @@ -180,6 +207,9 @@ export function InsertDropdownMenu({ editor }: TiptapEditorToolbarProps) {
<DropdownMenuItem asChild>
<DividerButton editor={editor} text="Divider" />
</DropdownMenuItem>
<DropdownMenuItem asChild>
<QuoteButton text="Quote" />
</DropdownMenuItem>
</StyledDropdownMenuWrapper>
</DropdownMenu>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Blockquote from "@tiptap/extension-blockquote"

export const Quote = Blockquote.extend({
name: "quote",

parseHTML() {
return [
{ tag: "quote" }, // Custom tag
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment: Let's stick to regular / valid HTML tags unless there's a very good reason.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are adding a custom tag because it would not be possible to reliably distinguish it from other regular HTML tags. Even Tiptap uses the <blockquote> tag to represent its default quote behavior.

To give a simple example: when we need to insert a paragraph after a quote node, we must be able to clearly identify what type of node it is. If we used a regular <div> tag, it could conflict with other <div> elements used elsewhere, making it difficult to determine the correct context.

Using a dedicated tag avoids these conflicts and allows us to handle quote-specific behavior more reliably.

{ tag: "blockquote" }, // Fallback for pasted content
]
},

// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderHTML({ HTMLAttributes }: { HTMLAttributes: { [key: string]: any } }) {
return ["quote", HTMLAttributes, 0]
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { forwardRef, useCallback } from "react"

// --- Tiptap UI ---
import type { UseQuoteConfig } from "./"
import { QUOTE_SHORTCUT_KEY, useQuote } from "./"

// --- Hooks ---
import { useTiptapEditor } from "../../../vendor/hooks/use-tiptap-editor"

// --- Lib ---
import { parseShortcutKeys } from "../../../vendor/lib/tiptap-utils"

// --- UI Primitives ---
import type { ButtonProps } from "../../../vendor/components/tiptap-ui-primitive/button"
import { Button } from "../../../vendor/components/tiptap-ui-primitive/button"
import { Badge } from "../../../vendor/components/tiptap-ui-primitive/badge"

export interface QuoteButtonProps
extends Omit<ButtonProps, "type">,
UseQuoteConfig {
/**
* Optional text to display alongside the icon.
*/
text?: string
/**
* Optional show shortcut keys in the button.
* @default false
*/
showShortcut?: boolean
}

export function QuoteShortcutBadge({
shortcutKeys = QUOTE_SHORTCUT_KEY,
}: {
shortcutKeys?: string
}) {
return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>
}

/**
* Button component for toggling blockquote in a Tiptap editor.
*
* For custom button implementations, use the `useQuote` hook instead.
*/
export const QuoteButton = forwardRef<HTMLButtonElement, QuoteButtonProps>(
(
{
editor: providedEditor,
text,
hideWhenUnavailable = false,
onToggled,
showShortcut = false,
onClick,
children,
...buttonProps
},
ref,
) => {
const { editor } = useTiptapEditor(providedEditor)
const {
isVisible,
canToggle,
isActive,
handleToggle,
label,
shortcutKeys,
Icon,
} = useQuote({
editor,
hideWhenUnavailable,
onToggled,
})

const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
handleToggle()
},
[handleToggle, onClick],
)

if (!isVisible) {
return null
}

return (
<Button
type="button"
data-style="ghost"
data-active-state={isActive ? "on" : "off"}
tabIndex={-1}
disabled={!canToggle}
data-disabled={!canToggle}
aria-label={label}
aria-pressed={isActive}
tooltip="Quote"
onClick={handleClick}
{...buttonProps}
ref={ref}
>
{children ?? (
<>
<Icon className="tiptap-button-icon" />
{text && <span className="tiptap-button-text">{text}</span>}
{showShortcut && <QuoteShortcutBadge shortcutKeys={shortcutKeys} />}
</>
)}
</Button>
)
},
)

QuoteButton.displayName = "QuoteButton"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./QuoteButton"
export * from "./useQuote"
Loading
Loading