Skip to content

Commit 0fd1697

Browse files
JacobCoffeeclaude
andcommitted
feat(layer): add layer panel UI with real-time management
Implement LayerManager JavaScript module for canvas editor: - Layer list with type icons, visibility/lock toggles - Layer selection with visual feedback - Action buttons: bring to front, move up/down, send to back, delete - Disabled state for locked elements - WebSocket integration for real-time sync - Auto-sorting by z-index (highest on top) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent f71bbea commit 0fd1697

File tree

1 file changed

+282
-4
lines changed

1 file changed

+282
-4
lines changed

src/scribbl_py/templates/canvas_editor.html

Lines changed: 282 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,27 +180,39 @@ <h3 class="font-semibold">Layers</h3>
180180
</div>
181181
<div class="flex-1 overflow-auto p-2" id="layers-list">
182182
<!-- Layers will be populated dynamically -->
183-
<div class="text-center text-base-content/50 py-8">
183+
<div id="layers-empty" class="text-center text-base-content/50 py-8">
184184
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
185185
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
186186
</svg>
187187
<p class="text-sm">No elements yet</p>
188188
<p class="text-xs">Start drawing to add layers</p>
189189
</div>
190+
<!-- Layer items container -->
191+
<div id="layers-container" class="space-y-1 hidden"></div>
190192
</div>
191193
<div class="p-2 border-t border-base-300">
192194
<div class="btn-group w-full">
193-
<button class="btn btn-sm flex-1" title="Move layer up">
195+
<button class="btn btn-sm flex-1" id="layer-bring-front" title="Bring to front" disabled>
196+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
197+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 11l7-7 7 7M5 19l7-7 7 7"/>
198+
</svg>
199+
</button>
200+
<button class="btn btn-sm flex-1" id="layer-move-up" title="Move layer up" disabled>
194201
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
195202
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
196203
</svg>
197204
</button>
198-
<button class="btn btn-sm flex-1" title="Move layer down">
205+
<button class="btn btn-sm flex-1" id="layer-move-down" title="Move layer down" disabled>
199206
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
200207
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
201208
</svg>
202209
</button>
203-
<button class="btn btn-sm flex-1" title="Delete layer">
210+
<button class="btn btn-sm flex-1" id="layer-send-back" title="Send to back" disabled>
211+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
212+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 13l-7 7-7-7M19 5l-7 7-7-7"/>
213+
</svg>
214+
</button>
215+
<button class="btn btn-sm flex-1 btn-error" id="layer-delete" title="Delete layer" disabled>
204216
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
205217
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
206218
</svg>
@@ -213,6 +225,242 @@ <h3 class="font-semibold">Layers</h3>
213225

214226
{% block scripts %}
215227
<script>
228+
// Layer Management System
229+
const LayerManager = {
230+
elements: [],
231+
selectedId: null,
232+
ws: null,
233+
234+
init(websocket) {
235+
this.ws = websocket;
236+
this.bindEvents();
237+
},
238+
239+
bindEvents() {
240+
document.getElementById('layer-bring-front')?.addEventListener('click', () => this.bringToFront());
241+
document.getElementById('layer-move-up')?.addEventListener('click', () => this.moveUp());
242+
document.getElementById('layer-move-down')?.addEventListener('click', () => this.moveDown());
243+
document.getElementById('layer-send-back')?.addEventListener('click', () => this.sendToBack());
244+
document.getElementById('layer-delete')?.addEventListener('click', () => this.deleteSelected());
245+
},
246+
247+
updateElements(elements) {
248+
this.elements = elements.sort((a, b) => b.z_index - a.z_index);
249+
this.render();
250+
},
251+
252+
addElement(element) {
253+
this.elements.push(element);
254+
this.elements.sort((a, b) => b.z_index - a.z_index);
255+
this.render();
256+
},
257+
258+
removeElement(elementId) {
259+
this.elements = this.elements.filter(e => e.id !== elementId);
260+
if (this.selectedId === elementId) {
261+
this.selectedId = null;
262+
}
263+
this.render();
264+
},
265+
266+
updateElement(elementId, updates) {
267+
const idx = this.elements.findIndex(e => e.id === elementId);
268+
if (idx !== -1) {
269+
this.elements[idx] = { ...this.elements[idx], ...updates };
270+
this.elements.sort((a, b) => b.z_index - a.z_index);
271+
this.render();
272+
}
273+
},
274+
275+
selectElement(elementId) {
276+
this.selectedId = elementId;
277+
this.render();
278+
this.updateButtonStates();
279+
},
280+
281+
render() {
282+
const container = document.getElementById('layers-container');
283+
const empty = document.getElementById('layers-empty');
284+
if (!container || !empty) return;
285+
286+
if (this.elements.length === 0) {
287+
empty.classList.remove('hidden');
288+
container.classList.add('hidden');
289+
return;
290+
}
291+
292+
empty.classList.add('hidden');
293+
container.classList.remove('hidden');
294+
container.innerHTML = '';
295+
296+
this.elements.forEach(el => {
297+
const item = this.createLayerItem(el);
298+
container.appendChild(item);
299+
});
300+
301+
this.updateButtonStates();
302+
},
303+
304+
createLayerItem(element) {
305+
const item = document.createElement('div');
306+
item.className = `layer-item flex items-center gap-2 p-2 rounded cursor-pointer hover:bg-base-200 ${this.selectedId === element.id ? 'bg-primary/20 border border-primary' : 'border border-transparent'}`;
307+
item.dataset.elementId = element.id;
308+
309+
// Element type icon
310+
const typeIcon = this.getTypeIcon(element.element_type);
311+
312+
// Visibility button
313+
const visibleBtn = document.createElement('button');
314+
visibleBtn.className = `btn btn-ghost btn-xs ${element.visible ? '' : 'opacity-40'}`;
315+
visibleBtn.title = element.visible ? 'Hide layer' : 'Show layer';
316+
visibleBtn.innerHTML = element.visible
317+
? '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>'
318+
: '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/></svg>';
319+
visibleBtn.addEventListener('click', (e) => {
320+
e.stopPropagation();
321+
this.toggleVisibility(element.id);
322+
});
323+
324+
// Lock button
325+
const lockBtn = document.createElement('button');
326+
lockBtn.className = `btn btn-ghost btn-xs ${element.locked ? 'text-warning' : ''}`;
327+
lockBtn.title = element.locked ? 'Unlock layer' : 'Lock layer';
328+
lockBtn.innerHTML = element.locked
329+
? '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>'
330+
: '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/></svg>';
331+
lockBtn.addEventListener('click', (e) => {
332+
e.stopPropagation();
333+
this.toggleLock(element.id);
334+
});
335+
336+
// Element name/label
337+
const label = document.createElement('span');
338+
label.className = `flex-1 text-sm truncate ${element.visible ? '' : 'opacity-40'} ${element.locked ? 'italic' : ''}`;
339+
label.textContent = this.getElementLabel(element);
340+
341+
item.innerHTML = typeIcon;
342+
item.appendChild(visibleBtn);
343+
item.appendChild(lockBtn);
344+
item.appendChild(label);
345+
346+
item.addEventListener('click', () => this.selectElement(element.id));
347+
348+
return item;
349+
},
350+
351+
getTypeIcon(type) {
352+
const icons = {
353+
'stroke': '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>',
354+
'shape': '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h12a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"/></svg>',
355+
'text': '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M8 6v14M16 6v14"/></svg>',
356+
'group': '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>'
357+
};
358+
return icons[type] || icons['stroke'];
359+
},
360+
361+
getElementLabel(element) {
362+
const typeLabels = { stroke: 'Stroke', shape: 'Shape', text: 'Text', group: 'Group' };
363+
const baseLabel = typeLabels[element.element_type] || 'Element';
364+
if (element.element_type === 'text' && element.content) {
365+
return element.content.substring(0, 20) + (element.content.length > 20 ? '...' : '');
366+
}
367+
if (element.element_type === 'group' && element.name) {
368+
return element.name;
369+
}
370+
return `${baseLabel} ${element.z_index}`;
371+
},
372+
373+
updateButtonStates() {
374+
const hasSelection = this.selectedId !== null;
375+
const selectedElement = this.elements.find(e => e.id === this.selectedId);
376+
const isLocked = selectedElement?.locked || false;
377+
378+
['layer-bring-front', 'layer-move-up', 'layer-move-down', 'layer-send-back'].forEach(id => {
379+
const btn = document.getElementById(id);
380+
if (btn) btn.disabled = !hasSelection || isLocked;
381+
});
382+
383+
const deleteBtn = document.getElementById('layer-delete');
384+
if (deleteBtn) deleteBtn.disabled = !hasSelection;
385+
},
386+
387+
// WebSocket actions
388+
toggleVisibility(elementId) {
389+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
390+
this.ws.send(JSON.stringify({
391+
type: 'layer_action',
392+
action: 'toggle_visibility',
393+
element_id: elementId
394+
}));
395+
}
396+
},
397+
398+
toggleLock(elementId) {
399+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
400+
this.ws.send(JSON.stringify({
401+
type: 'layer_action',
402+
action: 'toggle_lock',
403+
element_id: elementId
404+
}));
405+
}
406+
},
407+
408+
bringToFront() {
409+
if (!this.selectedId) return;
410+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
411+
this.ws.send(JSON.stringify({
412+
type: 'layer_action',
413+
action: 'bring_to_front',
414+
element_id: this.selectedId
415+
}));
416+
}
417+
},
418+
419+
moveUp() {
420+
if (!this.selectedId) return;
421+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
422+
this.ws.send(JSON.stringify({
423+
type: 'layer_action',
424+
action: 'move_forward',
425+
element_id: this.selectedId
426+
}));
427+
}
428+
},
429+
430+
moveDown() {
431+
if (!this.selectedId) return;
432+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
433+
this.ws.send(JSON.stringify({
434+
type: 'layer_action',
435+
action: 'move_backward',
436+
element_id: this.selectedId
437+
}));
438+
}
439+
},
440+
441+
sendToBack() {
442+
if (!this.selectedId) return;
443+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
444+
this.ws.send(JSON.stringify({
445+
type: 'layer_action',
446+
action: 'send_to_back',
447+
element_id: this.selectedId
448+
}));
449+
}
450+
},
451+
452+
deleteSelected() {
453+
if (!this.selectedId) return;
454+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
455+
this.ws.send(JSON.stringify({
456+
type: 'layer_action',
457+
action: 'delete',
458+
element_id: this.selectedId
459+
}));
460+
}
461+
}
462+
};
463+
216464
// Initialize canvas-specific WebSocket connection
217465
document.addEventListener('DOMContentLoaded', function() {
218466
const canvas = document.getElementById('drawing-canvas');
@@ -221,6 +469,36 @@ <h3 class="font-semibold">Layers</h3>
221469
const container = canvas.parentElement;
222470
canvas.width = container.offsetWidth;
223471
canvas.height = container.offsetHeight;
472+
473+
// Initialize WebSocket and layer manager
474+
const canvasId = canvas.dataset.canvasId;
475+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
476+
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/canvas/${canvasId}`);
477+
478+
ws.onopen = () => {
479+
LayerManager.init(ws);
480+
// Request initial elements
481+
ws.send(JSON.stringify({ type: 'get_elements' }));
482+
};
483+
484+
ws.onmessage = (event) => {
485+
const data = JSON.parse(event.data);
486+
487+
switch(data.type) {
488+
case 'elements_list':
489+
LayerManager.updateElements(data.elements || []);
490+
break;
491+
case 'element_added':
492+
LayerManager.addElement(data.element);
493+
break;
494+
case 'element_updated':
495+
LayerManager.updateElement(data.element_id, data.updates);
496+
break;
497+
case 'element_deleted':
498+
LayerManager.removeElement(data.element_id);
499+
break;
500+
}
501+
};
224502
}
225503
});
226504
</script>

0 commit comments

Comments
 (0)