diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json
index 06d7ae8a2d..e8880a897f 100644
--- a/frontends/ol-components/package.json
+++ b/frontends/ol-components/package.json
@@ -31,6 +31,7 @@
"@remixicon/react": "^4.2.0",
"@testing-library/dom": "^10.4.0",
"@tiptap/core": "^3.13.0",
+ "@tiptap/extension-blockquote": "^3.13.0",
"@tiptap/extension-document": "^3.13.0",
"@tiptap/extension-heading": "^3.13.0",
"@tiptap/extension-highlight": "^3.13.0",
diff --git a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx
index 07c3493f30..ebb979adeb 100644
--- a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx
+++ b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx
@@ -57,6 +57,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 { Callout } from "./extensions/node/Callout/Callout"
import {
HEADER_HEIGHT,
HEADER_HEIGHT_MD,
@@ -318,6 +319,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
Subscript,
Selection,
Image,
+ Callout,
MediaEmbedNode,
DividerNode,
ArticleByLineInfoBarNode,
diff --git a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx
index 31328d8e8c..cf6bdeb59a 100644
--- a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx
+++ b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx
@@ -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 { CalloutButton } from "./extensions/ui/Callout/CalloutButton"
import { RiArrowDropDownFill } from "@remixicon/react"
import {
DropdownMenu,
@@ -145,6 +146,24 @@ const StyledEditorContent = styled(EditorContent, {
marginBottom: 0,
},
},
+ callout: {
+ backgroundColor: theme.custom.colors.black,
+ padding: "40px",
+ borderRadius: "8px",
+ marginBottom: "40px",
+ color: theme.custom.colors.white,
+ display: "block",
+ ":before": {
+ display: "none",
+ },
+ p: {
+ position: "relative",
+ },
+ "p:last-child": {
+ marginBottom: 0,
+ marginTop: 0,
+ },
+ },
},
}))
@@ -199,6 +218,9 @@ export function InsertDropdownMenu({ editor }: TiptapEditorToolbarProps) {
+
+
+
)
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/Callout/Callout.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Callout/Callout.ts
new file mode 100644
index 0000000000..c4365b804f
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Callout/Callout.ts
@@ -0,0 +1,20 @@
+import { Node, mergeAttributes } from "@tiptap/core"
+
+export const Callout = Node.create({
+ name: "callout",
+
+ group: "block",
+ content: "block+",
+ defining: true,
+
+ parseHTML() {
+ return [
+ { tag: "callout" },
+ { tag: "div[data-type='callout']" }, // optional fallback
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ["callout", mergeAttributes(HTMLAttributes), 0]
+ },
+})
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/CalloutButton.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/CalloutButton.tsx
new file mode 100644
index 0000000000..5080c9c20a
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/CalloutButton.tsx
@@ -0,0 +1,116 @@
+import React, { forwardRef, useCallback } from "react"
+
+// --- Tiptap UI ---
+import type { UseCalloutConfig } from "./"
+import { CALLOUT_SHORTCUT_KEY, useCallout } 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"
+import { RiInformationFill } from "@remixicon/react"
+
+export interface CalloutButtonProps
+ extends Omit,
+ UseCalloutConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function CalloutShortcutBadge({
+ shortcutKeys = CALLOUT_SHORTCUT_KEY,
+}: {
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling blockquote in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useCallout` hook instead.
+ */
+export const CalloutButton = forwardRef(
+ (
+ {
+ editor: providedEditor,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ shortcutKeys,
+ } = useCallout({
+ editor,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+CalloutButton.displayName = "CalloutButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/index.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/index.tsx
new file mode 100644
index 0000000000..87c932dcc8
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/index.tsx
@@ -0,0 +1,2 @@
+export * from "./CalloutButton"
+export * from "./useCallout"
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/useCallout.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/useCallout.ts
new file mode 100644
index 0000000000..4948071e92
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Callout/useCallout.ts
@@ -0,0 +1,207 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import type { Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../vendor/hooks/use-tiptap-editor"
+
+// --- UI Utils ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+ selectionWithinConvertibleTypes,
+} from "../../../vendor/lib/tiptap-utils"
+
+export const CALLOUT_SHORTCUT_KEY = "mod+shift+b"
+
+/**
+ * Configuration for the callout functionality
+ */
+export interface UseCalloutConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether the button should hide when callout is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful toggle.
+ */
+ onToggled?: () => void
+}
+
+/**
+ * Checks if callout can be toggled in the current editor state
+ */
+export function canToggleCallout(
+ editor: Editor | null,
+ turnInto: boolean = true,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isNodeInSchema("callout", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ if (!turnInto) {
+ return editor.can().toggleWrap("callout")
+ }
+
+ // Ensure selection is in nodes we're allowed to convert
+ if (
+ !selectionWithinConvertibleTypes(editor, [
+ "paragraph",
+ "heading",
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "blockquote",
+ "callout",
+ "codeBlock",
+ ])
+ )
+ return false
+
+ // Either we can wrap in callout directly on the selection,
+ // or we can clear formatting/nodes to arrive at a callout.
+ return editor.can().toggleWrap("callout") || editor.can().clearNodes()
+}
+
+/**
+ * Toggles callout formatting for a specific node or the current selection
+ */
+export function toggleCallout(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleCallout(editor)) return false
+
+ try {
+ const view = editor.view
+ let state = view.state
+ let tr = state.tr
+
+ // No selection, find the the cursor position
+ if (state.selection.empty || state.selection instanceof TextSelection) {
+ const pos = findNodePosition({
+ editor,
+ node: state.selection.$anchor.node(1),
+ })?.pos
+ if (!isValidPosition(pos)) return false
+
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
+ view.dispatch(tr)
+ state = view.state
+ }
+
+ const selection = state.selection
+
+ let chain = editor.chain().focus()
+
+ // Handle NodeSelection
+ if (selection instanceof NodeSelection) {
+ const firstChild = selection.node.firstChild?.firstChild
+ const lastChild = selection.node.lastChild?.lastChild
+
+ const from = firstChild
+ ? selection.from + firstChild.nodeSize
+ : selection.from + 1
+
+ const to = lastChild
+ ? selection.to - lastChild.nodeSize
+ : selection.to - 1
+
+ const resolvedFrom = state.doc.resolve(from)
+ const resolvedTo = state.doc.resolve(to)
+
+ chain = chain
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
+ .clearNodes()
+ }
+
+ const toggle = editor.isActive("callout")
+ ? chain.lift("callout")
+ : chain.wrapIn("callout")
+
+ toggle.run()
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the callout button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema("callout", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleCallout(editor)
+ }
+
+ return true
+}
+
+export function useCallout(config?: UseCalloutConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggle = canToggleCallout(editor)
+ const isActive = editor?.isActive("callout") || false
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ const handleToggle = useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleCallout(editor)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle,
+ label: "Callout",
+ shortcutKeys: CALLOUT_SHORTCUT_KEY,
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index a6a622a73f..a3f50652ae 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -17139,6 +17139,7 @@ __metadata:
"@testing-library/react": "npm:^16.3.0"
"@testing-library/user-event": "npm:^14.5.2"
"@tiptap/core": "npm:^3.13.0"
+ "@tiptap/extension-blockquote": "npm:^3.13.0"
"@tiptap/extension-document": "npm:^3.13.0"
"@tiptap/extension-heading": "npm:^3.13.0"
"@tiptap/extension-highlight": "npm:^3.13.0"