diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json
index 39b509bfe5..dfa0420057 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.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",
diff --git a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx
index e9677bac5d..4a73aed1b5 100644
--- a/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx
+++ b/frontends/ol-components/src/components/TiptapEditor/ArticleEditor.tsx
@@ -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,
@@ -274,6 +275,7 @@ const ArticleEditor = ({ onSave, readOnly, article }: ArticleEditorProps) => {
Subscript,
Selection,
Image,
+ Quote,
MediaEmbedNode,
DividerNode,
ArticleByLineInfoBarNode,
diff --git a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx
index cb604b6c6c..0bdbb6f2f5 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 { QuoteButton } from "./extensions/ui/Quote/QuoteButton"
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -135,6 +136,23 @@ const StyledEditorContent = styled(EditorContent, {
marginBottom: 0,
},
},
+ quote: {
+ backgroundColor: theme.custom.colors.black,
+ padding: "40px",
+ color: theme.custom.colors.white,
+ borderRadius: "8px",
+ marginBottom: "40px",
+ display: "block",
+ ":before": {
+ display: "none",
+ },
+ p: {
+ position: "relative",
+ },
+ "p:last-child": {
+ marginBottom: 0,
+ },
+ },
},
}))
@@ -180,6 +198,9 @@ export function InsertDropdownMenu({ editor }: TiptapEditorToolbarProps) {
+
+
+
)
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/node/Quote/Quote.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Quote/Quote.ts
new file mode 100644
index 0000000000..9913360559
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/node/Quote/Quote.ts
@@ -0,0 +1,17 @@
+import Blockquote from "@tiptap/extension-blockquote"
+
+export const Quote = Blockquote.extend({
+ name: "quote",
+
+ parseHTML() {
+ return [
+ { tag: "quote" }, // Custom tag
+ { 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]
+ },
+})
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/QuoteButton.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/QuoteButton.tsx
new file mode 100644
index 0000000000..1c10ce9877
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/QuoteButton.tsx
@@ -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,
+ 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 {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling blockquote in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useQuote` hook instead.
+ */
+export const QuoteButton = forwardRef(
+ (
+ {
+ 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) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+QuoteButton.displayName = "QuoteButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/index.tsx b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/index.tsx
new file mode 100644
index 0000000000..884a63c23f
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/index.tsx
@@ -0,0 +1,2 @@
+export * from "./QuoteButton"
+export * from "./useQuote"
diff --git a/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/useQuote.ts b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/useQuote.ts
new file mode 100644
index 0000000000..f53c5f2979
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/extensions/ui/Quote/useQuote.ts
@@ -0,0 +1,208 @@
+"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"
+
+// --- Icons ---
+import { BlockquoteIcon } from "../../../vendor/components/tiptap-icons/blockquote-icon"
+
+// --- UI Utils ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+ selectionWithinConvertibleTypes,
+} from "../../../vendor/lib/tiptap-utils"
+
+export const QUOTE_SHORTCUT_KEY = "mod+shift+b"
+
+/**
+ * Configuration for the quote functionality
+ */
+export interface UseQuoteConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether the button should hide when quote is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful toggle.
+ */
+ onToggled?: () => void
+}
+
+/**
+ * Checks if quote can be toggled in the current editor state
+ */
+export function canToggleQuote(
+ editor: Editor | null,
+ turnInto: boolean = true,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema("quote", editor) || isNodeTypeSelected(editor, ["image"]))
+ return false
+
+ if (!turnInto) {
+ return editor.can().toggleWrap("quote")
+ }
+
+ // Ensure selection is in nodes we're allowed to convert
+ if (
+ !selectionWithinConvertibleTypes(editor, [
+ "paragraph",
+ "heading",
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "blockquote",
+ "quote",
+ "codeBlock",
+ ])
+ )
+ return false
+
+ // Either we can wrap in quote directly on the selection,
+ // or we can clear formatting/nodes to arrive at a quote.
+ return editor.can().toggleWrap("quote") || editor.can().clearNodes()
+}
+
+/**
+ * Toggles quote formatting for a specific node or the current selection
+ */
+export function toggleQuote(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleQuote(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("quote")
+ ? chain.lift("quote")
+ : chain.wrapIn("quote")
+
+ toggle.run()
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the quote 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("quote", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleQuote(editor)
+ }
+
+ return true
+}
+
+export function useQuote(config?: UseQuoteConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggle = canToggleQuote(editor)
+ const isActive = editor?.isActive("quote") || 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 = toggleQuote(editor)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle,
+ label: "Quote",
+ shortcutKeys: QUOTE_SHORTCUT_KEY,
+ Icon: BlockquoteIcon,
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index b6502648ae..bb00ca5594 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -17150,6 +17150,7 @@ __metadata:
"@testing-library/react": "npm:^16.3.0"
"@testing-library/user-event": "npm:^14.5.2"
"@tiptap/core": "npm:^3.11.0"
+ "@tiptap/extension-blockquote": "npm:^3.11.0"
"@tiptap/extension-document": "npm:^3.11.1"
"@tiptap/extension-heading": "npm:^3.11.1"
"@tiptap/extension-highlight": "npm:^3.11.0"