@blockslides/extension-add-slide-button 0.1.0 → 0.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blockslides/extension-add-slide-button",
3
3
  "description": "add slide button extension for blockslides",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "author": "keivanmojmali",
6
6
  "homepage": "https://github.com/keivanmojmali/blockslides",
7
7
  "keywords": [
@@ -29,11 +29,11 @@
29
29
  "dist"
30
30
  ],
31
31
  "devDependencies": {
32
- "@blockslides/core": "^0.1.0",
33
- "@blockslides/pm": "^0.1.0"
32
+ "@blockslides/pm": "^0.1.0",
33
+ "@blockslides/core": "^0.2.0"
34
34
  },
35
35
  "peerDependencies": {
36
- "@blockslides/core": "^0.1.0"
36
+ "@blockslides/core": "^0.2.0"
37
37
  },
38
38
  "repository": {
39
39
  "type": "git",
@@ -6,6 +6,17 @@ import type { EditorView } from "@blockslides/pm/view";
6
6
 
7
7
  //TODO: Add ability to easy choose a layout type like 1-1, 1-1-1, etc.
8
8
 
9
+ export interface PresetTemplateOption {
10
+ key: string;
11
+ label: string;
12
+ icon?: string;
13
+ build: () => any;
14
+ }
15
+
16
+ // Heroicons outline "window" to match requested default
17
+ const defaultTemplateIcon =
18
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 5.25A1.75 1.75 0 0 1 6.25 3.5h11.5A1.75 1.75 0 0 1 19.5 5.25v13.5a1.75 1.75 0 0 1-1.75 1.75H6.25A1.75 1.75 0 0 1 4.5 18.75Z"/><path d="M4.5 8.25h15M9 3.5v4.75"/></svg>';
19
+
9
20
  export interface AddSlideButtonOptions {
10
21
  /**
11
22
  * Whether to inject CSS styles for the button.
@@ -32,6 +43,30 @@ export interface AddSlideButtonOptions {
32
43
  * @example '+' | 'Add Slide' | '➕' | '<svg>...</svg>'
33
44
  */
34
45
  content: string;
46
+ /**
47
+ * Optional template chooser that renders a second button and modal.
48
+ * @default false
49
+ */
50
+ showPresets?: boolean;
51
+ /**
52
+ * Background color for the preset modal content area.
53
+ * @default '#ffffff'
54
+ */
55
+ presetBackground?: string;
56
+ /**
57
+ * Text/icon color for the preset modal content area.
58
+ * @default '#000000'
59
+ */
60
+ presetForeground?: string;
61
+ /**
62
+ * Presets to show in the modal when showPresets is true.
63
+ */
64
+ presets?: PresetTemplateOption[];
65
+ /**
66
+ * Content for the template button (emoji or HTML).
67
+ * @default '✨'
68
+ */
69
+ templateButtonContent?: string;
35
70
  /**
36
71
  * Custom click handler for the button.
37
72
  * If not provided, will insert a new slide at the clicked position.
@@ -54,6 +89,7 @@ class SlideNodeView implements NodeView {
54
89
  view: EditorView;
55
90
  getPos: () => number | undefined;
56
91
  node: ProseMirrorNode;
92
+ presetModal?: HTMLElement | null;
57
93
 
58
94
  constructor(
59
95
  node: ProseMirrorNode,
@@ -95,24 +131,34 @@ class SlideNodeView implements NodeView {
95
131
  this.contentDOM.setAttribute("data-node-type", "slide");
96
132
  }
97
133
 
98
- // Create the add button
99
- this.button = document.createElement("button");
100
- this.button.className = "add-slide-button";
101
- this.button.innerHTML = options.content;
102
- this.button.setAttribute("type", "button");
103
- this.button.contentEditable = "false";
104
-
105
- // Apply custom styles
106
- if (options.buttonStyle) {
107
- Object.entries(options.buttonStyle).forEach(([key, value]) => {
108
- const camelKey = key.replace(/-([a-z])/g, (_, letter) =>
109
- letter.toUpperCase()
110
- );
111
- (this.button.style as any)[camelKey] = value;
112
- });
113
- }
134
+ const createButton = (html: string) => {
135
+ const btn = document.createElement("button");
136
+ btn.className = "add-slide-button";
137
+ btn.innerHTML = html;
138
+ btn.setAttribute("type", "button");
139
+ btn.contentEditable = "false";
140
+ if (options.buttonStyle) {
141
+ Object.entries(options.buttonStyle).forEach(([key, value]) => {
142
+ const camelKey = key.replace(/-([a-z])/g, (_, letter) =>
143
+ letter.toUpperCase()
144
+ );
145
+ (btn.style as any)[camelKey] = value;
146
+ });
147
+ }
148
+ return btn;
149
+ };
114
150
 
115
- this.button.onclick = (event: MouseEvent) => {
151
+ const insertEmptySlide = (pos: number) => {
152
+ const schema = this.view.state.schema;
153
+ const slideType = schema.nodes.slide;
154
+ const paragraphType = schema.nodes.paragraph;
155
+ const slideContent = paragraphType.create();
156
+ const slide = slideType.create(null, slideContent);
157
+ const tr = this.view.state.tr.insert(pos + this.node.nodeSize, slide);
158
+ this.view.dispatch(tr);
159
+ };
160
+
161
+ const handlePlusClick = (event: MouseEvent) => {
116
162
  event.preventDefault();
117
163
  event.stopPropagation();
118
164
 
@@ -120,7 +166,6 @@ class SlideNodeView implements NodeView {
120
166
  if (pos === undefined) return;
121
167
 
122
168
  if (options.onClick) {
123
- // Calculate slide index
124
169
  let slideIndex = 0;
125
170
  this.view.state.doc.nodesBetween(0, pos, (n) => {
126
171
  if (n.type.name === "slide") slideIndex++;
@@ -133,23 +178,137 @@ class SlideNodeView implements NodeView {
133
178
  event,
134
179
  });
135
180
  } else {
136
- // Default: insert new slide after current one
137
- const schema = this.view.state.schema;
138
- const slideType = schema.nodes.slide;
139
- const paragraphType = schema.nodes.paragraph;
181
+ insertEmptySlide(pos);
182
+ }
183
+ };
184
+
185
+ const openPresetModal = (pos: number) => {
186
+ // Remove existing modal if present to avoid duplicates
187
+ if (this.presetModal) {
188
+ this.presetModal.remove();
189
+ }
140
190
 
141
- // Insert slide with empty paragraph
142
- const slideContent = paragraphType.create();
191
+ const overlay = document.createElement("div");
192
+ overlay.className = "add-slide-preset-modal";
143
193
 
144
- const slide = slideType.create(null, slideContent);
145
- const tr = this.view.state.tr.insert(pos + this.node.nodeSize, slide);
146
- this.view.dispatch(tr);
194
+ const dialog = document.createElement("div");
195
+ dialog.className = "add-slide-preset-dialog";
196
+ if (options.presetBackground) {
197
+ dialog.style.setProperty("--add-slide-preset-bg", options.presetBackground);
147
198
  }
199
+ if (options.presetForeground) {
200
+ dialog.style.setProperty("--add-slide-preset-fg", options.presetForeground);
201
+ }
202
+
203
+ const search = document.createElement("input");
204
+ search.className = "add-slide-preset-search";
205
+ search.type = "search";
206
+ search.placeholder = "Choose a template";
207
+ dialog.appendChild(search);
208
+
209
+ const list = document.createElement("div");
210
+ list.className = "add-slide-preset-list";
211
+
212
+ const items: HTMLElement[] = [];
213
+
214
+ (options.presets || []).forEach((preset) => {
215
+ const item = document.createElement("button");
216
+ item.className = "add-slide-preset-item";
217
+ item.setAttribute("type", "button");
218
+ item.innerHTML = `
219
+ <span class="add-slide-preset-icon">${preset.icon ?? ""}</span>
220
+ <span class="add-slide-preset-label">${preset.label}</span>
221
+ `;
222
+
223
+ item.onclick = (event) => {
224
+ event.preventDefault();
225
+ event.stopPropagation();
226
+ try {
227
+ const nodeJSON = preset.build() as any;
228
+ const newSlide = this.view.state.schema.nodeFromJSON(nodeJSON);
229
+ const tr = this.view.state.tr.insert(
230
+ pos + this.node.nodeSize,
231
+ newSlide
232
+ );
233
+ this.view.dispatch(tr);
234
+ } catch (err) {
235
+ console.error("Failed to insert preset slide", err);
236
+ } finally {
237
+ overlay.remove();
238
+ this.presetModal = null;
239
+ }
240
+ };
241
+
242
+ list.appendChild(item);
243
+ items.push(item);
244
+ });
245
+
246
+ search.oninput = () => {
247
+ const term = search.value.toLowerCase();
248
+ items.forEach((item) => {
249
+ const label = item
250
+ .querySelector(".add-slide-preset-label")
251
+ ?.textContent?.toLowerCase();
252
+ item.style.display = !term || label?.includes(term) ? "" : "none";
253
+ });
254
+ };
255
+
256
+ dialog.appendChild(list);
257
+
258
+ const close = () => {
259
+ overlay.remove();
260
+ this.presetModal = null;
261
+ };
262
+
263
+ overlay.onclick = (e) => {
264
+ if (e.target === overlay) {
265
+ close();
266
+ }
267
+ };
268
+
269
+ document.addEventListener(
270
+ "keydown",
271
+ (e) => {
272
+ if (e.key === "Escape") {
273
+ close();
274
+ }
275
+ },
276
+ { once: true }
277
+ );
278
+
279
+ overlay.appendChild(dialog);
280
+ document.body.appendChild(overlay);
281
+ this.presetModal = overlay;
148
282
  };
149
283
 
150
- // Append to wrapper
151
- this.dom.appendChild(this.contentDOM);
152
- this.dom.appendChild(this.button);
284
+ const plusButton = createButton(options.content);
285
+ plusButton.onclick = handlePlusClick;
286
+ this.button = plusButton;
287
+
288
+ if (options.showPresets) {
289
+ const templateButton = createButton(
290
+ options.templateButtonContent ?? "✨"
291
+ );
292
+ templateButton.onclick = (event: MouseEvent) => {
293
+ event.preventDefault();
294
+ event.stopPropagation();
295
+ const pos = this.getPos();
296
+ if (pos === undefined) return;
297
+ openPresetModal(pos);
298
+ };
299
+
300
+ const group = document.createElement("div");
301
+ group.className = "add-slide-button-group";
302
+ group.appendChild(plusButton);
303
+ group.appendChild(templateButton);
304
+
305
+ this.dom.appendChild(this.contentDOM);
306
+ this.dom.appendChild(group);
307
+ } else {
308
+ this.button = plusButton;
309
+ this.dom.appendChild(this.contentDOM);
310
+ this.dom.appendChild(this.button);
311
+ }
153
312
  }
154
313
 
155
314
  update(node: ProseMirrorNode) {
@@ -159,7 +318,14 @@ class SlideNodeView implements NodeView {
159
318
  }
160
319
 
161
320
  destroy() {
162
- this.button.onclick = null;
321
+ if (this.button) {
322
+ this.button.onclick = null;
323
+ }
324
+
325
+ if (this.presetModal) {
326
+ this.presetModal.remove();
327
+ this.presetModal = null;
328
+ }
163
329
  }
164
330
  }
165
331
 
@@ -202,6 +368,131 @@ const addSlideButtonStyles = `
202
368
  outline: 2px solid var(--editor-focus, #3b82f6);
203
369
  outline-offset: 2px;
204
370
  }
371
+
372
+ .add-slide-button-group {
373
+ display: grid;
374
+ grid-template-columns: repeat(2, 1fr);
375
+ width: 180px;
376
+ margin: 16px auto 32px auto;
377
+ border: 1px solid var(--slide-border, #e5e5e5);
378
+ border-radius: 12px;
379
+ overflow: hidden;
380
+ background-color: var(--slide-bg, #ffffff);
381
+ box-shadow: var(--slide-shadow, 0 4px 12px rgba(0, 0, 0, 0.08));
382
+ }
383
+
384
+ .add-slide-button-group .add-slide-button {
385
+ margin: 0;
386
+ border: none;
387
+ border-radius: 0;
388
+ width: 100%;
389
+ height: 48px;
390
+ }
391
+
392
+ .add-slide-button-group .add-slide-button:first-child {
393
+ border-right: 1px solid var(--slide-border, #e5e5e5);
394
+ border-top-left-radius: 12px;
395
+ border-bottom-left-radius: 12px;
396
+ }
397
+
398
+ .add-slide-button-group .add-slide-button:last-child {
399
+ border-top-right-radius: 12px;
400
+ border-bottom-right-radius: 12px;
401
+ white-space: nowrap;
402
+ font-size: 14px;
403
+ padding: 0 12px;
404
+ }
405
+
406
+ .add-slide-preset-modal {
407
+ position: fixed;
408
+ inset: 0;
409
+ background: rgba(0, 0, 0, 0.35);
410
+ display: flex;
411
+ align-items: center;
412
+ justify-content: center;
413
+ z-index: 9999;
414
+ }
415
+
416
+ .add-slide-preset-dialog {
417
+ background: var(--add-slide-preset-bg, #ffffff);
418
+ color: var(--add-slide-preset-fg, #000000);
419
+ border-radius: 8px;
420
+ padding: 16px 16px 12px 16px;
421
+ min-width: 140px;
422
+ max-width: 480px;
423
+ max-height: 60vh;
424
+ overflow: hidden;
425
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
426
+ }
427
+
428
+ .add-slide-preset-search {
429
+ width: 100%;
430
+ padding: 10px 12px;
431
+ margin-bottom: 10px;
432
+ border-radius: 8px;
433
+ border: 1px solid color-mix(in srgb, var(--add-slide-preset-fg, #000000) 18%, transparent);
434
+ font-size: 14px;
435
+ color: inherit;
436
+ background: color-mix(in srgb, var(--add-slide-preset-bg, #ffffff) 90%, var(--add-slide-preset-fg, #000000) 10%);
437
+ }
438
+ .add-slide-preset-search:focus {
439
+ outline: 2px solid color-mix(in srgb, var(--add-slide-preset-fg, #3b82f6) 35%, transparent);
440
+ outline-offset: 1px;
441
+ }
442
+
443
+ .add-slide-preset-list {
444
+ display: flex;
445
+ flex-direction: column;
446
+ gap: 6px;
447
+ max-height: 52vh;
448
+ overflow: auto;
449
+ scrollbar-width: none;
450
+ }
451
+
452
+ .add-slide-preset-list::-webkit-scrollbar {
453
+ display: none;
454
+ }
455
+
456
+ .add-slide-preset-item {
457
+ display: flex;
458
+ flex-direction: column;
459
+ align-items: center;
460
+ gap: 8px;
461
+ width: 100%;
462
+ border: none;
463
+ background: transparent;
464
+ color: inherit;
465
+ padding: 10px 0 12px 0;
466
+ cursor: pointer;
467
+ border-bottom: 1px solid color-mix(in srgb, var(--add-slide-preset-fg, #000000) 12%, transparent);
468
+ }
469
+
470
+ .add-slide-preset-item:last-child {
471
+ border-bottom: none;
472
+ }
473
+
474
+ .add-slide-preset-item:hover {
475
+ background: rgba(0, 0, 0, 0.03);
476
+ }
477
+
478
+ .add-slide-preset-icon {
479
+ display: block;
480
+ width: 100%;
481
+ height: auto;
482
+ line-height: 0;
483
+ }
484
+ .add-slide-preset-icon > svg {
485
+ width: 100%;
486
+ height: auto;
487
+ display: block;
488
+ }
489
+
490
+ .add-slide-preset-label {
491
+ text-align: center;
492
+ font-size: 14px;
493
+ font-weight: 600;
494
+ width: 100%;
495
+ }
205
496
  `;
206
497
 
207
498
  export const AddSlideButton = Extension.create<AddSlideButtonOptions>({
@@ -213,7 +504,12 @@ export const AddSlideButton = Extension.create<AddSlideButtonOptions>({
213
504
  injectNonce: undefined,
214
505
  buttonStyle: {},
215
506
  content: "+",
507
+ showPresets: false,
508
+ presets: [],
509
+ templateButtonContent: "Template +",
216
510
  onClick: null,
511
+ presetBackground: "#ffffff",
512
+ presetForeground: "#000000",
217
513
  };
218
514
  },
219
515