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"