@markup-carve/carve-grammars 0.1.2
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/LICENSE +21 -0
- package/README.md +240 -0
- package/highlightjs/carve.js +537 -0
- package/highlightjs/carve.mjs +25 -0
- package/package.json +119 -0
- package/prism/carve.js +471 -0
- package/shiki/carve.css +46 -0
- package/shiki/index.js +188 -0
- package/textmate/carve.tmLanguage.json +1023 -0
- package/tiptap/carve-kit.js +507 -0
- package/tiptap/extensions/carve-abbreviation.js +79 -0
- package/tiptap/extensions/carve-definition-list.js +270 -0
- package/tiptap/extensions/carve-delete.js +54 -0
- package/tiptap/extensions/carve-div.js +188 -0
- package/tiptap/extensions/carve-embed.js +248 -0
- package/tiptap/extensions/carve-footnote-definition.js +81 -0
- package/tiptap/extensions/carve-footnote.js +76 -0
- package/tiptap/extensions/carve-insert.js +54 -0
- package/tiptap/extensions/carve-keymap.js +71 -0
- package/tiptap/extensions/carve-math.js +82 -0
- package/tiptap/extensions/carve-mention.js +44 -0
- package/tiptap/extensions/carve-span.js +101 -0
- package/tiptap/extensions/carve-tabs.js +158 -0
- package/tiptap/extensions/index.js +14 -0
- package/tiptap/index.js +53 -0
- package/tiptap/serializer.js +686 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
import { carveMediaDirective } from '../serializer.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A preview iframe URL for the node view. Prefer an explicit embed src captured
|
|
6
|
+
* from the render; otherwise derive one from the Carve source directive so the
|
|
7
|
+
* player still shows after an inline edit.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} attrs - carveEmbed node attrs.
|
|
10
|
+
* @returns {string} An https URL to embed, or '' if none can be derived.
|
|
11
|
+
*/
|
|
12
|
+
function embedPreviewSrc(attrs) {
|
|
13
|
+
if (attrs.src) {
|
|
14
|
+
return attrs.src.startsWith('//') ? `https:${attrs.src}` : attrs.src;
|
|
15
|
+
}
|
|
16
|
+
const source = attrs.carveSource || '';
|
|
17
|
+
let m = source.match(/^:youtube\[([\w-]+)\]/i);
|
|
18
|
+
if (m) return `https://www.youtube.com/embed/${m[1]}`;
|
|
19
|
+
m = source.match(/^:vimeo\[(\d+)\]/i);
|
|
20
|
+
if (m) return `https://player.vimeo.com/video/${m[1]}`;
|
|
21
|
+
m = source.match(/^:media\[([^\]]+)\]/i);
|
|
22
|
+
if (m) return m[1].startsWith('//') ? `https:${m[1]}` : m[1];
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Carve Embed node extension for Tiptap
|
|
28
|
+
*
|
|
29
|
+
* Preserves video embeds, iframes, and oEmbed content during round-trips.
|
|
30
|
+
* Stores the original Carve source (e.g., YouTube URL) in data-carve-src.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```js
|
|
34
|
+
* import { CarveEmbed } from 'carve-grammars/tiptap'
|
|
35
|
+
*
|
|
36
|
+
* const editor = new Editor({
|
|
37
|
+
* extensions: [CarveEmbed],
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* // Insert an embed
|
|
41
|
+
* editor.chain().focus().setCarveEmbed({
|
|
42
|
+
* src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
43
|
+
* html: '<iframe...></iframe>',
|
|
44
|
+
* }).run()
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const CarveEmbed = Node.create({
|
|
48
|
+
name: 'carveEmbed',
|
|
49
|
+
|
|
50
|
+
group: 'block',
|
|
51
|
+
|
|
52
|
+
atom: true,
|
|
53
|
+
|
|
54
|
+
addAttributes() {
|
|
55
|
+
return {
|
|
56
|
+
// The exact Carve source stamped by the renderer (data-carve-source),
|
|
57
|
+
// e.g. ":youtube[id]" or ":media[url]". When present it is emitted
|
|
58
|
+
// verbatim on serialize - lossless for every provider, no guessing.
|
|
59
|
+
carveSource: {
|
|
60
|
+
default: null,
|
|
61
|
+
parseHTML: element => element.getAttribute('data-carve-source')
|
|
62
|
+
|| element.querySelector('[data-carve-source]')?.getAttribute('data-carve-source')
|
|
63
|
+
|| null,
|
|
64
|
+
renderHTML: attributes => {
|
|
65
|
+
if (!attributes.carveSource) return {};
|
|
66
|
+
return { 'data-carve-source': attributes.carveSource };
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
src: {
|
|
70
|
+
default: null,
|
|
71
|
+
parseHTML: element => {
|
|
72
|
+
// Check for data-carve-src first
|
|
73
|
+
const carveSrc = element.getAttribute('data-carve-src');
|
|
74
|
+
if (carveSrc) return carveSrc;
|
|
75
|
+
// The element may BE the iframe (matched by the tag rule) or
|
|
76
|
+
// wrap one.
|
|
77
|
+
if (element.tagName === 'IFRAME') return element.getAttribute('src');
|
|
78
|
+
const iframe = element.querySelector('iframe');
|
|
79
|
+
if (iframe) return iframe.getAttribute('src');
|
|
80
|
+
// Check for video source
|
|
81
|
+
const video = element.querySelector('video source');
|
|
82
|
+
if (video) return video.getAttribute('src');
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
renderHTML: attributes => {
|
|
86
|
+
if (!attributes.src) return {};
|
|
87
|
+
return { 'data-carve-src': attributes.src };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
html: {
|
|
91
|
+
default: null,
|
|
92
|
+
// Keep the full original embed markup so the editor previews the
|
|
93
|
+
// real player (all iframe attributes: allow, referrerpolicy, ...),
|
|
94
|
+
// not a stripped-down reconstruction.
|
|
95
|
+
parseHTML: element => {
|
|
96
|
+
if (element.tagName === 'IFRAME') return element.outerHTML;
|
|
97
|
+
const iframe = element.querySelector('iframe');
|
|
98
|
+
if (iframe) return iframe.outerHTML;
|
|
99
|
+
return element.innerHTML || null;
|
|
100
|
+
},
|
|
101
|
+
renderHTML: () => ({}),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
parseHTML() {
|
|
107
|
+
return [
|
|
108
|
+
// Match WordPress embed wrappers
|
|
109
|
+
{ tag: 'figure.wp-block-embed' },
|
|
110
|
+
{ tag: 'div.wp-block-embed' },
|
|
111
|
+
// Match wpcarve-embed class
|
|
112
|
+
{ tag: 'figure.wpcarve-embed' },
|
|
113
|
+
{ tag: 'div.wpcarve-embed' },
|
|
114
|
+
// A source-stamped element round-trips exactly - match it first.
|
|
115
|
+
{ tag: '[data-carve-source]', priority: 60 },
|
|
116
|
+
// Match elements with data-carve-src
|
|
117
|
+
{ tag: '[data-carve-src]' },
|
|
118
|
+
// Match iframes that look like video embeds
|
|
119
|
+
{
|
|
120
|
+
tag: 'iframe',
|
|
121
|
+
getAttrs: element => {
|
|
122
|
+
const src = element.getAttribute('src') || '';
|
|
123
|
+
// Only match video embed iframes
|
|
124
|
+
if (src.includes('youtube') || src.includes('vimeo') ||
|
|
125
|
+
src.includes('dailymotion') || src.includes('wistia')) {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
135
|
+
const wrapper = document.createElement('div');
|
|
136
|
+
wrapper.innerHTML = node.attrs.html || '';
|
|
137
|
+
|
|
138
|
+
return ['figure', mergeAttributes(HTMLAttributes, {
|
|
139
|
+
class: 'wpcarve-embed',
|
|
140
|
+
'data-carve-src': node.attrs.src,
|
|
141
|
+
}), node.attrs.html ? ['div', { innerHTML: node.attrs.html }] : ['p', 'Embedded content']];
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
addNodeView() {
|
|
145
|
+
return ({ node, editor, getPos }) => {
|
|
146
|
+
let current = node;
|
|
147
|
+
const dom = document.createElement('figure');
|
|
148
|
+
dom.classList.add('wpcarve-embed');
|
|
149
|
+
dom.contentEditable = 'false';
|
|
150
|
+
|
|
151
|
+
const media = document.createElement('div');
|
|
152
|
+
media.className = 'wpcarve-embed-media';
|
|
153
|
+
|
|
154
|
+
// Inline edit affordance: change the media URL / video id in place.
|
|
155
|
+
const editBtn = document.createElement('button');
|
|
156
|
+
editBtn.type = 'button';
|
|
157
|
+
editBtn.className = 'carve-embed-edit';
|
|
158
|
+
editBtn.textContent = 'Edit';
|
|
159
|
+
editBtn.contentEditable = 'false';
|
|
160
|
+
editBtn.setAttribute('aria-label', 'Edit media URL');
|
|
161
|
+
editBtn.addEventListener('mousedown', e => e.stopPropagation());
|
|
162
|
+
editBtn.addEventListener('click', () => {
|
|
163
|
+
if (typeof getPos !== 'function') return;
|
|
164
|
+
const shown = current.attrs.carveSource || current.attrs.src || '';
|
|
165
|
+
const val = window.prompt('Media URL or video id', shown);
|
|
166
|
+
if (val === null) return;
|
|
167
|
+
const input = val.trim();
|
|
168
|
+
if (!input) return;
|
|
169
|
+
// A `:name[...]` directive is kept as-is; a URL/id becomes one
|
|
170
|
+
// (a bare id is assumed to be YouTube).
|
|
171
|
+
const directive = input.startsWith(':')
|
|
172
|
+
? input
|
|
173
|
+
: carveMediaDirective(/^https?:|^\/\//.test(input) ? input : `https://www.youtube.com/watch?v=${input}`);
|
|
174
|
+
editor.chain().focus().command(({ tr }) => {
|
|
175
|
+
tr.setNodeMarkup(getPos(), undefined, {
|
|
176
|
+
...current.attrs,
|
|
177
|
+
carveSource: directive,
|
|
178
|
+
src: null,
|
|
179
|
+
html: null,
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
}).run();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const paint = (n) => {
|
|
186
|
+
media.innerHTML = '';
|
|
187
|
+
const src = embedPreviewSrc(n.attrs);
|
|
188
|
+
if (n.attrs.html) {
|
|
189
|
+
media.innerHTML = n.attrs.html;
|
|
190
|
+
} else if (src) {
|
|
191
|
+
const iframe = document.createElement('iframe');
|
|
192
|
+
iframe.src = src;
|
|
193
|
+
iframe.setAttribute('loading', 'lazy');
|
|
194
|
+
iframe.setAttribute('allowfullscreen', '');
|
|
195
|
+
iframe.setAttribute('frameborder', '0');
|
|
196
|
+
iframe.width = '480';
|
|
197
|
+
iframe.height = '270';
|
|
198
|
+
media.appendChild(iframe);
|
|
199
|
+
} else {
|
|
200
|
+
const p = document.createElement('p');
|
|
201
|
+
p.textContent = `Embedded: ${n.attrs.carveSource || 'unknown'}`;
|
|
202
|
+
media.appendChild(p);
|
|
203
|
+
}
|
|
204
|
+
if (n.attrs.src) {
|
|
205
|
+
dom.setAttribute('data-carve-src', n.attrs.src);
|
|
206
|
+
} else {
|
|
207
|
+
dom.removeAttribute('data-carve-src');
|
|
208
|
+
}
|
|
209
|
+
if (n.attrs.carveSource) {
|
|
210
|
+
dom.setAttribute('data-carve-source', n.attrs.carveSource);
|
|
211
|
+
} else {
|
|
212
|
+
dom.removeAttribute('data-carve-source');
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
dom.appendChild(media);
|
|
217
|
+
dom.appendChild(editBtn);
|
|
218
|
+
paint(node);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
dom,
|
|
222
|
+
update: (updated) => {
|
|
223
|
+
if (updated.type !== current.type) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
current = updated;
|
|
227
|
+
paint(updated);
|
|
228
|
+
return true;
|
|
229
|
+
},
|
|
230
|
+
// Atom chrome: never let ProseMirror re-read our internal DOM.
|
|
231
|
+
ignoreMutation: () => true,
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
addCommands() {
|
|
237
|
+
return {
|
|
238
|
+
setCarveEmbed: (attributes) => ({ commands }) => {
|
|
239
|
+
return commands.insertContent({
|
|
240
|
+
type: this.name,
|
|
241
|
+
attrs: attributes,
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
export default CarveEmbed;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carve Footnote Definition node for Tiptap.
|
|
5
|
+
*
|
|
6
|
+
* A block that holds a footnote's body, paired with an inline `[^label]`
|
|
7
|
+
* reference (see CarveFootnote). Serializes to Carve as:
|
|
8
|
+
*
|
|
9
|
+
* ```
|
|
10
|
+
* [^label]: the footnote body
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Hosts typically collect these at the end of the document. The body is regular
|
|
14
|
+
* block content (`paragraph+`).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```js
|
|
18
|
+
* import { CarveFootnoteDefinition } from 'carve-grammars/tiptap'
|
|
19
|
+
*
|
|
20
|
+
* editor.chain().focus().insertCarveFootnoteDefinition({ label: '1' }).run()
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export const CarveFootnoteDefinition = Node.create({
|
|
24
|
+
name: 'carveFootnoteDefinition',
|
|
25
|
+
|
|
26
|
+
group: 'block',
|
|
27
|
+
|
|
28
|
+
content: 'paragraph+',
|
|
29
|
+
|
|
30
|
+
defining: true,
|
|
31
|
+
|
|
32
|
+
addAttributes() {
|
|
33
|
+
return {
|
|
34
|
+
label: {
|
|
35
|
+
default: 'note',
|
|
36
|
+
parseHTML: element => element.getAttribute('data-footnote-label')
|
|
37
|
+
// carve-php / carve-js reference: id="fnN".
|
|
38
|
+
|| element.id.replace(/^fn/, '')
|
|
39
|
+
|| 'note',
|
|
40
|
+
renderHTML: attributes => ({ 'data-footnote-label': attributes.label }),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
parseHTML() {
|
|
46
|
+
return [
|
|
47
|
+
{ tag: 'li[data-footnote-label]' },
|
|
48
|
+
{ tag: 'section.carve-footnotes > ol > li' },
|
|
49
|
+
// carve-php / carve-js render the footnote section as
|
|
50
|
+
// <section role="doc-endnotes"><hr><ol><li id="fnN"><p>body
|
|
51
|
+
// <a role="doc-backlink">↩</a></p></li></ol></section>. Take each li
|
|
52
|
+
// as a definition; unwrap the <ol>, drop the <hr> and the back-link
|
|
53
|
+
// so only the body survives.
|
|
54
|
+
{ tag: 'section[role="doc-endnotes"] li', priority: 60 },
|
|
55
|
+
{ tag: 'section[role="doc-endnotes"] ol', skip: true, priority: 60 },
|
|
56
|
+
{ tag: 'section[role="doc-endnotes"] hr', ignore: true, priority: 60 },
|
|
57
|
+
{ tag: 'a[role="doc-backlink"]', ignore: true, priority: 60 },
|
|
58
|
+
];
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
62
|
+
return ['li', mergeAttributes(HTMLAttributes, {
|
|
63
|
+
class: 'carve-footnote-definition',
|
|
64
|
+
'data-footnote-label': node.attrs.label,
|
|
65
|
+
}), 0];
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
addCommands() {
|
|
69
|
+
return {
|
|
70
|
+
insertCarveFootnoteDefinition: attributes => ({ commands }) => {
|
|
71
|
+
return commands.insertContent({
|
|
72
|
+
type: this.name,
|
|
73
|
+
attrs: attributes,
|
|
74
|
+
content: [{ type: 'paragraph' }],
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export default CarveFootnoteDefinition;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carve Footnote node extension for Tiptap
|
|
5
|
+
*
|
|
6
|
+
* Renders as [^label] in Carve markup
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```js
|
|
10
|
+
* import { CarveFootnote } from 'carve-grammars/tiptap'
|
|
11
|
+
*
|
|
12
|
+
* const editor = new Editor({
|
|
13
|
+
* extensions: [CarveFootnote],
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // Insert a footnote reference
|
|
17
|
+
* editor.chain().focus().insertCarveFootnote({ label: 'note1' }).run()
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const CarveFootnote = Node.create({
|
|
21
|
+
name: 'carveFootnote',
|
|
22
|
+
|
|
23
|
+
group: 'inline',
|
|
24
|
+
|
|
25
|
+
inline: true,
|
|
26
|
+
|
|
27
|
+
atom: true,
|
|
28
|
+
|
|
29
|
+
addAttributes() {
|
|
30
|
+
return {
|
|
31
|
+
label: {
|
|
32
|
+
default: 'note',
|
|
33
|
+
parseHTML: element => element.getAttribute('data-footnote-label')
|
|
34
|
+
|| element.textContent?.replace(/[[\]^]/g, '').trim()
|
|
35
|
+
// carve-php / carve-js reference: id="fnrefN".
|
|
36
|
+
|| element.id.replace(/^fnref/, '')
|
|
37
|
+
|| 'note',
|
|
38
|
+
renderHTML: attributes => {
|
|
39
|
+
return { 'data-footnote-label': attributes.label };
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
parseHTML() {
|
|
46
|
+
return [
|
|
47
|
+
{ tag: 'sup.carve-footnote' },
|
|
48
|
+
{ tag: 'span.carve-footnote-ref' },
|
|
49
|
+
// carve-php / carve-js render the reference as
|
|
50
|
+
// <a id="fnrefN" role="doc-noteref"><sup>N</sup></a>. Beat Link to it.
|
|
51
|
+
{ tag: 'a[role="doc-noteref"]', priority: 60 },
|
|
52
|
+
];
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
renderHTML({ HTMLAttributes }) {
|
|
56
|
+
const label = HTMLAttributes['data-footnote-label'] || 'note';
|
|
57
|
+
return ['sup', mergeAttributes(HTMLAttributes, {
|
|
58
|
+
class: 'carve-footnote',
|
|
59
|
+
'data-footnote-label': label,
|
|
60
|
+
contenteditable: 'false',
|
|
61
|
+
}), `[^${label}]`];
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
addCommands() {
|
|
65
|
+
return {
|
|
66
|
+
insertCarveFootnote: (attributes) => ({ commands }) => {
|
|
67
|
+
return commands.insertContent({
|
|
68
|
+
type: this.name,
|
|
69
|
+
attrs: attributes,
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export default CarveFootnote;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Mark, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carve Insert mark extension for Tiptap
|
|
5
|
+
*
|
|
6
|
+
* Renders as {+text+} in Carve markup
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```js
|
|
10
|
+
* import { CarveInsert } from 'carve-grammars/tiptap'
|
|
11
|
+
*
|
|
12
|
+
* const editor = new Editor({
|
|
13
|
+
* extensions: [CarveInsert],
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // Toggle insert mark
|
|
17
|
+
* editor.chain().focus().toggleCarveInsert().run()
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const CarveInsert = Mark.create({
|
|
21
|
+
name: 'carveInsert',
|
|
22
|
+
|
|
23
|
+
// Outrank StarterKit's Strike, whose parseHTML also claims <del> - at
|
|
24
|
+
// default priority the strike mark wins and {-...-} degrades to ~...~
|
|
25
|
+
// after an HTML round-trip.
|
|
26
|
+
priority: 101,
|
|
27
|
+
|
|
28
|
+
parseHTML() {
|
|
29
|
+
return [
|
|
30
|
+
{ tag: 'ins' },
|
|
31
|
+
{ tag: 'span.carve-insert' },
|
|
32
|
+
];
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
renderHTML({ HTMLAttributes }) {
|
|
36
|
+
return ['span', mergeAttributes(HTMLAttributes, { class: 'carve-insert' }), 0];
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
addCommands() {
|
|
40
|
+
return {
|
|
41
|
+
toggleCarveInsert: () => ({ commands }) => commands.toggleMark(this.name),
|
|
42
|
+
setCarveInsert: () => ({ commands }) => commands.setMark(this.name),
|
|
43
|
+
unsetCarveInsert: () => ({ commands }) => commands.unsetMark(this.name),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
addKeyboardShortcuts() {
|
|
48
|
+
return {
|
|
49
|
+
'Mod-Shift-i': () => this.editor.commands.toggleCarveInsert(),
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export default CarveInsert;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carve keyboard shortcuts.
|
|
5
|
+
*
|
|
6
|
+
* Tiptap only binds Mod-Alt-1..6 for headings, so plain Ctrl/Cmd+1..6 fall
|
|
7
|
+
* through to the browser (switching tabs). These bindings cover the common
|
|
8
|
+
* Carve constructs, all return true so the browser default is prevented, and
|
|
9
|
+
* use toggle* commands so pressing a heading level on an existing heading
|
|
10
|
+
* REPLACES it (or toggles back to a paragraph) rather than stacking.
|
|
11
|
+
*
|
|
12
|
+
* Included in CarveKit by default; import standalone to add to a custom set.
|
|
13
|
+
*/
|
|
14
|
+
export const CarveKeymap = Extension.create({
|
|
15
|
+
name: 'carveKeymap',
|
|
16
|
+
|
|
17
|
+
addKeyboardShortcuts() {
|
|
18
|
+
const editor = this.editor;
|
|
19
|
+
const heading = (level) => () => editor.chain().focus().toggleHeading({ level }).run();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
'Mod-1': heading(1),
|
|
23
|
+
'Mod-2': heading(2),
|
|
24
|
+
'Mod-3': heading(3),
|
|
25
|
+
'Mod-4': heading(4),
|
|
26
|
+
'Mod-5': heading(5),
|
|
27
|
+
'Mod-6': heading(6),
|
|
28
|
+
'Mod-e': () => editor.chain().focus().toggleCode().run(),
|
|
29
|
+
'Mod-.': () => editor.chain().focus().toggleSuperscript().run(),
|
|
30
|
+
'Mod-,': () => editor.chain().focus().toggleSubscript().run(),
|
|
31
|
+
'Mod-Shift-x': () => editor.chain().focus().toggleStrike().run(),
|
|
32
|
+
'Mod-Shift-h': () => editor.chain().focus().toggleHighlight().run(),
|
|
33
|
+
'Mod-Shift-e': () => editor.chain().focus().toggleCodeBlock().run(),
|
|
34
|
+
'Mod-Shift-.': () => editor.chain().focus().toggleBlockquote().run(),
|
|
35
|
+
'Mod-Shift-8': () => editor.chain().focus().toggleBulletList().run(),
|
|
36
|
+
'Mod-Shift-7': () => editor.chain().focus().toggleOrderedList().run(),
|
|
37
|
+
// Clear formatting: node type back to paragraph + drop all marks.
|
|
38
|
+
'Mod-\\': () => editor.chain().focus().clearNodes().unsetAllMarks().run(),
|
|
39
|
+
// Enter at the END of a top-level textblock starts a fresh, mark-free
|
|
40
|
+
// paragraph - so heading/bold/italic reset on a new line instead of
|
|
41
|
+
// carrying over. Mid-block splits, lists, blockquotes and containers
|
|
42
|
+
// keep the default behavior.
|
|
43
|
+
Enter: () => {
|
|
44
|
+
const { $from, empty } = editor.state.selection;
|
|
45
|
+
if (!empty) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const parent = $from.parent;
|
|
49
|
+
const atEnd = $from.parentOffset === parent.content.size;
|
|
50
|
+
const isPlainTextblock = parent.isTextblock && parent.type.name !== 'codeBlock';
|
|
51
|
+
const topLevel = $from.depth === 1;
|
|
52
|
+
if (!atEnd || !isPlainTextblock || !topLevel) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return editor
|
|
56
|
+
.chain()
|
|
57
|
+
.insertContentAt($from.after(), { type: 'paragraph' })
|
|
58
|
+
.setTextSelection($from.after() + 1)
|
|
59
|
+
.command(({ tr, dispatch }) => {
|
|
60
|
+
if (dispatch) {
|
|
61
|
+
dispatch(tr.setStoredMarks([]));
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
})
|
|
65
|
+
.run();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export default CarveKeymap;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carve Math node extension for Tiptap.
|
|
5
|
+
*
|
|
6
|
+
* Inline atom holding raw math source. Serializes to Carve as:
|
|
7
|
+
* - inline: `` $`x`$ ``
|
|
8
|
+
* - display: `` $$`x`$$ `` (when `display` is true)
|
|
9
|
+
*
|
|
10
|
+
* The source is stored verbatim in `data-carve-math`; rendering of the math
|
|
11
|
+
* itself (KaTeX/MathML/etc.) is left to the host application's node view.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```js
|
|
15
|
+
* import { CarveMath } from 'carve-grammars/tiptap'
|
|
16
|
+
*
|
|
17
|
+
* editor.chain().focus().insertCarveMath({ src: 'E = mc^2' }).run()
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const CarveMath = Node.create({
|
|
21
|
+
name: 'carveMath',
|
|
22
|
+
|
|
23
|
+
group: 'inline',
|
|
24
|
+
|
|
25
|
+
inline: true,
|
|
26
|
+
|
|
27
|
+
atom: true,
|
|
28
|
+
|
|
29
|
+
addAttributes() {
|
|
30
|
+
return {
|
|
31
|
+
src: {
|
|
32
|
+
default: '',
|
|
33
|
+
parseHTML: element => {
|
|
34
|
+
const explicit = element.getAttribute('data-carve-math');
|
|
35
|
+
if (explicit) return explicit;
|
|
36
|
+
// carve-php renders <span class="math ...">\(TEX\)</span> or
|
|
37
|
+
// \[TEX\] for display; recover the raw TeX by stripping the
|
|
38
|
+
// \( \) / \[ \] delimiters.
|
|
39
|
+
return (element.textContent || '')
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/^\\[([]/, '')
|
|
42
|
+
.replace(/\\[)\]]$/, '')
|
|
43
|
+
.trim();
|
|
44
|
+
},
|
|
45
|
+
renderHTML: attributes => ({ 'data-carve-math': attributes.src }),
|
|
46
|
+
},
|
|
47
|
+
display: {
|
|
48
|
+
default: false,
|
|
49
|
+
parseHTML: element => element.getAttribute('data-display') === 'true' || element.classList.contains('display'),
|
|
50
|
+
renderHTML: attributes => (attributes.display ? { 'data-display': 'true' } : {}),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
parseHTML() {
|
|
56
|
+
return [
|
|
57
|
+
{ tag: 'span[data-carve-math]' },
|
|
58
|
+
// carve-php rendered output.
|
|
59
|
+
{ tag: 'span.math' },
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
64
|
+
return ['span', mergeAttributes(HTMLAttributes, {
|
|
65
|
+
class: 'carve-math',
|
|
66
|
+
'data-carve-math': node.attrs.src,
|
|
67
|
+
}), node.attrs.src];
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
addCommands() {
|
|
71
|
+
return {
|
|
72
|
+
insertCarveMath: attributes => ({ commands }) => {
|
|
73
|
+
return commands.insertContent({
|
|
74
|
+
type: this.name,
|
|
75
|
+
attrs: attributes,
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export default CarveMath;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carve mention / tag inline nodes.
|
|
5
|
+
*
|
|
6
|
+
* carve-php / carve-js render `@name` as
|
|
7
|
+
* `<span class="mention"><strong>@name</strong></span>` and `#tag` as
|
|
8
|
+
* `<span class="tag"><strong>#tag</strong></span>`. Citations use a mention:
|
|
9
|
+
* `[@key]` is literal brackets around a mention. These are inline atoms so they
|
|
10
|
+
* round-trip verbatim (the bare `@`/`#` is escaped in ordinary prose, so only a
|
|
11
|
+
* real mention/tag node emits an unescaped one).
|
|
12
|
+
*/
|
|
13
|
+
function mentionNode(name, cssClass, sigil) {
|
|
14
|
+
return Node.create({
|
|
15
|
+
name,
|
|
16
|
+
group: 'inline',
|
|
17
|
+
inline: true,
|
|
18
|
+
atom: true,
|
|
19
|
+
|
|
20
|
+
addAttributes() {
|
|
21
|
+
return {
|
|
22
|
+
id: {
|
|
23
|
+
default: '',
|
|
24
|
+
parseHTML: element => (element.textContent || '').replace(/^[@#]/, '').trim(),
|
|
25
|
+
renderHTML: attributes => ({ 'data-id': attributes.id }),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
parseHTML() {
|
|
31
|
+
// Beat CarveSpan (which also matches span[class]).
|
|
32
|
+
return [{ tag: `span.${cssClass}`, priority: 60 }];
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
36
|
+
return ['span', mergeAttributes(HTMLAttributes, { class: cssClass }), sigil + node.attrs.id];
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const CarveMention = mentionNode('carveMention', 'mention', '@');
|
|
42
|
+
export const CarveTag = mentionNode('carveTag', 'tag', '#');
|
|
43
|
+
|
|
44
|
+
export default CarveMention;
|