@commonpub/editor 0.7.6 → 0.7.8

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.
@@ -4,10 +4,9 @@ declare module '@tiptap/core' {
4
4
  buildStep: {
5
5
  setBuildStep: (attributes: {
6
6
  stepNumber: number;
7
- instructions: string;
8
- image?: string;
7
+ title?: string;
9
8
  time?: string;
10
- partsUsed?: string[];
9
+ children?: Array<[string, Record<string, unknown>]>;
11
10
  }) => ReturnType;
12
11
  };
13
12
  }
@@ -1 +1 @@
1
- {"version":3,"file":"buildStep.d.ts","sourceRoot":"","sources":["../../src/extensions/buildStep.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAErD,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,SAAS,EAAE;YACT,YAAY,EAAE,CAAC,UAAU,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAC;gBAAC,YAAY,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;aAAE,KAAK,UAAU,CAAC;SAC7I,CAAC;KACH;CACF;AAED,eAAO,MAAM,kBAAkB,gBA+C7B,CAAC"}
1
+ {"version":3,"file":"buildStep.d.ts","sourceRoot":"","sources":["../../src/extensions/buildStep.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAErD,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,QAAQ,CAAC,UAAU;QAC3B,SAAS,EAAE;YACT,YAAY,EAAE,CAAC,UAAU,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,QAAQ,CAAC,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;aAAE,KAAK,UAAU,CAAC;SACtJ,CAAC;KACH;CACF;AAED,eAAO,MAAM,kBAAkB,gBAiD7B,CAAC"}
@@ -6,23 +6,25 @@ export const CommonPubBuildStep = Node.create({
6
6
  addAttributes() {
7
7
  return {
8
8
  stepNumber: { default: 1 },
9
- image: { default: null },
10
- instructions: { default: '' },
9
+ title: { default: '' },
11
10
  time: { default: null },
12
- partsUsed: {
11
+ children: {
13
12
  default: [],
14
13
  parseHTML: (element) => {
15
14
  try {
16
- return JSON.parse(element.getAttribute('data-parts-used') || '[]');
15
+ return JSON.parse(element.getAttribute('data-children') || '[]');
17
16
  }
18
17
  catch {
19
18
  return [];
20
19
  }
21
20
  },
22
21
  renderHTML: (attributes) => ({
23
- 'data-parts-used': JSON.stringify(attributes.partsUsed),
22
+ 'data-children': JSON.stringify(attributes.children),
24
23
  }),
25
24
  },
25
+ // Legacy attributes — kept for migration from old format
26
+ instructions: { default: null },
27
+ image: { default: null },
26
28
  };
27
29
  },
28
30
  parseHTML() {
@@ -31,7 +33,7 @@ export const CommonPubBuildStep = Node.create({
31
33
  renderHTML({ node, HTMLAttributes }) {
32
34
  return ['div', mergeAttributes(HTMLAttributes, { class: 'cpub-build-step' }),
33
35
  ['span', { class: 'cpub-build-step-number' }, `Step ${node.attrs.stepNumber}`],
34
- ['p', {}, node.attrs.instructions],
36
+ ['span', { class: 'cpub-build-step-title' }, node.attrs.title || ''],
35
37
  ];
36
38
  },
37
39
  addCommands() {
@@ -1 +1 @@
1
- {"version":3,"file":"buildStep.js","sourceRoot":"","sources":["../../src/extensions/buildStep.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAUrD,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC,MAAM,CAAC;IAC5C,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,OAAO;IACd,IAAI,EAAE,IAAI;IAEV,aAAa;QACX,OAAO;YACL,UAAU,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE;YAC1B,KAAK,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;YACxB,YAAY,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;YAC7B,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;YACvB,SAAS,EAAE;gBACT,OAAO,EAAE,EAAE;gBACX,SAAS,EAAE,CAAC,OAAoB,EAAE,EAAE;oBAClC,IAAI,CAAC;wBACH,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,YAAY,CAAC,iBAAiB,CAAC,IAAI,IAAI,CAAC,CAAC;oBACrE,CAAC;oBAAC,MAAM,CAAC;wBACP,OAAO,EAAE,CAAC;oBACZ,CAAC;gBACH,CAAC;gBACD,UAAU,EAAE,CAAC,UAAmC,EAAE,EAAE,CAAC,CAAC;oBACpD,iBAAiB,EAAE,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,SAAS,CAAC;iBACxD,CAAC;aACH;SACF,CAAC;IACJ,CAAC;IAED,SAAS;QACP,OAAO,CAAC,EAAE,GAAG,EAAE,qBAAqB,EAAE,CAAC,CAAC;IAC1C,CAAC;IAED,UAAU,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE;QACjC,OAAO,CAAC,KAAK,EAAE,eAAe,CAAC,cAAc,EAAE,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;YAC1E,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,QAAQ,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC;YAC9E,CAAC,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC;SACnC,CAAC;IACJ,CAAC;IAED,WAAW;QACT,OAAO;YACL,YAAY,EACV,CAAC,UAAU,EAAE,EAAE,CACf,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE;gBACf,OAAO,QAAQ,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;YACxE,CAAC;SACJ,CAAC;IACJ,CAAC;CACF,CAAC,CAAC"}
1
+ {"version":3,"file":"buildStep.js","sourceRoot":"","sources":["../../src/extensions/buildStep.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAUrD,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,CAAC,MAAM,CAAC;IAC5C,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,OAAO;IACd,IAAI,EAAE,IAAI;IAEV,aAAa;QACX,OAAO;YACL,UAAU,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE;YAC1B,KAAK,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;YACtB,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;YACvB,QAAQ,EAAE;gBACR,OAAO,EAAE,EAAE;gBACX,SAAS,EAAE,CAAC,OAAoB,EAAE,EAAE;oBAClC,IAAI,CAAC;wBACH,OAAO,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,YAAY,CAAC,eAAe,CAAC,IAAI,IAAI,CAAC,CAAC;oBACnE,CAAC;oBAAC,MAAM,CAAC;wBACP,OAAO,EAAE,CAAC;oBACZ,CAAC;gBACH,CAAC;gBACD,UAAU,EAAE,CAAC,UAAmC,EAAE,EAAE,CAAC,CAAC;oBACpD,eAAe,EAAE,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC;iBACrD,CAAC;aACH;YACD,yDAAyD;YACzD,YAAY,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;YAC/B,KAAK,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE;SACzB,CAAC;IACJ,CAAC;IAED,SAAS;QACP,OAAO,CAAC,EAAE,GAAG,EAAE,qBAAqB,EAAE,CAAC,CAAC;IAC1C,CAAC;IAED,UAAU,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE;QACjC,OAAO,CAAC,KAAK,EAAE,eAAe,CAAC,cAAc,EAAE,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;YAC1E,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,wBAAwB,EAAE,EAAE,QAAQ,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC;YAC9E,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,uBAAuB,EAAE,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC;SACrE,CAAC;IACJ,CAAC;IAED,WAAW;QACT,OAAO;YACL,YAAY,EACV,CAAC,UAAU,EAAE,EAAE,CACf,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE;gBACf,OAAO,QAAQ,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC;YACxE,CAAC;SACJ,CAAC;IACJ,CAAC;CACF,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/editor",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "type": "module",
5
5
  "description": "TipTap block editor with 18+ maker-focused extensions for CommonPub",
6
6
  "license": "AGPL-3.0-or-later",
@@ -50,8 +50,8 @@
50
50
  "remark-rehype": "^11.1.2",
51
51
  "unified": "^11.0.5",
52
52
  "zod": "^4.3.6",
53
- "@commonpub/schema": "0.9.5",
54
- "@commonpub/config": "0.9.0"
53
+ "@commonpub/config": "0.9.0",
54
+ "@commonpub/schema": "0.9.5"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vue": "^3.4.0",
@@ -456,8 +456,9 @@ defineExpose({
456
456
  <BlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
457
457
  </div>
458
458
 
459
- <!-- Insert zone after each block -->
459
+ <!-- Insert zone after each block (always visible on last block) -->
460
460
  <BlockInsertZone
461
+ :always-visible="index === blockEditor.blocks.value.length - 1"
461
462
  @insert="openPicker(index + 1)"
462
463
  @drop="onDrop(index + 1, $event)"
463
464
  />
@@ -522,29 +523,23 @@ defineExpose({
522
523
 
523
524
  <style scoped>
524
525
  .cpub-block-canvas {
525
- padding: 36px 0 52px;
526
+ padding: 16px 0 52px;
526
527
  min-height: 300px;
527
528
  position: relative;
528
529
  display: flex;
529
530
  flex-direction: column;
530
- align-items: center;
531
+ align-items: stretch;
531
532
  }
532
533
 
533
534
  .cpub-canvas-page {
534
535
  width: 100%;
535
- max-width: 680px;
536
- background: var(--surface);
537
- border: var(--border-width-default) solid var(--border);
538
- box-shadow: var(--shadow-md);
539
- padding: 44px 56px;
536
+ padding: 0 24px;
540
537
  position: relative;
541
538
  }
542
539
 
543
540
  @media (max-width: 768px) {
544
541
  .cpub-canvas-page {
545
- border: none;
546
- box-shadow: none;
547
- padding: 16px;
542
+ padding: 0 12px;
548
543
  }
549
544
  .cpub-block-canvas {
550
545
  padding: 8px 0 48px;
@@ -2,9 +2,14 @@
2
2
  /**
3
3
  * Insert zone between blocks — shows "+ Insert block" button.
4
4
  * Appears between every block and at the top/bottom of the canvas.
5
+ * Button shows on hover unless `alwaysVisible` is set.
5
6
  */
6
7
  import { ref } from 'vue';
7
8
 
9
+ defineProps<{
10
+ alwaysVisible?: boolean;
11
+ }>();
12
+
8
13
  defineEmits<{
9
14
  insert: [];
10
15
  }>();
@@ -25,7 +30,10 @@ function onDragLeave(): void {
25
30
  <template>
26
31
  <div
27
32
  class="cpub-insert-zone"
28
- :class="{ 'cpub-insert-zone--dragover': isDragOver }"
33
+ :class="{
34
+ 'cpub-insert-zone--dragover': isDragOver,
35
+ 'cpub-insert-zone--hover-only': !alwaysVisible,
36
+ }"
29
37
  @dragover="onDragOver"
30
38
  @dragleave="onDragLeave"
31
39
  @drop="isDragOver = false"
@@ -42,31 +50,17 @@ function onDragLeave(): void {
42
50
  display: flex;
43
51
  align-items: center;
44
52
  justify-content: center;
45
- height: 8px;
53
+ height: 28px;
46
54
  position: relative;
47
- transition: height 0.15s;
48
- }
49
-
50
- /* Always-visible thin line */
51
- .cpub-insert-zone::before {
52
- content: '';
53
- position: absolute;
54
- left: 0;
55
- right: 0;
56
- top: 50%;
57
- height: 1px;
58
- background: var(--border, rgba(0, 0, 0, 0.1));
59
- pointer-events: none;
60
55
  }
61
56
 
62
- .cpub-insert-zone:hover::before,
63
- .cpub-insert-zone--dragover::before {
64
- background: transparent;
57
+ .cpub-insert-zone--hover-only .cpub-insert-btn {
58
+ opacity: 0;
59
+ transition: opacity 0.12s;
65
60
  }
66
61
 
67
- .cpub-insert-zone:hover,
68
- .cpub-insert-zone--dragover {
69
- height: 36px;
62
+ .cpub-insert-zone--hover-only:hover .cpub-insert-btn {
63
+ opacity: 1;
70
64
  }
71
65
 
72
66
  .cpub-insert-btn {
@@ -78,17 +72,10 @@ function onDragLeave(): void {
78
72
  letter-spacing: 0.04em;
79
73
  color: var(--text-faint);
80
74
  background: transparent;
81
- border: 2px dashed transparent;
75
+ border: 2px dashed var(--border2, rgba(0, 0, 0, 0.08));
82
76
  padding: 4px 14px;
83
77
  cursor: pointer;
84
- opacity: 0;
85
- transition: opacity 0.15s, background 0.1s, border-color 0.1s, color 0.1s;
86
- }
87
-
88
- .cpub-insert-zone:hover .cpub-insert-btn,
89
- .cpub-insert-zone--dragover .cpub-insert-btn {
90
- opacity: 1;
91
- border-color: var(--border2);
78
+ transition: background 0.1s, border-color 0.1s, color 0.1s;
92
79
  }
93
80
 
94
81
  .cpub-insert-btn:hover {
@@ -1,8 +1,40 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Build step block — numbered step with title and instruction text.
3
+ * Build step block — numbered step with title, time, and nested child blocks.
4
+ * Uses its own useBlockEditor instance for children (text, image, code, callout, divider).
5
+ * Migrates old flat format (instructions + image) to children on load.
4
6
  */
5
- import { computed } from 'vue';
7
+ import { ref, computed, watch, inject, type Component } from 'vue';
8
+ import type { BlockTuple } from '@commonpub/editor';
9
+ import { useBlockEditor } from '../../composables/useBlockEditor.js';
10
+ import { UPLOAD_HANDLER_KEY } from '../../provide.js';
11
+
12
+ import TextBlock from './TextBlock.vue';
13
+ import ImageBlock from './ImageBlock.vue';
14
+ import CodeBlock from './CodeBlock.vue';
15
+ import CalloutBlock from './CalloutBlock.vue';
16
+ import DividerBlock from './DividerBlock.vue';
17
+
18
+ const CHILD_COMPONENTS: Record<string, Component> = {
19
+ paragraph: TextBlock,
20
+ text: TextBlock,
21
+ image: ImageBlock,
22
+ code: CodeBlock,
23
+ code_block: CodeBlock,
24
+ codeBlock: CodeBlock,
25
+ callout: CalloutBlock,
26
+ divider: DividerBlock,
27
+ horizontalRule: DividerBlock,
28
+ };
29
+
30
+ const INSERTABLE_TYPES = [
31
+ { type: 'paragraph', label: 'Text', icon: 'fa-solid fa-paragraph' },
32
+ { type: 'image', label: 'Image', icon: 'fa-regular fa-image' },
33
+ { type: 'code_block', label: 'Code', icon: 'fa-solid fa-code' },
34
+ { type: 'callout', label: 'Callout', icon: 'fa-solid fa-circle-info' },
35
+ { type: 'divider', label: 'Divider', icon: 'fa-solid fa-minus' },
36
+ ] as const;
37
+
6
38
  const props = defineProps<{
7
39
  content: Record<string, unknown>;
8
40
  }>();
@@ -11,13 +43,122 @@ const emit = defineEmits<{
11
43
  update: [content: Record<string, unknown>];
12
44
  }>();
13
45
 
46
+ const uploadHandler = inject(UPLOAD_HANDLER_KEY, undefined);
47
+
14
48
  const stepNumber = computed(() => (props.content.stepNumber as number) ?? 1);
15
49
  const title = computed(() => (props.content.title as string) ?? '');
16
- const instructions = computed(() => (props.content.instructions as string) ?? '');
17
50
  const time = computed(() => (props.content.time as string) ?? '');
18
51
 
52
+ // --- Migration: old flat format → children BlockTuple[] ---
53
+ function migrateToChildren(content: Record<string, unknown>): BlockTuple[] {
54
+ if (content.children && Array.isArray(content.children) && content.children.length > 0) {
55
+ return content.children as BlockTuple[];
56
+ }
57
+ const children: BlockTuple[] = [];
58
+ const instructions = content.instructions as string | undefined;
59
+ if (instructions && instructions.trim()) {
60
+ // Wrap plain text in <p> tags if not already HTML
61
+ const html = instructions.startsWith('<') ? instructions : `<p>${instructions}</p>`;
62
+ children.push(['paragraph', { html }]);
63
+ }
64
+ const image = content.image as string | undefined;
65
+ if (image && image.trim()) {
66
+ children.push(['image', { src: image, alt: '', caption: '' }]);
67
+ }
68
+ if (children.length === 0) {
69
+ children.push(['paragraph', { html: '' }]);
70
+ }
71
+ return children;
72
+ }
73
+
74
+ // --- Child block editor ---
75
+ const initialChildren = migrateToChildren(props.content);
76
+ const childEditor = useBlockEditor(initialChildren);
77
+
78
+ /** Track what we last emitted so we can distinguish our own updates from external (undo) */
79
+ let lastEmittedJson = JSON.stringify(initialChildren);
80
+
81
+ function emitFullUpdate(): void {
82
+ const children = childEditor.toBlockTuples();
83
+ lastEmittedJson = JSON.stringify(children);
84
+ emit('update', {
85
+ stepNumber: stepNumber.value,
86
+ title: title.value,
87
+ time: time.value,
88
+ children,
89
+ });
90
+ }
91
+
19
92
  function updateField(field: string, value: unknown): void {
20
- emit('update', { ...props.content, [field]: value });
93
+ const children = childEditor.toBlockTuples();
94
+ lastEmittedJson = JSON.stringify(children);
95
+ emit('update', {
96
+ stepNumber: stepNumber.value,
97
+ title: title.value,
98
+ time: time.value,
99
+ children,
100
+ [field]: value,
101
+ });
102
+ }
103
+
104
+ function onChildUpdate(blockId: string, content: Record<string, unknown>): void {
105
+ childEditor.updateBlock(blockId, content);
106
+ emitFullUpdate();
107
+ }
108
+
109
+ function addChild(type: string): void {
110
+ childEditor.addBlock(type);
111
+ emitFullUpdate();
112
+ }
113
+
114
+ function removeChild(blockId: string): void {
115
+ if (childEditor.blocks.value.length <= 1) return;
116
+ childEditor.removeBlock(blockId);
117
+ emitFullUpdate();
118
+ }
119
+
120
+ function moveChildUp(blockId: string): void {
121
+ childEditor.moveBlockUp(blockId);
122
+ emitFullUpdate();
123
+ }
124
+
125
+ function moveChildDown(blockId: string): void {
126
+ childEditor.moveBlockDown(blockId);
127
+ emitFullUpdate();
128
+ }
129
+
130
+ function getChildComponent(type: string): Component {
131
+ return CHILD_COMPONENTS[type] ?? TextBlock;
132
+ }
133
+
134
+ function needsUpload(type: string): boolean {
135
+ return type === 'image';
136
+ }
137
+
138
+ // --- Sync from external changes (undo/redo) ---
139
+ watch(
140
+ () => props.content.children,
141
+ (newChildren) => {
142
+ if (!newChildren || !Array.isArray(newChildren)) return;
143
+ const newJson = JSON.stringify(newChildren);
144
+ if (newJson === lastEmittedJson) return;
145
+ // External change — re-initialize child editor
146
+ lastEmittedJson = newJson;
147
+ childEditor.fromBlockTuples(newChildren as BlockTuple[]);
148
+ },
149
+ { deep: true },
150
+ );
151
+
152
+ // --- Add menu ---
153
+ const showAddMenu = ref(false);
154
+
155
+ function toggleAddMenu(): void {
156
+ showAddMenu.value = !showAddMenu.value;
157
+ }
158
+
159
+ function onAddType(type: string): void {
160
+ showAddMenu.value = false;
161
+ addChild(type);
21
162
  }
22
163
  </script>
23
164
 
@@ -40,14 +181,63 @@ function updateField(field: string, value: unknown): void {
40
181
  @input="updateField('time', ($event.target as HTMLInputElement).value)"
41
182
  />
42
183
  </div>
43
- <div class="cpub-step-body">
44
- <textarea
45
- class="cpub-step-instructions"
46
- :value="instructions"
47
- placeholder="Describe this step..."
48
- rows="4"
49
- @input="updateField('instructions', ($event.target as HTMLTextAreaElement).value)"
50
- />
184
+
185
+ <div class="cpub-step-children">
186
+ <div
187
+ v-for="(block, idx) in childEditor.blocks.value"
188
+ :key="block.id"
189
+ class="cpub-step-child"
190
+ >
191
+ <component
192
+ :is="getChildComponent(block.type)"
193
+ :content="block.content"
194
+ v-bind="needsUpload(block.type) && uploadHandler ? { onUpload: uploadHandler } : {}"
195
+ @update="(c: Record<string, unknown>) => onChildUpdate(block.id, c)"
196
+ />
197
+ <div class="cpub-step-child-actions">
198
+ <button
199
+ v-if="idx > 0"
200
+ class="cpub-step-child-btn"
201
+ title="Move up"
202
+ @click="moveChildUp(block.id)"
203
+ >
204
+ <i class="fa-solid fa-chevron-up"></i>
205
+ </button>
206
+ <button
207
+ v-if="idx < childEditor.blocks.value.length - 1"
208
+ class="cpub-step-child-btn"
209
+ title="Move down"
210
+ @click="moveChildDown(block.id)"
211
+ >
212
+ <i class="fa-solid fa-chevron-down"></i>
213
+ </button>
214
+ <button
215
+ v-if="childEditor.blocks.value.length > 1"
216
+ class="cpub-step-child-btn cpub-step-child-btn--danger"
217
+ title="Remove block"
218
+ @click="removeChild(block.id)"
219
+ >
220
+ <i class="fa-solid fa-xmark"></i>
221
+ </button>
222
+ </div>
223
+ </div>
224
+
225
+ <!-- Add block button -->
226
+ <div class="cpub-step-add-row">
227
+ <button class="cpub-step-add-btn" @click="toggleAddMenu">
228
+ <i class="fa-solid fa-plus"></i> Add block
229
+ </button>
230
+ <div v-if="showAddMenu" class="cpub-step-add-menu">
231
+ <button
232
+ v-for="t in INSERTABLE_TYPES"
233
+ :key="t.type"
234
+ class="cpub-step-add-option"
235
+ @click="onAddType(t.type)"
236
+ >
237
+ <i :class="t.icon"></i> {{ t.label }}
238
+ </button>
239
+ </div>
240
+ </div>
51
241
  </div>
52
242
  </div>
53
243
  </template>
@@ -91,13 +281,112 @@ function updateField(field: string, value: unknown): void {
91
281
  .cpub-step-time:focus { border-color: var(--accent); }
92
282
  .cpub-step-time::placeholder { color: var(--text-faint); }
93
283
 
94
- .cpub-step-body { padding: 14px 16px; }
284
+ /* --- Children --- */
285
+ .cpub-step-children {
286
+ padding: 12px 16px;
287
+ display: flex;
288
+ flex-direction: column;
289
+ gap: 8px;
290
+ }
95
291
 
96
- .cpub-step-instructions {
97
- width: 100%; font-size: 13px; line-height: 1.65;
98
- background: transparent; border: none; outline: none;
99
- color: var(--text); resize: vertical;
100
- font-family: var(--font-sans, system-ui);
292
+ .cpub-step-child {
293
+ position: relative;
294
+ border: var(--border-width-default) solid transparent;
295
+ padding: 4px 0;
296
+ transition: border-color 0.1s;
297
+ }
298
+
299
+ .cpub-step-child:hover {
300
+ border-color: var(--border2);
301
+ }
302
+
303
+ .cpub-step-child-actions {
304
+ position: absolute;
305
+ top: 2px;
306
+ right: 2px;
307
+ display: flex;
308
+ gap: 2px;
309
+ opacity: 0;
310
+ transition: opacity 0.1s;
311
+ }
312
+
313
+ .cpub-step-child:hover .cpub-step-child-actions {
314
+ opacity: 1;
315
+ }
316
+
317
+ .cpub-step-child-btn {
318
+ width: 22px; height: 22px;
319
+ background: var(--surface2);
320
+ border: var(--border-width-default) solid var(--border2);
321
+ color: var(--text-faint);
322
+ cursor: pointer;
323
+ display: flex; align-items: center; justify-content: center;
324
+ font-size: 9px;
325
+ padding: 0;
326
+ }
327
+
328
+ .cpub-step-child-btn:hover {
329
+ background: var(--surface3);
330
+ color: var(--text);
331
+ }
332
+
333
+ .cpub-step-child-btn--danger:hover {
334
+ background: var(--red-bg);
335
+ color: var(--red);
336
+ border-color: var(--red-border);
337
+ }
338
+
339
+ /* --- Add block --- */
340
+ .cpub-step-add-row {
341
+ position: relative;
342
+ display: flex;
343
+ align-items: center;
344
+ padding-top: 4px;
345
+ }
346
+
347
+ .cpub-step-add-btn {
348
+ display: flex; align-items: center; gap: 6px;
349
+ padding: 4px 10px;
350
+ font-size: 11px; font-family: var(--font-mono);
351
+ background: transparent;
352
+ border: var(--border-width-default) dashed var(--border2);
353
+ color: var(--text-faint);
354
+ cursor: pointer;
355
+ transition: all 0.1s;
356
+ }
357
+
358
+ .cpub-step-add-btn:hover {
359
+ border-color: var(--accent);
360
+ color: var(--accent);
361
+ background: var(--accent-bg);
362
+ }
363
+
364
+ .cpub-step-add-menu {
365
+ position: absolute;
366
+ bottom: 100%;
367
+ left: 0;
368
+ margin-bottom: 4px;
369
+ display: flex;
370
+ gap: 0;
371
+ background: var(--surface);
372
+ border: var(--border-width-default) solid var(--border);
373
+ box-shadow: var(--shadow-md);
374
+ z-index: 10;
375
+ }
376
+
377
+ .cpub-step-add-option {
378
+ display: flex; align-items: center; gap: 6px;
379
+ padding: 6px 12px;
380
+ font-size: 11px;
381
+ background: transparent;
382
+ border: none;
383
+ color: var(--text-dim);
384
+ cursor: pointer;
385
+ white-space: nowrap;
386
+ }
387
+
388
+ .cpub-step-add-option:hover {
389
+ background: var(--accent-bg);
390
+ color: var(--accent);
101
391
  }
102
- .cpub-step-instructions::placeholder { color: var(--text-faint); }
103
392
  </style>
@@ -21,7 +21,7 @@ const BLOCK_DEFAULTS: Record<string, () => Record<string, unknown>> = {
21
21
  embed: () => ({ url: '', type: 'generic', html: '' }),
22
22
  horizontal_rule: () => ({}),
23
23
  partsList: () => ({ parts: [] }),
24
- buildStep: () => ({ stepNumber: 1, instructions: '', image: '', time: '' }),
24
+ buildStep: () => ({ stepNumber: 1, title: '', time: '', children: [['paragraph', { html: '' }]] }),
25
25
  toolList: () => ({ tools: [] }),
26
26
  downloads: () => ({ files: [] }),
27
27
  quiz: () => ({ question: '', options: [], feedback: '' }),