mui-tiptap
Version:
A Material-UI (MUI) styled WYSIWYG rich text editor, using Tiptap
453 lines (452 loc) • 21.3 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getUtilityClasses = exports.getUtilityClass = exports.getImageBackgroundColorStyles = exports.getEditorStyles = exports.Z_INDEXES = void 0;
const material_1 = require("@mui/material");
const omit_1 = __importDefault(require("lodash/omit"));
const tss_react_1 = require("tss-react");
exports.Z_INDEXES = {
TABLE_ELEMENT: 1,
// The menu bar must sit higher than the table components (like the
// column-resize-handle and selectedCells) of the editor.
MENU_BAR: 2,
// The notched outline of the "outlined" field variant should be at the same z-index
// as the menu-bar, so that it can contain/enclose it
NOTCHED_OUTLINE: 2,
};
function getEditorStyles(theme) {
var _a, _b, _c, _d;
// Check whether the user has enabled responsive typography
// (https://mui.com/material-ui/customization/typography/#responsive-font-sizes)
const hasResponsiveStyles = Object.keys((_a = theme.typography.h1) !== null && _a !== void 0 ? _a : {}).some((key) => key.includes("@media"));
const cursorDelayOpacityChangeAnimation = (0, tss_react_1.keyframes) `
0%, 95% {
opacity: 1;
}
100% {
opacity: 0;
}
`;
return Object.assign(Object.assign({}, (0, omit_1.default)(theme.typography.body1, ["lineHeight"])), { "&:focus": {
outline: "none",
}, "& h1": Object.assign({
// We don't use MUI's default heading typography styles of h1-h6 here
// since h1 and h2 are a bit too huge/dramatic. Instead, we use h3-h6,
// subtitle1, and subtitle2.
// For h1, we take our usual font family, set a bold font weight, and set
// the font size based on a scaled-up 20% increase to `typography.h4`
// (which we use as our next smaller header). Note that if the MUI
// `responsiveFontSizes` theme utility was used, we increase the font size
// at all responsive breakpoints.
fontFamily: (_b = theme.typography.h3) === null || _b === void 0 ? void 0 : _b.fontFamily, fontWeight: "bold" }, (hasResponsiveStyles
? {
fontSize: `${(1.5625 * 1.2).toFixed(4)}rem`,
[theme.breakpoints.up("sm")]: {
fontSize: `${(1.8219 * 1.2).toFixed(4)}rem`,
},
[theme.breakpoints.up("md")]: {
fontSize: `${(2.0243 * 1.2).toFixed(4)}rem`,
},
[theme.breakpoints.up("lg")]: {
fontSize: `${(2.0243 * 1.2).toFixed(4)}rem`,
},
}
: {
fontSize: `${(2.0243 * 1.2).toFixed(4)}rem`,
})), "& h2": Object.assign(Object.assign({}, (0, omit_1.default)(theme.typography.h4, ["lineHeight"])), { fontWeight: 500 }), "& h3": Object.assign(Object.assign({}, (0, omit_1.default)(theme.typography.h5, ["lineHeight"])), { fontWeight: 500 }), "& h4": Object.assign(Object.assign({}, (0, omit_1.default)(theme.typography.h6, ["lineHeight"])), { fontWeight: 500 }), "& h5": Object.assign(Object.assign({}, (0, omit_1.default)(theme.typography.subtitle1, ["lineHeight"])), { fontWeight: 500 }), "& h6": Object.assign(Object.assign({}, (0, omit_1.default)(theme.typography.subtitle2, ["lineHeight"])), { fontWeight: 500 }),
// Remove above/below margins from all of our blocks
"& h1, & h2, & h3, & h4, & h5, & h6, & p": {
marginBlockStart: 0,
marginBlockEnd: 0,
}, '& a:not([data-type="mention"])': {
color: theme.palette.primary.main,
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
}, "& ul, & ol": {
marginBlockStart: 0,
marginBlockEnd: 0,
}, "& ol": {
listStyleType: "decimal",
"& ol": {
listStyleType: "lower-alpha",
"& ol": {
listStyleType: "lower-roman",
"& ol": {
listStyleType: "decimal",
"& ol": {
listStyleType: "lower-alpha",
"& ol": {
listStyleType: "lower-roman",
},
},
},
},
},
},
// Note that although browsers would typically match for the first three
// here (disc, circle, then square), this gets broken if the editor happens
// to be rendering inside of some outer list. (We also improve the deeper
// nested uls somewhat while we're at it).
"& ul": {
listStyleType: "disc",
"& ul": {
listStyleType: "circle",
"& ul": {
listStyleType: "square",
"& ul": {
listStyleType: "disc",
"& ul": {
listStyleType: "circle",
"& ul": {
listStyleType: "square",
},
},
},
},
},
},
// These styles are based on the example here https://tiptap.dev/api/nodes/task-list
'& ul[data-type="taskList"]': {
listStyle: "none",
padding: 0,
"& li": {
display: "flex",
"& > label": {
flex: "0 0 auto",
marginRight: "0.5rem",
userSelect: "none",
},
"& > div": {
flex: "1 1 auto",
},
},
}, "& blockquote": {
paddingLeft: "1rem",
marginInlineStart: theme.spacing(1),
marginInlineEnd: theme.spacing(1),
position: "relative",
"&:before": {
// This pseudo-element approach mimics Slack's technique, which allows
// for rounded edges
position: "absolute",
top: 0,
bottom: 0,
left: 0,
display: "block",
width: 4,
borderRadius: theme.shape.borderRadius,
background: theme.palette.text.disabled,
content: '""',
},
}, "& :not(pre) > code": {
padding: "2px 3px 1px",
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: 3,
backgroundColor: theme.palette.action.hover,
color: theme.palette.mode === "dark"
? theme.palette.secondary.main
: (0, material_1.darken)(theme.palette.secondary.dark, 0.1),
}, "& pre": {
marginTop: theme.spacing(0.5),
marginBottom: theme.spacing(0.5),
padding: theme.spacing(1),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
background: theme.palette.action.hover,
// By default the line-height of some monospace fonts (like "Ubuntu Mono")
// appears to be a bit taller than necessary in pre block format
lineHeight: 1.4,
overflowX: "auto",
// Override the default Prosemirror styles, which use pre-wrap. We want code
// blocks to be horizontally scrollable, like they are on GitHub or StackOverflow
// (since that makes reading code, logs, etc. much easier), with no wrapping.
whiteSpace: "pre !important",
}, '& [data-type="mention"]': {
padding: "0 0.25rem",
// Setting the line-height here prevents the at-mentions from bumping up against
// one another on consecutive lines
lineHeight: "1.3em",
borderRadius: theme.shape.borderRadius,
color: theme.palette.primary.main,
background: theme.palette.mode === "dark"
? (0, material_1.alpha)((0, material_1.darken)(theme.palette.primary.dark, 0.7), 0.5)
: (0, material_1.alpha)((0, material_1.lighten)(theme.palette.primary.light, 0.6), 0.3),
textDecoration: "none",
},
// We style all images which are *not* the ProseMirror-separator (an element added
// via Prosemirror, which is there for a hack to get contenteditable to appear
// correctly for text blocks and line breaks; see
// https://discuss.prosemirror.net/t/what-is-addhacknode/4254)
"& img:not(.ProseMirror-separator)": Object.assign(Object.assign({
// TODO(Steven DeMartini): Decide if we should let folks make the images wider
// than the doc. If so, we'll need to make the overall doc container hide overflow
// and add a scrollbar
maxWidth: "100%", height: "auto",
// Using inline-flex allows the image to be correctly positioned, whether
// the Image/ResizableImage extension is configured with `inline` as false
// or true. In the former case (the default), it should have other
// block-level elements on either side of it, allowing it to flow
// correctly as a block.
display: "inline-flex" }, getImageBackgroundColorStyles(theme)), {
// Behavior when an image (node) is selected, at which point it can be deleted,
// moved, etc.
"&.ProseMirror-selectednode": {
outline: `3px solid ${theme.palette.primary.main}`,
} }), "& hr": {
borderWidth: 0,
borderTopWidth: "thin",
borderStyle: "solid",
borderColor: theme.palette.text.secondary,
"&.ProseMirror-selectednode": {
borderColor: theme.palette.primary.main,
},
}, "& table": {
borderCollapse: "collapse",
tableLayout: "fixed",
// TODO(Steven DeMartini): Maybe we want to give the users a way to toggle the width of the
// table to be 100% or not? Similar to what Confluence does with their "Responsive" option
// width: "100%",
margin: 0,
overflowY: "hidden",
// If we don't have enough horizontal space for a table (when in read-only mode),
// we need it to add a horizontal scrollbar, which requires using display:block
// instead of display:table.
overflowX: "auto",
display: "block",
"& td, th": {
minWidth: "1em",
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.mode === "dark"
? theme.palette.grey[500]
: theme.palette.grey[400],
padding: "3px 5px",
verticalAlign: "top",
boxSizing: "border-box",
position: "relative",
"& > *": {
marginBottom: 0,
},
},
"& th": {
fontWeight: 500,
textAlign: "left",
backgroundColor: theme.palette.action.selected,
},
},
// When in editing mode, the <table> element is wrapped in a div with a
// `tableWrapper` class. When we have that arrangement, we change how
// overflow works on the table to instead overflow with the wrapper and
// revert back to display:table, as we'd typically expect/want.
"& .tableWrapper": {
overflowX: "auto",
"& table": {
overflow: "hidden",
display: "table",
},
}, "& .selectedCell:after": {
zIndex: exports.Z_INDEXES.TABLE_ELEMENT,
position: "absolute",
content: '""',
left: 0,
right: 0,
top: 0,
bottom: 0,
background: "rgba(200, 200, 255, 0.4)",
pointerEvents: "none",
},
// Only when the editor has `editable` set to `true` should the table column
// resize tools should be revealed and be usable
'&[contenteditable="true"]': {
"& .column-resize-handle": {
position: "absolute",
right: -2,
top: -1,
bottom: -2,
width: 4,
// This z-index proved necessary to ensure the handle sits above the
// background of any cell (header and non-header)
zIndex: exports.Z_INDEXES.TABLE_ELEMENT,
backgroundColor: theme.palette.primary.light,
pointerEvents: "none",
},
"&.resize-cursor": {
cursor: "col-resize",
},
}, '&[contenteditable="false"]': {
"& .column-resize-handle": {
display: "none",
},
"&.resize-cursor": {
// To ensure that users cannot resize tables when the editor is supposed
// to be read-only, we have to disable pointer events for the entire
// editor whenever the resize-cursor class is added (i.e. when a user
// hovers over a column border that would otherwise allow for dragging
// and resizing when in editable mode). This is because the underlying
// prosemirror-tables `columnResizing` plugin doesn't know/care about
// `editable` state, and so adds the "resize-cursor" class and tries to
// listen for events regardless.
pointerEvents: "none",
},
},
// Based on the example styles from https://tiptap.dev/api/extensions/placeholder,
// this adds the placeholder text at the top
"& p.is-editor-empty:first-of-type::before": {
color: theme.palette.text.disabled,
content: "attr(data-placeholder)",
float: "left",
height: 0,
pointerEvents: "none",
}, "& .ProseMirror-gapcursor:after": {
// Override the default color provided for the Gapcursor extension (for better
// dark/light mode compatibility)
// https://github.com/ueberdosis/tiptap/blob/ab4a0e2507b4b92c46d293a0bb06bb00a04af6e0/packages/core/src/style.ts#L47
borderColor: theme.palette.text.primary,
},
// These styles were based on Tiptap's example here
// https://tiptap.dev/api/extensions/collaboration-cursor
"& .collaboration-cursor__caret": {
borderLeft: "1px solid #0d0d0d",
borderRight: "1px solid #0d0d0d",
marginLeft: "-1px",
marginRight: "-1px",
position: "relative",
wordBreak: "normal",
cursor: "text",
// Add a larger dot at the top of the cursor, like Google Docs does, which helps
// indicate that you can hover there and see the user's name label
"&:after": {
position: "absolute",
content: '""',
left: -3,
right: 0,
top: -2,
borderWidth: 3,
borderStyle: "solid",
borderColor: "inherit",
},
// When hovering, show the user's name
"&:hover .collaboration-cursor__label": {
opacity: 1,
// This transition will be used when fading in (after starting to hover), so
// keep it brief and with no delay
transition: theme.transitions.create("opacity", {
delay: 0,
duration: 100,
easing: "linear",
}),
},
},
// Render the user name above the caret
"& .collaboration-cursor__label": {
borderRadius: "3px 3px 3px 0",
color: "#0d0d0d",
fontSize: 12,
fontStyle: "normal",
fontWeight: 600,
// Make sure we always use our standard font and don't take on the font of an
// element this is showing up inside of (like <code>)
fontFamily: (_d = (_c = theme.typography.body1) === null || _c === void 0 ? void 0 : _c.fontFamily) !== null && _d !== void 0 ? _d : "initial",
left: -1,
lineHeight: "normal",
padding: "0.1rem 0.3rem",
position: "absolute",
top: "-1.4em",
userSelect: "none",
whiteSpace: "nowrap",
// Don't use pointer-events, since that will end up making the entire user name
// surface part of the parent's hover zone and prevent interaction with content
// behind it
pointerEvents: "none",
// Hide the user name by default, so we can transition it in when hovering over
// the cursor caret
opacity: 0,
// This transition will be used when fading out after no longer hovering, so
// delay it a bit longer than default so the name doesn't immediately disappear
transition: theme.transitions.create("opacity", {
delay: 500,
duration: 100,
easing: "linear",
}),
// So that we initially show the user name above the caret on first render
// (e.g. when a user clicks to move their cursor, and on page load), use
// an animation to delay updating the opacity. We'll then use transitions
// based on :hover selectors (above) on the caret to let users view the
// name again while hovering thereafter. We start at fully visible, then
// fade out after the user would've had a chance to see/read the user
// name.
animation: `${cursorDelayOpacityChangeAnimation} 3s linear 1`,
} });
}
exports.getEditorStyles = getEditorStyles;
/**
* Get the background color styles to use for user-provided images being previewed.
*
* Useful for handling transparent images better in dark mode, since they typically
* would have been created on a light-colored background context (e.g. may have black
* text labels that wouldn't be readable in dark mode otherwise).
*/
function getImageBackgroundColorStyles(theme) {
if (theme.palette.mode !== "dark") {
// We only need to alter the colors in dark mode
return {};
}
const backgroundColor = theme.palette.grey[200];
return {
// We add a light grey background to the image when in dark mode, similar to what
// Chrome does in dark mode when viewing an image in its own tab. (Chrome uses a
// background color of "hsl(0, 0%, 90%)", or equivalently "#e6e6e6".)
backgroundColor,
// The "alt text" of an image will be shown if it fails to render (e.g. for
// tif or other file formats that can't be rendered in-browser, like with a
// pending image upload), so make sure the font color is readable
color: theme.palette.getContrastText(backgroundColor),
};
}
exports.getImageBackgroundColorStyles = getImageBackgroundColorStyles;
const UTILITY_CLASS_PREFIX_DEFAULT = "MuiTiptap-";
/**
* Get a utility class of the form "MuiTiptap-Foo-root" for the <Foo />
* component and "root" (root element) slot.
*
* For convenience in users targeting certain CSS selectors to override
* component styles, similar to what MUI does with its "Mui<Component>-<slot>"
* classes (as described here
* https://mui.com/material-ui/experimental-api/classname-generator/#setup and
* somewhat here
* https://mui.com/base-ui/getting-started/customization/#applying-custom-css-rules).
*
* A utility class is just used for targeting elements and overriding styles in
* nested components (rather than us delivering CSS for the utility class
* directly, since we instead use tss-react to generate CSS).
*/
function getUtilityClass(componentName, slot) {
return `${UTILITY_CLASS_PREFIX_DEFAULT}${componentName}-${slot}`;
}
exports.getUtilityClass = getUtilityClass;
/**
* Get a Record mapping each slot name for a component to its utility class for
* that component.
*
* These returned utility classes are used for targeting and overriding styles
* in nested components (rather than us delivering CSS for the utility classes
* directly, since we instead use tss-react to generate CSS).
*
* Ex: {"root": "MuiTiptap-Foo-root"} for the <Foo /> component.
*/
function getUtilityClasses(componentName, slots) {
const result = {};
slots.forEach((slot) => {
result[slot] = getUtilityClass(componentName, slot);
});
return result;
}
exports.getUtilityClasses = getUtilityClasses;
;