Add more highlighter color choices (#7012)
* Add more highlighter color choices, closes #7011 * docs
This commit is contained in:
31
app/components/Icons/CircleIcon.tsx
Normal file
31
app/components/Icons/CircleIcon.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The size of the icon, 24px is default to match standard icons */
|
||||||
|
size?: number;
|
||||||
|
/** The color of the icon, defaults to the current text color */
|
||||||
|
color?: string;
|
||||||
|
/** If true, the icon will retain its color in selected menus and other places that attempt to override it */
|
||||||
|
retainColor?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CircleIcon({
|
||||||
|
size = 24,
|
||||||
|
color = "currentColor",
|
||||||
|
retainColor,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
fill={color}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
style={retainColor ? { fill: color } : undefined}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ type Props = {
|
|||||||
/*
|
/*
|
||||||
* Renders a dropdown menu in the floating toolbar.
|
* Renders a dropdown menu in the floating toolbar.
|
||||||
*/
|
*/
|
||||||
function ToolbarDropdown(props: { item: MenuItem }) {
|
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||||
const menu = useMenuState();
|
const menu = useMenuState();
|
||||||
const { commands, view } = useEditor();
|
const { commands, view } = useEditor();
|
||||||
const { item } = props;
|
const { item } = props;
|
||||||
@@ -102,7 +102,7 @@ function ToolbarMenu(props: Props) {
|
|||||||
key={index}
|
key={index}
|
||||||
>
|
>
|
||||||
{item.children ? (
|
{item.children ? (
|
||||||
<ToolbarDropdown item={item} />
|
<ToolbarDropdown active={isActive && !item.label} item={item} />
|
||||||
) : (
|
) : (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
onClick={handleClick(item)}
|
onClick={handleClick(item)}
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import {
|
|||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import Highlight from "@shared/editor/marks/Highlight";
|
||||||
|
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
|
||||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||||
import { isInList } from "@shared/editor/queries/isInList";
|
import { isInList } from "@shared/editor/queries/isInList";
|
||||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||||
import { MenuItem } from "@shared/editor/types";
|
import { MenuItem } from "@shared/editor/types";
|
||||||
|
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||||
import { Dictionary } from "~/hooks/useDictionary";
|
import { Dictionary } from "~/hooks/useDictionary";
|
||||||
|
|
||||||
export default function formattingMenuItems(
|
export default function formattingMenuItems(
|
||||||
@@ -38,6 +41,12 @@ export default function formattingMenuItems(
|
|||||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||||
const isEmpty = state.selection.empty;
|
const isEmpty = state.selection.empty;
|
||||||
|
|
||||||
|
const highlight = getMarksBetween(
|
||||||
|
state.selection.from,
|
||||||
|
state.selection.to,
|
||||||
|
state
|
||||||
|
).find(({ mark }) => mark.type.name === "highlight");
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: "placeholder",
|
name: "placeholder",
|
||||||
@@ -72,11 +81,21 @@ export default function formattingMenuItems(
|
|||||||
visible: !isCode && (!isMobile || !isEmpty),
|
visible: !isCode && (!isMobile || !isEmpty),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "highlight",
|
|
||||||
tooltip: dictionary.mark,
|
tooltip: dictionary.mark,
|
||||||
icon: <HighlightIcon />,
|
icon: highlight ? (
|
||||||
active: isMarkActive(schema.marks.highlight),
|
<CircleIcon color={highlight.mark.attrs.color} />
|
||||||
|
) : (
|
||||||
|
<HighlightIcon />
|
||||||
|
),
|
||||||
|
active: () => !!highlight,
|
||||||
visible: !isCode && (!isMobile || !isEmpty),
|
visible: !isCode && (!isMobile || !isEmpty),
|
||||||
|
children: Highlight.colors.map((color, index) => ({
|
||||||
|
name: "highlight",
|
||||||
|
label: Highlight.colorNames[index],
|
||||||
|
icon: <CircleIcon retainColor color={color} />,
|
||||||
|
active: isMarkActive(schema.marks.highlight, { color }),
|
||||||
|
attrs: { color },
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "code_inline",
|
name: "code_inline",
|
||||||
|
|||||||
@@ -1179,11 +1179,10 @@ code {
|
|||||||
|
|
||||||
mark {
|
mark {
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
color: ${props.theme.textHighlightForeground};
|
color: ${props.theme.text};
|
||||||
background: ${props.theme.textHighlight};
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: ${props.theme.textHighlightForeground};
|
color: ${props.theme.text};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
import { InputRule } from "prosemirror-inputrules";
|
import { InputRule } from "prosemirror-inputrules";
|
||||||
import { MarkType, Mark } from "prosemirror-model";
|
import { MarkType } from "prosemirror-model";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
|
import { getMarksBetween } from "../queries/getMarksBetween";
|
||||||
function getMarksBetween(start: number, end: number, state: EditorState) {
|
|
||||||
let marks: { start: number; end: number; mark: Mark }[] = [];
|
|
||||||
|
|
||||||
state.doc.nodesBetween(start, end, (node, pos) => {
|
|
||||||
marks = [
|
|
||||||
...marks,
|
|
||||||
...node.marks.map((mark) => ({
|
|
||||||
start: pos,
|
|
||||||
end: pos + node.nodeSize,
|
|
||||||
mark,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
return marks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A factory function for creating Prosemirror plugins that automatically apply a mark to text
|
* A factory function for creating Prosemirror plugins that automatically apply a mark to text
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rgba } from "polished";
|
||||||
import { toggleMark } from "prosemirror-commands";
|
import { toggleMark } from "prosemirror-commands";
|
||||||
import { MarkSpec, MarkType } from "prosemirror-model";
|
import { MarkSpec, MarkType } from "prosemirror-model";
|
||||||
import markInputRule from "../lib/markInputRule";
|
import markInputRule from "../lib/markInputRule";
|
||||||
@@ -5,14 +6,48 @@ import markRule from "../rules/mark";
|
|||||||
import Mark from "./Mark";
|
import Mark from "./Mark";
|
||||||
|
|
||||||
export default class Highlight extends Mark {
|
export default class Highlight extends Mark {
|
||||||
|
/** The colors that can be used for highlighting */
|
||||||
|
static colors = ["#FDEA9B", "#FED46A", "#FA551E", "#B4DC19", "#C8AFF0"];
|
||||||
|
|
||||||
|
/** The names of the colors that can be used for highlighting, must match length of array above */
|
||||||
|
static colorNames = ["Coral", "Apricot", "Sunset", "Smoothie", "Bubblegum"];
|
||||||
|
|
||||||
|
/** The default opacity of the highlight */
|
||||||
|
static opacity = 0.4;
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return "highlight";
|
return "highlight";
|
||||||
}
|
}
|
||||||
|
|
||||||
get schema(): MarkSpec {
|
get schema(): MarkSpec {
|
||||||
return {
|
return {
|
||||||
parseDOM: [{ tag: "mark" }],
|
attrs: {
|
||||||
toDOM: () => ["mark"],
|
color: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: "mark",
|
||||||
|
getAttrs: (dom) => {
|
||||||
|
const color = dom.getAttribute("data-color") || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: Highlight.colors.includes(color) ? color : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => [
|
||||||
|
"mark",
|
||||||
|
{
|
||||||
|
"data-color": node.attrs.color,
|
||||||
|
style: `background-color: ${rgba(
|
||||||
|
node.attrs.color || Highlight.colors[0],
|
||||||
|
Highlight.opacity
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
shared/editor/queries/getMarksBetween.ts
Normal file
31
shared/editor/queries/getMarksBetween.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Mark } from "prosemirror-model";
|
||||||
|
import { EditorState } from "prosemirror-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all marks that are applied to text between two positions.
|
||||||
|
*
|
||||||
|
* @param start The start position
|
||||||
|
* @param end The end position
|
||||||
|
* @param state The editor state
|
||||||
|
* @returns A list of marks
|
||||||
|
*/
|
||||||
|
export function getMarksBetween(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
state: EditorState
|
||||||
|
) {
|
||||||
|
let marks: { start: number; end: number; mark: Mark }[] = [];
|
||||||
|
|
||||||
|
state.doc.nodesBetween(start, end, (node, pos) => {
|
||||||
|
marks = [
|
||||||
|
...marks,
|
||||||
|
...node.marks.map((mark) => ({
|
||||||
|
start: pos,
|
||||||
|
end: pos + node.nodeSize,
|
||||||
|
mark,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Node, Schema } from "prosemirror-model";
|
import { Node, Schema } from "prosemirror-model";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a node is a list node.
|
||||||
|
*
|
||||||
|
* @param node The node to check
|
||||||
|
* @param schema The schema to check against
|
||||||
|
* @returns True if the node is a list node, false otherwise
|
||||||
|
*/
|
||||||
export function isList(node: Node, schema: Schema) {
|
export function isList(node: Node, schema: Schema) {
|
||||||
return (
|
return (
|
||||||
node.type === schema.nodes.bullet_list ||
|
node.type === schema.nodes.bullet_list ||
|
||||||
|
|||||||
@@ -1,16 +1,38 @@
|
|||||||
import { MarkType } from "prosemirror-model";
|
import { MarkType } from "prosemirror-model";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
|
import { Primitive } from "utility-types";
|
||||||
|
import { getMarksBetween } from "./getMarksBetween";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a mark is active in the current selection or not.
|
||||||
|
*
|
||||||
|
* @param type The mark type to check.
|
||||||
|
* @param attrs The attributes to check.
|
||||||
|
* @returns A function that checks if a mark is active in the current selection or not.
|
||||||
|
*/
|
||||||
export const isMarkActive =
|
export const isMarkActive =
|
||||||
(type: MarkType) =>
|
(type: MarkType, attrs?: Record<string, Primitive>) =>
|
||||||
(state: EditorState): boolean => {
|
(state: EditorState): boolean => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { from, $from, to, empty } = state.selection;
|
const { from, $from, to, empty } = state.selection;
|
||||||
|
const hasMark = !!(empty
|
||||||
return !!(empty
|
|
||||||
? type.isInSet(state.storedMarks || $from.marks())
|
? type.isInSet(state.storedMarks || $from.marks())
|
||||||
: state.doc.rangeHasMark(from, to, type));
|
: state.doc.rangeHasMark(from, to, type));
|
||||||
|
|
||||||
|
if (!hasMark) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (attrs) {
|
||||||
|
const results = getMarksBetween(from, to, state);
|
||||||
|
return results.some(
|
||||||
|
({ mark }) =>
|
||||||
|
mark.type === type &&
|
||||||
|
Object.keys(attrs).every((key) => mark.attrs[key] === attrs[key])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { EditorState } from "prosemirror-state";
|
|||||||
import { Primitive } from "utility-types";
|
import { Primitive } from "utility-types";
|
||||||
import { findParentNode } from "./findParentNode";
|
import { findParentNode } from "./findParentNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a node is active in the current selection or not.
|
||||||
|
*
|
||||||
|
* @param type The node type to check.
|
||||||
|
* @param attrs The attributes to check.
|
||||||
|
* @returns A function that checks if a node is active in the current selection or not.
|
||||||
|
*/
|
||||||
export const isNodeActive =
|
export const isNodeActive =
|
||||||
(type: NodeType, attrs: Record<string, Primitive> = {}) =>
|
(type: NodeType, attrs: Record<string, Primitive> = {}) =>
|
||||||
(state: EditorState) => {
|
(state: EditorState) => {
|
||||||
@@ -14,9 +21,7 @@ export const isNodeActive =
|
|||||||
let node = nodeAfter?.type === type ? nodeAfter : undefined;
|
let node = nodeAfter?.type === type ? nodeAfter : undefined;
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
const parent = findParentNode((node) => node.type === type)(
|
const parent = findParentNode((n) => n.type === type)(state.selection);
|
||||||
state.selection
|
|
||||||
);
|
|
||||||
node = parent?.node;
|
node = parent?.node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user