mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a9444fda2 | |||
| 4b6d43ac54 | |||
| dff6f2b553 | |||
| 6780d17b2b | |||
| f0f13dbc31 |
@@ -91,6 +91,7 @@
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@types/sorted-array-functions": "^1.3.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"addressparser": "^1.0.1",
|
||||
"autotrack": "^2.4.1",
|
||||
@@ -228,6 +229,7 @@
|
||||
"socket.io-client": "^4.8.0",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.7.1",
|
||||
"sorted-array-functions": "^1.3.0",
|
||||
"stoppable": "^1.1.0",
|
||||
"string-replace-to-array": "^2.1.1",
|
||||
"styled-components": "^5.3.11",
|
||||
|
||||
@@ -32,8 +32,3 @@ export default function headingToSlug(node: Node, index = 0) {
|
||||
}
|
||||
return `${slugified}-${index}`;
|
||||
}
|
||||
|
||||
export function headingToPersistenceKey(node: Node, id?: string) {
|
||||
const slug = headingToSlug(node);
|
||||
return `rme-${id || window?.location.pathname}–${slug}`;
|
||||
}
|
||||
|
||||
@@ -10,15 +10,21 @@ import { Command, Plugin, Selection } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { toast } from "sonner";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "../../utils/Storage";
|
||||
import backspaceToParagraph from "../commands/backspaceToParagraph";
|
||||
import splitHeading from "../commands/splitHeading";
|
||||
import toggleBlockType from "../commands/toggleBlockType";
|
||||
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
|
||||
import headingToSlug from "../lib/headingToSlug";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
|
||||
import { HeadingTracker } from "../plugins/HeadingTracker";
|
||||
import Node from "./Node";
|
||||
|
||||
export enum HeadingLevel {
|
||||
One = 1,
|
||||
Two,
|
||||
Three,
|
||||
Four,
|
||||
}
|
||||
|
||||
export default class Heading extends Node {
|
||||
className = "heading-name";
|
||||
|
||||
@@ -28,7 +34,9 @@ export default class Heading extends Node {
|
||||
|
||||
get defaultOptions() {
|
||||
return {
|
||||
levels: [1, 2, 3, 4],
|
||||
levels: Object.values(HeadingLevel).filter(
|
||||
(value) => typeof value === "number"
|
||||
),
|
||||
collapsed: undefined,
|
||||
};
|
||||
}
|
||||
@@ -135,40 +143,29 @@ export default class Heading extends Node {
|
||||
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);
|
||||
const pos = view.posAtDOM(event.currentTarget, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const node = view.state.doc.nodeAt($pos.before());
|
||||
|
||||
if (node) {
|
||||
const endOfHeadingPos = result.inside + node.nodeSize;
|
||||
const $pos = view.state.doc.resolve(endOfHeadingPos);
|
||||
const collapsed = !node.attrs.collapsed;
|
||||
if (node) {
|
||||
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));
|
||||
}
|
||||
if (collapsed && view.state.selection.to > $pos.end()) {
|
||||
// move selection to the end of the collapsed heading
|
||||
const $end = view.state.doc.resolve($pos.end());
|
||||
tr.setSelection(Selection.near($end, -1));
|
||||
}
|
||||
|
||||
const transaction = tr.setNodeMarkup(result.inside, undefined, {
|
||||
...node.attrs,
|
||||
collapsed,
|
||||
});
|
||||
const transaction = tr.setNodeMarkup($pos.before(), undefined, {
|
||||
...node.attrs,
|
||||
collapsed,
|
||||
});
|
||||
|
||||
const persistKey = headingToPersistenceKey(node, this.editor.props.id);
|
||||
view.dispatch(transaction);
|
||||
|
||||
if (collapsed) {
|
||||
Storage.set(persistKey, "collapsed");
|
||||
} else {
|
||||
Storage.remove(persistKey);
|
||||
}
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
if (hadFocus) {
|
||||
view.focus();
|
||||
}
|
||||
if (hadFocus) {
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -274,23 +271,7 @@ export default class Heading extends Node {
|
||||
},
|
||||
});
|
||||
|
||||
const foldPlugin: Plugin = new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const { doc } = state;
|
||||
const decorations: Decoration[] = findCollapsedNodes(doc).map(
|
||||
(block) =>
|
||||
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
|
||||
class: "folded-content",
|
||||
})
|
||||
);
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return [foldPlugin, plugin];
|
||||
return [new HeadingTracker(), plugin];
|
||||
}
|
||||
|
||||
inputRules({ type }: { type: NodeType }) {
|
||||
|
||||
@@ -19,6 +19,10 @@ export default abstract class Node extends Extension {
|
||||
return {};
|
||||
}
|
||||
|
||||
get attrs(): NodeSpec["attrs"] {
|
||||
return {};
|
||||
}
|
||||
|
||||
get markdownToken(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import sorted from "sorted-array-functions";
|
||||
import Heading, { HeadingLevel } from "../nodes/Heading";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
import { findCollapsedNodes } from "../queries/findCollapsedNodes";
|
||||
|
||||
/**
|
||||
* Algorithm for finding which headings to unfold upon backspacing is as follows:
|
||||
*
|
||||
* 1. From a given cursor position(after backspacing), of all the headings that came before it,
|
||||
* find the closest heading corresponding to every level.
|
||||
* 2. Arrange those headings in a stack, maintaining the invariant that the
|
||||
* heading at a given position in the stack is closer to the cursor than the one below it,
|
||||
* and also, is of a level larger than that of the one below it.
|
||||
* This invariant ensures that the heading at a given position in the stack is the "descendant" of the
|
||||
* heading that's just below it in the stack.
|
||||
* 3. Pop out the headings from the stack, one by one and collect the ones which are collapsed.
|
||||
* 4. Unfold those collapsed headings.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* Consider the following document structure,
|
||||
* _____________________________________________________________________________
|
||||
* | H2(c) |
|
||||
* | This goes under H2 |
|
||||
* | |
|
||||
* | H1(c) |
|
||||
* | This goes under H1 |
|
||||
* | H3(c) |
|
||||
* | This goes under H3, which is under H1! |
|
||||
* | H3'(u) |
|
||||
* | This goes under H3', which is under H1! |
|
||||
* | H4(c) |
|
||||
* | This goes under H4, which is under H3, which, further, is under H1! |
|
||||
* |_____________________________________________________________________________|
|
||||
*
|
||||
* The (c) & (u) denote folded and unfolded states of the heading, respectively.
|
||||
*
|
||||
* Now, let's say the cursor lands under H4 upon backspacing. The desired state, after backspacing should be that H1,
|
||||
* along with its "descendant" H4 end up being unfolded, displaying respective content under them.
|
||||
*
|
||||
* So, Let's look at the the closest heading of each level from the cursor,
|
||||
* Level 1: H1
|
||||
* Level 2: H2
|
||||
* Level 3: H3'(and not H3)
|
||||
* Level 4: H4
|
||||
*
|
||||
* And, the stack maintaing the invariant would look like,
|
||||
*
|
||||
* | H4 |
|
||||
* | -- |
|
||||
* | H3'|
|
||||
* | -- |
|
||||
* | H1 |
|
||||
* ----
|
||||
*
|
||||
* * Level of H4(4) > Level of H3'(3) && H4 is closer to the cursor than H3'
|
||||
* * Level of H3'(3) > Level of H1(1) && H3' is closer to the cursor than H1
|
||||
*
|
||||
* Notice that we didn't push H2 onto the stack after H1, because that would violate the invariant considering
|
||||
* H2 is further away from the cursor than H1, and therefore, is NOT A DESCENDANT of H1.
|
||||
* So, we don't care about H2's collapsed state in this particular case.
|
||||
*
|
||||
* Now, we pop out headings and collect the ones which are collapsed, i.e, H1 and H4. Then, we unfold them.
|
||||
*/
|
||||
|
||||
export class HeadingTracker extends Plugin {
|
||||
private sortedHeadingPositions: SortedHeadingPositions;
|
||||
private closestHeadingsStack: Stack<HeadingWithPos>;
|
||||
private doc: Node;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
if (!transactions.some((tr) => tr.docChanged)) {
|
||||
return;
|
||||
}
|
||||
this.doc = newState.doc;
|
||||
this.updateHeadingPositions();
|
||||
const cursorPos = newState.selection.from;
|
||||
this.collectPrevClosestHeadingsFrom(cursorPos);
|
||||
const closestHeading = this.closestHeadingsStack.top();
|
||||
if (
|
||||
closestHeading &&
|
||||
cursorPos > closestHeading.pos.before &&
|
||||
cursorPos < closestHeading.pos.after
|
||||
) {
|
||||
// noop if the cursor lies within a heading node because
|
||||
// then this plugin overrides the behavior of heading toggle button
|
||||
// in a way that user can't collapse heading anymore by clicking the toggle button
|
||||
return;
|
||||
}
|
||||
const headingsToUnfold = this.findHeadingsToUnfold();
|
||||
let transaction = newState.tr;
|
||||
for (const heading of headingsToUnfold) {
|
||||
transaction = transaction.setNodeMarkup(
|
||||
heading.pos.before,
|
||||
undefined,
|
||||
{
|
||||
...heading.node.attrs,
|
||||
collapsed: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return transaction;
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const { doc } = state;
|
||||
const decorations: Decoration[] = findCollapsedNodes(doc).map(
|
||||
(block) =>
|
||||
Decoration.node(block.pos, block.pos + block.node.nodeSize, {
|
||||
class: "folded-content",
|
||||
})
|
||||
);
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private updateHeadingPositions() {
|
||||
this.sortedHeadingPositions = new SortedHeadingPositions();
|
||||
const blocks = findBlockNodes(this.doc);
|
||||
for (const block of blocks) {
|
||||
if (block.node.type.name === "heading") {
|
||||
const $pos = this.doc.resolve(block.pos + 1);
|
||||
this.sortedHeadingPositions.add(
|
||||
{ before: $pos.before(), after: $pos.after() },
|
||||
block.node.attrs.level
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findPrevHeadingFrom(
|
||||
pos: number,
|
||||
level: number
|
||||
): HeadingWithPos | null {
|
||||
const prevHeadingPos = this.sortedHeadingPositions.prev(pos, level);
|
||||
|
||||
const prevHeading = (
|
||||
prevHeadingPos ? this.doc.nodeAt(prevHeadingPos.before) : null
|
||||
) as Heading | null;
|
||||
return prevHeading ? { node: prevHeading, pos: prevHeadingPos! } : null;
|
||||
}
|
||||
|
||||
private collectPrevClosestHeadingsFrom(pos: number) {
|
||||
this.closestHeadingsStack = new Stack();
|
||||
for (let level = 1; level <= HeadingLevel.Four; level++) {
|
||||
const heading = this.findPrevHeadingFrom(pos, level);
|
||||
if (heading) {
|
||||
if (this.closestHeadingsStack.isEmpty()) {
|
||||
this.closestHeadingsStack.push(heading);
|
||||
} else {
|
||||
const closestHeadingSoFar = this.closestHeadingsStack.top();
|
||||
if (heading.pos.after > closestHeadingSoFar!.pos.after) {
|
||||
this.closestHeadingsStack.push(heading);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findHeadingsToUnfold() {
|
||||
const headings: HeadingWithPos[] = [];
|
||||
while (!this.closestHeadingsStack.isEmpty()) {
|
||||
const heading = this.closestHeadingsStack.pop();
|
||||
if (heading && heading.node.attrs?.collapsed) {
|
||||
headings.push(heading);
|
||||
}
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
}
|
||||
|
||||
class SortedHeadingPositions {
|
||||
private sortedPositionsByLevel: Map<number, HeadingPos[]>;
|
||||
private cmpFn = (a: HeadingPos, b: HeadingPos) => {
|
||||
if (a.before === b.before) {
|
||||
return 0;
|
||||
}
|
||||
return a.before < b.before ? -1 : 1;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.sortedPositionsByLevel = new Map();
|
||||
for (let l = 1; l <= HeadingLevel.Four; l++) {
|
||||
this.sortedPositionsByLevel.set(l, []);
|
||||
}
|
||||
}
|
||||
|
||||
public add(pos: HeadingPos, level: number) {
|
||||
sorted.add(this.sortedPositionsByLevel.get(level)!, pos, this.cmpFn);
|
||||
}
|
||||
|
||||
public prev(pos: number, level: number): HeadingPos | null {
|
||||
const prevIndex = sorted.lt(
|
||||
this.sortedPositionsByLevel.get(level)!,
|
||||
{ after: pos, before: pos },
|
||||
this.cmpFn
|
||||
);
|
||||
return prevIndex !== -1
|
||||
? this.sortedPositionsByLevel.get(level)![prevIndex]
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export class Stack<T> {
|
||||
private items: T[] = [];
|
||||
|
||||
public push(item: T): void {
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
public pop(): T | undefined {
|
||||
return this.items.pop();
|
||||
}
|
||||
|
||||
public top(): T | undefined {
|
||||
return this.items[this.items.length - 1];
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return this.items.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
type HeadingPos = {
|
||||
before: number;
|
||||
after: number;
|
||||
};
|
||||
|
||||
type HeadingWithPos = {
|
||||
node: Heading;
|
||||
pos: HeadingPos;
|
||||
};
|
||||
@@ -768,16 +768,7 @@
|
||||
"@babel/traverse" "^7.25.9"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-create-regexp-features-plugin@^7.18.6":
|
||||
version "7.25.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.7.tgz#dcb464f0e2cdfe0c25cc2a0a59c37ab940ce894e"
|
||||
integrity sha512-byHhumTj/X47wJ6C6eLpK7wW/WBEcnUeb7D0FNc/jFQnQVw7DOso3Zz5u9x/zLrFVkHa89ZGDbkAa1D54NdrCQ==
|
||||
dependencies:
|
||||
"@babel/helper-annotate-as-pure" "^7.25.7"
|
||||
regexpu-core "^6.1.1"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-create-regexp-features-plugin@^7.25.9":
|
||||
"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.25.9":
|
||||
version "7.26.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz#5169756ecbe1d95f7866b90bb555b022595302a0"
|
||||
integrity sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==
|
||||
@@ -1078,20 +1069,13 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.14.5"
|
||||
|
||||
"@babel/plugin-syntax-typescript@^7.25.9":
|
||||
"@babel/plugin-syntax-typescript@^7.25.9", "@babel/plugin-syntax-typescript@^7.7.2":
|
||||
version "7.25.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz#67dda2b74da43727cf21d46cf9afef23f4365399"
|
||||
integrity sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.25.9"
|
||||
|
||||
"@babel/plugin-syntax-typescript@^7.7.2":
|
||||
version "7.24.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz#b3bcc51f396d15f3591683f90239de143c076844"
|
||||
integrity sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.24.0"
|
||||
|
||||
"@babel/plugin-syntax-unicode-sets-regex@^7.18.6":
|
||||
version "7.18.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357"
|
||||
@@ -5238,6 +5222,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/slug/-/slug-5.0.7.tgz#dc0e5d3ea6ab6daf29bafaa2bcf689cfefaab28a"
|
||||
integrity "sha1-3A5dPqarba8puvqivPaJz++qsoo= sha512-u5T21+PqfRpHszRWSv5gJYDIkm6lmYUXLjZVYvQyWNO9aC0zs+a11qqtbxbsNRiNLTmw0Xi+5NWK/Qz+JjaB9w=="
|
||||
|
||||
"@types/sorted-array-functions@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/sorted-array-functions/-/sorted-array-functions-1.3.3.tgz#c7e9902b4677dfd0693e4658d85a3ccb35b6e091"
|
||||
integrity sha512-Znd+o08cWzYInbmSyoeK7D1aet3zFj346zuL0QWAnKVg3Sp57azvCzbxBfnxjDPNyHqOhzvK79K9sEV97BX5iQ==
|
||||
|
||||
"@types/stack-utils@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
|
||||
@@ -13519,19 +13508,7 @@ regexpp@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
|
||||
integrity "sha1-BCWido2PI7rXDKS5BGH6LxIT4bI= sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg=="
|
||||
|
||||
regexpu-core@^6.1.1:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.1.1.tgz#b469b245594cb2d088ceebc6369dceb8c00becac"
|
||||
integrity sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==
|
||||
dependencies:
|
||||
regenerate "^1.4.2"
|
||||
regenerate-unicode-properties "^10.2.0"
|
||||
regjsgen "^0.8.0"
|
||||
regjsparser "^0.11.0"
|
||||
unicode-match-property-ecmascript "^2.0.0"
|
||||
unicode-match-property-value-ecmascript "^2.1.0"
|
||||
|
||||
regexpu-core@^6.2.0:
|
||||
regexpu-core@^6.1.1, regexpu-core@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.2.0.tgz#0e5190d79e542bf294955dccabae04d3c7d53826"
|
||||
integrity sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==
|
||||
@@ -14231,6 +14208,11 @@ sort-keys@^5.0.0:
|
||||
dependencies:
|
||||
is-plain-obj "^4.0.0"
|
||||
|
||||
sorted-array-functions@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz#8605695563294dffb2c9796d602bd8459f7a0dd5"
|
||||
integrity sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==
|
||||
|
||||
source-list-map@~0.1.7:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106"
|
||||
|
||||
Reference in New Issue
Block a user