@ashraf_mizo/htmlcanvas 1.0.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/bin/cli.js +28 -0
- package/editor/alignment.js +211 -0
- package/editor/assets.js +724 -0
- package/editor/clipboard.js +177 -0
- package/editor/coords.js +121 -0
- package/editor/crop.js +325 -0
- package/editor/cssVars.js +134 -0
- package/editor/domModel.js +161 -0
- package/editor/editor.css +1996 -0
- package/editor/editor.js +833 -0
- package/editor/guides.js +513 -0
- package/editor/history.js +135 -0
- package/editor/index.html +540 -0
- package/editor/layers.js +389 -0
- package/editor/logo-final.svg +21 -0
- package/editor/logo-toolbar.svg +21 -0
- package/editor/manipulation.js +864 -0
- package/editor/multiSelect.js +436 -0
- package/editor/properties.js +1583 -0
- package/editor/selection.js +432 -0
- package/editor/serializer.js +160 -0
- package/editor/shortcuts.js +143 -0
- package/editor/slidePanel.js +361 -0
- package/editor/slides.js +101 -0
- package/editor/snap.js +98 -0
- package/editor/textEdit.js +538 -0
- package/editor/zoom.js +96 -0
- package/package.json +28 -0
- package/server.js +588 -0
package/editor/assets.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
// editor/assets.js — Asset browser, component library, shape insertion, image replace
|
|
2
|
+
//
|
|
3
|
+
// Exports:
|
|
4
|
+
// initAssets(iframe, iframeDoc, canvasArea)
|
|
5
|
+
// SHAPE_TEMPLATES
|
|
6
|
+
// loadAssets()
|
|
7
|
+
//
|
|
8
|
+
// Imports: serializer (stripHcIds, recordChange, recordDeletion), history (push), selection, coords
|
|
9
|
+
|
|
10
|
+
import { stripHcIds, recordChange, recordDeletion } from './serializer.js';
|
|
11
|
+
import { push } from './history.js';
|
|
12
|
+
import { getSelectedElement, getSelectedHcId } from './selection.js';
|
|
13
|
+
import { canvasToIframe } from './coords.js';
|
|
14
|
+
import { getTopZIndex, notifyLayersChanged } from './layers.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the currently visible .page element (accounts for slide panel mode).
|
|
18
|
+
* Uses getCurrentSlideIndex if available, otherwise falls back to first visible page.
|
|
19
|
+
*/
|
|
20
|
+
function getVisiblePage(iframeDoc) {
|
|
21
|
+
const pages = iframeDoc.querySelectorAll('.page');
|
|
22
|
+
if (pages.length === 0) return iframeDoc.body;
|
|
23
|
+
|
|
24
|
+
// If slide panel is tracking a current index, use that
|
|
25
|
+
try {
|
|
26
|
+
const { getCurrentSlideIndex, isSlideMode } = window._slidePanelAPI || {};
|
|
27
|
+
if (isSlideMode && isSlideMode() && getCurrentSlideIndex) {
|
|
28
|
+
const idx = getCurrentSlideIndex();
|
|
29
|
+
if (pages[idx]) return pages[idx];
|
|
30
|
+
}
|
|
31
|
+
} catch (_) {}
|
|
32
|
+
|
|
33
|
+
// Fallback: first visible page
|
|
34
|
+
for (const page of pages) {
|
|
35
|
+
if (page.style.display !== 'none') return page;
|
|
36
|
+
}
|
|
37
|
+
return pages[0] || iframeDoc.body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Shape templates ──────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export const SHAPE_TEMPLATES = {
|
|
43
|
+
// ── Basic shapes — CSS divs: border always traces shape, scales perfectly ──
|
|
44
|
+
rect: '<div style="display:block;width:100px;height:60px;background:#E87420;box-sizing:border-box;"></div>',
|
|
45
|
+
'round-rect':'<div style="display:block;width:100px;height:60px;background:#E87420;border-radius:12px;box-sizing:border-box;"></div>',
|
|
46
|
+
'pill': '<div style="display:block;width:120px;height:48px;background:#E87420;border-radius:9999px;box-sizing:border-box;"></div>',
|
|
47
|
+
circle: '<div style="display:block;width:80px;height:80px;background:#E87420;border-radius:50%;box-sizing:border-box;"></div>',
|
|
48
|
+
ellipse: '<div style="display:block;width:100px;height:60px;background:#E87420;border-radius:50%;box-sizing:border-box;"></div>',
|
|
49
|
+
badge: '<div style="display:block;width:100px;height:40px;background:#E87420;border-radius:6px;box-sizing:border-box;"></div>',
|
|
50
|
+
|
|
51
|
+
// ── SVG shapes — viewBox ensures scaling; overflow:visible prevents stroke clip ──
|
|
52
|
+
triangle: '<svg viewBox="0 0 80 70" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="80" height="70" overflow="visible"><polygon points="40,1 79,69 1,69" fill="#E87420" stroke="none"/></svg>',
|
|
53
|
+
'right-tri': '<svg viewBox="0 0 80 70" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="80" height="70" overflow="visible"><polygon points="1,1 79,69 1,69" fill="#E87420" stroke="none"/></svg>',
|
|
54
|
+
diamond: '<svg viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="70" height="70" overflow="visible"><polygon points="35,1 69,35 35,69 1,35" fill="#E87420" stroke="none"/></svg>',
|
|
55
|
+
pentagon: '<svg viewBox="0 0 80 76" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="80" height="76" overflow="visible"><polygon points="40,1 79,29 64,75 16,75 1,29" fill="#E87420" stroke="none"/></svg>',
|
|
56
|
+
hexagon: '<svg viewBox="0 0 80 70" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="80" height="70" overflow="visible"><polygon points="21,1 59,1 79,35 59,69 21,69 1,35" fill="#E87420" stroke="none"/></svg>',
|
|
57
|
+
octagon: '<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="80" height="80" overflow="visible"><polygon points="25,1 55,1 79,25 79,55 55,79 25,79 1,55 1,25" fill="#E87420" stroke="none"/></svg>',
|
|
58
|
+
|
|
59
|
+
// ── Stars & complex ──
|
|
60
|
+
star5: '<svg viewBox="0 0 80 76" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" width="80" height="76" overflow="visible"><polygon points="40,1 49,29 79,29 55,47 64,75 40,58 16,75 25,47 1,29 31,29" fill="#E87420" stroke="none"/></svg>',
|
|
61
|
+
star4: '<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" width="80" height="80" overflow="visible"><polygon points="40,1 50,30 79,40 50,50 40,79 30,50 1,40 30,30" fill="#E87420" stroke="none"/></svg>',
|
|
62
|
+
star6: '<svg viewBox="0 0 80 70" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" width="80" height="70" overflow="visible"><polygon points="40,1 50,20 72,7 60,28 79,35 60,42 72,63 50,50 40,69 30,50 8,63 20,42 1,35 20,28 8,7 30,20" fill="#E87420" stroke="none"/></svg>',
|
|
63
|
+
cross: '<svg viewBox="0 0 70 70" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="70" height="70" overflow="visible"><polygon points="21,1 49,1 49,21 69,21 69,49 49,49 49,69 21,69 21,49 1,49 1,21 21,21" fill="#E87420" stroke="none"/></svg>',
|
|
64
|
+
heart: '<svg viewBox="0 0 80 72" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" width="80" height="72" overflow="visible"><path d="M40,70 C20,50 1,35 1,19 A19,19,0,0,1,40,15 A19,19,0,0,1,79,19 C79,35 60,50 40,70Z" fill="#E87420" stroke="none"/></svg>',
|
|
65
|
+
|
|
66
|
+
// ── Arrows ──
|
|
67
|
+
line: '<svg viewBox="0 0 100 4" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="100" height="4" overflow="visible"><line x1="0" y1="2" x2="100" y2="2" stroke="#333" stroke-width="2"/></svg>',
|
|
68
|
+
arrow: '<svg viewBox="0 0 100 20" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="100" height="20" overflow="visible"><line x1="0" y1="10" x2="88" y2="10" stroke="#333" stroke-width="2"/><polygon points="88,4 100,10 88,16" fill="#333" stroke="none"/></svg>',
|
|
69
|
+
'double-arrow':'<svg viewBox="0 0 100 20" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="100" height="20" overflow="visible"><line x1="12" y1="10" x2="88" y2="10" stroke="#333" stroke-width="2"/><polygon points="88,4 100,10 88,16" fill="#333" stroke="none"/><polygon points="12,4 0,10 12,16" fill="#333" stroke="none"/></svg>',
|
|
70
|
+
'block-arrow':'<svg viewBox="0 0 100 50" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="100" height="50" overflow="visible"><polygon points="1,15 65,15 65,1 99,25 65,49 65,35 1,35" fill="#E87420" stroke="none"/></svg>',
|
|
71
|
+
chevron: '<svg viewBox="0 0 100 50" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="100" height="50" overflow="visible"><polygon points="1,1 79,1 99,25 79,49 1,49 21,25" fill="#E87420" stroke="none"/></svg>',
|
|
72
|
+
|
|
73
|
+
// ── Callouts ──
|
|
74
|
+
callout: '<svg viewBox="0 0 120 80" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="120" height="80" overflow="visible"><path d="M1,1 H119 V54 H40 L25,79 L30,54 H1 Z" fill="#E87420" stroke="none"/></svg>',
|
|
75
|
+
'callout-round':'<svg viewBox="0 0 120 80" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" width="120" height="80" overflow="visible"><path d="M8,1 H112 Q119,1 119,8 V46 Q119,54 112,54 H40 L25,79 L30,54 H8 Q1,54 1,46 V8 Q1,1 8,1Z" fill="#E87420" stroke="none"/></svg>',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Shape categories for the picker UI */
|
|
79
|
+
export const SHAPE_CATEGORIES = [
|
|
80
|
+
{
|
|
81
|
+
name: 'Basic',
|
|
82
|
+
shapes: [
|
|
83
|
+
{ id: 'rect', label: 'Rectangle', icon: '<rect x="1" y="3" width="14" height="10" rx="0"/>' },
|
|
84
|
+
{ id: 'round-rect', label: 'Rounded Rectangle', icon: '<rect x="1" y="3" width="14" height="10" rx="3"/>' },
|
|
85
|
+
{ id: 'pill', label: 'Pill', icon: '<rect x="1" y="4" width="14" height="8" rx="4"/>' },
|
|
86
|
+
{ id: 'circle', label: 'Circle', icon: '<circle cx="8" cy="8" r="6"/>' },
|
|
87
|
+
{ id: 'ellipse', label: 'Ellipse', icon: '<ellipse cx="8" cy="8" rx="7" ry="4.5"/>' },
|
|
88
|
+
{ id: 'triangle', label: 'Triangle', icon: '<polygon points="8,1 15,14 1,14"/>' },
|
|
89
|
+
{ id: 'right-tri', label: 'Right Triangle', icon: '<polygon points="1,1 15,14 1,14"/>' },
|
|
90
|
+
{ id: 'diamond', label: 'Diamond', icon: '<polygon points="8,1 15,8 8,15 1,8"/>' },
|
|
91
|
+
{ id: 'pentagon', label: 'Pentagon', icon: '<polygon points="8,1 15,6 13,14 3,14 1,6"/>' },
|
|
92
|
+
{ id: 'hexagon', label: 'Hexagon', icon: '<polygon points="4,1 12,1 15,8 12,15 4,15 1,8"/>' },
|
|
93
|
+
{ id: 'octagon', label: 'Octagon', icon: '<polygon points="5,1 11,1 15,5 15,11 11,15 5,15 1,11 1,5"/>' },
|
|
94
|
+
]
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'Stars & Badges',
|
|
98
|
+
shapes: [
|
|
99
|
+
{ id: 'star5', label: '5-Point Star', icon: '<polygon points="8,1 9.8,5.8 15,5.8 10.9,9.2 12.4,14.2 8,11.2 3.6,14.2 5.1,9.2 1,5.8 6.2,5.8"/>' },
|
|
100
|
+
{ id: 'star4', label: '4-Point Star', icon: '<polygon points="8,1 9.5,6 15,8 9.5,10 8,15 6.5,10 1,8 6.5,6"/>' },
|
|
101
|
+
{ id: 'star6', label: '6-Point Star', icon: '<polygon points="8,1 10,5 14,2 12,7 16,8 12,9 14,14 10,11 8,15 6,11 2,14 4,9 0,8 4,7 2,2 6,5"/>' },
|
|
102
|
+
{ id: 'cross', label: 'Cross / Plus', icon: '<polygon points="5,1 11,1 11,5 15,5 15,11 11,11 11,15 5,15 5,11 1,11 1,5 5,5"/>' },
|
|
103
|
+
{ id: 'heart', label: 'Heart', icon: '<path d="M8,14 C5,11 1,8 1,5 A3.5,3.5,0,0,1,8,4 A3.5,3.5,0,0,1,15,5 C15,8 11,11 8,14Z"/>' },
|
|
104
|
+
{ id: 'badge', label: 'Badge', icon: '<rect x="1" y="4" width="14" height="8" rx="2"/>' },
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'Lines & Arrows',
|
|
109
|
+
shapes: [
|
|
110
|
+
{ id: 'line', label: 'Line', icon: '<line x1="2" y1="14" x2="14" y2="2"/>' },
|
|
111
|
+
{ id: 'arrow', label: 'Arrow', icon: '<line x1="2" y1="8" x2="12" y2="8"/><polyline points="9,4 14,8 9,12" fill="none"/>' },
|
|
112
|
+
{ id: 'double-arrow', label: 'Double Arrow', icon: '<line x1="5" y1="8" x2="11" y2="8"/><polyline points="9,4 14,8 9,12" fill="none"/><polyline points="7,4 2,8 7,12" fill="none"/>' },
|
|
113
|
+
{ id: 'block-arrow', label: 'Block Arrow', icon: '<polygon points="1,5 9,5 9,1 15,8 9,15 9,11 1,11"/>' },
|
|
114
|
+
{ id: 'chevron', label: 'Chevron', icon: '<polygon points="1,1 11,1 15,8 11,15 1,15 5,8"/>' },
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'Callouts',
|
|
119
|
+
shapes: [
|
|
120
|
+
{ id: 'callout', label: 'Callout', icon: '<path d="M1,1 H15 V10 H7 L4,15 L5,10 H1 Z"/>' },
|
|
121
|
+
{ id: 'callout-round', label: 'Rounded Callout', icon: '<path d="M3,1 H13 Q15,1 15,3 V8 Q15,10 13,10 H7 L4,15 L5,10 H3 Q1,10 1,8 V3 Q1,1 3,1Z"/>' },
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// ── Module state ──────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
let _iframe = null;
|
|
129
|
+
let _iframeDoc = null;
|
|
130
|
+
let _canvasArea = null;
|
|
131
|
+
let _contextMenu = null;
|
|
132
|
+
|
|
133
|
+
// ── ID management ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Scans all data-hc-id values in the document and returns max+1.
|
|
137
|
+
*/
|
|
138
|
+
function getNextHcId(iframeDoc) {
|
|
139
|
+
const allTagged = iframeDoc.querySelectorAll('[data-hc-id]');
|
|
140
|
+
let max = 0;
|
|
141
|
+
for (const el of allTagged) {
|
|
142
|
+
const id = el.getAttribute('data-hc-id');
|
|
143
|
+
const m = id.match(/^hc-(\d+)$/);
|
|
144
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
145
|
+
}
|
|
146
|
+
return max + 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Assigns fresh data-hc-id attributes to element and all its descendants.
|
|
151
|
+
*/
|
|
152
|
+
function assignFreshIds(element, startId) {
|
|
153
|
+
let counter = startId;
|
|
154
|
+
element.setAttribute('data-hc-id', `hc-${counter++}`);
|
|
155
|
+
const descendants = element.querySelectorAll('*');
|
|
156
|
+
for (const desc of descendants) {
|
|
157
|
+
if (desc.nodeType === 1 && desc.tagName) {
|
|
158
|
+
const tag = desc.tagName.toLowerCase();
|
|
159
|
+
if (tag !== 'script' && tag !== 'style') {
|
|
160
|
+
desc.setAttribute('data-hc-id', `hc-${counter++}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return counter;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Insert command factory ────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Creates a Command (push-compatible) that inserts an element into the document.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} html - HTML string for the element to insert
|
|
173
|
+
* @param {Document} iframeDoc - The iframe's contentDocument
|
|
174
|
+
* @param {Element} targetParent - Parent element to append into
|
|
175
|
+
* @param {{ x: number, y: number } | null} position - iframe-space coords for absolute positioning
|
|
176
|
+
* @returns {{ description: string, execute: Function, undo: Function }}
|
|
177
|
+
*/
|
|
178
|
+
function makeInsertCommand(html, iframeDoc, targetParent, position) {
|
|
179
|
+
const temp = iframeDoc.createElement('div');
|
|
180
|
+
temp.innerHTML = html;
|
|
181
|
+
const newElement = temp.firstElementChild;
|
|
182
|
+
if (!newElement) return null;
|
|
183
|
+
|
|
184
|
+
const startId = getNextHcId(iframeDoc);
|
|
185
|
+
assignFreshIds(newElement, startId);
|
|
186
|
+
const newHcId = newElement.getAttribute('data-hc-id');
|
|
187
|
+
|
|
188
|
+
// Apply absolute positioning if position provided
|
|
189
|
+
if (position) {
|
|
190
|
+
newElement.style.position = 'absolute';
|
|
191
|
+
newElement.style.left = Math.round(position.x) + 'px';
|
|
192
|
+
newElement.style.top = Math.round(position.y) + 'px';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Place new element on top of all existing siblings
|
|
196
|
+
// z-index requires a positioned element — ensure at least position: relative
|
|
197
|
+
const topZ = getTopZIndex(targetParent);
|
|
198
|
+
newElement.style.zIndex = String(topZ);
|
|
199
|
+
if (!newElement.style.position || newElement.style.position === 'static') {
|
|
200
|
+
newElement.style.position = 'relative';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const cleanHTML = stripHcIds(newElement.outerHTML);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
description: `Insert <${newElement.tagName.toLowerCase()}>`,
|
|
207
|
+
|
|
208
|
+
execute() {
|
|
209
|
+
targetParent.appendChild(newElement);
|
|
210
|
+
recordChange(newHcId, cleanHTML);
|
|
211
|
+
notifyLayersChanged();
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
undo() {
|
|
215
|
+
newElement.remove();
|
|
216
|
+
recordDeletion(newHcId);
|
|
217
|
+
notifyLayersChanged();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Asset panel ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fetches the asset list from the server and populates the asset grid.
|
|
226
|
+
*/
|
|
227
|
+
export async function loadAssets() {
|
|
228
|
+
const grid = document.getElementById('asset-grid');
|
|
229
|
+
if (!grid) return;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetch('/api/assets');
|
|
233
|
+
const { assets } = await res.json();
|
|
234
|
+
|
|
235
|
+
if (!assets || assets.length === 0) {
|
|
236
|
+
grid.innerHTML = '<p class="panel-placeholder">No assets found</p>';
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
grid.innerHTML = '';
|
|
241
|
+
for (const asset of assets) {
|
|
242
|
+
const wrapper = document.createElement('div');
|
|
243
|
+
wrapper.className = 'asset-item';
|
|
244
|
+
|
|
245
|
+
const thumb = document.createElement('img');
|
|
246
|
+
thumb.src = asset.url;
|
|
247
|
+
thumb.alt = asset.name;
|
|
248
|
+
thumb.title = asset.name;
|
|
249
|
+
thumb.className = 'asset-thumb';
|
|
250
|
+
thumb.dataset.url = asset.url;
|
|
251
|
+
thumb.dataset.type = asset.type;
|
|
252
|
+
thumb.draggable = true;
|
|
253
|
+
|
|
254
|
+
thumb.addEventListener('dragstart', (e) => {
|
|
255
|
+
e.dataTransfer.setData('text/hc-asset-url', asset.url);
|
|
256
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
wrapper.appendChild(thumb);
|
|
260
|
+
grid.appendChild(wrapper);
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.warn('Failed to load assets:', err);
|
|
264
|
+
grid.innerHTML = '<p class="panel-placeholder">Could not load assets</p>';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Component library ─────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fetches the saved component list and populates the component panel.
|
|
272
|
+
*/
|
|
273
|
+
export async function loadComponents() {
|
|
274
|
+
const list = document.getElementById('component-list');
|
|
275
|
+
if (!list) return;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch('/api/components');
|
|
279
|
+
const { components } = await res.json();
|
|
280
|
+
|
|
281
|
+
if (!components || components.length === 0) {
|
|
282
|
+
list.innerHTML = '<p class="panel-placeholder">No components saved</p>';
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
list.innerHTML = '';
|
|
287
|
+
for (const comp of components) {
|
|
288
|
+
const item = document.createElement('div');
|
|
289
|
+
item.className = 'component-item';
|
|
290
|
+
item.title = comp.name;
|
|
291
|
+
item.textContent = comp.name;
|
|
292
|
+
item.draggable = true;
|
|
293
|
+
item.dataset.html = comp.html;
|
|
294
|
+
|
|
295
|
+
item.addEventListener('dragstart', (e) => {
|
|
296
|
+
e.dataTransfer.setData('text/hc-component-html', comp.html);
|
|
297
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
list.appendChild(item);
|
|
301
|
+
}
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.warn('Failed to load components:', err);
|
|
304
|
+
list.innerHTML = '<p class="panel-placeholder">Could not load components</p>';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Saves the selected element as a reusable component.
|
|
310
|
+
* Strips hc-ids from the HTML, prompts for a name, then POSTs to /api/components.
|
|
311
|
+
*
|
|
312
|
+
* @param {Element} element - The element to save
|
|
313
|
+
*/
|
|
314
|
+
async function saveAsComponent(element) {
|
|
315
|
+
if (!element) return;
|
|
316
|
+
|
|
317
|
+
const html = stripHcIds(element.outerHTML);
|
|
318
|
+
const name = window.prompt('Name this component:', element.tagName.toLowerCase() + '-component');
|
|
319
|
+
if (!name) return;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch('/api/components', {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { 'Content-Type': 'application/json' },
|
|
325
|
+
body: JSON.stringify({ name, html }),
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok) throw new Error((await res.json()).error);
|
|
328
|
+
await loadComponents();
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.warn('Failed to save component:', err);
|
|
331
|
+
alert('Could not save component: ' + err.message);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Context menu ──────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
function createContextMenu() {
|
|
338
|
+
const menu = document.createElement('div');
|
|
339
|
+
menu.className = 'context-menu';
|
|
340
|
+
menu.style.display = 'none';
|
|
341
|
+
document.body.appendChild(menu);
|
|
342
|
+
return menu;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function showContextMenu(x, y) {
|
|
346
|
+
if (!_contextMenu) return;
|
|
347
|
+
|
|
348
|
+
_contextMenu.innerHTML = '';
|
|
349
|
+
|
|
350
|
+
const saveItem = document.createElement('div');
|
|
351
|
+
saveItem.className = 'context-menu-item';
|
|
352
|
+
saveItem.textContent = 'Save as Component';
|
|
353
|
+
saveItem.addEventListener('click', () => {
|
|
354
|
+
hideContextMenu();
|
|
355
|
+
const el = getSelectedElement();
|
|
356
|
+
if (el) saveAsComponent(el);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
_contextMenu.appendChild(saveItem);
|
|
360
|
+
_contextMenu.style.left = x + 'px';
|
|
361
|
+
_contextMenu.style.top = y + 'px';
|
|
362
|
+
_contextMenu.style.display = 'block';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function hideContextMenu() {
|
|
366
|
+
if (_contextMenu) _contextMenu.style.display = 'none';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Canvas drop handling ──────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
function onCanvasDragOver(e) {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function onCanvasDrop(e) {
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
|
|
379
|
+
if (!_iframeDoc || !_iframe) return;
|
|
380
|
+
|
|
381
|
+
// Convert drop position to iframe coordinates
|
|
382
|
+
const canvasArea = _canvasArea;
|
|
383
|
+
const canvasRect = canvasArea.getBoundingClientRect();
|
|
384
|
+
const canvasX = e.clientX - canvasRect.left + canvasArea.scrollLeft;
|
|
385
|
+
const canvasY = e.clientY - canvasRect.top + canvasArea.scrollTop;
|
|
386
|
+
const iframePos = canvasToIframe(canvasX, canvasY, _iframe, canvasArea);
|
|
387
|
+
|
|
388
|
+
// Determine target parent: use currently visible .page or body
|
|
389
|
+
const targetParent = getVisiblePage(_iframeDoc);
|
|
390
|
+
|
|
391
|
+
const assetUrl = e.dataTransfer.getData('text/hc-asset-url');
|
|
392
|
+
const componentHtml = e.dataTransfer.getData('text/hc-component-html');
|
|
393
|
+
|
|
394
|
+
let html = null;
|
|
395
|
+
|
|
396
|
+
if (assetUrl) {
|
|
397
|
+
html = `<img src="${assetUrl}" alt="asset" style="max-width:200px;">`;
|
|
398
|
+
} else if (componentHtml) {
|
|
399
|
+
html = componentHtml;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!html) return;
|
|
403
|
+
|
|
404
|
+
const cmd = makeInsertCommand(html, _iframeDoc, targetParent, iframePos);
|
|
405
|
+
if (cmd) {
|
|
406
|
+
push(cmd);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Shape insertion ───────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
function insertShape(shapeName) {
|
|
413
|
+
if (!_iframeDoc || !_iframe || !_canvasArea) return;
|
|
414
|
+
|
|
415
|
+
const html = SHAPE_TEMPLATES[shapeName];
|
|
416
|
+
if (!html) return;
|
|
417
|
+
|
|
418
|
+
const targetParent = getVisiblePage(_iframeDoc);
|
|
419
|
+
|
|
420
|
+
// Position relative to the target slide/page center
|
|
421
|
+
const pageW = targetParent.offsetWidth || 794;
|
|
422
|
+
const pageH = targetParent.offsetHeight || 1123;
|
|
423
|
+
const iframePos = {
|
|
424
|
+
x: Math.round(pageW / 2 - 50),
|
|
425
|
+
y: Math.round(pageH / 4)
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const cmd = makeInsertCommand(html, _iframeDoc, targetParent, iframePos);
|
|
429
|
+
if (cmd) {
|
|
430
|
+
push(cmd);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Image replacement ─────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Triggers image replacement for the currently selected <img> element.
|
|
438
|
+
* Opens a file picker, uploads the file, then swaps src via a Command.
|
|
439
|
+
*/
|
|
440
|
+
function replaceImage() {
|
|
441
|
+
const el = getSelectedElement();
|
|
442
|
+
const hcId = getSelectedHcId();
|
|
443
|
+
if (!el || !hcId || el.tagName.toLowerCase() !== 'img') return;
|
|
444
|
+
|
|
445
|
+
const input = document.createElement('input');
|
|
446
|
+
input.type = 'file';
|
|
447
|
+
input.accept = 'image/*';
|
|
448
|
+
input.style.display = 'none';
|
|
449
|
+
document.body.appendChild(input);
|
|
450
|
+
|
|
451
|
+
input.addEventListener('change', async () => {
|
|
452
|
+
const file = input.files[0];
|
|
453
|
+
if (!file) { input.remove(); return; }
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const formData = new FormData();
|
|
457
|
+
formData.append('file', file);
|
|
458
|
+
|
|
459
|
+
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
460
|
+
if (!res.ok) {
|
|
461
|
+
const text = await res.text();
|
|
462
|
+
let msg = `Upload failed (${res.status})`;
|
|
463
|
+
try { msg = JSON.parse(text).error || msg; } catch (_) {}
|
|
464
|
+
throw new Error(msg);
|
|
465
|
+
}
|
|
466
|
+
const { url } = await res.json();
|
|
467
|
+
|
|
468
|
+
const oldSrc = el.src;
|
|
469
|
+
const cmd = {
|
|
470
|
+
description: 'Replace image',
|
|
471
|
+
execute() {
|
|
472
|
+
el.src = url;
|
|
473
|
+
recordChange(hcId, stripHcIds(el.outerHTML));
|
|
474
|
+
},
|
|
475
|
+
undo() {
|
|
476
|
+
el.src = oldSrc;
|
|
477
|
+
recordChange(hcId, stripHcIds(el.outerHTML));
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
push(cmd);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
console.warn('Image upload failed:', err);
|
|
483
|
+
alert('Could not upload image: ' + err.message);
|
|
484
|
+
} finally {
|
|
485
|
+
input.remove();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
input.click();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Collapsible panel sections ────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
function initCollapsibleSections() {
|
|
495
|
+
document.querySelectorAll('.panel-header-collapsible').forEach(header => {
|
|
496
|
+
header.addEventListener('click', () => {
|
|
497
|
+
const body = header.nextElementSibling;
|
|
498
|
+
if (!body) return;
|
|
499
|
+
const isVisible = body.style.display !== 'none';
|
|
500
|
+
body.style.display = isVisible ? 'none' : 'block';
|
|
501
|
+
header.classList.toggle('collapsed', isVisible);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── Shape picker dropdown ──────────────────────────────────────────────────────
|
|
507
|
+
|
|
508
|
+
let _shapePickerOpen = false;
|
|
509
|
+
|
|
510
|
+
let _shapePickerInited = false;
|
|
511
|
+
export function initShapePicker() {
|
|
512
|
+
if (_shapePickerInited) return;
|
|
513
|
+
_shapePickerInited = true;
|
|
514
|
+
const btn = document.getElementById('btn-shapes');
|
|
515
|
+
const dropdown = document.getElementById('shape-picker');
|
|
516
|
+
if (!btn || !dropdown) return;
|
|
517
|
+
|
|
518
|
+
// Build the dropdown content from SHAPE_CATEGORIES
|
|
519
|
+
dropdown.innerHTML = '';
|
|
520
|
+
for (const cat of SHAPE_CATEGORIES) {
|
|
521
|
+
const catLabel = document.createElement('div');
|
|
522
|
+
catLabel.className = 'shape-picker-cat';
|
|
523
|
+
catLabel.textContent = cat.name;
|
|
524
|
+
dropdown.appendChild(catLabel);
|
|
525
|
+
|
|
526
|
+
const grid = document.createElement('div');
|
|
527
|
+
grid.className = 'shape-picker-grid';
|
|
528
|
+
|
|
529
|
+
for (const shape of cat.shapes) {
|
|
530
|
+
const item = document.createElement('button');
|
|
531
|
+
item.className = 'shape-picker-item';
|
|
532
|
+
item.title = shape.label;
|
|
533
|
+
item.dataset.shape = shape.id;
|
|
534
|
+
item.innerHTML = `<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3">${shape.icon}</svg>`;
|
|
535
|
+
item.addEventListener('click', () => {
|
|
536
|
+
insertShape(shape.id);
|
|
537
|
+
closeShapePicker();
|
|
538
|
+
});
|
|
539
|
+
grid.appendChild(item);
|
|
540
|
+
}
|
|
541
|
+
dropdown.appendChild(grid);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Toggle dropdown
|
|
545
|
+
btn.addEventListener('click', (e) => {
|
|
546
|
+
e.stopPropagation();
|
|
547
|
+
if (_shapePickerOpen) closeShapePicker();
|
|
548
|
+
else openShapePicker();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Close on click outside
|
|
552
|
+
document.addEventListener('mousedown', (e) => {
|
|
553
|
+
if (_shapePickerOpen && !dropdown.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
|
|
554
|
+
closeShapePicker();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
document.addEventListener('keydown', (e) => {
|
|
559
|
+
if (e.key === 'Escape' && _shapePickerOpen) closeShapePicker();
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function openShapePicker() {
|
|
564
|
+
const dropdown = document.getElementById('shape-picker');
|
|
565
|
+
if (!dropdown) return;
|
|
566
|
+
dropdown.style.display = 'block';
|
|
567
|
+
_shapePickerOpen = true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function closeShapePicker() {
|
|
571
|
+
const dropdown = document.getElementById('shape-picker');
|
|
572
|
+
if (!dropdown) return;
|
|
573
|
+
dropdown.style.display = 'none';
|
|
574
|
+
_shapePickerOpen = false;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function initShapeButtons() {
|
|
578
|
+
// Legacy support: wire any remaining data-shape buttons
|
|
579
|
+
document.querySelectorAll('[data-shape]').forEach(btn => {
|
|
580
|
+
if (btn.closest('.shape-picker-dropdown')) return; // handled by picker
|
|
581
|
+
btn.addEventListener('click', () => insertShape(btn.dataset.shape));
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Insert image from file picker ────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
function insertImageFromFile() {
|
|
588
|
+
console.log('[IMAGE] insertImageFromFile called, iframeDoc:', !!_iframeDoc, 'iframe:', !!_iframe, 'canvasArea:', !!_canvasArea);
|
|
589
|
+
if (!_iframeDoc || !_iframe || !_canvasArea) {
|
|
590
|
+
alert('Open an HTML file first, then insert an image.');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const input = document.createElement('input');
|
|
595
|
+
input.type = 'file';
|
|
596
|
+
input.accept = 'image/*';
|
|
597
|
+
input.style.display = 'none';
|
|
598
|
+
document.body.appendChild(input);
|
|
599
|
+
|
|
600
|
+
input.addEventListener('change', async () => {
|
|
601
|
+
const file = input.files[0];
|
|
602
|
+
if (!file) { input.remove(); return; }
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
const formData = new FormData();
|
|
606
|
+
formData.append('file', file);
|
|
607
|
+
|
|
608
|
+
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
609
|
+
if (!res.ok) {
|
|
610
|
+
const text = await res.text();
|
|
611
|
+
let msg = `Upload failed (${res.status})`;
|
|
612
|
+
try { msg = JSON.parse(text).error || msg; } catch (_) {}
|
|
613
|
+
throw new Error(msg);
|
|
614
|
+
}
|
|
615
|
+
const { url } = await res.json();
|
|
616
|
+
|
|
617
|
+
// Use absolute URL so the iframe's <base> tag doesn't redirect it
|
|
618
|
+
const absUrl = new URL(url, window.location.origin).href;
|
|
619
|
+
const html = `<img src="${absUrl}" alt="${file.name}" style="max-width:300px;">`;
|
|
620
|
+
const targetParent = getVisiblePage(_iframeDoc);
|
|
621
|
+
|
|
622
|
+
// Position relative to the target slide/page center
|
|
623
|
+
const pageW = targetParent.offsetWidth || 794;
|
|
624
|
+
const pageH = targetParent.offsetHeight || 1123;
|
|
625
|
+
// Place near center of the page (offset slightly so it's visible)
|
|
626
|
+
const iframePos = {
|
|
627
|
+
x: Math.round(pageW / 2 - 150),
|
|
628
|
+
y: Math.round(pageH / 4)
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const cmd = makeInsertCommand(html, _iframeDoc, targetParent, iframePos);
|
|
632
|
+
if (cmd) push(cmd);
|
|
633
|
+
} catch (err) {
|
|
634
|
+
console.warn('Image insert failed:', err);
|
|
635
|
+
alert('Could not insert image: ' + err.message);
|
|
636
|
+
} finally {
|
|
637
|
+
input.remove();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
input.click();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── Canvas drop event wiring ──────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
function initCanvasDrop() {
|
|
647
|
+
const canvasArea = document.getElementById('canvas-area');
|
|
648
|
+
if (!canvasArea) return;
|
|
649
|
+
canvasArea.addEventListener('dragover', onCanvasDragOver);
|
|
650
|
+
canvasArea.addEventListener('drop', onCanvasDrop);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ── Context menu wiring (iframe contextmenu event) ────────────────────────────
|
|
654
|
+
|
|
655
|
+
function initContextMenu(iframeDoc) {
|
|
656
|
+
_contextMenu = _contextMenu || createContextMenu();
|
|
657
|
+
|
|
658
|
+
// Listen for contextmenu inside the iframe
|
|
659
|
+
iframeDoc.addEventListener('contextmenu', (e) => {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
|
|
662
|
+
// Only show if an element is selected
|
|
663
|
+
const selected = getSelectedElement();
|
|
664
|
+
if (!selected) return;
|
|
665
|
+
|
|
666
|
+
// Convert iframe viewport coords to editor viewport coords
|
|
667
|
+
const iframeRect = _iframe.getBoundingClientRect();
|
|
668
|
+
const menuX = iframeRect.left + e.clientX;
|
|
669
|
+
const menuY = iframeRect.top + e.clientY;
|
|
670
|
+
|
|
671
|
+
showContextMenu(menuX, menuY);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Hide on click outside
|
|
675
|
+
document.addEventListener('click', (e) => {
|
|
676
|
+
if (_contextMenu && !_contextMenu.contains(e.target)) {
|
|
677
|
+
hideContextMenu();
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
document.addEventListener('keydown', (e) => {
|
|
682
|
+
if (e.key === 'Escape') hideContextMenu();
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── Replace image button in properties panel ─────────────────────────────────
|
|
687
|
+
|
|
688
|
+
function updateReplaceImageButton() {
|
|
689
|
+
// Expose replaceImage on window so the properties panel can call it
|
|
690
|
+
window._hcReplaceImage = replaceImage;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Initialises the asset/component browser, shapes, and image replacement.
|
|
697
|
+
*
|
|
698
|
+
* @param {HTMLIFrameElement} iframe
|
|
699
|
+
* @param {Document} iframeDoc
|
|
700
|
+
* @param {HTMLElement} canvasArea
|
|
701
|
+
*/
|
|
702
|
+
export function initAssets(iframe, iframeDoc, canvasArea) {
|
|
703
|
+
_iframe = iframe;
|
|
704
|
+
_iframeDoc = iframeDoc;
|
|
705
|
+
_canvasArea = canvasArea;
|
|
706
|
+
|
|
707
|
+
initCollapsibleSections();
|
|
708
|
+
initShapePicker();
|
|
709
|
+
initShapeButtons();
|
|
710
|
+
initCanvasDrop();
|
|
711
|
+
initContextMenu(iframeDoc);
|
|
712
|
+
updateReplaceImageButton();
|
|
713
|
+
|
|
714
|
+
// Wire "Insert Image" toolbar button (once only)
|
|
715
|
+
const btnInsertImage = document.getElementById('btn-insert-image');
|
|
716
|
+
if (btnInsertImage && !btnInsertImage._hcBound) {
|
|
717
|
+
btnInsertImage._hcBound = true;
|
|
718
|
+
btnInsertImage.addEventListener('click', () => insertImageFromFile());
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Load assets and components
|
|
722
|
+
loadAssets();
|
|
723
|
+
loadComponents();
|
|
724
|
+
}
|