@witchcraft/editor 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/runtime/pm/features/Blocks/components/DragTreeHandle.vue +7 -0
- package/dist/runtime/pm/generator.d.ts +82 -0
- package/dist/runtime/pm/generator.js +205 -0
- package/dist/runtime/pm/utils/generateRandomDoc.d.ts +23 -0
- package/dist/runtime/pm/utils/generateRandomDoc.js +83 -0
- package/dist/runtime/pm/utils/generateRandomTree.d.ts +50 -0
- package/dist/runtime/pm/utils/generateRandomTree.js +38 -0
- package/package.json +7 -5
- package/src/runtime/pm/commands/changeAttrs.ts +1 -1
- package/src/runtime/pm/features/Blocks/Item.ts +1 -1
- package/src/runtime/pm/features/Blocks/commands/moveItem.ts +1 -1
- package/src/runtime/pm/features/Blocks/components/DragTreeHandle.vue +14 -9
- package/src/runtime/pm/features/EmbeddedDocument/Embedded.ts +1 -1
- package/src/runtime/pm/features/FileLoader/types.ts +2 -2
- package/src/runtime/pm/generator.ts +266 -0
- package/src/runtime/pm/schema.ts +1 -0
- package/src/runtime/pm/utils/generateRandomDoc.ts +140 -0
- package/src/runtime/pm/utils/generateRandomTree.ts +100 -0
package/dist/module.json
CHANGED
|
@@ -99,9 +99,16 @@
|
|
|
99
99
|
</div>
|
|
100
100
|
</template>
|
|
101
101
|
|
|
102
|
+
<script>
|
|
103
|
+
export default {};
|
|
104
|
+
</script>
|
|
105
|
+
|
|
102
106
|
<script setup>
|
|
103
107
|
import { twMerge } from "@witchcraft/ui/utils/twMerge";
|
|
104
108
|
import { onMounted, onUnmounted, ref, useAttrs } from "vue";
|
|
109
|
+
defineOptions({
|
|
110
|
+
name: "DragTreeHandle"
|
|
111
|
+
});
|
|
105
112
|
const $attrs = useAttrs();
|
|
106
113
|
defineProps({
|
|
107
114
|
hasChildren: { type: Boolean, required: true },
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Mark, Node } from "@tiptap/pm/model";
|
|
2
|
+
export declare const pm: {
|
|
3
|
+
schema: import("@tiptap/pm/model").Schema<"paragraph" | "item" | "codeBlock" | "heading" | "iframe" | "image" | "table" | "text" | "blockquote" | "cite" | "list" | "tableCell" | "doc" | "embeddedDoc" | "tableRow" | "tableHeader", "bold" | "code" | "italic" | "strike" | "subscript" | "superscript" | "underline" | "hardBreak" | "highlight">;
|
|
4
|
+
} & {
|
|
5
|
+
readonly [x: string]: import("prosemirror-test-builder").NodeBuilder;
|
|
6
|
+
readonly paragraph: import("prosemirror-test-builder").NodeBuilder;
|
|
7
|
+
readonly item: import("prosemirror-test-builder").NodeBuilder;
|
|
8
|
+
readonly codeBlock: import("prosemirror-test-builder").NodeBuilder;
|
|
9
|
+
readonly heading: import("prosemirror-test-builder").NodeBuilder;
|
|
10
|
+
readonly iframe: import("prosemirror-test-builder").NodeBuilder;
|
|
11
|
+
readonly image: import("prosemirror-test-builder").NodeBuilder;
|
|
12
|
+
readonly table: import("prosemirror-test-builder").NodeBuilder;
|
|
13
|
+
readonly text: import("prosemirror-test-builder").NodeBuilder;
|
|
14
|
+
readonly blockquote: import("prosemirror-test-builder").NodeBuilder;
|
|
15
|
+
readonly cite: import("prosemirror-test-builder").NodeBuilder;
|
|
16
|
+
readonly list: import("prosemirror-test-builder").NodeBuilder;
|
|
17
|
+
readonly tableCell: import("prosemirror-test-builder").NodeBuilder;
|
|
18
|
+
readonly doc: import("prosemirror-test-builder").NodeBuilder;
|
|
19
|
+
readonly embeddedDoc: import("prosemirror-test-builder").NodeBuilder;
|
|
20
|
+
readonly tableRow: import("prosemirror-test-builder").NodeBuilder;
|
|
21
|
+
readonly tableHeader: import("prosemirror-test-builder").NodeBuilder;
|
|
22
|
+
} & {
|
|
23
|
+
readonly [x: string]: import("prosemirror-test-builder").MarkBuilder;
|
|
24
|
+
readonly bold: import("prosemirror-test-builder").MarkBuilder;
|
|
25
|
+
readonly code: import("prosemirror-test-builder").MarkBuilder;
|
|
26
|
+
readonly italic: import("prosemirror-test-builder").MarkBuilder;
|
|
27
|
+
readonly strike: import("prosemirror-test-builder").MarkBuilder;
|
|
28
|
+
readonly subscript: import("prosemirror-test-builder").MarkBuilder;
|
|
29
|
+
readonly superscript: import("prosemirror-test-builder").MarkBuilder;
|
|
30
|
+
readonly underline: import("prosemirror-test-builder").MarkBuilder;
|
|
31
|
+
readonly hardBreak: import("prosemirror-test-builder").MarkBuilder;
|
|
32
|
+
readonly highlight: import("prosemirror-test-builder").MarkBuilder;
|
|
33
|
+
} & {
|
|
34
|
+
[name: string]: import("prosemirror-test-builder").NodeBuilder | import("prosemirror-test-builder").MarkBuilder;
|
|
35
|
+
};
|
|
36
|
+
export type GeneratorConfigEntry<TChildrenType extends "node" | "text" = "node" | "text", TParentType extends "node" | "text" = "node" | "text", TChildren extends TChildrenType extends "node" ? Node : Mark | string = TChildrenType extends "node" ? Node : Mark | string, TParent extends TParentType extends "node" ? Node : Mark | string = TParentType extends "node" ? Node : Mark | string> = {
|
|
37
|
+
parents: {
|
|
38
|
+
/**
|
|
39
|
+
* The type of the parent (node or "text") where "text" is a string or mark.
|
|
40
|
+
* Determines the call signature of the create function.
|
|
41
|
+
*/
|
|
42
|
+
type: TParentType;
|
|
43
|
+
/** Possible parent nodes that can have the given children. The children need not be direct descendants, see `ignoreValidation`. */
|
|
44
|
+
names: string[];
|
|
45
|
+
};
|
|
46
|
+
children: {
|
|
47
|
+
/**
|
|
48
|
+
* The type of children (node or "text") where "text" is a string or mark.
|
|
49
|
+
* Determines the call signature of the create function.
|
|
50
|
+
*/
|
|
51
|
+
type: TChildrenType;
|
|
52
|
+
names?: string[];
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Set to true to ignore all children mismatches, or set an array of children names to ignore.
|
|
56
|
+
*
|
|
57
|
+
* This is needed for creating "end" nodes that aren't valid names (e.g. `text` is not technically allowed since you can't do `builder.text()`).
|
|
58
|
+
*
|
|
59
|
+
* Or when the children are not direct children of the parent types as there is some in-between wrapper node that needs to be generated.
|
|
60
|
+
*/
|
|
61
|
+
ignoreValidation?: boolean | string[];
|
|
62
|
+
isRoot?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Creates the node. Can return undefined to "terminate" the branch being created, the node will be filtered out of the nodes passed to it's parent.
|
|
65
|
+
*
|
|
66
|
+
* If not set, a default `builder[parentType]({}, ...children)` will be used.
|
|
67
|
+
*
|
|
68
|
+
* Note also, it is not required to use the children. You can ignore them or use a subset or create a different one if needed (some node types *require* text and if your text not can return "" this can be a problem).
|
|
69
|
+
*/
|
|
70
|
+
create?: (parent: string, children: TChildren[]) => TParent | undefined;
|
|
71
|
+
};
|
|
72
|
+
export declare function getTextOrMarkLength(textOrMark: string | Mark): number;
|
|
73
|
+
export declare function createGeneratorConfig<TChildrenType extends "node" | "text" = "node" | "text", TParentType extends "node" | "text" = "node" | "text", TChildren extends TChildrenType extends "node" ? Node : Mark | string = TChildrenType extends "node" ? Node : Mark | string, TParent extends TParentType extends "node" ? Node : Mark | string = TParentType extends "node" ? Node : Mark | string>(config: GeneratorConfigEntry<TChildrenType, TParentType, TChildren, TParent>): GeneratorConfigEntry<TChildrenType, TParentType, TChildren, TParent>;
|
|
74
|
+
export declare const generatorConfig: (GeneratorConfigEntry<"node", "node", Node, Node & {
|
|
75
|
+
tag: {
|
|
76
|
+
[tag: string]: number;
|
|
77
|
+
};
|
|
78
|
+
}> | GeneratorConfigEntry<"text", "node", string | Mark, Node & {
|
|
79
|
+
tag: {
|
|
80
|
+
[tag: string]: number;
|
|
81
|
+
};
|
|
82
|
+
}> | GeneratorConfigEntry<"text", "text", string | Mark, string | Mark>)[];
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { faker } from "@faker-js/faker";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import { builders } from "prosemirror-test-builder";
|
|
4
|
+
import { schema } from "./schema.js";
|
|
5
|
+
export const pm = builders(schema);
|
|
6
|
+
export function getTextOrMarkLength(textOrMark) {
|
|
7
|
+
if (typeof textOrMark === "string") {
|
|
8
|
+
return textOrMark.length;
|
|
9
|
+
}
|
|
10
|
+
const textNodes = textOrMark.flat.map((_) => "text" in _ ? _.text.length : getTextOrMarkLength(_));
|
|
11
|
+
return textNodes.reduce((a, b) => a + b, 0);
|
|
12
|
+
}
|
|
13
|
+
export function createGeneratorConfig(config) {
|
|
14
|
+
return config;
|
|
15
|
+
}
|
|
16
|
+
function createPsuedoSentence() {
|
|
17
|
+
const sentenceLength = faker.number.int({ min: 0, max: 1e3 });
|
|
18
|
+
const sentence = Array.from(
|
|
19
|
+
{ length: sentenceLength },
|
|
20
|
+
() => faker.string.sample({ min: 0, max: 1e3 })
|
|
21
|
+
).join(" ");
|
|
22
|
+
return sentence;
|
|
23
|
+
}
|
|
24
|
+
export const generatorConfig = [
|
|
25
|
+
createGeneratorConfig({
|
|
26
|
+
isRoot: true,
|
|
27
|
+
parents: { type: "node", names: ["doc"] },
|
|
28
|
+
ignoreValidation: true,
|
|
29
|
+
children: { type: "node", names: ["item"] },
|
|
30
|
+
create: (_parent, children) => {
|
|
31
|
+
if (!children || children.length === 0) {
|
|
32
|
+
return pm.doc(pm.list(pm.item({ blockId: nanoid(10) }, pm.paragraph({}, ""))));
|
|
33
|
+
}
|
|
34
|
+
return pm.doc(pm.list(...children));
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
createGeneratorConfig({
|
|
38
|
+
parents: { type: "node", names: ["list"] },
|
|
39
|
+
children: { type: "node", names: ["item"] },
|
|
40
|
+
create: (_parent, children) => {
|
|
41
|
+
if (!children || children.length === 0) {
|
|
42
|
+
return pm.list(pm.item({ blockId: nanoid(10) }, pm.paragraph("")));
|
|
43
|
+
}
|
|
44
|
+
return pm.list(...children);
|
|
45
|
+
}
|
|
46
|
+
}),
|
|
47
|
+
createGeneratorConfig({
|
|
48
|
+
parents: { type: "node", names: ["item"] },
|
|
49
|
+
children: { type: "node", names: [
|
|
50
|
+
"paragraph",
|
|
51
|
+
"heading",
|
|
52
|
+
"codeBlock",
|
|
53
|
+
"embeddedDoc",
|
|
54
|
+
"iframe",
|
|
55
|
+
"table",
|
|
56
|
+
"image",
|
|
57
|
+
"blockquote"
|
|
58
|
+
] },
|
|
59
|
+
create: (_parent, children) => {
|
|
60
|
+
if (!children || children.length === 0) {
|
|
61
|
+
return pm.item({ blockId: nanoid(10) }, pm.paragraph(""));
|
|
62
|
+
}
|
|
63
|
+
return pm.item(
|
|
64
|
+
{ blockId: nanoid(10) },
|
|
65
|
+
children[0],
|
|
66
|
+
...children.length > 1 ? [pm.list(
|
|
67
|
+
{},
|
|
68
|
+
...children.slice(1).map(
|
|
69
|
+
(_) => pm.item({ blockId: nanoid(10) }, _)
|
|
70
|
+
)
|
|
71
|
+
)] : []
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
createGeneratorConfig({
|
|
76
|
+
parents: { type: "node", names: ["blockquote"] },
|
|
77
|
+
children: { type: "node", names: ["paragraph", "cite"] },
|
|
78
|
+
create: (_parent, children) => {
|
|
79
|
+
const parTypes = [];
|
|
80
|
+
const citeTypes = [];
|
|
81
|
+
for (const child of children) {
|
|
82
|
+
if (child.type.name === "cite") {
|
|
83
|
+
citeTypes.push(child);
|
|
84
|
+
} else {
|
|
85
|
+
parTypes.push(child);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (parTypes.length === 0) {
|
|
89
|
+
return pm.blockquote({}, pm.paragraph(""));
|
|
90
|
+
} else {
|
|
91
|
+
const someCite = citeTypes.find((_) => _.textContent.length > 0) ?? pm.cite({}, createPsuedoSentence());
|
|
92
|
+
return pm.blockquote({}, ...parTypes, someCite);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
createGeneratorConfig({
|
|
97
|
+
parents: { type: "node", names: ["table"] },
|
|
98
|
+
children: { type: "node", names: [
|
|
99
|
+
"tableHeader",
|
|
100
|
+
"tableCell"
|
|
101
|
+
] },
|
|
102
|
+
ignoreValidation: true,
|
|
103
|
+
// we're handling the in-between tableRow nodes
|
|
104
|
+
create: (_parent, children) => {
|
|
105
|
+
const headerType = [];
|
|
106
|
+
const cellType = [];
|
|
107
|
+
for (const child of children) {
|
|
108
|
+
if (child.type.name === "tableHeader") {
|
|
109
|
+
headerType.push(child);
|
|
110
|
+
} else if (child.type.name === "tableCell") {
|
|
111
|
+
cellType.push(child);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const colNum = headerType.length;
|
|
115
|
+
if (colNum === 0) {
|
|
116
|
+
return pm.table({}, pm.tableRow({}, pm.tableCell(pm.paragraph(""))));
|
|
117
|
+
}
|
|
118
|
+
const rowCount = Math.ceil(cellType.length / colNum);
|
|
119
|
+
const rows = [];
|
|
120
|
+
for (let i = 0; i < rowCount; i++) {
|
|
121
|
+
rows.push(pm.tableRow({}, ...cellType.slice(i * colNum, (i + 1) * colNum)));
|
|
122
|
+
}
|
|
123
|
+
return pm.table({}, pm.tableRow({}, ...headerType), ...rows);
|
|
124
|
+
}
|
|
125
|
+
}),
|
|
126
|
+
createGeneratorConfig({
|
|
127
|
+
parents: { type: "node", names: ["tableHeader", "tableCell"] },
|
|
128
|
+
children: { type: "node", names: ["paragraph"] },
|
|
129
|
+
create: (_parent, children) => {
|
|
130
|
+
if (!children || children.length === 0) {
|
|
131
|
+
return pm.tableCell(pm.paragraph(""));
|
|
132
|
+
}
|
|
133
|
+
return pm.tableCell(...children.slice(0, 1));
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
createGeneratorConfig({
|
|
137
|
+
parents: { type: "node", names: ["paragraph", "heading", "codeBlock", "cite", "heading"] },
|
|
138
|
+
children: { type: "text", names: [
|
|
139
|
+
"bold",
|
|
140
|
+
"italic",
|
|
141
|
+
"underline",
|
|
142
|
+
"strike",
|
|
143
|
+
"subscript",
|
|
144
|
+
"superscript",
|
|
145
|
+
"code",
|
|
146
|
+
"link"
|
|
147
|
+
] },
|
|
148
|
+
create(parent, children) {
|
|
149
|
+
if (parent === "heading") {
|
|
150
|
+
return pm[parent]({ level: 1 }, ...children);
|
|
151
|
+
}
|
|
152
|
+
if (parent === "cite") {
|
|
153
|
+
return pm[parent]({}, faker.lorem.sentence());
|
|
154
|
+
}
|
|
155
|
+
if (parent === "paragraph") {
|
|
156
|
+
const someIndex = faker.number.int({ min: 0, max: children.length });
|
|
157
|
+
children.splice(someIndex, 0, pm.hardBreak());
|
|
158
|
+
}
|
|
159
|
+
return pm[parent]({}, ...children);
|
|
160
|
+
}
|
|
161
|
+
}),
|
|
162
|
+
createGeneratorConfig({
|
|
163
|
+
parents: {
|
|
164
|
+
type: "text",
|
|
165
|
+
names: [
|
|
166
|
+
"bold",
|
|
167
|
+
"italic",
|
|
168
|
+
"underline",
|
|
169
|
+
"strike",
|
|
170
|
+
"subscript",
|
|
171
|
+
"superscript",
|
|
172
|
+
"link"
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
// note the addition of text
|
|
176
|
+
children: { type: "text", names: [
|
|
177
|
+
"bold",
|
|
178
|
+
"italic",
|
|
179
|
+
"underline",
|
|
180
|
+
"strike",
|
|
181
|
+
"subscript",
|
|
182
|
+
"superscript",
|
|
183
|
+
"link",
|
|
184
|
+
"text"
|
|
185
|
+
] },
|
|
186
|
+
ignoreValidation: ["text"]
|
|
187
|
+
// text is not a real node
|
|
188
|
+
// create: (parent, children) => {
|
|
189
|
+
// return pm[parent]({}, ...children as any) as any
|
|
190
|
+
// }
|
|
191
|
+
}),
|
|
192
|
+
createGeneratorConfig({
|
|
193
|
+
ignoreValidation: true,
|
|
194
|
+
// text is not a real node
|
|
195
|
+
parents: { type: "text", names: ["code"] },
|
|
196
|
+
children: { type: "text", names: ["text"] }
|
|
197
|
+
}),
|
|
198
|
+
createGeneratorConfig({
|
|
199
|
+
parents: { type: "text", names: ["text"] },
|
|
200
|
+
children: { type: "text" },
|
|
201
|
+
create: (_parent) => {
|
|
202
|
+
return createPsuedoSentence();
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Node } from "@tiptap/pm/model";
|
|
2
|
+
import type { builders } from "prosemirror-test-builder";
|
|
3
|
+
import { generateRandomTree } from "./generateRandomTree.js";
|
|
4
|
+
import type { GeneratorConfigEntry } from "../generator.js";
|
|
5
|
+
/**
|
|
6
|
+
* Generates a random doc using faker js.
|
|
7
|
+
*
|
|
8
|
+
* A config describing how to create the doc is required. One is available under `pm/generator`.
|
|
9
|
+
*
|
|
10
|
+
* @experimental
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateRandomDoc<TBuilder extends ReturnType<typeof builders>, TData extends string>(builder: TBuilder, config?: GeneratorConfigEntry<any, any, any, any>[], treeOptions?: Parameters<typeof generateRandomTree>[1], { checkAfterNodeCreation }?: {
|
|
13
|
+
/**
|
|
14
|
+
* Checks if every returned node is valid for debugging invalid generator configs.
|
|
15
|
+
*
|
|
16
|
+
* A check is always done on the root node at the end regardless of this option.
|
|
17
|
+
*
|
|
18
|
+
* It is very slow (each check, rechecks children).
|
|
19
|
+
*
|
|
20
|
+
* @default false
|
|
21
|
+
*/
|
|
22
|
+
checkAfterNodeCreation?: boolean;
|
|
23
|
+
}): Node;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { unreachable } from "@alanscodelog/utils/unreachable";
|
|
2
|
+
import { faker } from "@faker-js/faker";
|
|
3
|
+
import { generateRandomTree } from "./generateRandomTree.js";
|
|
4
|
+
import { generatorConfig } from "../generator.js";
|
|
5
|
+
export function generateRandomDoc(builder, config = generatorConfig, treeOptions, {
|
|
6
|
+
checkAfterNodeCreation = false
|
|
7
|
+
} = {}) {
|
|
8
|
+
const schema = builder.schema;
|
|
9
|
+
const map = {};
|
|
10
|
+
function schemaFilter(name) {
|
|
11
|
+
return name !== "text" && name !== schema.topNodeType.name;
|
|
12
|
+
}
|
|
13
|
+
for (const entry of config) {
|
|
14
|
+
for (const node of entry.parents.names) {
|
|
15
|
+
if (node === "") throw new Error(`Empty node name ""`);
|
|
16
|
+
const type = entry.parents.type === "node" ? schema.nodes[node] : schema.marks[node];
|
|
17
|
+
if (type === void 0 && node !== "text") {
|
|
18
|
+
throw new Error(`${node} (on entry of type ${entry.parents.type}) not found in schema. Valid names are ${Object.keys(entry.parents.type === "node" ? schema.nodes : schema.marks)}`);
|
|
19
|
+
}
|
|
20
|
+
for (const node2 of entry.parents.names) {
|
|
21
|
+
const subEntry = { ...entry };
|
|
22
|
+
if (entry.ignoreValidation === void 0 || entry.ignoreValidation !== true) {
|
|
23
|
+
const childrenToValidate = (entry.children.names ?? []).filter((_) => !Array.isArray(entry.ignoreValidation) || !entry.ignoreValidation.includes(_));
|
|
24
|
+
for (const child of childrenToValidate) {
|
|
25
|
+
if (child === "") throw new Error(`Empty node name ""`);
|
|
26
|
+
if (entry.children.type === "node") {
|
|
27
|
+
const childType = schema.nodes[child];
|
|
28
|
+
const possibleNames = [childType.name, ...(childType.spec.group ?? "").split(" ").filter((e) => e !== "")];
|
|
29
|
+
const isAllowedChild = type.spec.content && possibleNames.some((n) => type.spec.content.includes(n));
|
|
30
|
+
if (!(isAllowedChild && schemaFilter(childType.name))) {
|
|
31
|
+
throw new Error(`Node ${node2} cannot contain child of type ${child}`);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
const childType = schema.marks[child];
|
|
35
|
+
if (entry.parents.type === "node") {
|
|
36
|
+
} else {
|
|
37
|
+
const possibleNames = [childType.name, ...(childType.spec.group ?? "").split(" ").filter((e) => e !== "")];
|
|
38
|
+
const t = type;
|
|
39
|
+
const isExcluded = t.spec.excludes !== void 0 && possibleNames.some((n) => {
|
|
40
|
+
return t.spec.excludes.includes(n) || t.spec.excludes.includes("_");
|
|
41
|
+
});
|
|
42
|
+
if (isExcluded) {
|
|
43
|
+
throw new Error(`Mark ${node2} cannot contain mark child of type ${child}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
map[node2] = { ...subEntry };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const children = generateRandomTree({
|
|
54
|
+
parentData: (data) => {
|
|
55
|
+
if (data === "") return "";
|
|
56
|
+
if (!data) unreachable();
|
|
57
|
+
if (!map[data] || !map[data].children.names || map[data].children.names.length === 0) {
|
|
58
|
+
return "";
|
|
59
|
+
} else {
|
|
60
|
+
const picked = faker.helpers.arrayElement(map[data].children.names);
|
|
61
|
+
return picked;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
createNode: (children2, _isLeaf, data) => {
|
|
65
|
+
if (data === "") return void 0;
|
|
66
|
+
if (!data) unreachable();
|
|
67
|
+
const type = schema.nodes[data];
|
|
68
|
+
if (!type) return void 0;
|
|
69
|
+
const parent = map[data]?.create?.(data, children2) ?? builder[data]({}, ...children2);
|
|
70
|
+
if (checkAfterNodeCreation) {
|
|
71
|
+
;
|
|
72
|
+
parent?.check?.();
|
|
73
|
+
}
|
|
74
|
+
return parent;
|
|
75
|
+
}
|
|
76
|
+
}, {
|
|
77
|
+
...treeOptions,
|
|
78
|
+
initialData: schema.topNodeType.name
|
|
79
|
+
});
|
|
80
|
+
const doc = map[schema.topNodeType.name]?.create?.(schema.topNodeType.name, children) ?? builder[schema.topNodeType.name]({}, ...children);
|
|
81
|
+
doc.check?.();
|
|
82
|
+
return doc;
|
|
83
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a random tree structure where the probability of generating children
|
|
3
|
+
* decreases linearly with depth, resulting in a tree that is bushy at the top
|
|
4
|
+
* and sparse towards the maximum depth.
|
|
5
|
+
*
|
|
6
|
+
* This function is agnostic to the actual node structure, relying on the caller-provided
|
|
7
|
+
* callbacks to create and link nodes.
|
|
8
|
+
*
|
|
9
|
+
* It uses faker.js for randomness (even on the option parameters).
|
|
10
|
+
*
|
|
11
|
+
* See {@link generateRandomDoc} for an example of how to use it.
|
|
12
|
+
*
|
|
13
|
+
* @experimental
|
|
14
|
+
*/
|
|
15
|
+
export declare function generateRandomTree<T, TData>({ createNode, parentData }: {
|
|
16
|
+
/** A function that creates a new node (type T) given its depth. */
|
|
17
|
+
createNode: (children: T[], isLeaf: boolean, parentData?: TData) => T | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* A function that is called before creating a a node or it's children whose result is passed to both. This allows creating children that will be compatible with the parent (via this same function, it is passed it's parent).
|
|
20
|
+
*
|
|
21
|
+
* For example, we could randomly generate a parent type. It's then passed both to the children, to limit the types of children nodes, and to the parent so it can actually create it.
|
|
22
|
+
*/
|
|
23
|
+
parentData?: (parentData?: TData) => TData;
|
|
24
|
+
}, { rootNodes: rootNodes, depth, minChildren, maxInitialChildren, initialData }?: {
|
|
25
|
+
/**
|
|
26
|
+
* The exact number of nodes at depth 0.
|
|
27
|
+
*
|
|
28
|
+
* @default faker.number.int({ min: 0, max: 5 })
|
|
29
|
+
*/
|
|
30
|
+
rootNodes?: number;
|
|
31
|
+
/**
|
|
32
|
+
* The maximum depth of the tree (0-indexed). Nodes at this depth will have 0 children.
|
|
33
|
+
*
|
|
34
|
+
* @default faker.number.int({ min: 0, max: 5 })
|
|
35
|
+
*/
|
|
36
|
+
depth?: number;
|
|
37
|
+
/**
|
|
38
|
+
* The absolute minimum number of children any node can have.
|
|
39
|
+
*
|
|
40
|
+
* @default 0
|
|
41
|
+
*/
|
|
42
|
+
minChildren?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The maximum children a node can have at depth 0. This value scales down as depth increases.
|
|
45
|
+
*
|
|
46
|
+
* @default 10
|
|
47
|
+
*/
|
|
48
|
+
maxInitialChildren?: number;
|
|
49
|
+
initialData?: TData;
|
|
50
|
+
}): T[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { faker } from "@faker-js/faker";
|
|
2
|
+
export function generateRandomTree({
|
|
3
|
+
createNode,
|
|
4
|
+
parentData
|
|
5
|
+
}, {
|
|
6
|
+
rootNodes = faker.number.int({ min: 0, max: 5 }),
|
|
7
|
+
depth = faker.number.int({ min: 0, max: 5 }),
|
|
8
|
+
minChildren = 0,
|
|
9
|
+
maxInitialChildren = 10,
|
|
10
|
+
initialData
|
|
11
|
+
} = {}) {
|
|
12
|
+
const generateChildren = (currentDepth, childCount, pData) => {
|
|
13
|
+
const res = [];
|
|
14
|
+
for (let i = 0; i < childCount; i++) {
|
|
15
|
+
const numChildren = calculateNumChildren(depth, currentDepth, minChildren, maxInitialChildren);
|
|
16
|
+
const data = parentData?.(pData) ?? void 0;
|
|
17
|
+
const parent = createNode(
|
|
18
|
+
generateChildren(currentDepth + 1, numChildren, data),
|
|
19
|
+
numChildren === 0,
|
|
20
|
+
data
|
|
21
|
+
);
|
|
22
|
+
if (parent === void 0) continue;
|
|
23
|
+
res.push(parent);
|
|
24
|
+
}
|
|
25
|
+
return res;
|
|
26
|
+
};
|
|
27
|
+
return generateChildren(0, rootNodes, initialData);
|
|
28
|
+
}
|
|
29
|
+
function calculateNumChildren(depth, currentDepth, minChildren, maxInitialChildren) {
|
|
30
|
+
const decayFactor = depth > 0 ? (depth - currentDepth) / depth : 0;
|
|
31
|
+
const maxAtDepth = minChildren + (maxInitialChildren - minChildren) * decayFactor;
|
|
32
|
+
const maxChildrenAtDepth = Math.max(minChildren, Math.floor(maxAtDepth));
|
|
33
|
+
let numChildren = 0;
|
|
34
|
+
if (currentDepth < depth) {
|
|
35
|
+
numChildren = faker.number.int({ min: minChildren, max: maxChildrenAtDepth });
|
|
36
|
+
}
|
|
37
|
+
return numChildren;
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@witchcraft/editor",
|
|
3
3
|
"description": "Block base prosemirror editor with partial/full editable document embeds, infinite embeds, and document uploads.",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.7",
|
|
5
5
|
"main": "./dist/runtime/main.lib.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
@@ -110,11 +110,13 @@
|
|
|
110
110
|
"@alanscodelog/eslint-config": "^6.3.1",
|
|
111
111
|
"@alanscodelog/utils": "^6.0.2",
|
|
112
112
|
"@commitlint/cli": "^19.8.1",
|
|
113
|
+
"@faker-js/faker": "^10.0.0",
|
|
113
114
|
"@fortawesome/fontawesome-svg-core": "^7.0.1",
|
|
114
115
|
"@fortawesome/free-brands-svg-icons": "^7.0.1",
|
|
115
116
|
"@fortawesome/free-regular-svg-icons": "^7.0.1",
|
|
116
117
|
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
|
117
118
|
"@nuxt/eslint-config": "^1.9.0",
|
|
119
|
+
"@tiptap/html": "^3.4.2",
|
|
118
120
|
"@witchcraft/nuxt-utils": "^0.3.6",
|
|
119
121
|
"@witchcraft/ui": "^0.3.7",
|
|
120
122
|
"colord": "^2.9.3",
|
|
@@ -141,7 +143,7 @@
|
|
|
141
143
|
"@nuxt/module-builder": "^1.0.2",
|
|
142
144
|
"@nuxt/schema": "^4.1.2",
|
|
143
145
|
"@nuxt/types": "^2.18.1",
|
|
144
|
-
"@playwright/test": "
|
|
146
|
+
"@playwright/test": "=1.56.0",
|
|
145
147
|
"@rollup/plugin-dynamic-import-vars": "^2.1.5",
|
|
146
148
|
"@tailwindcss/cli": "^4.1.13",
|
|
147
149
|
"@tailwindcss/vite": "^4.1.13",
|
|
@@ -153,15 +155,15 @@
|
|
|
153
155
|
"@witchcraft/ui": "^0.3.7",
|
|
154
156
|
"concurrently": "^9.2.1",
|
|
155
157
|
"cross-env": "^10.0.0",
|
|
156
|
-
"eslint": "^9.
|
|
158
|
+
"eslint": "^9.38.0",
|
|
157
159
|
"fast-glob": "^3.3.3",
|
|
158
160
|
"http-server": "^14.1.1",
|
|
159
161
|
"husky": "^9.1.7",
|
|
160
162
|
"madge": "^8.0.0",
|
|
161
163
|
"nuxt": "^4.1.2",
|
|
162
164
|
"onchange": "^7.1.0",
|
|
163
|
-
"playwright": "
|
|
164
|
-
"playwright-core": "
|
|
165
|
+
"playwright": "=1.56.0",
|
|
166
|
+
"playwright-core": "=1.56.0",
|
|
165
167
|
"prosemirror-test-builder": "^1.1.1",
|
|
166
168
|
"radix-vue": "^1.9.17",
|
|
167
169
|
"semantic-release": "^24.2.8",
|
|
@@ -32,7 +32,6 @@ declare module "@tiptap/core" {
|
|
|
32
32
|
export const Item = Node.create<ItemNodeOptions>({
|
|
33
33
|
name: "item" satisfies NodeItemName,
|
|
34
34
|
content: "block list? | list",
|
|
35
|
-
|
|
36
35
|
addOptions() {
|
|
37
36
|
return {
|
|
38
37
|
HTMLAttributes: {},
|
|
@@ -174,3 +173,4 @@ export const Item = Node.create<ItemNodeOptions>({
|
|
|
174
173
|
}
|
|
175
174
|
})
|
|
176
175
|
export type NodeItemName = "item"
|
|
176
|
+
|
|
@@ -97,22 +97,27 @@
|
|
|
97
97
|
</div>
|
|
98
98
|
</template>
|
|
99
99
|
|
|
100
|
-
<!--
|
|
101
|
-
Multipurpose drag handle + collapse indicator.
|
|
102
|
-
|
|
103
|
-
This is incredibly useful for making a compact draggable tree view.
|
|
104
|
-
|
|
105
|
-
The collapse indicator has a default height, but it should be set manually. For example `[&>.collapse-indicator]:h-[...]`
|
|
106
|
-
|
|
107
|
-
The component only emits a few events, it does not handle the dragging itself or what actually happens on any clicks/input.
|
|
108
|
-
-->
|
|
109
100
|
<script lang="ts">
|
|
101
|
+
/**
|
|
102
|
+
* Multipurpose drag handle + collapse indicator.
|
|
103
|
+
*
|
|
104
|
+
* This is incredibly useful for making a compact draggable tree view.
|
|
105
|
+
*
|
|
106
|
+
* The collapse indicator has a default height, but it should be set manually. For example `[&>.collapse-indicator]:h-[...]`
|
|
107
|
+
*
|
|
108
|
+
* The component only emits a few events, it does not handle the dragging itself or what actually happens on any clicks/input.
|
|
109
|
+
*/
|
|
110
|
+
export default { }
|
|
110
111
|
</script>
|
|
111
112
|
|
|
112
113
|
<script setup lang="ts">
|
|
113
114
|
import { twMerge } from "@witchcraft/ui/utils/twMerge"
|
|
114
115
|
import { onMounted, onUnmounted, ref, useAttrs } from "vue"
|
|
115
116
|
|
|
117
|
+
|
|
118
|
+
defineOptions({
|
|
119
|
+
name: "DragTreeHandle"
|
|
120
|
+
})
|
|
116
121
|
interface Props {
|
|
117
122
|
hasChildren: boolean
|
|
118
123
|
passedDragThreshold: boolean
|
|
@@ -46,7 +46,7 @@ export type IFileLoaderHandler<
|
|
|
46
46
|
editor: Editor,
|
|
47
47
|
pos: number | undefined,
|
|
48
48
|
error: Error,
|
|
49
|
-
loadingKey: TKey
|
|
49
|
+
loadingKey: TKey
|
|
50
50
|
) => void
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -77,7 +77,7 @@ export type IFileLoaderHandler<
|
|
|
77
77
|
editor: Editor,
|
|
78
78
|
pos: number,
|
|
79
79
|
res: T,
|
|
80
|
-
loadingKey: TKey
|
|
80
|
+
loadingKey: TKey
|
|
81
81
|
) => void
|
|
82
82
|
/**
|
|
83
83
|
* Return the file (or whatever type you'd like) to allow the extension to handle it.
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { faker } from "@faker-js/faker"
|
|
2
|
+
import type { Mark, Node } from "@tiptap/pm/model"
|
|
3
|
+
import { nanoid } from "nanoid"
|
|
4
|
+
import { builders } from "prosemirror-test-builder"
|
|
5
|
+
|
|
6
|
+
import { schema } from "./schema.js"
|
|
7
|
+
|
|
8
|
+
export const pm = builders(schema)
|
|
9
|
+
|
|
10
|
+
export type GeneratorConfigEntry<
|
|
11
|
+
TChildrenType extends "node" | "text" = "node" | "text",
|
|
12
|
+
TParentType extends "node" | "text" = "node" | "text",
|
|
13
|
+
TChildren extends TChildrenType extends "node" ? Node : Mark | string = TChildrenType extends "node" ? Node : Mark | string,
|
|
14
|
+
TParent extends TParentType extends "node" ? Node : Mark | string = TParentType extends "node" ? Node : Mark | string
|
|
15
|
+
> = {
|
|
16
|
+
parents: {
|
|
17
|
+
/**
|
|
18
|
+
* The type of the parent (node or "text") where "text" is a string or mark.
|
|
19
|
+
* Determines the call signature of the create function.
|
|
20
|
+
*/
|
|
21
|
+
type: TParentType
|
|
22
|
+
/** Possible parent nodes that can have the given children. The children need not be direct descendants, see `ignoreValidation`. */
|
|
23
|
+
names: string[]
|
|
24
|
+
}
|
|
25
|
+
children: {
|
|
26
|
+
/**
|
|
27
|
+
* The type of children (node or "text") where "text" is a string or mark.
|
|
28
|
+
* Determines the call signature of the create function.
|
|
29
|
+
*/
|
|
30
|
+
type: TChildrenType
|
|
31
|
+
/* Children types to generate. */
|
|
32
|
+
names?: string[]
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Set to true to ignore all children mismatches, or set an array of children names to ignore.
|
|
36
|
+
*
|
|
37
|
+
* This is needed for creating "end" nodes that aren't valid names (e.g. `text` is not technically allowed since you can't do `builder.text()`).
|
|
38
|
+
*
|
|
39
|
+
* Or when the children are not direct children of the parent types as there is some in-between wrapper node that needs to be generated.
|
|
40
|
+
*/
|
|
41
|
+
ignoreValidation?: boolean | string[]
|
|
42
|
+
/* Whether the parent listed is the root node. */
|
|
43
|
+
isRoot?: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Creates the node. Can return undefined to "terminate" the branch being created, the node will be filtered out of the nodes passed to it's parent.
|
|
46
|
+
*
|
|
47
|
+
* If not set, a default `builder[parentType]({}, ...children)` will be used.
|
|
48
|
+
*
|
|
49
|
+
* Note also, it is not required to use the children. You can ignore them or use a subset or create a different one if needed (some node types *require* text and if your text not can return "" this can be a problem).
|
|
50
|
+
*/
|
|
51
|
+
create?: (parent: string, children: TChildren[]) => TParent | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getTextOrMarkLength(textOrMark: string | Mark) {
|
|
55
|
+
if (typeof textOrMark === "string") {
|
|
56
|
+
return textOrMark.length
|
|
57
|
+
}
|
|
58
|
+
const textNodes = (textOrMark as any).flat.map((_: any) => "text" in _ ? _.text.length : getTextOrMarkLength(_ as any)) as number[]
|
|
59
|
+
return textNodes.reduce((a, b) => a + b, 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createGeneratorConfig<
|
|
63
|
+
TChildrenType extends "node" | "text" = "node" | "text",
|
|
64
|
+
TParentType extends "node" | "text" = "node" | "text",
|
|
65
|
+
TChildren extends TChildrenType extends "node" ? Node : Mark | string = TChildrenType extends "node" ? Node : Mark | string,
|
|
66
|
+
TParent extends TParentType extends "node" ? Node : Mark | string = TParentType extends "node" ? Node : Mark | string
|
|
67
|
+
>(
|
|
68
|
+
config: GeneratorConfigEntry<TChildrenType, TParentType, TChildren, TParent>
|
|
69
|
+
): GeneratorConfigEntry<TChildrenType, TParentType, TChildren, TParent> {
|
|
70
|
+
return config
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createPsuedoSentence() {
|
|
74
|
+
// sentence generated with string.sample (which contains all possible chars) instead of lorem.sentence
|
|
75
|
+
const sentenceLength = faker.number.int({ min: 0, max: 1000 })
|
|
76
|
+
const sentence = Array.from(
|
|
77
|
+
{ length: sentenceLength },
|
|
78
|
+
() => faker.string.sample({ min: 0, max: 1000 })
|
|
79
|
+
).join(" ")
|
|
80
|
+
return sentence
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const generatorConfig = [
|
|
84
|
+
createGeneratorConfig({
|
|
85
|
+
isRoot: true,
|
|
86
|
+
parents: { type: "node", names: ["doc"] },
|
|
87
|
+
ignoreValidation: true,
|
|
88
|
+
children: { type: "node", names: ["item"] },
|
|
89
|
+
create: (_parent, children) => {
|
|
90
|
+
if (!children || children.length === 0) {
|
|
91
|
+
return pm.doc(pm.list(pm.item({ blockId: nanoid(10) }, pm.paragraph({}, ""))))
|
|
92
|
+
}
|
|
93
|
+
return pm.doc(pm.list(...children))
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
createGeneratorConfig({
|
|
97
|
+
parents: { type: "node", names: ["list"] },
|
|
98
|
+
children: { type: "node", names: ["item"] },
|
|
99
|
+
create: (_parent, children) => {
|
|
100
|
+
if (!children || children.length === 0) {
|
|
101
|
+
return pm.list(pm.item({ blockId: nanoid(10) }, pm.paragraph("")))
|
|
102
|
+
}
|
|
103
|
+
return pm.list(...children)
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
createGeneratorConfig({
|
|
107
|
+
parents: { type: "node", names: ["item"] },
|
|
108
|
+
children: { type: "node", names: [
|
|
109
|
+
"paragraph",
|
|
110
|
+
"heading",
|
|
111
|
+
"codeBlock",
|
|
112
|
+
"embeddedDoc",
|
|
113
|
+
"iframe",
|
|
114
|
+
"table",
|
|
115
|
+
"image",
|
|
116
|
+
"blockquote"
|
|
117
|
+
] },
|
|
118
|
+
create: (_parent, children) => {
|
|
119
|
+
if (!children || children.length === 0) {
|
|
120
|
+
return pm.item({ blockId: nanoid(10) }, pm.paragraph(""))
|
|
121
|
+
}
|
|
122
|
+
return pm.item(
|
|
123
|
+
{ blockId: nanoid(10) },
|
|
124
|
+
children[0]!,
|
|
125
|
+
...(children.length > 1
|
|
126
|
+
? [pm.list({},
|
|
127
|
+
...children.slice(1).map(_ =>
|
|
128
|
+
pm.item({ blockId: nanoid(10) }, _)
|
|
129
|
+
)
|
|
130
|
+
)]
|
|
131
|
+
: []
|
|
132
|
+
) as any
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
createGeneratorConfig({
|
|
137
|
+
parents: { type: "node", names: ["blockquote"] },
|
|
138
|
+
children: { type: "node", names: ["paragraph", "cite"] },
|
|
139
|
+
create: (_parent, children) => {
|
|
140
|
+
const parTypes = []
|
|
141
|
+
const citeTypes = []
|
|
142
|
+
for (const child of children) {
|
|
143
|
+
if (child.type.name === "cite") {
|
|
144
|
+
citeTypes.push(child)
|
|
145
|
+
} else {
|
|
146
|
+
parTypes.push(child)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (parTypes.length === 0) {
|
|
150
|
+
return pm.blockquote({}, pm.paragraph(""))
|
|
151
|
+
} else {
|
|
152
|
+
const someCite = citeTypes.find(_ => _.textContent.length > 0) ?? pm.cite({}, createPsuedoSentence())
|
|
153
|
+
return pm.blockquote({}, ...parTypes, someCite)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
createGeneratorConfig({
|
|
158
|
+
parents: { type: "node", names: ["table"] },
|
|
159
|
+
children: { type: "node", names: [
|
|
160
|
+
"tableHeader",
|
|
161
|
+
"tableCell"
|
|
162
|
+
] },
|
|
163
|
+
ignoreValidation: true, // we're handling the in-between tableRow nodes
|
|
164
|
+
create: (_parent, children) => {
|
|
165
|
+
const headerType = []
|
|
166
|
+
const cellType = []
|
|
167
|
+
|
|
168
|
+
for (const child of children) {
|
|
169
|
+
if (child.type.name === "tableHeader") {
|
|
170
|
+
headerType.push(child)
|
|
171
|
+
} else if (child.type.name === "tableCell") {
|
|
172
|
+
cellType.push(child)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const colNum = headerType.length
|
|
176
|
+
|
|
177
|
+
if (colNum === 0) {
|
|
178
|
+
return pm.table({}, pm.tableRow({}, pm.tableCell(pm.paragraph(""))))
|
|
179
|
+
}
|
|
180
|
+
const rowCount = Math.ceil(cellType.length / colNum)
|
|
181
|
+
const rows: Node[] = []
|
|
182
|
+
for (let i = 0; i < rowCount; i++) {
|
|
183
|
+
rows.push(pm.tableRow({}, ...cellType.slice(i * colNum, (i + 1) * colNum)))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return pm.table({}, pm.tableRow({}, ...headerType), ...rows)
|
|
187
|
+
}
|
|
188
|
+
}),
|
|
189
|
+
createGeneratorConfig({
|
|
190
|
+
parents: { type: "node", names: ["tableHeader", "tableCell"] },
|
|
191
|
+
children: { type: "node", names: ["paragraph"] },
|
|
192
|
+
create: (_parent, children) => {
|
|
193
|
+
if (!children || children.length === 0) {
|
|
194
|
+
return pm.tableCell(pm.paragraph(""))
|
|
195
|
+
}
|
|
196
|
+
return pm.tableCell(...children.slice(0, 1))
|
|
197
|
+
}
|
|
198
|
+
}),
|
|
199
|
+
createGeneratorConfig({
|
|
200
|
+
parents: { type: "node", names: ["paragraph", "heading", "codeBlock", "cite", "heading"] },
|
|
201
|
+
children: { type: "text", names: [
|
|
202
|
+
"bold",
|
|
203
|
+
"italic",
|
|
204
|
+
"underline",
|
|
205
|
+
"strike",
|
|
206
|
+
"subscript",
|
|
207
|
+
"superscript",
|
|
208
|
+
"code",
|
|
209
|
+
"link"
|
|
210
|
+
] },
|
|
211
|
+
create(parent, children) {
|
|
212
|
+
if (parent === "heading") {
|
|
213
|
+
return pm[parent]({ level: 1 }, ...children as any)
|
|
214
|
+
}
|
|
215
|
+
if (parent === "cite") {
|
|
216
|
+
// citation must have SOME text
|
|
217
|
+
return pm[parent]({}, faker.lorem.sentence())
|
|
218
|
+
}
|
|
219
|
+
if (parent === "paragraph") {
|
|
220
|
+
const someIndex = faker.number.int({ min: 0, max: children.length })
|
|
221
|
+
children.splice(someIndex, 0, pm.hardBreak() as any)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return pm[parent]({}, ...children as any)
|
|
225
|
+
}
|
|
226
|
+
}),
|
|
227
|
+
createGeneratorConfig({
|
|
228
|
+
parents: { type: "text", names: [
|
|
229
|
+
"bold",
|
|
230
|
+
"italic",
|
|
231
|
+
"underline",
|
|
232
|
+
"strike",
|
|
233
|
+
"subscript",
|
|
234
|
+
"superscript",
|
|
235
|
+
"link"
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
// note the addition of text
|
|
239
|
+
children: { type: "text", names: [
|
|
240
|
+
"bold",
|
|
241
|
+
"italic",
|
|
242
|
+
"underline",
|
|
243
|
+
"strike",
|
|
244
|
+
"subscript",
|
|
245
|
+
"superscript",
|
|
246
|
+
"link",
|
|
247
|
+
"text"
|
|
248
|
+
] },
|
|
249
|
+
ignoreValidation: ["text"] // text is not a real node
|
|
250
|
+
// create: (parent, children) => {
|
|
251
|
+
// return pm[parent]({}, ...children as any) as any
|
|
252
|
+
// }
|
|
253
|
+
}),
|
|
254
|
+
createGeneratorConfig({
|
|
255
|
+
ignoreValidation: true, // text is not a real node
|
|
256
|
+
parents: { type: "text", names: ["code"] },
|
|
257
|
+
children: { type: "text", names: ["text"] }
|
|
258
|
+
}),
|
|
259
|
+
createGeneratorConfig({
|
|
260
|
+
parents: { type: "text", names: ["text"] },
|
|
261
|
+
children: { type: "text" },
|
|
262
|
+
create: _parent => {
|
|
263
|
+
return createPsuedoSentence()
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
]
|
package/src/runtime/pm/schema.ts
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { unreachable } from "@alanscodelog/utils/unreachable"
|
|
2
|
+
import { faker } from "@faker-js/faker"
|
|
3
|
+
import type { Mark, MarkType, Node } from "@tiptap/pm/model"
|
|
4
|
+
import type { builders } from "prosemirror-test-builder"
|
|
5
|
+
|
|
6
|
+
import { generateRandomTree } from "./generateRandomTree.js"
|
|
7
|
+
|
|
8
|
+
import type { GeneratorConfigEntry } from "../generator.js"
|
|
9
|
+
import { generatorConfig } from "../generator.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generates a random doc using faker js.
|
|
13
|
+
*
|
|
14
|
+
* A config describing how to create the doc is required. One is available under `pm/generator`.
|
|
15
|
+
*
|
|
16
|
+
* @experimental
|
|
17
|
+
*/
|
|
18
|
+
export function generateRandomDoc<
|
|
19
|
+
TBuilder extends ReturnType<typeof builders>,
|
|
20
|
+
TData extends string
|
|
21
|
+
>(
|
|
22
|
+
builder: TBuilder,
|
|
23
|
+
config: GeneratorConfigEntry<any, any, any, any>[] = generatorConfig,
|
|
24
|
+
treeOptions?: Parameters<typeof generateRandomTree>[1],
|
|
25
|
+
{
|
|
26
|
+
checkAfterNodeCreation = false
|
|
27
|
+
}: {
|
|
28
|
+
/**
|
|
29
|
+
* Checks if every returned node is valid for debugging invalid generator configs.
|
|
30
|
+
*
|
|
31
|
+
* A check is always done on the root node at the end regardless of this option.
|
|
32
|
+
*
|
|
33
|
+
* It is very slow (each check, rechecks children).
|
|
34
|
+
*
|
|
35
|
+
* @default false
|
|
36
|
+
*/
|
|
37
|
+
checkAfterNodeCreation?: boolean
|
|
38
|
+
} = {}
|
|
39
|
+
): Node {
|
|
40
|
+
const schema = builder.schema
|
|
41
|
+
|
|
42
|
+
const map: Record<string, GeneratorConfigEntry<any, any, any, any>> = {}
|
|
43
|
+
function schemaFilter(name: string) {
|
|
44
|
+
return name !== "text" && name !== schema.topNodeType.name
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const entry of config) {
|
|
48
|
+
for (const node of entry.parents.names) {
|
|
49
|
+
if (node === "") throw new Error(`Empty node name ""`)
|
|
50
|
+
const type = entry.parents.type === "node" ? schema.nodes[node] : schema.marks[node]
|
|
51
|
+
if (type === undefined && node !== "text") {
|
|
52
|
+
throw new Error(`${node} (on entry of type ${entry.parents.type}) not found in schema. Valid names are ${Object.keys(entry.parents.type === "node" ? schema.nodes : schema.marks)}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const node of entry.parents.names) {
|
|
56
|
+
const subEntry = { ...entry }
|
|
57
|
+
if (entry.ignoreValidation === undefined || entry.ignoreValidation !== true) {
|
|
58
|
+
const childrenToValidate = (entry.children.names ?? []).filter(_ => !Array.isArray(entry.ignoreValidation) || !entry.ignoreValidation.includes(_))
|
|
59
|
+
for (const child of childrenToValidate) {
|
|
60
|
+
if (child === "") throw new Error(`Empty node name ""`)
|
|
61
|
+
if (entry.children.type === "node") {
|
|
62
|
+
const childType = schema.nodes[child]
|
|
63
|
+
const possibleNames = [childType.name, ...((childType.spec.group ?? "").split(" ").filter(e => e !== ""))]
|
|
64
|
+
const isAllowedChild = type.spec.content && possibleNames.some(n => type.spec.content!.includes(n))
|
|
65
|
+
// we don't use contentMatch.matchType because
|
|
66
|
+
// it can give false negatives when the content expression
|
|
67
|
+
// has multiple types like type1+ type2+,
|
|
68
|
+
// it will only return true for type1 in those cases
|
|
69
|
+
if (!(isAllowedChild && schemaFilter(childType.name))) {
|
|
70
|
+
throw new Error(`Node ${node} cannot contain child of type ${child}`)
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
const childType = schema.marks[child]
|
|
74
|
+
if (entry.parents.type === "node") {
|
|
75
|
+
// not sure why this is returning false when it shouldn't (e.g. paragraph can't contain bold)
|
|
76
|
+
// if (!(type as NodeType).allowsMarkType(childType)) {
|
|
77
|
+
// throw new Error(`Node ${node} cannot contain mark child of type ${child}`)
|
|
78
|
+
// }
|
|
79
|
+
} else {
|
|
80
|
+
const possibleNames = [childType.name, ...((childType.spec.group ?? "").split(" ").filter(e => e !== ""))]
|
|
81
|
+
const t = type as MarkType
|
|
82
|
+
const isExcluded = t.spec.excludes !== undefined
|
|
83
|
+
&& possibleNames.some(n => {
|
|
84
|
+
// _ this means exclude everything in prosemirror
|
|
85
|
+
return t.spec.excludes!.includes(n)
|
|
86
|
+
|| t.spec.excludes!.includes("_")
|
|
87
|
+
})
|
|
88
|
+
if (isExcluded) {
|
|
89
|
+
throw new Error(`Mark ${node} cannot contain mark child of type ${child}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
map[node] = { ...subEntry }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
const children = generateRandomTree<Node | Mark | string, TData>({
|
|
102
|
+
parentData: (data?: TData): TData => {
|
|
103
|
+
if (data === "") return "" as TData
|
|
104
|
+
if (!data) unreachable()
|
|
105
|
+
if (!map[data] || !map[data].children.names || map[data].children.names.length === 0) {
|
|
106
|
+
// we must prematurely end the branch as the node can contain no children
|
|
107
|
+
return "" as TData
|
|
108
|
+
} else {
|
|
109
|
+
const picked = faker.helpers.arrayElement(map[data].children.names)
|
|
110
|
+
return picked as TData
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
createNode: (children, _isLeaf, data): Node | Mark | string | undefined => {
|
|
114
|
+
if (data === "") return undefined
|
|
115
|
+
if (!data) unreachable()
|
|
116
|
+
const type = schema.nodes[data]
|
|
117
|
+
// type allows no children or isn't configured to generate children
|
|
118
|
+
if (!type) return undefined
|
|
119
|
+
|
|
120
|
+
const parent = map[data]?.create?.(data, children as any)
|
|
121
|
+
?? builder[data]({}, ...children as any)
|
|
122
|
+
|
|
123
|
+
if (checkAfterNodeCreation) {
|
|
124
|
+
;(parent as any)?.check?.()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return parent
|
|
128
|
+
}
|
|
129
|
+
}, {
|
|
130
|
+
...treeOptions,
|
|
131
|
+
initialData: schema.topNodeType.name as TData
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const doc = map[schema.topNodeType.name]?.create?.(schema.topNodeType.name as any, children as any)
|
|
135
|
+
?? builder[schema.topNodeType.name]({}, ...children as any)
|
|
136
|
+
|
|
137
|
+
;(doc as any).check?.()
|
|
138
|
+
return doc as Node
|
|
139
|
+
}
|
|
140
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { faker } from "@faker-js/faker"
|
|
2
|
+
/**
|
|
3
|
+
* Generates a random tree structure where the probability of generating children
|
|
4
|
+
* decreases linearly with depth, resulting in a tree that is bushy at the top
|
|
5
|
+
* and sparse towards the maximum depth.
|
|
6
|
+
*
|
|
7
|
+
* This function is agnostic to the actual node structure, relying on the caller-provided
|
|
8
|
+
* callbacks to create and link nodes.
|
|
9
|
+
*
|
|
10
|
+
* It uses faker.js for randomness (even on the option parameters).
|
|
11
|
+
*
|
|
12
|
+
* See {@link generateRandomDoc} for an example of how to use it.
|
|
13
|
+
*
|
|
14
|
+
* @experimental
|
|
15
|
+
*/
|
|
16
|
+
export function generateRandomTree<T, TData>(
|
|
17
|
+
{
|
|
18
|
+
createNode,
|
|
19
|
+
parentData
|
|
20
|
+
}: {
|
|
21
|
+
/** A function that creates a new node (type T) given its depth. */
|
|
22
|
+
createNode: (children: T[], isLeaf: boolean, parentData?: TData) => T | undefined
|
|
23
|
+
/**
|
|
24
|
+
* A function that is called before creating a a node or it's children whose result is passed to both. This allows creating children that will be compatible with the parent (via this same function, it is passed it's parent).
|
|
25
|
+
*
|
|
26
|
+
* For example, we could randomly generate a parent type. It's then passed both to the children, to limit the types of children nodes, and to the parent so it can actually create it.
|
|
27
|
+
*/
|
|
28
|
+
parentData?: (parentData?: TData) => TData
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
rootNodes: rootNodes = faker.number.int({ min: 0, max: 5 }),
|
|
32
|
+
depth = faker.number.int({ min: 0, max: 5 }),
|
|
33
|
+
minChildren = 0,
|
|
34
|
+
maxInitialChildren = 10,
|
|
35
|
+
initialData
|
|
36
|
+
}: {
|
|
37
|
+
/**
|
|
38
|
+
* The exact number of nodes at depth 0.
|
|
39
|
+
*
|
|
40
|
+
* @default faker.number.int({ min: 0, max: 5 })
|
|
41
|
+
*/
|
|
42
|
+
rootNodes?: number
|
|
43
|
+
/**
|
|
44
|
+
* The maximum depth of the tree (0-indexed). Nodes at this depth will have 0 children.
|
|
45
|
+
*
|
|
46
|
+
* @default faker.number.int({ min: 0, max: 5 })
|
|
47
|
+
*/
|
|
48
|
+
depth?: number
|
|
49
|
+
/**
|
|
50
|
+
* The absolute minimum number of children any node can have.
|
|
51
|
+
*
|
|
52
|
+
* @default 0
|
|
53
|
+
*/
|
|
54
|
+
minChildren?: number
|
|
55
|
+
/**
|
|
56
|
+
* The maximum children a node can have at depth 0. This value scales down as depth increases.
|
|
57
|
+
*
|
|
58
|
+
* @default 10
|
|
59
|
+
*/
|
|
60
|
+
maxInitialChildren?: number
|
|
61
|
+
initialData?: TData
|
|
62
|
+
} = {}
|
|
63
|
+
): T[] {
|
|
64
|
+
const generateChildren = (currentDepth: number, childCount: number, pData?: TData): T[] => {
|
|
65
|
+
const res: T[] = []
|
|
66
|
+
for (let i = 0; i < childCount; i++) {
|
|
67
|
+
const numChildren = calculateNumChildren(depth, currentDepth, minChildren, maxInitialChildren)
|
|
68
|
+
|
|
69
|
+
const data = parentData?.(pData) ?? undefined
|
|
70
|
+
const parent = createNode(
|
|
71
|
+
generateChildren(currentDepth + 1, numChildren, data),
|
|
72
|
+
numChildren === 0,
|
|
73
|
+
data
|
|
74
|
+
)
|
|
75
|
+
if (parent === undefined) continue
|
|
76
|
+
res.push(parent)
|
|
77
|
+
}
|
|
78
|
+
return res
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return generateChildren(0, rootNodes, initialData)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function calculateNumChildren(
|
|
85
|
+
depth: number,
|
|
86
|
+
currentDepth: number,
|
|
87
|
+
minChildren: number,
|
|
88
|
+
maxInitialChildren: number
|
|
89
|
+
): number {
|
|
90
|
+
const decayFactor = depth > 0 ? (depth - currentDepth) / depth : 0
|
|
91
|
+
const maxAtDepth = minChildren + (maxInitialChildren - minChildren) * decayFactor
|
|
92
|
+
const maxChildrenAtDepth = Math.max(minChildren, Math.floor(maxAtDepth))
|
|
93
|
+
|
|
94
|
+
let numChildren = 0
|
|
95
|
+
|
|
96
|
+
if (currentDepth < depth) {
|
|
97
|
+
numChildren = faker.number.int({ min: minChildren, max: maxChildrenAtDepth })
|
|
98
|
+
}
|
|
99
|
+
return numChildren
|
|
100
|
+
}
|