@slexkit/tiptap 0.3.3 → 0.4.0

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.
Files changed (3) hide show
  1. package/README.md +88 -88
  2. package/dist/index.js +114 -3
  3. package/package.json +68 -66
package/README.md CHANGED
@@ -1,88 +1,88 @@
1
- # @slexkit/tiptap
2
-
3
- Tiptap extension for explicit `slex` fenced UI blocks.
4
-
5
- ```sh
6
- npm install slexkit @slexkit/theme-shadcn @slexkit/tiptap @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-code-block @tiptap/markdown
7
- ```
8
-
9
- Import the SlexKit theme once in your app entry:
10
-
11
- ```ts
12
- import "@slexkit/theme-shadcn/style.css";
13
- import "@slexkit/tiptap/style.css";
14
- ```
15
-
16
- ## Minimal setup
17
-
18
- ```ts
19
- import { Editor } from "@tiptap/core";
20
- import StarterKit from "@tiptap/starter-kit";
21
- import { createSlexKitTiptapExtension } from "@slexkit/tiptap";
22
-
23
- const editor = new Editor({
24
- element: document.querySelector("#editor"),
25
- extensions: [
26
- StarterKit.configure({ codeBlock: false }),
27
- createSlexKitTiptapExtension({
28
- artifactId: "note-42",
29
- }),
30
- ],
31
- });
32
- ```
33
-
34
- Only code blocks whose language is exactly `slex` are rendered by SlexKit. Other code blocks stay ordinary Tiptap code blocks.
35
-
36
- ## Markdown roundtrip
37
-
38
- Add Tiptap's Markdown extension when loading or exporting Markdown:
39
-
40
- ```ts
41
- import { Markdown } from "@tiptap/markdown";
42
-
43
- const editor = new Editor({
44
- extensions: [
45
- StarterKit.configure({ codeBlock: false }),
46
- Markdown,
47
- createSlexKitTiptapExtension({ artifactId: "doc" }),
48
- ],
49
- content: markdown,
50
- contentType: "markdown",
51
- });
52
-
53
- const nextMarkdown = editor.getMarkdown();
54
- ```
55
-
56
- The adapter uses normal fenced code blocks instead of a custom tokenizer, so ` ```slex ` blocks remain readable in plain Markdown hosts.
57
-
58
- ## Shared state across fences
59
-
60
- All `slex` blocks in one editor share the same artifact runtime by default. A state-only fence can seed later renderable fences:
61
-
62
- ````md
63
- ```slex
64
- {
65
- namespace: "calc",
66
- g: { value: 21 }
67
- }
68
- ```
69
-
70
- ```slex
71
- {
72
- namespace: "calc",
73
- layout: {
74
- "text:answer": { "$text": "'answer: ' + (g.value * 2)" }
75
- }
76
- }
77
- ```
78
- ````
79
-
80
- ## Runtime boundary
81
-
82
- This adapter defaults to trusted runtime mode, intended for application-authored content, repository-maintained snippets, reviewed examples, and local document workflows. Use SlexKit secure runtime APIs for untrusted user Markdown or direct agent output.
83
-
84
- ## Documentation
85
-
86
- - [Official Tiptap editor example](https://slexkit.app.simgor.cn/examples/tiptap-host)
87
- - [Runnable example source](https://github.com/slexkit/slexkit/tree/main/examples/tiptap)
88
- - [SlexKit Host integration](https://github.com/slexkit/slexkit/blob/main/site/content/reference/integration/en-US.md)
1
+ # @slexkit/tiptap
2
+
3
+ Tiptap extension for explicit `slex` fenced UI blocks.
4
+
5
+ ```sh
6
+ npm install slexkit @slexkit/theme-shadcn @slexkit/tiptap @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-code-block @tiptap/markdown
7
+ ```
8
+
9
+ Import the SlexKit theme once in the app entry:
10
+
11
+ ```ts
12
+ import "@slexkit/theme-shadcn/style.css";
13
+ import "@slexkit/tiptap/style.css";
14
+ ```
15
+
16
+ ## Minimal setup
17
+
18
+ ```ts
19
+ import { Editor } from "@tiptap/core";
20
+ import StarterKit from "@tiptap/starter-kit";
21
+ import { createSlexKitTiptapExtension } from "@slexkit/tiptap";
22
+
23
+ const editor = new Editor({
24
+ element: document.querySelector("#editor"),
25
+ extensions: [
26
+ StarterKit.configure({ codeBlock: false }),
27
+ createSlexKitTiptapExtension({
28
+ artifactId: "note-42",
29
+ }),
30
+ ],
31
+ });
32
+ ```
33
+
34
+ Only code blocks whose language is exactly `slex` are rendered by SlexKit. Other code blocks stay ordinary Tiptap code blocks.
35
+
36
+ ## Markdown roundtrip
37
+
38
+ Add Tiptap's Markdown extension when loading or exporting Markdown:
39
+
40
+ ```ts
41
+ import { Markdown } from "@tiptap/markdown";
42
+
43
+ const editor = new Editor({
44
+ extensions: [
45
+ StarterKit.configure({ codeBlock: false }),
46
+ Markdown,
47
+ createSlexKitTiptapExtension({ artifactId: "doc" }),
48
+ ],
49
+ content: markdown,
50
+ contentType: "markdown",
51
+ });
52
+
53
+ const nextMarkdown = editor.getMarkdown();
54
+ ```
55
+
56
+ The adapter uses normal fenced code blocks instead of a custom tokenizer, so ` ```slex ` blocks remain readable in plain Markdown hosts.
57
+
58
+ ## Shared state across fences
59
+
60
+ All `slex` blocks in one editor share the same artifact runtime by default. A state-only fence can seed later renderable fences:
61
+
62
+ ````md
63
+ ```slex
64
+ {
65
+ namespace: "calc",
66
+ g: { value: 21 }
67
+ }
68
+ ```
69
+
70
+ ```slex
71
+ {
72
+ namespace: "calc",
73
+ layout: {
74
+ "text:answer": { "$text": "'answer: ' + (g.value * 2)" }
75
+ }
76
+ }
77
+ ```
78
+ ````
79
+
80
+ ## Runtime boundary
81
+
82
+ This adapter defaults to trusted runtime mode, intended for application-authored content, repository-maintained snippets, reviewed examples, and local document workflows. Use SlexKit secure runtime APIs for untrusted user Markdown or direct agent output.
83
+
84
+ ## Documentation
85
+
86
+ - [Official Tiptap editor example](https://slexkit.app.simgor.cn/examples/tiptap-host)
87
+ - [Runnable example source](https://github.com/slexkit/slexkit/tree/main/examples/tiptap)
88
+ - [SlexKit Host integration](https://github.com/slexkit/slexkit/blob/main/site/content/reference/integration/en-US.md)
package/dist/index.js CHANGED
@@ -20,6 +20,23 @@ const PREVIEW_INTERACTIVE_SELECTOR = [
20
20
  "[role='tab']",
21
21
  "[role='textbox']",
22
22
  ].join(",");
23
+ const TRANSIENT_POINTER_INTERACTIVE_SELECTOR = [
24
+ "a[href]",
25
+ "button",
26
+ "input[type='button']",
27
+ "input[type='checkbox']",
28
+ "input[type='image']",
29
+ "input[type='radio']",
30
+ "input[type='range']",
31
+ "input[type='reset']",
32
+ "input[type='submit']",
33
+ "[role='button']",
34
+ "[role='checkbox']",
35
+ "[role='radio']",
36
+ "[role='slider']",
37
+ "[role='switch']",
38
+ "[role='tab']",
39
+ ].join(",");
23
40
  const editorArtifactIds = new WeakMap();
24
41
  let nextArtifactId = 0;
25
42
  const sourceOpenStates = new Map();
@@ -125,8 +142,17 @@ function focusPreviewInteractiveTarget(event) {
125
142
  const target = event.target;
126
143
  const targetElement = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
127
144
  const interactive = targetElement?.closest(PREVIEW_INTERACTIVE_SELECTOR);
128
- if (interactive instanceof HTMLElement)
129
- interactive.focus({ preventScroll: true });
145
+ if (!(interactive instanceof HTMLElement))
146
+ return null;
147
+ interactive.focus({ preventScroll: true });
148
+ return interactive;
149
+ }
150
+ function shouldRestorePreviewFocusAfterPointer(element) {
151
+ return !!element.closest(TRANSIENT_POINTER_INTERACTIVE_SELECTOR);
152
+ }
153
+ function pointerIdFromEvent(event) {
154
+ const pointerEvent = event;
155
+ return typeof pointerEvent?.pointerId === "number" ? pointerEvent.pointerId : undefined;
130
156
  }
131
157
  function markPreviewInteractive(preview, force = false) {
132
158
  if (preview.getAttribute("contenteditable") !== "false") {
@@ -194,7 +220,77 @@ class SlexKitCodeBlockNodeView {
194
220
  preview;
195
221
  runtime;
196
222
  interactiveObserver;
223
+ pendingFocusRestore;
224
+ pointerFocusContext;
197
225
  renderToken = 0;
226
+ focusPreviewPointerTarget = (event) => {
227
+ const restoreEditor = this.editor.isFocused;
228
+ this.pointerFocusContext = { restoreEditor, expiresAt: Date.now() + 500 };
229
+ const interactive = focusPreviewInteractiveTarget(event);
230
+ if (!interactive || !shouldRestorePreviewFocusAfterPointer(interactive))
231
+ return;
232
+ this.pendingFocusRestore = {
233
+ target: interactive,
234
+ restoreEditor,
235
+ pointerId: typeof event.pointerId === "number" ? event.pointerId : null,
236
+ };
237
+ this.addFocusRestoreListeners();
238
+ };
239
+ clearPointerFocusedPreviewTarget = (event) => {
240
+ const context = this.pointerFocusContext;
241
+ if (!context || Date.now() > context.expiresAt)
242
+ return;
243
+ const target = event.target;
244
+ const targetElement = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
245
+ const interactive = targetElement?.closest(TRANSIENT_POINTER_INTERACTIVE_SELECTOR);
246
+ if (!(interactive instanceof HTMLElement))
247
+ return;
248
+ if (this.pendingFocusRestore?.target === interactive)
249
+ return;
250
+ this.queuePreviewFocusCleanup({
251
+ target: interactive,
252
+ restoreEditor: context.restoreEditor,
253
+ });
254
+ };
255
+ finishPreviewPointerFocus = (event) => {
256
+ const pending = this.pendingFocusRestore;
257
+ if (!pending)
258
+ return;
259
+ const pointerId = pointerIdFromEvent(event);
260
+ if (pointerId !== undefined
261
+ && pending.pointerId !== null
262
+ && pointerId !== pending.pointerId) {
263
+ return;
264
+ }
265
+ this.pendingFocusRestore = undefined;
266
+ this.removeFocusRestoreListeners();
267
+ this.queuePreviewFocusCleanup(pending);
268
+ };
269
+ queuePreviewFocusCleanup(pending) {
270
+ const doc = this.preview.ownerDocument || document;
271
+ const win = doc.defaultView;
272
+ const cleanupFocus = () => {
273
+ if (!this.dom.isConnected)
274
+ return;
275
+ const active = doc.activeElement;
276
+ const clearedTarget = active === pending.target;
277
+ if (clearedTarget)
278
+ pending.target.blur();
279
+ const nextActive = doc.activeElement;
280
+ const canRestoreEditor = pending.restoreEditor
281
+ && !this.editor.isDestroyed
282
+ && (clearedTarget || nextActive === doc.body || nextActive === doc.documentElement);
283
+ if (canRestoreEditor) {
284
+ this.editor.commands.focus(undefined, { scrollIntoView: false });
285
+ }
286
+ };
287
+ win?.setTimeout(cleanupFocus, 0);
288
+ win?.requestAnimationFrame(cleanupFocus);
289
+ win?.setTimeout(cleanupFocus, 50);
290
+ }
291
+ cancelPreviewPointerFocus = (event) => {
292
+ this.finishPreviewPointerFocus(event);
293
+ };
198
294
  constructor(node, props, options) {
199
295
  this.node = node;
200
296
  this.options = options;
@@ -211,7 +307,8 @@ class SlexKitCodeBlockNodeView {
211
307
  label.textContent = options.previewLabel;
212
308
  this.preview = createElement(doc, "div", "slex-tiptap-preview");
213
309
  this.preview.setAttribute("contenteditable", "false");
214
- this.preview.addEventListener("pointerdown", focusPreviewInteractiveTarget, { capture: true });
310
+ this.preview.addEventListener("pointerdown", this.focusPreviewPointerTarget, { capture: true });
311
+ this.preview.addEventListener("focusin", this.clearPointerFocusedPreviewTarget, { capture: true });
215
312
  this.preview.addEventListener("mousedown", focusPreviewInteractiveTarget, { capture: true });
216
313
  this.preview.addEventListener("click", focusPreviewInteractiveTarget, { capture: true });
217
314
  if (typeof MutationObserver !== "undefined") {
@@ -278,6 +375,8 @@ class SlexKitCodeBlockNodeView {
278
375
  }
279
376
  destroy() {
280
377
  this.interactiveObserver?.disconnect();
378
+ this.removeFocusRestoreListeners();
379
+ this.preview.removeEventListener("focusin", this.clearPointerFocusedPreviewTarget, { capture: true });
281
380
  this.preview.replaceChildren();
282
381
  unregisterArtifactRuntime(this.editor, this.runtime, this);
283
382
  }
@@ -315,6 +414,18 @@ class SlexKitCodeBlockNodeView {
315
414
  scheduleArtifactRender(this.runtime);
316
415
  });
317
416
  }
417
+ addFocusRestoreListeners() {
418
+ const doc = this.preview.ownerDocument || document;
419
+ doc.addEventListener("pointerup", this.finishPreviewPointerFocus, { capture: true });
420
+ doc.addEventListener("pointercancel", this.cancelPreviewPointerFocus, { capture: true });
421
+ doc.defaultView?.addEventListener("blur", this.cancelPreviewPointerFocus);
422
+ }
423
+ removeFocusRestoreListeners() {
424
+ const doc = this.preview.ownerDocument || document;
425
+ doc.removeEventListener("pointerup", this.finishPreviewPointerFocus, { capture: true });
426
+ doc.removeEventListener("pointercancel", this.cancelPreviewPointerFocus, { capture: true });
427
+ doc.defaultView?.removeEventListener("blur", this.cancelPreviewPointerFocus);
428
+ }
318
429
  renderWithRuntime(runtimeHost) {
319
430
  const source = this.source();
320
431
  this.dom.hidden = false;
package/package.json CHANGED
@@ -1,66 +1,68 @@
1
- {
2
- "name": "@slexkit/tiptap",
3
- "version": "0.3.3",
4
- "description": "Tiptap extension for SlexKit fenced UI blocks.",
5
- "author": "SlexKit contributors",
6
- "type": "module",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/slexkit/slexkit.git",
10
- "directory": "packages/tiptap"
11
- },
12
- "homepage": "https://github.com/slexkit/slexkit/tree/main/packages/tiptap#readme",
13
- "bugs": {
14
- "url": "https://github.com/slexkit/slexkit/issues"
15
- },
16
- "main": "./dist/index.js",
17
- "module": "./dist/index.js",
18
- "types": "./dist/index.d.ts",
19
- "exports": {
20
- ".": {
21
- "types": "./dist/index.d.ts",
22
- "import": "./dist/index.js"
23
- },
24
- "./style.css": "./style.css"
25
- },
26
- "files": [
27
- "dist/",
28
- "style.css",
29
- "README.md",
30
- "LICENSE"
31
- ],
32
- "scripts": {
33
- "build": "bun run scripts/build.ts"
34
- },
35
- "keywords": [
36
- "slexkit",
37
- "tiptap",
38
- "prosemirror",
39
- "markdown",
40
- "editor"
41
- ],
42
- "license": "MIT",
43
- "publishConfig": {
44
- "access": "public"
45
- },
46
- "peerDependencies": {
47
- "@tiptap/core": "^3.0.0",
48
- "@tiptap/extension-code-block": "^3.0.0",
49
- "@tiptap/markdown": "^3.0.0",
50
- "@tiptap/pm": "^3.0.0",
51
- "slexkit": "^0.3.3"
52
- },
53
- "peerDependenciesMeta": {
54
- "@tiptap/markdown": {
55
- "optional": true
56
- }
57
- },
58
- "devDependencies": {
59
- "@tiptap/core": "^3.27.1",
60
- "@tiptap/extension-code-block": "^3.27.1",
61
- "@tiptap/markdown": "^3.27.1",
62
- "@tiptap/pm": "^3.27.1",
63
- "@tiptap/starter-kit": "^3.27.1",
64
- "slexkit": "file:../.."
65
- }
66
- }
1
+ {
2
+ "name": "@slexkit/tiptap",
3
+ "version": "0.4.0",
4
+ "description": "Tiptap extension for SlexKit fenced UI blocks.",
5
+ "author": "SlexKit contributors",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/slexkit/slexkit.git",
10
+ "directory": "packages/tiptap"
11
+ },
12
+ "homepage": "https://github.com/slexkit/slexkit/tree/main/packages/tiptap#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/slexkit/slexkit/issues"
15
+ },
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./style.css": "./style.css"
25
+ },
26
+ "files": [
27
+ "dist/",
28
+ "style.css",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "build": "bun run scripts/build.ts"
34
+ },
35
+ "keywords": [
36
+ "slexkit",
37
+ "tiptap",
38
+ "prosemirror",
39
+ "markdown",
40
+ "editor"
41
+ ],
42
+ "license": "MIT",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "peerDependencies": {
47
+ "@tiptap/core": "^3.0.0",
48
+ "@tiptap/extension-code-block": "^3.0.0",
49
+ "@tiptap/markdown": "^3.0.0",
50
+ "@tiptap/pm": "^3.0.0",
51
+ "slexkit": "^0.4.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "slexkit": {
55
+ "optional": true
56
+ },
57
+ "@tiptap/markdown": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@tiptap/core": "^3.27.1",
63
+ "@tiptap/extension-code-block": "^3.27.1",
64
+ "@tiptap/markdown": "^3.27.1",
65
+ "@tiptap/pm": "^3.27.1",
66
+ "@tiptap/starter-kit": "^3.27.1"
67
+ }
68
+ }