Skip to content

Commit 24868db

Browse files
committed
feat: add external plugin example
1 parent 31ea580 commit 24868db

16 files changed

Lines changed: 722 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
.vscode/
10+
11+
node_modules
12+
dist
13+
dist-ssr
14+
*.local
15+
16+
# Editor directories and files
17+
.vscode/*
18+
!.vscode/extensions.json
19+
.idea
20+
.DS_Store
21+
*.suo
22+
*.ntvs*
23+
*.njsproj
24+
*.sln
25+
*.sw?
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# SuperDoc experiment - document sections, multi editor sync
2+
3+
## Example of creating a custom external plugin for SuperDoc
4+
## The plugin explores some dynamic content tracking, re-ordering with paragraph nodes
5+
6+
To try it locally:
7+
```
8+
npm install
9+
npm run dev
10+
```
11+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/superdoc-logo.png" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>SuperDoc - External Plugin and Multi Editors</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/main.js"></script>
12+
</body>
13+
</html>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "dynamic-content",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"preview": "vite preview"
10+
},
11+
"dependencies": {
12+
"@harbour-enterprises/superdoc": "0.14.19",
13+
"prosemirror-state": "^1.4.3",
14+
"prosemirror-view": "^1.38.1",
15+
"uuid": "^11.1.0",
16+
"vue": "^3.5.13"
17+
},
18+
"devDependencies": {
19+
"@vitejs/plugin-vue": "^5.2.1",
20+
"vite": "^6.2.0"
21+
}
22+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { Extensions } from '@harbour-enterprises/superdoc/super-editor';
2+
import { Decoration, DecorationSet } from 'prosemirror-view';
3+
import { PluginKey } from 'prosemirror-state';
4+
import { DOMSerializer } from 'prosemirror-model';
5+
6+
const activeNodeDecorationKey = new PluginKey('activeNodeDecoration');
7+
const ContentMapPluginKey = new PluginKey('contentMapPlugin');
8+
9+
const documentHighlight = (item, state) => {
10+
if (!item || !item.node || typeof item.pos !== 'number') {
11+
return false;
12+
}
13+
const { pos, node } = item;
14+
const nodeDeco = Decoration.inline(pos, pos + node.nodeSize, {
15+
class: 'document-section-active'
16+
});
17+
18+
const tr = state.tr.setMeta(activeNodeDecorationKey, { deco: nodeDeco });
19+
return tr;
20+
}
21+
22+
/**
23+
* A basic document map extension for SuperDoc.
24+
* It allows for selecting nodes, reordering them, and syncing content with a content editor.
25+
* It is for demo and education purposes only. It is purposely simple.
26+
* It does not handle all edge cases and is not production-ready.
27+
*
28+
* To extend SuperDoc, simply create a new extension:
29+
*/
30+
export const DocumentMapExtension = Extensions.Extension.create({
31+
32+
/**
33+
* You can add custom commands here that will be available via editor.commands
34+
*/
35+
addCommands() {
36+
return {
37+
documentMapSelectNode: (item) => ({ state, dispatch }) => {
38+
if (dispatch) {
39+
const tr = documentHighlight(item, state);
40+
dispatch(tr);
41+
}
42+
return true;
43+
},
44+
documentMapReorder: ({ draggedIndex, targetIndex }) => ({ state, dispatch }) => {
45+
const pluginState = ContentMapPluginKey.getState(state);
46+
const docMap = pluginState.docMap;
47+
48+
const draggedItem = docMap[draggedIndex];
49+
const targetItem = docMap[targetIndex];
50+
const { doc } = state;
51+
let tr = state.tr;
52+
53+
const paragraphNode = state.schema.nodes.paragraph.create();
54+
const isBefore = draggedItem.pos < targetItem.pos;
55+
if (!isBefore) {
56+
tr.delete(draggedItem.pos, Math.min(doc.content.size, draggedItem.pos + draggedItem.node.nodeSize));
57+
const insertPos = tr.mapping.map(targetItem.pos);
58+
tr.insert(Math.max(insertPos - 1, 0), draggedItem.node);
59+
tr.insert(Math.max(insertPos + draggedItem.node.nodeSize, 0), paragraphNode);
60+
} else {
61+
const insertPos = tr.mapping.map(targetItem.pos + targetItem.node.nodeSize - 1);
62+
tr.insert(Math.max(insertPos, 0), paragraphNode);
63+
tr.insert(Math.max(insertPos + paragraphNode.nodeSize, 0), draggedItem.node);
64+
tr.delete(draggedItem.pos, Math.min(doc.content.size, draggedItem.pos + draggedItem.node.nodeSize));
65+
}
66+
dispatch(tr);
67+
return true;
68+
},
69+
syncContent: ({ item, contentEditor }) => ({ tr, editor, state, dispatch }) => {
70+
const contentJson = contentEditor.getJSON();
71+
const newNode = state.schema.nodeFromJSON(contentJson);
72+
73+
const activeNode = state.doc.nodeAt(item.pos - 1);
74+
tr.replaceWith(item.pos - 1, item.pos + activeNode.nodeSize - 1, newNode);
75+
tr.setMeta(ContentMapPluginKey, { syncContent: true, item });
76+
dispatch(tr);
77+
78+
const newItem = { ...item, node: newNode, json: newNode.toJSON() };
79+
const newTr = documentHighlight(newItem, state);
80+
dispatch(newTr);
81+
return true;
82+
},
83+
updateDocumentMap: () => ({ tr, state, dispatch }) => {
84+
return true;
85+
}
86+
};
87+
},
88+
89+
90+
/**
91+
* Create a prosemirror plugin that will be added to the editor.
92+
* See https://prosemirror.net/docs/ref/#state.Plugin_System
93+
*/
94+
addPmPlugins() {
95+
let hasInitialized = false;
96+
const editor = this.editor;
97+
const ContentMapPLugin = new Extensions.Plugin({
98+
key: ContentMapPluginKey,
99+
state: {
100+
init(_, state) {
101+
// Initialize the document map with the current state.
102+
// Collect all paragraph nodes with content.
103+
const initialPositions = [];
104+
state.doc.descendants((node, pos) => {
105+
if (node.type.name === 'paragraph' && node.content.size > 0) {
106+
initialPositions.push({ pos, node });
107+
}
108+
});
109+
110+
// Add custom IDs to each node so we can track them.
111+
const { dispatch } = editor.view;
112+
const { tr: newTr } = state;
113+
initialPositions.forEach(({ node, pos }) => {
114+
const newAttrs = {
115+
...node.attrs,
116+
extraAttrs: {
117+
customTrackingId: `custom-id-${pos}`,
118+
}
119+
}
120+
newTr.setNodeMarkup(pos, undefined, newAttrs);
121+
});
122+
123+
dispatch(newTr);
124+
const initialDocMap = createMap(state, null);
125+
editor.emit('document-map-update', initialDocMap);
126+
127+
return {
128+
hasInitialized: false,
129+
docMap: initialDocMap,
130+
};
131+
},
132+
apply(tr, pluginState, oldState, newState) {
133+
let activeItem;
134+
135+
const docChanged = tr.docChanged || tr.selectionSet || tr.storedMarksSet;
136+
if (!docChanged) {
137+
return pluginState;
138+
}
139+
140+
const pluginMeta = tr.getMeta(ContentMapPluginKey);
141+
if (pluginMeta?.syncContent) {
142+
activeItem = pluginMeta?.item;
143+
return pluginState;
144+
}
145+
146+
const decoMeta = tr.getMeta(activeNodeDecorationKey);
147+
if (decoMeta?.deco) {
148+
return pluginState;
149+
}
150+
151+
pluginState.docMap = createMap(newState, activeItem);
152+
editor.emit('document-map-update', pluginState.docMap);
153+
return pluginState;
154+
},
155+
},
156+
});
157+
return [ContentMapPLugin];
158+
},
159+
});
160+
161+
/**
162+
* Helper function to create the document map.
163+
* It builds a tree of block nodes from the document.
164+
* @param {Object} newState - The new state of the editor.
165+
* @param {Object} activeItem - The currently active item in the document map.
166+
* @returns {Array} - An array of block nodes with their properties.
167+
*/
168+
const createMap = (newState, activeItem) => {
169+
const { doc } = newState;
170+
const initialElements = buildBlockNodesTree(doc, activeItem);
171+
return initialElements;
172+
};
173+
174+
/**
175+
* Recursively builds a tree of paragraph nodes from the ProseMirror document.
176+
* It serializes each block node to HTML and includes its position, text content, and children.
177+
* @param {Object} node - The ProseMirror node to process.
178+
* @param {Object} activeItem - The currently active item in the document map.
179+
* @param {number} pos - The current position in the document.
180+
* @returns {Array} - An array of objects representing the block nodes.
181+
*/
182+
function buildBlockNodesTree(node, activeItem, pos = 0) {
183+
const treeNodes = [];
184+
node.forEach((child, offset) => {
185+
const childPos = pos + offset + 1;
186+
const isActive = childPos === activeItem?.pos;
187+
188+
if (child.isBlock) {
189+
if (child.type.name === 'paragraph') {
190+
if (!child.textContent.trim()) {
191+
return;
192+
}
193+
194+
const serializer = DOMSerializer.fromSchema(child.type.schema);
195+
const fragment = serializer.serializeFragment(child.content);
196+
const container = document.createElement('div');
197+
container.appendChild(fragment);
198+
const html = container.innerHTML;
199+
200+
treeNodes.push({
201+
id: childPos,
202+
node: child,
203+
json: child.toJSON(),
204+
pos: childPos,
205+
html,
206+
isActive,
207+
text: child.textContent.slice(0, 50),
208+
children: buildBlockNodesTree(child, activeItem, childPos),
209+
});
210+
}
211+
}
212+
});
213+
return treeNodes;
214+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './document-map';
Binary file not shown.
283 KB
Loading
836 KB
Loading

0 commit comments

Comments
 (0)