mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
38eda7fa61
Safari keeps the heading actions widget at the end of headings to avoid selection issues, but the widget was still using side metadata suited to the leading placement. With Chinese IME composition at the end of a heading, same-position insertion could interact with the contentEditable=false widget and leave the editor selection stuck. After that, Backspace stopped working until the page was refreshed. Use positive side metadata for the Safari trailing widget so composed text stays before it, allow relaxed selection around the widget, and add a narrow Safari-only ArrowLeft fallback for the heading-end boundary. Chinese IME has been manually verified. Other composition-based IMEs may follow a similar path, but are not claimed as verified here.
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import copy from "copy-to-clipboard";
|
||
import { t } from "i18next";
|
||
import type Token from "markdown-it/lib/token.mjs";
|
||
import { textblockTypeInputRule } from "prosemirror-inputrules";
|
||
import type {
|
||
Node as ProsemirrorNode,
|
||
NodeSpec,
|
||
NodeType,
|
||
Schema,
|
||
} from "prosemirror-model";
|
||
import type { Command } from "prosemirror-state";
|
||
import { Plugin, Selection, TextSelection } from "prosemirror-state";
|
||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||
import { toast } from "sonner";
|
||
import type { Primitive } from "utility-types";
|
||
import { isSafari } from "../../utils/browser";
|
||
import Storage from "../../utils/Storage";
|
||
import backspaceToParagraph from "../commands/backspaceToParagraph";
|
||
import splitHeading from "../commands/splitHeading";
|
||
import toggleBlockType from "../commands/toggleBlockType";
|
||
import { headingToPersistenceKey } from "../lib/headingToSlug";
|
||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
|
||
import Node from "./Node";
|
||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||
|
||
export enum HeadingLevel {
|
||
One = 1,
|
||
Two,
|
||
Three,
|
||
Four,
|
||
}
|
||
|
||
/**
|
||
* Options for the Heading node.
|
||
*/
|
||
type HeadingOptions = {
|
||
/** Heading levels (1-based) that are enabled in this editor. */
|
||
levels: number[];
|
||
/** Offset added to the rendered heading level (e.g. 1 renders an `h2` for level 1). */
|
||
offset?: number;
|
||
};
|
||
|
||
export default class Heading extends Node<HeadingOptions> {
|
||
get name() {
|
||
return "heading";
|
||
}
|
||
|
||
get defaultOptions(): Partial<HeadingOptions> {
|
||
return {
|
||
levels: [1, 2, 3, 4],
|
||
};
|
||
}
|
||
|
||
get schema(): NodeSpec {
|
||
return {
|
||
attrs: {
|
||
level: {
|
||
default: 1,
|
||
validate: "number",
|
||
},
|
||
collapsed: {
|
||
default: undefined,
|
||
},
|
||
},
|
||
content: "inline*",
|
||
group: "block",
|
||
defining: true,
|
||
draggable: false,
|
||
parseDOM: this.options.levels.map((level: number) => ({
|
||
tag: `h${level}`,
|
||
attrs: { level },
|
||
})),
|
||
toDOM: (node) => [
|
||
`h${node.attrs.level + (this.options.offset || 0)}`,
|
||
{
|
||
dir: "auto",
|
||
class: "heading-content",
|
||
},
|
||
0,
|
||
],
|
||
};
|
||
}
|
||
|
||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||
state.write(state.repeat("#", node.attrs.level) + " ");
|
||
state.renderInline(node);
|
||
state.closeBlock(node);
|
||
}
|
||
|
||
parseMarkdown() {
|
||
return {
|
||
block: "heading",
|
||
getAttrs: (token: Token) => ({
|
||
level: +token.tag.slice(1),
|
||
}),
|
||
};
|
||
}
|
||
|
||
commands({ type, schema }: { type: NodeType; schema: Schema }) {
|
||
return (attrs: Record<string, Primitive>) =>
|
||
toggleBlockType(type, schema.nodes.paragraph, attrs);
|
||
}
|
||
|
||
handleFoldContent = (event: MouseEvent) => {
|
||
event.preventDefault();
|
||
if (
|
||
!(event.currentTarget instanceof HTMLButtonElement) ||
|
||
event.button !== 0
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const { view } = this.editor;
|
||
const hadFocus = view.hasFocus();
|
||
const { tr } = view.state;
|
||
const { top, left } = event.currentTarget.getBoundingClientRect();
|
||
const result = view.posAtCoords({ top, left });
|
||
|
||
if (result) {
|
||
const node = view.state.doc.nodeAt(result.inside);
|
||
|
||
if (node) {
|
||
const endOfHeadingPos = result.inside + node.nodeSize;
|
||
const $pos = view.state.doc.resolve(endOfHeadingPos);
|
||
const collapsed = !node.attrs.collapsed;
|
||
|
||
if (collapsed && view.state.selection.to > endOfHeadingPos) {
|
||
// move selection to the end of the collapsed heading
|
||
tr.setSelection(Selection.near($pos, -1));
|
||
}
|
||
|
||
const transaction = tr.setNodeMarkup(result.inside, undefined, {
|
||
...node.attrs,
|
||
collapsed,
|
||
});
|
||
|
||
const persistKey = headingToPersistenceKey(node, this.editor.props.id);
|
||
|
||
if (collapsed) {
|
||
Storage.set(persistKey, "collapsed");
|
||
} else {
|
||
Storage.remove(persistKey);
|
||
}
|
||
|
||
view.dispatch(transaction);
|
||
|
||
if (hadFocus) {
|
||
view.focus();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
handleCopyLink = (event: MouseEvent) => {
|
||
if (!(event.currentTarget instanceof HTMLButtonElement)) {
|
||
return;
|
||
}
|
||
|
||
const heading = event.currentTarget.closest(".heading-content");
|
||
if (!heading) {
|
||
return;
|
||
}
|
||
|
||
// Search previous siblings for the anchor element, as other elements
|
||
// (e.g. multiplayer cursors) may be inserted between the anchor and heading.
|
||
let anchor = heading.previousElementSibling;
|
||
while (
|
||
anchor &&
|
||
!anchor.className?.includes(EditorStyleHelper.headingPositionAnchor)
|
||
) {
|
||
anchor = anchor.previousElementSibling;
|
||
}
|
||
|
||
if (!anchor) {
|
||
return;
|
||
}
|
||
|
||
const hash = `#${anchor.id}`;
|
||
|
||
// the existing url might contain a hash already, lets make sure to remove
|
||
// that rather than appending another one.
|
||
const normalizedUrl = window.location.href
|
||
.split("#")[0]
|
||
.replace("/edit", "");
|
||
copy(normalizedUrl + hash);
|
||
|
||
toast.message(t("Link copied to clipboard"));
|
||
};
|
||
|
||
keys({ type, schema }: { type: NodeType; schema: Schema }) {
|
||
const options = this.options.levels.reduce(
|
||
(items: Record<string, Command>, level: number) => ({
|
||
...items,
|
||
...{
|
||
[`Shift-Ctrl-${level}`]: toggleBlockType(
|
||
type,
|
||
schema.nodes.paragraph,
|
||
{ level }
|
||
),
|
||
},
|
||
}),
|
||
{}
|
||
);
|
||
|
||
return {
|
||
...options,
|
||
Backspace: backspaceToParagraph(type),
|
||
Enter: splitHeading(type),
|
||
ArrowLeft: ((state, dispatch) => {
|
||
if (!isSafari) {
|
||
return false;
|
||
}
|
||
|
||
const { $from, empty } = state.selection;
|
||
if (!empty || $from.parent.type !== type) {
|
||
return false;
|
||
}
|
||
|
||
const end = $from.end();
|
||
if ($from.pos !== end || !$from.parent.lastChild?.isText) {
|
||
return false;
|
||
}
|
||
|
||
if (dispatch) {
|
||
dispatch(
|
||
state.tr
|
||
.setSelection(TextSelection.create(state.doc, end - 1))
|
||
.scrollIntoView()
|
||
);
|
||
}
|
||
return true;
|
||
}) as Command,
|
||
// Cmd+Left in Firefox lands the DOM caret inside the heading-actions
|
||
// widget (contentEditable=false, ignoreSelection: true), so Prosemirror
|
||
// does not update its model. Subsequent commands like Enter then operate
|
||
// on the stale position. Move the model selection explicitly to keep it
|
||
// in sync with the visual caret.
|
||
"Mod-ArrowLeft": ((state, dispatch) => {
|
||
const { $from, empty } = state.selection;
|
||
if (!empty || $from.parent.type !== type) {
|
||
return false;
|
||
}
|
||
const start = $from.start();
|
||
if ($from.pos === start) {
|
||
return false;
|
||
}
|
||
if (dispatch) {
|
||
dispatch(
|
||
state.tr
|
||
.setSelection(TextSelection.create(state.doc, start))
|
||
.scrollIntoView()
|
||
);
|
||
}
|
||
return true;
|
||
}) as Command,
|
||
};
|
||
}
|
||
|
||
get plugins() {
|
||
const createWidgetDecorations = (doc: ProsemirrorNode): Decoration[] => {
|
||
const decorations: Decoration[] = [];
|
||
|
||
doc.descendants((node, pos) => {
|
||
if (node.type.name === "heading") {
|
||
// Create anchor button
|
||
const anchor = document.createElement("button");
|
||
anchor.innerText = "#";
|
||
anchor.type = "button";
|
||
anchor.className = "heading-anchor";
|
||
anchor.setAttribute("aria-label", "Copy link to heading");
|
||
anchor.addEventListener("mousedown", (event) =>
|
||
this.handleCopyLink(event)
|
||
);
|
||
|
||
// Create fold button
|
||
const fold = document.createElement("button");
|
||
fold.innerText = "";
|
||
fold.innerHTML =
|
||
'<svg fill="currentColor" width="12" height="24" viewBox="6 0 12 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.23823905,10.6097108 L11.207376,14.4695888 L11.207376,14.4695888 C11.54411,14.907343 12.1719566,14.989236 12.6097108,14.652502 C12.6783439,14.5997073 12.7398293,14.538222 12.792624,14.4695888 L15.761761,10.6097108 L15.761761,10.6097108 C16.0984949,10.1719566 16.0166019,9.54410997 15.5788477,9.20737601 C15.4040391,9.07290785 15.1896811,9 14.969137,9 L9.03086304,9 L9.03086304,9 C8.47857829,9 8.03086304,9.44771525 8.03086304,10 C8.03086304,10.2205442 8.10377089,10.4349022 8.23823905,10.6097108 Z" /></svg>';
|
||
fold.type = "button";
|
||
fold.className = `heading-fold ${
|
||
node.attrs.collapsed ? "collapsed" : ""
|
||
}`;
|
||
fold.setAttribute(
|
||
"aria-label",
|
||
node.attrs.collapsed ? "Expand section" : "Collapse section"
|
||
);
|
||
fold.setAttribute(
|
||
"aria-expanded",
|
||
(!node.attrs.collapsed).toString()
|
||
);
|
||
fold.addEventListener("click", (event) =>
|
||
this.handleFoldContent(event)
|
||
);
|
||
|
||
// Create container span
|
||
const container = document.createElement("span");
|
||
container.contentEditable = "false";
|
||
container.className = `heading-actions ${
|
||
node.attrs.collapsed ? "collapsed" : ""
|
||
}`;
|
||
container.appendChild(anchor);
|
||
container.appendChild(fold);
|
||
|
||
decorations.push(
|
||
// Contains the heading actions
|
||
Decoration.widget(
|
||
// Safari requires the widget to be placed at the end of the node rather than the beginning
|
||
// or caret selection is not correct, browser quirk – see issue #1234
|
||
isSafari ? pos + node.nodeSize - 1 : pos + 1,
|
||
container,
|
||
{
|
||
// Safari keeps this widget at the end; positive side preserves IME
|
||
// insertion order, while relaxed side preserves caret navigation.
|
||
side: isSafari ? 1 : -1,
|
||
ignoreSelection: true,
|
||
relaxedSide: isSafari,
|
||
key: pos.toString(),
|
||
}
|
||
)
|
||
);
|
||
|
||
// Creates a "space" for the caret to move to before the widget.
|
||
// Without this it is very hard to place the caret at the beginning
|
||
// of the heading when it begins with an atom element.
|
||
if (node.firstChild?.isAtom === false) {
|
||
decorations.push(
|
||
Decoration.widget(pos + 1, () => document.createElement("span"), {
|
||
side: -1,
|
||
ignoreSelection: true,
|
||
relaxedSide: true,
|
||
key: "span",
|
||
})
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
return decorations;
|
||
};
|
||
|
||
const widgetsPlugin: Plugin = new Plugin({
|
||
state: {
|
||
init(_, { doc }) {
|
||
return DecorationSet.create(doc, createWidgetDecorations(doc));
|
||
},
|
||
apply(tr, oldDecoSet) {
|
||
if (tr.docChanged) {
|
||
return DecorationSet.create(
|
||
tr.doc,
|
||
createWidgetDecorations(tr.doc)
|
||
);
|
||
}
|
||
return oldDecoSet.map(tr.mapping, tr.doc);
|
||
},
|
||
},
|
||
props: {
|
||
decorations(state) {
|
||
return this.getState(state);
|
||
},
|
||
},
|
||
});
|
||
|
||
const foldPlugin: Plugin = new Plugin({
|
||
state: {
|
||
init(_, { doc }) {
|
||
const decorations: Decoration[] = findCollapsedNodes(doc).map(
|
||
(block) =>
|
||
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
|
||
class: "folded-content",
|
||
})
|
||
);
|
||
return DecorationSet.create(doc, decorations);
|
||
},
|
||
apply(tr, oldDecoSet) {
|
||
if (tr.docChanged) {
|
||
const decorations: Decoration[] = findCollapsedNodes(tr.doc).map(
|
||
(block) =>
|
||
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
|
||
class: "folded-content",
|
||
})
|
||
);
|
||
return DecorationSet.create(tr.doc, decorations);
|
||
}
|
||
return oldDecoSet.map(tr.mapping, tr.doc);
|
||
},
|
||
},
|
||
props: {
|
||
decorations(state) {
|
||
return this.getState(state);
|
||
},
|
||
},
|
||
});
|
||
|
||
return [widgetsPlugin, foldPlugin];
|
||
}
|
||
|
||
inputRules({ type }: { type: NodeType }) {
|
||
return this.options.levels.map((level: number) =>
|
||
textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), type, () => ({
|
||
level,
|
||
}))
|
||
);
|
||
}
|
||
}
|