PluginProbe ʕ •ᴥ•ʔ
Event Tickets with Ticket Scanner / 3.1.2
Event Tickets with Ticket Scanner v3.1.2
3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.4 trunk 2.6.0 2.7.0 2.7.1 2.7.10 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.10 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7 2.9.8 2.9.9 3.0.0 3.0.1 3.0.2 3.0.3
event-tickets-with-ticket-scanner / js / seating_designer.js
event-tickets-with-ticket-scanner / js Last commit date
congress-admin.js 1 week ago congress-frontend.js 1 week ago seating_admin.js 1 week ago seating_designer.js 1 week ago seating_frontend.js 1 week ago
seating_designer.js
4719 lines
1 /**
2 * Seating Plan Visual Designer
3 *
4 * SVG-based visual editor for seating plans.
5 * Handles shapes, lines, labels, and seats with drag & drop.
6 *
7 * @package Event_Tickets_With_Ticket_Scanner
8 * @since 2.8.0
9 */
10
11 (function($) {
12 'use strict';
13
14 // Designer instance
15 window.SasoSeatingDesigner = null;
16
17 /**
18 * Seating Designer Class
19 *
20 * @param {Object} config Configuration object
21 */
22 function SeatingDesigner(config) {
23 this.config = $.extend({
24 container: '#saso-designer-container',
25 planId: 0,
26 canvasWidth: 800,
27 canvasHeight: 600,
28 backgroundColor: '#ffffff',
29 backgroundImage: '',
30 gridSize: 10,
31 snapToGrid: true,
32 colors: {
33 available: '#4CAF50',
34 reserved: '#FFC107',
35 booked: '#F44336',
36 selected: '#2196F3'
37 },
38 ajaxUrl: '',
39 ajaxAction: '',
40 nonce: '',
41 i18n: {}
42 }, config);
43
44 // State
45 this.svg = null;
46 this.layers = {};
47 this.elements = {
48 seats: [],
49 decorations: [],
50 lines: [],
51 labels: []
52 };
53 this.selectedElement = null;
54 this.selectedElements = []; // Array for multi-select
55 this.currentTool = 'select';
56 this.isDragging = false;
57 this.justFinishedDragging = false; // Flag to ignore click after drag
58 this.dragStart = { x: 0, y: 0 };
59 this.hasUnsavedChanges = false;
60 this.isDrawingLine = false;
61 this.lineStart = null;
62
63 // Marquee selection state
64 this.isMarqueeSelecting = false;
65 this.marqueeStart = { x: 0, y: 0 };
66 this.marqueeRect = null;
67
68 // Resize state
69 this.isResizing = false;
70 this.resizeHandle = null;
71 this.resizeStart = { x: 0, y: 0, width: 0, height: 0, r: 0 };
72
73 // Zoom and pan state
74 this.zoom = 1;
75 this.minZoom = 0.25;
76 this.maxZoom = 4;
77 this.pan = { x: 0, y: 0 };
78 this.isPanning = false;
79 this.panStart = { x: 0, y: 0, panX: 0, panY: 0 };
80
81 // Version toggle state (draft/published preview)
82 this.viewingPublished = false;
83 this.plan = null; // Das komplette Sitzplan-Objekt (von getFullPlan)
84 this.spaceKeyHeld = false; // For Space+drag panning
85
86 // Bulk insert state
87 this.bulkInsertPreview = null; // SVG group for preview
88 this.bulkInsertStart = null; // Starting position
89
90 // Element ID counter
91 this.nextId = 1;
92
93 // Initialize
94 this.init();
95 }
96
97 /**
98 * Initialize the designer
99 */
100 SeatingDesigner.prototype.init = function() {
101 this.createCanvas();
102 this.createToolbar();
103 this.createPropertiesPanel();
104 this.createActionsPanel();
105 this.bindEvents();
106 this.bindVersionToggleHandlers();
107 // Data is loaded by openDesigner() via applyLoadedData(), not here
108 };
109
110 // =========================================================================
111 // Canvas Creation
112 // =========================================================================
113
114 /**
115 * Create SVG canvas with layers
116 */
117 SeatingDesigner.prototype.createCanvas = function() {
118 var self = this;
119 var $container = $(this.config.container);
120
121 // Create wrapper
122 var $wrapper = $('<div class="saso-designer-canvas-wrapper"></div>');
123 $wrapper.css({
124 width: this.config.canvasWidth + 'px',
125 height: this.config.canvasHeight + 'px',
126 position: 'relative',
127 overflow: 'hidden',
128 border: '1px solid #c3c4c7',
129 borderRadius: '4px',
130 backgroundColor: this.config.backgroundColor
131 });
132
133 // Create SVG
134 var svgNS = 'http://www.w3.org/2000/svg';
135 this.svg = document.createElementNS(svgNS, 'svg');
136 this.svg.setAttribute('width', this.config.canvasWidth);
137 this.svg.setAttribute('height', this.config.canvasHeight);
138 this.svg.setAttribute('class', 'saso-designer-svg');
139 this.svg.style.display = 'block';
140
141 // Initialize viewBox (zoom and pan start at 1x, 0,0)
142 this.zoom = 1;
143 this.pan = { x: 0, y: 0 };
144 this.svg.setAttribute('viewBox', '0 0 ' + this.config.canvasWidth + ' ' + this.config.canvasHeight);
145
146 // Create layers (order matters for z-index)
147 var layerNames = ['background', 'lines', 'decorations', 'seats', 'labels'];
148 layerNames.forEach(function(name, index) {
149 var group = document.createElementNS(svgNS, 'g');
150 group.setAttribute('class', 'saso-layer-' + name);
151 group.setAttribute('data-layer', name);
152 self.svg.appendChild(group);
153 self.layers[name] = group;
154 });
155
156 // Add background rect
157 this.createBackgroundRect();
158
159 // Add SVG to wrapper
160 $wrapper.append(this.svg);
161 $container.find('.saso-designer-canvas-area').html('').append($wrapper);
162
163 // Store reference
164 this.$canvas = $wrapper;
165
166 // Rebind canvas events (important when canvas is recreated)
167 this.bindCanvasEvents();
168 };
169
170 /**
171 * Create background rectangle
172 */
173 SeatingDesigner.prototype.createBackgroundRect = function() {
174 var svgNS = 'http://www.w3.org/2000/svg';
175 var rect = document.createElementNS(svgNS, 'rect');
176 rect.setAttribute('x', 0);
177 rect.setAttribute('y', 0);
178 rect.setAttribute('width', this.config.canvasWidth);
179 rect.setAttribute('height', this.config.canvasHeight);
180 rect.setAttribute('fill', this.config.backgroundColor);
181 rect.setAttribute('class', 'saso-background-rect');
182 this.layers.background.appendChild(rect);
183
184 // Add background image if set
185 if (this.config.backgroundImage) {
186 this.setBackgroundImage(this.config.backgroundImage);
187 }
188 };
189
190 /**
191 * Set background image
192 *
193 * @param {string} url Image URL
194 */
195 SeatingDesigner.prototype.setBackgroundImage = function(url) {
196 var svgNS = 'http://www.w3.org/2000/svg';
197
198 // Remove existing image
199 var existing = this.layers.background.querySelector('.saso-background-image');
200 if (existing) {
201 existing.remove();
202 }
203
204 if (!url) return;
205
206 // Get fit and align settings
207 var fit = this.config.backgroundImageFit || 'contain';
208 var align = this.config.backgroundImageAlign || 'center';
209 var aspectRatio;
210
211 switch (fit) {
212 case 'contain':
213 aspectRatio = this.getPreserveAspectRatio(align, 'meet');
214 break;
215 case 'cover':
216 aspectRatio = this.getPreserveAspectRatio(align, 'slice');
217 break;
218 case 'stretch':
219 aspectRatio = 'none';
220 break;
221 case 'original':
222 aspectRatio = this.getPreserveAspectRatio(align, 'meet');
223 break;
224 default:
225 aspectRatio = 'xMidYMid meet';
226 }
227
228 var image = document.createElementNS(svgNS, 'image');
229 image.setAttribute('x', 0);
230 image.setAttribute('y', 0);
231 image.setAttribute('width', this.config.canvasWidth);
232 image.setAttribute('height', this.config.canvasHeight);
233 image.setAttribute('href', url);
234 image.setAttribute('preserveAspectRatio', aspectRatio);
235 image.setAttribute('class', 'saso-background-image');
236 this.layers.background.appendChild(image);
237
238 this.config.backgroundImage = url;
239 };
240
241 // =========================================================================
242 // Toolbar
243 // =========================================================================
244
245 /**
246 * Create toolbar
247 */
248 SeatingDesigner.prototype.createToolbar = function() {
249 var self = this;
250 var $container = $(this.config.container);
251
252 var tools = [
253 { id: 'select', icon: 'dashicons-move', label: this.config.i18n.toolSelect || 'Select' },
254 { id: 'seat', icon: 'dashicons-tickets-alt', label: this.config.i18n.toolSeat || 'Seat', primary: true },
255 { id: 'row', icon: 'dashicons-grid-view', label: this.config.i18n.toolRow || 'Row' },
256 { id: 'rect', icon: 'dashicons-screenoptions', label: this.config.i18n.toolRect || 'Rectangle' },
257 { id: 'circle', icon: 'dashicons-marker', label: this.config.i18n.toolCircle || 'Circle' },
258 { id: 'line', icon: 'dashicons-minus', label: this.config.i18n.toolLine || 'Line' },
259 { id: 'text', icon: 'dashicons-editor-textcolor', label: this.config.i18n.toolText || 'Text' },
260 { id: 'delete', icon: 'dashicons-trash', label: this.config.i18n.toolDelete || 'Delete' }
261 ];
262
263 var $toolbar = $('<div class="saso-designer-toolbar"></div>');
264
265 tools.forEach(function(tool) {
266 var btnClass = 'saso-tool-btn';
267 if (tool.primary) btnClass += ' saso-tool-primary';
268
269 var $btn = $('<button type="button" class="' + btnClass + '" data-tool="' + tool.id + '" title="' + tool.label + '">' +
270 '<span class="dashicons ' + tool.icon + '"></span>' +
271 '<span class="tool-label">' + tool.label + '</span>' +
272 '</button>');
273
274 if (tool.id === self.currentTool) {
275 $btn.addClass('active');
276 }
277
278 $toolbar.append($btn);
279 });
280
281 // Add separator and additional controls
282 $toolbar.append('<span class="toolbar-separator"></span>');
283
284 // Grid snap toggle
285 var $gridSnap = $('<label class="saso-grid-snap">' +
286 '<input type="checkbox" ' + (this.config.snapToGrid ? 'checked' : '') + '> ' +
287 (this.config.i18n.snapToGrid || 'Snap to Grid') +
288 '</label>');
289 $toolbar.append($gridSnap);
290
291 // Help button
292 var $helpBtn = $('<button type="button" class="saso-tool-btn saso-help-btn" title="' + (this.config.i18n.help || 'Help') + '">' +
293 '<span class="dashicons dashicons-editor-help"></span>' +
294 '</button>');
295 $toolbar.append($helpBtn);
296
297 // Zoom controls separator
298 $toolbar.append('<span class="toolbar-separator"></span>');
299
300 // Zoom out button
301 var $zoomOut = $('<button type="button" class="saso-tool-btn saso-zoom-out" title="' + (this.config.i18n.zoomOut || 'Zoom Out') + '">' +
302 '<span class="dashicons dashicons-minus"></span>' +
303 '</button>');
304 $toolbar.append($zoomOut);
305
306 // Zoom level display
307 var $zoomLevel = $('<span class="saso-zoom-level" title="' + (this.config.i18n.zoomLevel || 'Zoom Level') + '">100%</span>');
308 $toolbar.append($zoomLevel);
309
310 // Zoom in button
311 var $zoomIn = $('<button type="button" class="saso-tool-btn saso-zoom-in" title="' + (this.config.i18n.zoomIn || 'Zoom In') + '">' +
312 '<span class="dashicons dashicons-plus"></span>' +
313 '</button>');
314 $toolbar.append($zoomIn);
315
316 // Fit to view button
317 var $fitView = $('<button type="button" class="saso-tool-btn saso-fit-view" title="' + (this.config.i18n.fitToView || 'Fit to View') + '">' +
318 '<span class="dashicons dashicons-fullscreen-alt"></span>' +
319 '</button>');
320 $toolbar.append($fitView);
321
322 // Reset zoom button
323 var $resetZoom = $('<button type="button" class="saso-tool-btn saso-reset-zoom" title="' + (this.config.i18n.resetZoom || 'Reset Zoom (100%)') + '">' +
324 '<span class="dashicons dashicons-image-rotate"></span>' +
325 '</button>');
326 $toolbar.append($resetZoom);
327
328 $container.find('.saso-designer-toolbar-area').html('').append($toolbar);
329 };
330
331 /**
332 * Show help modal
333 */
334 SeatingDesigner.prototype.showHelpModal = function() {
335 var i18n = this.config.i18n;
336 var html = '<div class="saso-modal saso-help-modal" style="display:flex;">' +
337 '<div class="saso-modal-content" style="max-width:600px;">' +
338 '<div class="saso-modal-header">' +
339 '<h3 class="saso-modal-title">' + (i18n.helpTitle || 'Visual Designer - Help') + '</h3>' +
340 '<button type="button" class="saso-modal-close">&times;</button>' +
341 '</div>' +
342 '<div class="saso-modal-body">' +
343
344 '<h4>' + (i18n.helpToolsTitle || 'Tools') + '</h4>' +
345 '<table class="saso-help-table">' +
346 '<tr><td><span class="dashicons dashicons-move"></span> <strong>Select</strong></td>' +
347 '<td>' + (i18n.helpSelectTool || 'Select and drag elements to move them') + '</td></tr>' +
348
349 '<tr><td><span class="dashicons dashicons-tickets-alt"></span> <strong>Seat</strong></td>' +
350 '<td>' + (i18n.helpSeatTool || 'Quick-add a bookable seat (creates rectangle with "Is Seat" enabled)') + '</td></tr>' +
351
352 '<tr><td><span class="dashicons dashicons-grid-view"></span> <strong>Row</strong></td>' +
353 '<td>' + (i18n.helpRowTool || 'Bulk insert a row or grid of seats with auto-numbering') + '</td></tr>' +
354
355 '<tr><td><span class="dashicons dashicons-screenoptions"></span> <strong>Rectangle</strong></td>' +
356 '<td>' + (i18n.helpRectTool || 'Add a rectangle shape (decoration or convert to seat via Properties)') + '</td></tr>' +
357
358 '<tr><td><span class="dashicons dashicons-marker"></span> <strong>Circle</strong></td>' +
359 '<td>' + (i18n.helpCircleTool || 'Add a circle shape (decoration or convert to seat)') + '</td></tr>' +
360
361 '<tr><td><span class="dashicons dashicons-minus"></span> <strong>Line</strong></td>' +
362 '<td>' + (i18n.helpLineTool || 'Click twice to draw a line from point A to B') + '</td></tr>' +
363
364 '<tr><td><span class="dashicons dashicons-editor-textcolor"></span> <strong>Text</strong></td>' +
365 '<td>' + (i18n.helpTextTool || 'Add text labels (e.g., "Stage", "Exit", row numbers)') + '</td></tr>' +
366
367 '<tr><td><span class="dashicons dashicons-trash"></span> <strong>Delete</strong></td>' +
368 '<td>' + (i18n.helpDeleteTool || 'Click on elements to delete them') + '</td></tr>' +
369 '</table>' +
370
371 '<h4>' + (i18n.helpPropertiesTitle || 'Properties Panel') + '</h4>' +
372 '<p>' + (i18n.helpPropertiesDesc || 'Select an element to edit its properties. Enable "Is Seat" to make a shape bookable. Seats require a unique Seat ID (e.g., "A-1") which is shown on tickets.') + '</p>' +
373
374 '<h4>' + (i18n.helpKeyboardTitle || 'Keyboard Shortcuts') + '</h4>' +
375 '<table class="saso-help-table">' +
376 '<tr><td><code>Delete</code> / <code>Backspace</code></td><td>' + (i18n.helpDeleteKey || 'Delete selected element(s)') + '</td></tr>' +
377 '<tr><td><code>Escape</code></td><td>' + (i18n.helpEscapeKey || 'Deselect all') + '</td></tr>' +
378 '<tr><td><code>Ctrl+A</code></td><td>' + (i18n.helpSelectAll || 'Select all elements') + '</td></tr>' +
379 '<tr><td><code>Ctrl+S</code></td><td>' + (i18n.helpSaveKey || 'Save draft') + '</td></tr>' +
380 '<tr><td><code>Shift+Click</code></td><td>' + (i18n.helpShiftClick || 'Add/remove from selection') + '</td></tr>' +
381 '<tr><td>' + (i18n.helpMarquee || 'Drag on canvas') + '</td><td>' + (i18n.helpMarqueeDesc || 'Marquee select multiple elements') + '</td></tr>' +
382 '</table>' +
383
384 '<h4>' + (i18n.helpZoomTitle || 'Zoom & Pan') + '</h4>' +
385 '<table class="saso-help-table">' +
386 '<tr><td><code>Mouse Wheel</code></td><td>' + (i18n.helpZoomWheel || 'Zoom in/out at cursor position') + '</td></tr>' +
387 '<tr><td><code>+</code> / <code>-</code></td><td>' + (i18n.helpZoomKeys || 'Zoom in / Zoom out') + '</td></tr>' +
388 '<tr><td><code>0</code></td><td>' + (i18n.helpZoomReset || 'Reset zoom to 100%') + '</td></tr>' +
389 '<tr><td>' + (i18n.helpRightMouse || 'Right Mouse Drag') + '</td><td>' + (i18n.helpRightMouseDesc || 'Pan/scroll the canvas') + '</td></tr>' +
390 '<tr><td><code>Space</code> + ' + (i18n.helpDrag || 'Drag') + '</td><td>' + (i18n.helpSpaceDrag || 'Pan/scroll the canvas (hold Space, then drag)') + '</td></tr>' +
391 '<tr><td>' + (i18n.helpMiddleMouse || 'Middle Mouse') + '</td><td>' + (i18n.helpMiddleMouseDesc || 'Pan/scroll the canvas') + '</td></tr>' +
392 '</table>' +
393
394 '<h4>' + (i18n.helpWorkflowTitle || 'Workflow') + '</h4>' +
395 '<ol>' +
396 '<li>' + (i18n.helpStep1 || 'Add seats using the Seat tool or create shapes and enable "Is Seat"') + '</li>' +
397 '<li>' + (i18n.helpStep2 || 'Use Text tool to add labels like "Stage", "Row A", etc.') + '</li>' +
398 '<li>' + (i18n.helpStep3 || 'Save Draft to save your work without publishing') + '</li>' +
399 '<li>' + (i18n.helpStep4 || 'Publish to make changes visible to customers') + '</li>' +
400 '</ol>' +
401
402 '</div>' +
403 '</div>' +
404 '</div>';
405
406 var $modal = $(html);
407 $modal.on('click', '.saso-modal-close', function() { $modal.remove(); });
408 $modal.on('click', function(e) { if (e.target === this) $modal.remove(); });
409 $('body').append($modal);
410 };
411
412 // =========================================================================
413 // Bulk Insert (Row/Grid)
414 // =========================================================================
415
416 /**
417 * Show bulk insert modal for row/grid creation
418 *
419 * @param {number} startX Starting X position
420 * @param {number} startY Starting Y position
421 */
422 SeatingDesigner.prototype.showBulkInsertModal = function(startX, startY) {
423 var self = this;
424 var i18n = this.config.i18n;
425
426 // Store starting position
427 this.bulkInsertStart = { x: startX, y: startY };
428
429 // Default values
430 var defaults = {
431 count: 10,
432 rowLabel: this.getNextRowLabel(),
433 startNumber: 1,
434 spacing: 35,
435 direction: 'horizontal',
436 rows: 1,
437 rowSpacing: 40
438 };
439
440 var html = '<div class="saso-modal saso-bulk-insert-modal" style="display:flex;">' +
441 '<div class="saso-modal-content" style="max-width:450px;">' +
442 '<div class="saso-modal-header">' +
443 '<h3 class="saso-modal-title">' + (i18n.bulkInsertTitle || 'Insert Seat Row') + '</h3>' +
444 '<button type="button" class="saso-modal-close">&times;</button>' +
445 '</div>' +
446 '<div class="saso-modal-body">' +
447
448 '<div class="saso-bulk-form">' +
449 // Row Label
450 '<div class="saso-bulk-row">' +
451 '<label>' + (i18n.rowLabel || 'Row Label') + '</label>' +
452 '<input type="text" id="saso-bulk-row-label" value="' + defaults.rowLabel + '" placeholder="A, B, 1, 2...">' +
453 '</div>' +
454
455 // Number of seats
456 '<div class="saso-bulk-row">' +
457 '<label>' + (i18n.seatsPerRow || 'Seats per Row') + '</label>' +
458 '<input type="number" id="saso-bulk-count" value="' + defaults.count + '" min="1" max="100">' +
459 '</div>' +
460
461 // Starting number
462 '<div class="saso-bulk-row">' +
463 '<label>' + (i18n.startNumber || 'Start Number') + '</label>' +
464 '<input type="number" id="saso-bulk-start" value="' + defaults.startNumber + '" min="1">' +
465 '</div>' +
466
467 // Spacing
468 '<div class="saso-bulk-row">' +
469 '<label>' + (i18n.spacing || 'Spacing (px)') + '</label>' +
470 '<input type="number" id="saso-bulk-spacing" value="' + defaults.spacing + '" min="20" max="200">' +
471 '</div>' +
472
473 // Direction
474 '<div class="saso-bulk-row">' +
475 '<label>' + (i18n.direction || 'Direction') + '</label>' +
476 '<select id="saso-bulk-direction">' +
477 '<option value="horizontal">' + (i18n.horizontal || 'Horizontal (Left to Right)') + '</option>' +
478 '<option value="horizontal-rtl">' + (i18n.horizontalRtl || 'Horizontal (Right to Left)') + '</option>' +
479 '<option value="vertical">' + (i18n.vertical || 'Vertical (Top to Bottom)') + '</option>' +
480 '<option value="vertical-btt">' + (i18n.verticalBtt || 'Vertical (Bottom to Top)') + '</option>' +
481 '</select>' +
482 '</div>' +
483
484 // Grid mode toggle
485 '<div class="saso-bulk-row saso-bulk-grid-toggle">' +
486 '<label class="checkbox-label">' +
487 '<input type="checkbox" id="saso-bulk-grid-mode"> ' +
488 (i18n.gridMode || 'Grid Mode (Multiple Rows)') +
489 '</label>' +
490 '</div>' +
491
492 // Grid options (hidden by default)
493 '<div class="saso-bulk-grid-options" style="display:none;">' +
494 '<div class="saso-bulk-row">' +
495 '<label>' + (i18n.numberOfRows || 'Number of Rows') + '</label>' +
496 '<input type="number" id="saso-bulk-rows" value="' + defaults.rows + '" min="1" max="50">' +
497 '</div>' +
498 '<div class="saso-bulk-row">' +
499 '<label>' + (i18n.rowSpacing || 'Row Spacing (px)') + '</label>' +
500 '<input type="number" id="saso-bulk-row-spacing" value="' + defaults.rowSpacing + '" min="20" max="200">' +
501 '</div>' +
502 '<div class="saso-bulk-row">' +
503 '<label class="checkbox-label">' +
504 '<input type="checkbox" id="saso-bulk-auto-label" checked> ' +
505 (i18n.autoIncrementLabel || 'Auto-increment row labels (A→B→C)') +
506 '</label>' +
507 '</div>' +
508 '</div>' +
509
510 // Preview info
511 '<div class="saso-bulk-preview-info">' +
512 '<span class="dashicons dashicons-info"></span> ' +
513 '<span id="saso-bulk-preview-text">' + (i18n.previewText || 'Will create') + ' <strong>' + defaults.count + '</strong> ' + (i18n.seats || 'seats') + '</span>' +
514 '</div>' +
515
516 '</div>' +
517
518 '</div>' +
519 '<div class="saso-modal-footer">' +
520 '<button type="button" class="button saso-bulk-cancel">' + (i18n.cancel || 'Cancel') + '</button>' +
521 '<button type="button" class="button button-primary saso-bulk-insert">' + (i18n.insertSeats || 'Insert Seats') + '</button>' +
522 '</div>' +
523 '</div>' +
524 '</div>';
525
526 var $modal = $(html);
527
528 // Update preview text when values change
529 function updatePreview() {
530 var count = parseInt($modal.find('#saso-bulk-count').val()) || 1;
531 var isGrid = $modal.find('#saso-bulk-grid-mode').is(':checked');
532 var rows = isGrid ? (parseInt($modal.find('#saso-bulk-rows').val()) || 1) : 1;
533 var total = count * rows;
534 $modal.find('#saso-bulk-preview-text').html(
535 (i18n.previewText || 'Will create') + ' <strong>' + total + '</strong> ' + (i18n.seats || 'seats') +
536 (rows > 1 ? ' (' + rows + ' ' + (i18n.rows || 'rows') + ')' : '')
537 );
538 }
539
540 // Toggle grid options
541 $modal.on('change', '#saso-bulk-grid-mode', function() {
542 $modal.find('.saso-bulk-grid-options').toggle($(this).is(':checked'));
543 updatePreview();
544 });
545
546 // Update preview on input changes
547 $modal.on('input change', '#saso-bulk-count, #saso-bulk-rows', updatePreview);
548
549 // Cancel button
550 $modal.on('click', '.saso-bulk-cancel, .saso-modal-close', function() {
551 self.clearBulkInsertPreview();
552 $modal.remove();
553 });
554
555 // Close on backdrop click
556 $modal.on('click', function(e) {
557 if (e.target === this) {
558 self.clearBulkInsertPreview();
559 $modal.remove();
560 }
561 });
562
563 // Insert button
564 $modal.on('click', '.saso-bulk-insert', function() {
565 var settings = {
566 rowLabel: $modal.find('#saso-bulk-row-label').val() || 'A',
567 count: parseInt($modal.find('#saso-bulk-count').val()) || 10,
568 startNumber: parseInt($modal.find('#saso-bulk-start').val()) || 1,
569 spacing: parseInt($modal.find('#saso-bulk-spacing').val()) || 35,
570 direction: $modal.find('#saso-bulk-direction').val(),
571 isGrid: $modal.find('#saso-bulk-grid-mode').is(':checked'),
572 rows: parseInt($modal.find('#saso-bulk-rows').val()) || 1,
573 rowSpacing: parseInt($modal.find('#saso-bulk-row-spacing').val()) || 40,
574 autoLabel: $modal.find('#saso-bulk-auto-label').is(':checked')
575 };
576
577 self.createSeatRow(settings);
578 $modal.remove();
579 });
580
581 $('body').append($modal);
582
583 // Focus first input
584 $modal.find('#saso-bulk-row-label').focus().select();
585 };
586
587 /**
588 * Get the next available row label
589 *
590 * @return {string} Next row label (A, B, C... or 1, 2, 3...)
591 */
592 SeatingDesigner.prototype.getNextRowLabel = function() {
593 var usedLabels = {};
594
595 // Collect used row labels from existing seats
596 this.elements.seats.forEach(function(seat) {
597 if (seat.identifier) {
598 // Extract row part (everything before the last number/dash)
599 var match = seat.identifier.match(/^([A-Za-z]+|\d+)/);
600 if (match) {
601 usedLabels[match[1].toUpperCase()] = true;
602 }
603 }
604 });
605
606 // Try letters first (A-Z)
607 for (var i = 0; i < 26; i++) {
608 var letter = String.fromCharCode(65 + i);
609 if (!usedLabels[letter]) {
610 return letter;
611 }
612 }
613
614 // Fall back to numbers
615 for (var n = 1; n <= 100; n++) {
616 if (!usedLabels[n.toString()]) {
617 return n.toString();
618 }
619 }
620
621 return 'A';
622 };
623
624 /**
625 * Get next row label (A→B, B→C, 1→2, etc.)
626 *
627 * @param {string} current Current label
628 * @return {string} Next label
629 */
630 SeatingDesigner.prototype.incrementRowLabel = function(current) {
631 if (!current) return 'A';
632
633 // Check if it's a letter
634 if (/^[A-Za-z]+$/.test(current)) {
635 var chars = current.toUpperCase().split('');
636 var i = chars.length - 1;
637
638 while (i >= 0) {
639 if (chars[i] === 'Z') {
640 chars[i] = 'A';
641 i--;
642 } else {
643 chars[i] = String.fromCharCode(chars[i].charCodeAt(0) + 1);
644 break;
645 }
646 }
647
648 if (i < 0) {
649 chars.unshift('A');
650 }
651
652 return chars.join('');
653 }
654
655 // It's a number
656 var num = parseInt(current);
657 if (!isNaN(num)) {
658 return (num + 1).toString();
659 }
660
661 return current;
662 };
663
664 /**
665 * Create a row (or grid) of seats
666 *
667 * @param {Object} settings Bulk insert settings
668 */
669 SeatingDesigner.prototype.createSeatRow = function(settings) {
670 var startX = this.bulkInsertStart.x;
671 var startY = this.bulkInsertStart.y;
672 var currentRowLabel = settings.rowLabel;
673 var totalRows = settings.isGrid ? settings.rows : 1;
674 var createdSeats = [];
675
676 for (var row = 0; row < totalRows; row++) {
677 for (var i = 0; i < settings.count; i++) {
678 var seatNum = settings.startNumber + i;
679 var identifier = currentRowLabel + '-' + seatNum;
680
681 // Calculate position based on direction
682 var x = startX;
683 var y = startY;
684
685 switch (settings.direction) {
686 case 'horizontal':
687 x = startX + (i * settings.spacing);
688 y = startY + (row * settings.rowSpacing);
689 break;
690 case 'horizontal-rtl':
691 x = startX - (i * settings.spacing);
692 y = startY + (row * settings.rowSpacing);
693 break;
694 case 'vertical':
695 x = startX + (row * settings.rowSpacing);
696 y = startY + (i * settings.spacing);
697 break;
698 case 'vertical-btt':
699 x = startX + (row * settings.rowSpacing);
700 y = startY - (i * settings.spacing);
701 break;
702 }
703
704 // Create seat element
705 var seat = this.createSeatElement(x, y, identifier, currentRowLabel + ' ' + seatNum);
706 createdSeats.push(seat);
707 }
708
709 // Increment row label for next row
710 if (settings.isGrid && settings.autoLabel) {
711 currentRowLabel = this.incrementRowLabel(currentRowLabel);
712 }
713 }
714
715 // Select all newly created seats
716 this.deselectAll();
717 var self = this;
718 createdSeats.forEach(function(seat) {
719 self.addToSelection(seat);
720 });
721 this.updatePropertiesPanelMulti();
722 this.updateElementCounts();
723 this.markUnsaved();
724
725 // Show success message
726 var total = createdSeats.length;
727 this.showNotice(
728 (this.config.i18n.seatsCreated || '{count} seats created').replace('{count}', total),
729 'success'
730 );
731 };
732
733 /**
734 * Create a single seat element (used by bulk insert)
735 *
736 * @param {number} x X position
737 * @param {number} y Y position
738 * @param {string} identifier Seat identifier (e.g., "A-1")
739 * @param {string} label Display label (e.g., "A 1")
740 * @return {Object} Created seat element
741 */
742 SeatingDesigner.prototype.createSeatElement = function(x, y, identifier, label) {
743 var id = 'seat_new_' + this.nextId++;
744
745 var element = {
746 id: id,
747 type: 'rect',
748 x: x,
749 y: y,
750 width: 30,
751 height: 30,
752 fill: this.config.colors.available,
753 fillOpacity: 100,
754 stroke: '#333333',
755 strokeOpacity: 0,
756 isSeat: true,
757 identifier: identifier,
758 label: label,
759 labelColor: '#ffffff',
760 labelColorOpacity: 100,
761 labelStroke: '#000000',
762 labelStrokeOpacity: 50,
763 category: '',
764 seat_desc: ''
765 };
766
767 this.elements.seats.push(element);
768 this.renderElement(element);
769
770 return element;
771 };
772
773 /**
774 * Clear bulk insert preview elements
775 */
776 SeatingDesigner.prototype.clearBulkInsertPreview = function() {
777 if (this.bulkInsertPreview) {
778 $(this.bulkInsertPreview).remove();
779 this.bulkInsertPreview = null;
780 }
781 };
782
783 // =========================================================================
784 // Properties Panel
785 // =========================================================================
786
787 /**
788 * Create properties panel
789 */
790 SeatingDesigner.prototype.createPropertiesPanel = function() {
791 var $container = $(this.config.container);
792
793 var $panel = $('<div class="saso-designer-properties">' +
794 '<h4>' + (this.config.i18n.properties || 'Properties') + '</h4>' +
795 '<div class="saso-props-content">' +
796 '<p class="saso-no-selection">' + (this.config.i18n.noSelection || 'Select an element to edit its properties') + '</p>' +
797 '</div>' +
798 '</div>');
799
800 $container.find('.saso-designer-properties-area').html('').append($panel);
801 this.$propsPanel = $panel.find('.saso-props-content');
802 };
803
804 /**
805 * Update properties panel for selected element
806 * Shows canvas properties when no element is selected
807 *
808 * @param {Object} element Selected element data (null for canvas)
809 */
810 SeatingDesigner.prototype.updatePropertiesPanel = function(element) {
811 if (!element) {
812 this.showCanvasProperties();
813 return;
814 }
815
816 var self = this;
817 var html = '';
818
819 // Common properties
820 html += '<div class="saso-prop-group">';
821 html += '<label>' + (this.config.i18n.propType || 'Type') + '</label>';
822 html += '<span class="prop-value">' + element.type + '</span>';
823 html += '</div>';
824
825 // Position
826 html += '<div class="saso-prop-row">';
827 html += '<div class="saso-prop-group half">';
828 html += '<label>X</label>';
829 html += '<input type="number" class="prop-input" data-prop="x" value="' + (element.x || 0) + '">';
830 html += '</div>';
831 html += '<div class="saso-prop-group half">';
832 html += '<label>Y</label>';
833 html += '<input type="number" class="prop-input" data-prop="y" value="' + (element.y || 0) + '">';
834 html += '</div>';
835 html += '</div>';
836
837 // Size (for shapes)
838 if (element.type === 'rect' || element.type === 'circle' || element.type === 'seat') {
839 html += '<div class="saso-prop-row">';
840 html += '<div class="saso-prop-group half">';
841 html += '<label>' + (element.type === 'circle' ? 'Radius' : (this.config.i18n.propWidth || 'Width')) + '</label>';
842 // For circles: show radius (r), not width (diameter)
843 var sizeValue = element.type === 'circle' ? (element.r || 15) : (element.width || 30);
844 html += '<input type="number" class="prop-input" data-prop="' + (element.type === 'circle' ? 'r' : 'width') + '" value="' + sizeValue + '">';
845 html += '</div>';
846 if (element.type !== 'circle') {
847 html += '<div class="saso-prop-group half">';
848 html += '<label>' + (this.config.i18n.propHeight || 'Height') + '</label>';
849 html += '<input type="number" class="prop-input" data-prop="height" value="' + (element.height || 30) + '">';
850 html += '</div>';
851 }
852 html += '</div>';
853
854 // Rotation
855 html += '<div class="saso-prop-group">';
856 html += '<label>' + (this.config.i18n.propRotation || 'Rotation') + '</label>';
857 html += '<div class="saso-rotation-input">';
858 html += '<input type="number" class="prop-input" data-prop="rotation" value="' + (element.rotation || 0) + '" min="0" max="359" step="1">';
859 html += '<span class="saso-rotation-unit">°</span>';
860 html += '<div class="saso-rotation-presets">';
861 html += '<button type="button" class="saso-rotation-preset" data-angle="0">0°</button>';
862 html += '<button type="button" class="saso-rotation-preset" data-angle="45">45°</button>';
863 html += '<button type="button" class="saso-rotation-preset" data-angle="90">90°</button>';
864 html += '<button type="button" class="saso-rotation-preset" data-angle="180">180°</button>';
865 html += '<button type="button" class="saso-rotation-preset" data-angle="270">270°</button>';
866 html += '</div>';
867 html += '</div>';
868 html += '</div>';
869 }
870
871 // Color (fill) with opacity for shapes
872 if (element.type !== 'line') {
873 html += '<div class="saso-prop-row">';
874 html += '<div class="saso-prop-group half">';
875 html += '<label>' + (this.config.i18n.propColor || 'Fill') + '</label>';
876 html += this.renderColorButton('fill', element.fill || '#cccccc', 'fillOpacity', element.fillOpacity !== undefined ? element.fillOpacity : 100);
877 html += '</div>';
878 html += '<div class="saso-prop-group half">';
879 html += '<label>' + (this.config.i18n.propOutline || 'Outline') + '</label>';
880 html += this.renderColorButton('stroke', element.stroke || '#333333', 'strokeOpacity', element.strokeOpacity !== undefined ? element.strokeOpacity : 0);
881 html += '</div>';
882 html += '</div>';
883 }
884
885 // Stroke for lines
886 if (element.type === 'line') {
887 html += '<div class="saso-prop-group">';
888 html += '<label>' + (this.config.i18n.propStroke || 'Line Color') + '</label>';
889 html += this.renderColorButton('stroke', element.stroke || '#333333', 'strokeOpacity', element.strokeOpacity !== undefined ? element.strokeOpacity : 100);
890 html += '</div>';
891 }
892
893 // Stroke width for lines
894 if (element.type === 'line') {
895 html += '<div class="saso-prop-group">';
896 html += '<label>' + (this.config.i18n.propStrokeWidth || 'Stroke Width') + '</label>';
897 html += '<input type="number" class="prop-input" data-prop="strokeWidth" value="' + (element.strokeWidth || 2) + '" min="1" max="20">';
898 html += '</div>';
899 }
900
901 // Text content for text labels
902 if (element.type === 'text') {
903 html += '<div class="saso-prop-group">';
904 html += '<label>' + (this.config.i18n.propText || 'Text') + '</label>';
905 html += '<input type="text" class="prop-input" data-prop="text" value="' + (element.text || '') + '">';
906 html += '</div>';
907 html += '<div class="saso-prop-group">';
908 html += '<label>' + (this.config.i18n.propFontSize || 'Font Size') + '</label>';
909 html += '<input type="number" class="prop-input" data-prop="fontSize" value="' + (element.fontSize || 14) + '" min="8" max="72">';
910 html += '</div>';
911 }
912
913 // Label field for all elements except text (which uses 'text' property)
914 if (element.type !== 'text') {
915 html += '<div class="saso-prop-group">';
916 html += '<label>' + (this.config.i18n.propLabel || 'Label') + '</label>';
917 html += '<input type="text" class="prop-input" data-prop="label" value="' + (element.label || '') + '">';
918 html += '</div>';
919
920 // Label colors (text color and outline color) with opacity
921 html += '<div class="saso-prop-row">';
922 html += '<div class="saso-prop-group half">';
923 html += '<label>' + (this.config.i18n.propLabelColor || 'Label Color') + '</label>';
924 html += this.renderColorButton('labelColor', element.labelColor || '#333333', 'labelColorOpacity', element.labelColorOpacity !== undefined ? element.labelColorOpacity : 100);
925 html += '</div>';
926 html += '<div class="saso-prop-group half">';
927 html += '<label>' + (this.config.i18n.propLabelOutline || 'Label Outline') + '</label>';
928 html += this.renderColorButton('labelStroke', element.labelStroke || '#ffffff', 'labelStrokeOpacity', element.labelStrokeOpacity !== undefined ? element.labelStrokeOpacity : 100);
929 html += '</div>';
930 html += '</div>';
931 }
932
933 // Is Seat checkbox (for shapes)
934 if (element.type === 'rect' || element.type === 'circle') {
935 html += '<div class="saso-prop-group">';
936 html += '<label class="checkbox-label">';
937 html += '<input type="checkbox" class="prop-input" data-prop="isSeat" ' + (element.isSeat ? 'checked' : '') + '>';
938 html += ' ' + (this.config.i18n.propIsSeat || 'Is Seat (bookable)');
939 html += '</label>';
940 html += '</div>';
941
942 // Seat-specific properties (Seat ID and Category - Label is already shown above)
943 if (element.isSeat) {
944 html += '<div class="saso-seat-props">';
945 html += '<div class="saso-prop-group">';
946 html += '<label>' + (this.config.i18n.propIdentifier || 'Seat ID') + '</label>';
947 html += '<input type="text" class="prop-input" data-prop="identifier" value="' + (element.identifier || '') + '" placeholder="A-1">';
948 html += '<p class="description">' + (this.config.i18n.propIdentifierDesc || 'Unique ID for booking') + '</p>';
949 html += '</div>';
950 html += '<div class="saso-prop-group">';
951 html += '<label>' + (this.config.i18n.propCategory || 'Category') + '</label>';
952 html += '<input type="text" class="prop-input" data-prop="category" value="' + (element.category || '') + '">';
953 html += '</div>';
954 html += '</div>';
955 }
956 }
957
958 // Description (optional)
959 html += '<div class="saso-prop-group">';
960 html += '<label>' + (this.config.i18n.propDescription || 'Description') + '</label>';
961 html += '<textarea class="prop-input" data-prop="seat_desc" rows="2">' + (element.seat_desc || '') + '</textarea>';
962 html += '</div>';
963
964 // Action buttons
965 html += '<div class="saso-prop-actions">';
966 html += '<button type="button" class="button saso-duplicate-element">';
967 html += '<span class="dashicons dashicons-admin-page"></span> ' + (this.config.i18n.duplicate || 'Duplicate');
968 html += '</button>';
969 html += '<button type="button" class="button saso-delete-element">';
970 html += '<span class="dashicons dashicons-trash"></span> ' + (this.config.i18n.delete || 'Delete');
971 html += '</button>';
972 html += '</div>';
973
974 this.$propsPanel.html(html);
975
976 // Bind property change events
977 this.$propsPanel.find('.prop-input').on('change input', function() {
978 var prop = $(this).data('prop');
979 var value = $(this).attr('type') === 'checkbox' ? $(this).is(':checked') : $(this).val();
980
981 if ($(this).attr('type') === 'number') {
982 value = parseFloat(value) || 0;
983 }
984
985 self.updateElementProperty(element.id, prop, value);
986 });
987
988 // Bind action buttons
989 this.$propsPanel.find('.saso-duplicate-element').on('click', function() {
990 self.duplicateElement(element.id);
991 });
992
993 this.$propsPanel.find('.saso-delete-element').on('click', function() {
994 if (element.isSeat) {
995 if (confirm(self.config.i18n.confirmDeleteSeat || 'Delete this seat? This cannot be undone.')) {
996 self.deleteElement(element.id);
997 }
998 } else {
999 self.deleteElement(element.id);
1000 }
1001 });
1002
1003 // Bind color buttons
1004 this.bindColorButtons(element);
1005
1006 // Bind rotation preset buttons
1007 this.$propsPanel.find('.saso-rotation-preset').on('click', function() {
1008 var angle = parseInt($(this).data('angle')) || 0;
1009 self.$propsPanel.find('.prop-input[data-prop="rotation"]').val(angle).trigger('change');
1010 });
1011 };
1012
1013 /**
1014 * Render a color button with preview (opens modal with color + opacity)
1015 */
1016 SeatingDesigner.prototype.renderColorButton = function(colorProp, colorValue, opacityProp, opacityValue, isMultiSelect) {
1017 var isMixed = colorValue === 'mixed';
1018 var displayColor = isMixed ? '#888888' : colorValue;
1019 var opacity = opacityValue / 100;
1020 var rgbaPreview = this.hexToRgba(displayColor, isMixed ? 0.5 : opacity);
1021 return '<button type="button" class="saso-color-btn' + (isMultiSelect ? ' multi-select' : '') + '" ' +
1022 'data-color-prop="' + colorProp + '" ' +
1023 'data-opacity-prop="' + opacityProp + '" ' +
1024 'data-color="' + displayColor + '" ' +
1025 'data-opacity="' + opacityValue + '">' +
1026 '<span class="saso-color-preview"><span class="saso-color-swatch" style="background:' + rgbaPreview + ';"></span></span>' +
1027 '<span class="saso-color-value">' + (isMixed ? 'Mixed' : (opacityValue < 100 ? opacityValue + '%' : '')) + '</span>' +
1028 '</button>';
1029 };
1030
1031 /**
1032 * Bind color button click events
1033 */
1034 SeatingDesigner.prototype.bindColorButtons = function(element) {
1035 var self = this;
1036 this.$propsPanel.find('.saso-color-btn').on('click', function(e) {
1037 e.preventDefault();
1038 var $btn = $(this);
1039 var isMulti = $btn.hasClass('multi-select');
1040 self.openColorModal($btn, isMulti ? null : element, isMulti);
1041 });
1042 };
1043
1044 /**
1045 * Open color picker modal
1046 *
1047 * @param {jQuery} $btn Button element
1048 * @param {Object|null} element Single element or null for multi-select
1049 * @param {boolean} isMultiSelect Whether this is for multiple elements
1050 */
1051 SeatingDesigner.prototype.openColorModal = function($btn, element, isMultiSelect) {
1052 var self = this;
1053 var colorProp = $btn.data('color-prop');
1054 var opacityProp = $btn.data('opacity-prop');
1055 var currentColor = $btn.data('color');
1056 var currentOpacity = $btn.data('opacity');
1057
1058 // Remove existing modal
1059 $('.saso-color-modal').remove();
1060
1061 // Create modal
1062 var $modal = $('<div class="saso-color-modal">' +
1063 '<div class="saso-color-modal-content">' +
1064 '<div class="saso-color-modal-row">' +
1065 '<input type="color" class="saso-modal-color" value="' + currentColor + '">' +
1066 '<div class="saso-modal-opacity-wrap">' +
1067 '<input type="range" class="saso-modal-opacity" min="0" max="100" value="' + currentOpacity + '">' +
1068 '<span class="saso-modal-opacity-value">' + currentOpacity + '%</span>' +
1069 '</div>' +
1070 '</div>' +
1071 '<div class="saso-color-modal-preview">' +
1072 '<span class="saso-preview-box"><span class="saso-preview-swatch" style="background:' + this.hexToRgba(currentColor, currentOpacity/100) + ';"></span></span>' +
1073 '</div>' +
1074 '</div>' +
1075 '</div>');
1076
1077 // Position modal near button
1078 var btnOffset = $btn.offset();
1079 var btnHeight = $btn.outerHeight();
1080 $modal.css({
1081 position: 'absolute',
1082 top: btnOffset.top + btnHeight + 5,
1083 left: btnOffset.left,
1084 zIndex: 100000
1085 });
1086
1087 $('body').append($modal);
1088
1089 // Update preview on change
1090 var updatePreview = function() {
1091 var color = $modal.find('.saso-modal-color').val();
1092 var opacity = parseInt($modal.find('.saso-modal-opacity').val(), 10);
1093 $modal.find('.saso-modal-opacity-value').text(opacity + '%');
1094 $modal.find('.saso-preview-swatch').css('background', self.hexToRgba(color, opacity/100));
1095
1096 // Update button preview
1097 $btn.data('color', color);
1098 $btn.data('opacity', opacity);
1099 $btn.find('.saso-color-swatch').css('background', self.hexToRgba(color, opacity/100));
1100 $btn.find('.saso-color-value').text(opacity < 100 ? opacity + '%' : '');
1101
1102 if (isMultiSelect) {
1103 // Update all selected elements
1104 self.updateSelectedProperty(colorProp, color);
1105 self.updateSelectedProperty(opacityProp, opacity);
1106 } else {
1107 // Update single element
1108 element[colorProp] = color;
1109 element[opacityProp] = opacity;
1110 self.updateSvgElement(element);
1111 self.markUnsaved();
1112 }
1113 };
1114
1115 $modal.find('.saso-modal-color').on('input change', updatePreview);
1116 $modal.find('.saso-modal-opacity').on('input change', updatePreview);
1117
1118 // Close on click outside
1119 setTimeout(function() {
1120 $(document).on('click.colormodal', function(e) {
1121 if (!$(e.target).closest('.saso-color-modal, .saso-color-btn').length) {
1122 $modal.remove();
1123 $(document).off('click.colormodal');
1124 }
1125 });
1126 }, 10);
1127 };
1128
1129 /**
1130 * Convert hex color to rgba
1131 */
1132 SeatingDesigner.prototype.hexToRgba = function(hex, alpha) {
1133 var r = parseInt(hex.slice(1, 3), 16);
1134 var g = parseInt(hex.slice(3, 5), 16);
1135 var b = parseInt(hex.slice(5, 7), 16);
1136 return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
1137 };
1138
1139 /**
1140 * Show canvas properties (when no element selected)
1141 */
1142 SeatingDesigner.prototype.showCanvasProperties = function() {
1143 var self = this;
1144 var html = '';
1145
1146 html += '<div class="saso-canvas-props">';
1147 html += '<p class="saso-prop-title"><span class="dashicons dashicons-art"></span> ' +
1148 (this.config.i18n.canvasProperties || 'Canvas Properties') + '</p>';
1149
1150 // Canvas size
1151 html += '<div class="saso-prop-row">';
1152 html += '<div class="saso-prop-group half">';
1153 html += '<label>' + (this.config.i18n.propWidth || 'Width') + '</label>';
1154 html += '<input type="number" class="prop-input canvas-prop" data-prop="canvasWidth" value="' + this.config.canvasWidth + '" min="400" max="2000">';
1155 html += '</div>';
1156 html += '<div class="saso-prop-group half">';
1157 html += '<label>' + (this.config.i18n.propHeight || 'Height') + '</label>';
1158 html += '<input type="number" class="prop-input canvas-prop" data-prop="canvasHeight" value="' + this.config.canvasHeight + '" min="300" max="2000">';
1159 html += '</div>';
1160 html += '</div>';
1161
1162 // Background color
1163 html += '<div class="saso-prop-group">';
1164 html += '<label>' + (this.config.i18n.backgroundColor || 'Background Color') + '</label>';
1165 html += '<input type="color" class="prop-input canvas-prop" data-prop="backgroundColor" value="' + (this.config.backgroundColor || '#ffffff') + '">';
1166 html += '</div>';
1167
1168 // Background image
1169 html += '<div class="saso-prop-group">';
1170 html += '<label>' + (this.config.i18n.backgroundImage || 'Background Image') + '</label>';
1171 if (this.config.backgroundImage) {
1172 html += '<div class="saso-bg-image-preview"><img src="' + this.config.backgroundImage + '" alt=""></div>';
1173 }
1174 html += '<div class="saso-image-buttons">';
1175 html += '<button type="button" class="button saso-select-bg-image">' +
1176 '<span class="dashicons dashicons-format-image"></span> ' +
1177 (this.config.backgroundImage ? (this.config.i18n.changeImage || 'Change Image') : (this.config.i18n.selectImage || 'Select Image')) +
1178 '</button>';
1179 if (this.config.backgroundImage) {
1180 html += ' <button type="button" class="button saso-remove-bg-image">' +
1181 '<span class="dashicons dashicons-no"></span> ' +
1182 (this.config.i18n.removeImage || 'Remove') +
1183 '</button>';
1184 }
1185 html += '</div>';
1186
1187 // Image fit options (only show if image is set)
1188 if (this.config.backgroundImage) {
1189 var currentFit = this.config.backgroundImageFit || 'contain';
1190 var currentAlign = this.config.backgroundImageAlign || 'center';
1191
1192 html += '<div class="saso-prop-row" style="margin-top:10px;">';
1193 html += '<div class="saso-prop-group half">';
1194 html += '<label>' + (this.config.i18n.imageFit || 'Fit') + '</label>';
1195 html += '<select class="canvas-prop" data-prop="backgroundImageFit">';
1196 html += '<option value="contain"' + (currentFit === 'contain' ? ' selected' : '') + '>' + (this.config.i18n.fitContain || 'Contain') + '</option>';
1197 html += '<option value="cover"' + (currentFit === 'cover' ? ' selected' : '') + '>' + (this.config.i18n.fitCover || 'Cover') + '</option>';
1198 html += '<option value="stretch"' + (currentFit === 'stretch' ? ' selected' : '') + '>' + (this.config.i18n.fitStretch || 'Stretch') + '</option>';
1199 html += '<option value="original"' + (currentFit === 'original' ? ' selected' : '') + '>' + (this.config.i18n.fitOriginal || 'Original') + '</option>';
1200 html += '</select>';
1201 html += '</div>';
1202 html += '<div class="saso-prop-group half">';
1203 html += '<label>' + (this.config.i18n.imageAlign || 'Align') + '</label>';
1204 html += '<select class="canvas-prop" data-prop="backgroundImageAlign">';
1205 html += '<option value="top-left"' + (currentAlign === 'top-left' ? ' selected' : '') + '>↖ ' + (this.config.i18n.alignTopLeft || 'Top Left') + '</option>';
1206 html += '<option value="top"' + (currentAlign === 'top' ? ' selected' : '') + '>↑ ' + (this.config.i18n.alignTop || 'Top') + '</option>';
1207 html += '<option value="top-right"' + (currentAlign === 'top-right' ? ' selected' : '') + '>↗ ' + (this.config.i18n.alignTopRight || 'Top Right') + '</option>';
1208 html += '<option value="left"' + (currentAlign === 'left' ? ' selected' : '') + '>← ' + (this.config.i18n.alignLeft || 'Left') + '</option>';
1209 html += '<option value="center"' + (currentAlign === 'center' ? ' selected' : '') + '>⊙ ' + (this.config.i18n.alignCenter || 'Center') + '</option>';
1210 html += '<option value="right"' + (currentAlign === 'right' ? ' selected' : '') + '>→ ' + (this.config.i18n.alignRight || 'Right') + '</option>';
1211 html += '<option value="bottom-left"' + (currentAlign === 'bottom-left' ? ' selected' : '') + '>↙ ' + (this.config.i18n.alignBottomLeft || 'Bottom Left') + '</option>';
1212 html += '<option value="bottom"' + (currentAlign === 'bottom' ? ' selected' : '') + '>↓ ' + (this.config.i18n.alignBottom || 'Bottom') + '</option>';
1213 html += '<option value="bottom-right"' + (currentAlign === 'bottom-right' ? ' selected' : '') + '>↘ ' + (this.config.i18n.alignBottomRight || 'Bottom Right') + '</option>';
1214 html += '</select>';
1215 html += '</div>';
1216 html += '</div>';
1217 }
1218
1219 html += '<p class="description">' + (this.config.i18n.backgroundImageDesc || 'Add a floor plan or venue layout as background reference.') + '</p>';
1220 html += '</div>';
1221
1222 // Seat status colors
1223 html += '<div class="saso-prop-group">';
1224 html += '<label><strong>' + (this.config.i18n.seatStatusColors || 'Seat Status Colors') + '</strong></label>';
1225 html += '<div class="saso-prop-row">';
1226 html += '<div class="saso-prop-group half">';
1227 html += '<label>' + (this.config.i18n.colorAvailable || 'Available') + '</label>';
1228 html += '<input type="color" class="prop-input canvas-prop color-prop" data-prop="colorAvailable" value="' + (this.config.colors.available || '#4CAF50') + '">';
1229 html += '</div>';
1230 html += '<div class="saso-prop-group half">';
1231 html += '<label>' + (this.config.i18n.colorReserved || 'Reserved') + '</label>';
1232 html += '<input type="color" class="prop-input canvas-prop color-prop" data-prop="colorReserved" value="' + (this.config.colors.reserved || '#FFC107') + '">';
1233 html += '</div>';
1234 html += '</div>';
1235 html += '<div class="saso-prop-row">';
1236 html += '<div class="saso-prop-group half">';
1237 html += '<label>' + (this.config.i18n.colorBooked || 'Booked/Sold') + '</label>';
1238 html += '<input type="color" class="prop-input canvas-prop color-prop" data-prop="colorBooked" value="' + (this.config.colors.booked || '#F44336') + '">';
1239 html += '</div>';
1240 html += '<div class="saso-prop-group half">';
1241 html += '<label>' + (this.config.i18n.colorSelected || 'Selected') + '</label>';
1242 html += '<input type="color" class="prop-input canvas-prop color-prop" data-prop="colorSelected" value="' + (this.config.colors.selected || '#2196F3') + '">';
1243 html += '</div>';
1244 html += '</div>';
1245 html += '<p class="description">' + (this.config.i18n.seatStatusColorsDesc || 'Colors shown in the frontend seat map.') + '</p>';
1246 html += '</div>';
1247
1248 html += '</div>';
1249
1250 this.$propsPanel.html(html);
1251
1252 // Bind canvas property changes
1253 this.$propsPanel.find('.canvas-prop').on('change input', function() {
1254 var prop = $(this).data('prop');
1255 var value = $(this).val();
1256
1257 if ($(this).attr('type') === 'number') {
1258 value = parseInt(value, 10) || 0;
1259 }
1260
1261 self.updateCanvasProperty(prop, value);
1262 });
1263
1264 // Bind image buttons
1265 this.$propsPanel.find('.saso-select-bg-image').on('click', function() {
1266 self.openBackgroundImageChooser();
1267 });
1268
1269 this.$propsPanel.find('.saso-remove-bg-image').on('click', function() {
1270 self.removeBackgroundImage();
1271 });
1272
1273 };
1274
1275 /**
1276 * Update properties panel for multiple selected elements
1277 * Shows common properties that can be changed for all selected elements
1278 */
1279 SeatingDesigner.prototype.updatePropertiesPanelMulti = function() {
1280 var self = this;
1281 var count = this.selectedElements.length;
1282
1283 if (count === 0) {
1284 this.showCanvasProperties();
1285 return;
1286 }
1287
1288 if (count === 1) {
1289 this.updatePropertiesPanel(this.selectedElements[0]);
1290 return;
1291 }
1292
1293 var html = '';
1294
1295 // Header showing selection count with highlight
1296 html += '<div class="saso-multi-select-header">';
1297 html += '<span class="saso-multi-badge">' + count + '</span>';
1298 html += '<span>' + (this.config.i18n.elementsSelected || 'elements selected') + '</span>';
1299 html += '</div>';
1300
1301 // Get common properties
1302 var commonX = this.getCommonProperty('x');
1303 var commonY = this.getCommonProperty('y');
1304 var commonWidth = this.getCommonProperty('width');
1305 var commonHeight = this.getCommonProperty('height');
1306 var commonRotation = this.getCommonProperty('rotation');
1307 var commonFill = this.getCommonProperty('fill');
1308 var commonStroke = this.getCommonProperty('stroke');
1309 var commonFillOpacity = this.getCommonProperty('fillOpacity');
1310 var commonStrokeOpacity = this.getCommonProperty('strokeOpacity');
1311 var commonCategory = this.getCommonProperty('category');
1312 var commonDescription = this.getCommonProperty('seat_desc');
1313 var commonIsSeat = this.getCommonProperty('isSeat');
1314
1315 // Position (only if identical)
1316 if (commonX !== undefined && commonX !== 'mixed') {
1317 html += '<div class="saso-prop-row">';
1318 html += '<div class="saso-prop-group half">';
1319 html += '<label>X</label>';
1320 html += '<input type="number" class="prop-input multi-prop" data-prop="x" value="' + commonX + '">';
1321 html += '</div>';
1322 if (commonY !== undefined && commonY !== 'mixed') {
1323 html += '<div class="saso-prop-group half">';
1324 html += '<label>Y</label>';
1325 html += '<input type="number" class="prop-input multi-prop" data-prop="y" value="' + commonY + '">';
1326 html += '</div>';
1327 }
1328 html += '</div>';
1329 } else if (commonY !== undefined && commonY !== 'mixed') {
1330 html += '<div class="saso-prop-group">';
1331 html += '<label>Y</label>';
1332 html += '<input type="number" class="prop-input multi-prop" data-prop="y" value="' + commonY + '">';
1333 html += '</div>';
1334 }
1335
1336 // Size (only if identical)
1337 if ((commonWidth !== undefined && commonWidth !== 'mixed') || (commonHeight !== undefined && commonHeight !== 'mixed')) {
1338 html += '<div class="saso-prop-row">';
1339 if (commonWidth !== undefined && commonWidth !== 'mixed') {
1340 html += '<div class="saso-prop-group half">';
1341 html += '<label>' + (this.config.i18n.propWidth || 'Width') + '</label>';
1342 html += '<input type="number" class="prop-input multi-prop" data-prop="width" value="' + commonWidth + '">';
1343 html += '</div>';
1344 }
1345 if (commonHeight !== undefined && commonHeight !== 'mixed') {
1346 html += '<div class="saso-prop-group half">';
1347 html += '<label>' + (this.config.i18n.propHeight || 'Height') + '</label>';
1348 html += '<input type="number" class="prop-input multi-prop" data-prop="height" value="' + commonHeight + '">';
1349 html += '</div>';
1350 }
1351 html += '</div>';
1352 }
1353
1354 // Rotation (for shapes only)
1355 var allShapesForRotation = this.selectedElements.every(function(el) {
1356 return el.type === 'rect' || el.type === 'circle' || el.type === 'seat';
1357 });
1358 if (allShapesForRotation) {
1359 html += '<div class="saso-prop-group">';
1360 html += '<label>' + (this.config.i18n.propRotation || 'Rotation') + '</label>';
1361 html += '<div class="saso-rotation-input">';
1362 var rotationValue = (commonRotation !== undefined && commonRotation !== 'mixed') ? commonRotation : 0;
1363 var rotationMixed = commonRotation === 'mixed';
1364 html += '<input type="number" class="prop-input multi-prop" data-prop="rotation" value="' + rotationValue + '" min="0" max="359" step="1"' + (rotationMixed ? ' placeholder="mixed"' : '') + '>';
1365 html += '<span class="saso-rotation-unit">°</span>';
1366 if (rotationMixed) {
1367 html += '<span class="saso-mixed-indicator">(mixed)</span>';
1368 }
1369 html += '<div class="saso-rotation-presets">';
1370 html += '<button type="button" class="saso-rotation-preset multi-preset" data-angle="0">0°</button>';
1371 html += '<button type="button" class="saso-rotation-preset multi-preset" data-angle="45">45°</button>';
1372 html += '<button type="button" class="saso-rotation-preset multi-preset" data-angle="90">90°</button>';
1373 html += '<button type="button" class="saso-rotation-preset multi-preset" data-angle="180">180°</button>';
1374 html += '<button type="button" class="saso-rotation-preset multi-preset" data-angle="270">270°</button>';
1375 html += '</div>';
1376 html += '</div>';
1377 html += '</div>';
1378 }
1379
1380 // Fill color (if all have fill property)
1381 if (commonFill !== undefined) {
1382 html += '<div class="saso-prop-row">';
1383 html += '<div class="saso-prop-group half">';
1384 html += '<label>' + (this.config.i18n.propColor || 'Fill') + '</label>';
1385 html += this.renderColorButton('fill', commonFill || '#cccccc', 'fillOpacity', commonFillOpacity !== undefined ? commonFillOpacity : 100, true);
1386 html += '</div>';
1387 // Stroke color
1388 if (commonStroke !== undefined) {
1389 html += '<div class="saso-prop-group half">';
1390 html += '<label>' + (this.config.i18n.propStroke || 'Outline') + '</label>';
1391 html += this.renderColorButton('stroke', commonStroke || '#333333', 'strokeOpacity', commonStrokeOpacity !== undefined ? commonStrokeOpacity : 0, true);
1392 html += '</div>';
1393 }
1394 html += '</div>';
1395 } else if (commonStroke !== undefined) {
1396 html += '<div class="saso-prop-group">';
1397 html += '<label>' + (this.config.i18n.propStroke || 'Outline') + '</label>';
1398 html += this.renderColorButton('stroke', commonStroke || '#333333', 'strokeOpacity', commonStrokeOpacity !== undefined ? commonStrokeOpacity : 0, true);
1399 html += '</div>';
1400 }
1401
1402 // Is Seat checkbox - ALWAYS show for shapes (can overwrite)
1403 var allShapes = this.selectedElements.every(function(el) {
1404 return el.type === 'rect' || el.type === 'circle';
1405 });
1406 if (allShapes) {
1407 html += '<div class="saso-prop-group">';
1408 html += '<label class="checkbox-label">';
1409 var isSeatChecked = commonIsSeat === true;
1410 var isSeatMixed = commonIsSeat === 'mixed';
1411 html += '<input type="checkbox" class="prop-input multi-prop" data-prop="isSeat" ' +
1412 (isSeatChecked ? 'checked' : '') +
1413 (isSeatMixed ? ' data-mixed="true"' : '') + '>';
1414 html += ' ' + (this.config.i18n.propIsSeat || 'Is Seat (bookable)');
1415 if (isSeatMixed) {
1416 html += ' <span class="saso-mixed-indicator">(mixed)</span>';
1417 }
1418 html += '</label>';
1419 html += '</div>';
1420 }
1421
1422 // Category (only if identical)
1423 if (commonCategory !== undefined && commonCategory !== 'mixed') {
1424 html += '<div class="saso-prop-group">';
1425 html += '<label>' + (this.config.i18n.propCategory || 'Category') + '</label>';
1426 html += '<input type="text" class="prop-input multi-prop" data-prop="category" value="' + (commonCategory || '') + '">';
1427 html += '</div>';
1428 }
1429
1430 // Description (only if identical)
1431 if (commonDescription !== undefined && commonDescription !== 'mixed') {
1432 html += '<div class="saso-prop-group">';
1433 html += '<label>' + (this.config.i18n.propDescription || 'Description') + '</label>';
1434 html += '<textarea class="prop-input multi-prop" data-prop="seat_desc" rows="2">' + (commonDescription || '') + '</textarea>';
1435 html += '</div>';
1436 }
1437
1438 // Group Rotation (only for shapes)
1439 if (allShapesForRotation) {
1440 html += '<div class="saso-prop-group saso-group-rotation">';
1441 html += '<label>' + (this.config.i18n.rotateGroup || 'Rotate Group') + '</label>';
1442 html += '<div class="saso-rotation-presets">';
1443 html += '<button type="button" class="saso-group-rotate-btn" data-angle="-90">↺ 90°</button>';
1444 html += '<button type="button" class="saso-group-rotate-btn" data-angle="-45">↺ 45°</button>';
1445 html += '<button type="button" class="saso-group-rotate-btn" data-angle="45">↻ 45°</button>';
1446 html += '<button type="button" class="saso-group-rotate-btn" data-angle="90">↻ 90°</button>';
1447 html += '</div>';
1448 html += '</div>';
1449 }
1450
1451 // Actions
1452 html += '<div class="saso-prop-actions saso-multi-actions">';
1453 html += '<button type="button" class="button saso-duplicate-selected">';
1454 html += '<span class="dashicons dashicons-admin-page"></span> ' + (this.config.i18n.duplicateSelected || 'Duplicate All');
1455 html += '</button>';
1456 html += '<button type="button" class="button button-link-delete saso-delete-selected">';
1457 html += '<span class="dashicons dashicons-trash"></span> ' + (this.config.i18n.deleteSelected || 'Delete All');
1458 html += '</button>';
1459 html += '</div>';
1460
1461 // Tip
1462 html += '<p class="description saso-multi-tip">';
1463 html += (this.config.i18n.multiSelectTip || 'Shift+Click to add/remove. Press Delete to remove all selected.');
1464 html += '</p>';
1465
1466 this.$propsPanel.html(html);
1467
1468 // Bind property change events for multi-select
1469 this.$propsPanel.find('.multi-prop').on('change input', function() {
1470 var prop = $(this).data('prop');
1471 var value = $(this).attr('type') === 'checkbox' ? $(this).is(':checked') : $(this).val();
1472
1473 if ($(this).attr('type') === 'number') {
1474 value = parseFloat(value) || 0;
1475 }
1476
1477 self.updateSelectedProperty(prop, value);
1478 });
1479
1480 // Bind color buttons for multi-select
1481 this.bindColorButtons(null);
1482
1483 // Bind rotation preset buttons for multi-select
1484 this.$propsPanel.find('.saso-rotation-preset.multi-preset').on('click', function() {
1485 var angle = parseInt($(this).data('angle')) || 0;
1486 self.$propsPanel.find('.multi-prop[data-prop="rotation"]').val(angle).trigger('change');
1487 });
1488
1489 // Bind group rotation buttons
1490 this.$propsPanel.find('.saso-group-rotate-btn').on('click', function() {
1491 var angle = parseInt($(this).data('angle')) || 0;
1492 self.rotateGroup(angle);
1493 });
1494
1495 // Bind duplicate button
1496 this.$propsPanel.find('.saso-duplicate-selected').on('click', function() {
1497 self.duplicateSelectedElements();
1498 });
1499
1500 // Bind delete button
1501 this.$propsPanel.find('.saso-delete-selected').on('click', function() {
1502 self.deleteSelectedElements();
1503 });
1504 };
1505
1506 /**
1507 * Duplicate all selected elements
1508 */
1509 SeatingDesigner.prototype.duplicateSelectedElements = function() {
1510 var self = this;
1511 var offset = 20;
1512 var newElements = [];
1513
1514 this.selectedElements.forEach(function(el) {
1515 var newEl = self.duplicateElementInternal(el, offset);
1516 if (newEl) {
1517 newElements.push(newEl);
1518 }
1519 });
1520
1521 // Select the new elements
1522 if (newElements.length > 0) {
1523 this.clearSelection();
1524 newElements.forEach(function(el) {
1525 self.addToSelection(el);
1526 });
1527 this.updatePropertiesPanelMulti();
1528 this.markUnsaved();
1529 }
1530 };
1531
1532 /**
1533 * Rotate all selected elements as a group around their common center
1534 *
1535 * @param {number} angle Rotation angle in degrees (positive = clockwise)
1536 */
1537 SeatingDesigner.prototype.rotateGroup = function(angle) {
1538 if (this.selectedElements.length < 2) return;
1539
1540 var self = this;
1541
1542 // Calculate bounding box center
1543 var minX = Infinity, minY = Infinity;
1544 var maxX = -Infinity, maxY = -Infinity;
1545
1546 this.selectedElements.forEach(function(el) {
1547 var elWidth = el.width || (el.r ? el.r * 2 : 40);
1548 var elHeight = el.height || (el.r ? el.r * 2 : 40);
1549 minX = Math.min(minX, el.x);
1550 minY = Math.min(minY, el.y);
1551 maxX = Math.max(maxX, el.x + elWidth);
1552 maxY = Math.max(maxY, el.y + elHeight);
1553 });
1554
1555 var centerX = (minX + maxX) / 2;
1556 var centerY = (minY + maxY) / 2;
1557
1558 // Convert angle to radians
1559 var rad = angle * Math.PI / 180;
1560 var cos = Math.cos(rad);
1561 var sin = Math.sin(rad);
1562
1563 // Rotate each element around the group center
1564 this.selectedElements.forEach(function(el) {
1565 var elWidth = el.width || (el.r ? el.r * 2 : 40);
1566 var elHeight = el.height || (el.r ? el.r * 2 : 40);
1567
1568 // Element center
1569 var elCenterX = el.x + elWidth / 2;
1570 var elCenterY = el.y + elHeight / 2;
1571
1572 // Vector from group center to element center
1573 var dx = elCenterX - centerX;
1574 var dy = elCenterY - centerY;
1575
1576 // Rotate the vector
1577 var newCenterX = centerX + dx * cos - dy * sin;
1578 var newCenterY = centerY + dx * sin + dy * cos;
1579
1580 // Update position (top-left corner)
1581 el.x = Math.round(newCenterX - elWidth / 2);
1582 el.y = Math.round(newCenterY - elHeight / 2);
1583
1584 // Update element's own rotation
1585 el.rotation = ((el.rotation || 0) + angle) % 360;
1586 if (el.rotation < 0) el.rotation += 360;
1587
1588 // Update SVG
1589 self.updateSvgElement(el);
1590 });
1591
1592 this.updatePropertiesPanelMulti();
1593 this.markUnsaved();
1594 };
1595
1596 /**
1597 * Internal duplicate helper - creates a copy of an element
1598 */
1599 SeatingDesigner.prototype.duplicateElementInternal = function(element, offset) {
1600 offset = offset || 20;
1601
1602 var newElement = JSON.parse(JSON.stringify(element));
1603 newElement.id = element.type + '_' + this.nextId++;
1604 newElement.x = (element.x || 0) + offset;
1605 newElement.y = (element.y || 0) + offset;
1606
1607 // Clear DB ID for seats (will be created as new)
1608 if (newElement.dbId) {
1609 delete newElement.dbId;
1610 }
1611
1612 // Make identifier unique for seats
1613 if (newElement.identifier) {
1614 newElement.identifier = newElement.identifier + '_copy';
1615 }
1616
1617 // Add to appropriate array
1618 if (element.type === 'line') {
1619 // Also offset line endpoints
1620 if (newElement.x2 !== undefined) newElement.x2 += offset;
1621 if (newElement.y2 !== undefined) newElement.y2 += offset;
1622 this.elements.lines.push(newElement);
1623 } else if (element.type === 'text') {
1624 this.elements.labels.push(newElement);
1625 } else if (element.isSeat) {
1626 this.elements.seats.push(newElement);
1627 } else {
1628 this.elements.decorations.push(newElement);
1629 }
1630
1631 this.renderElement(newElement);
1632 return newElement;
1633 };
1634
1635 /**
1636 * Get a property value if it's common across all selected elements
1637 * Returns undefined if elements don't all have this property
1638 * Returns 'mixed' if values differ
1639 *
1640 * @param {string} prop Property name
1641 * @return {*} Common value, 'mixed', or undefined
1642 */
1643 SeatingDesigner.prototype.getCommonProperty = function(prop) {
1644 if (this.selectedElements.length === 0) return undefined;
1645
1646 var firstValue = this.selectedElements[0][prop];
1647 var allHave = true;
1648 var allSame = true;
1649
1650 for (var i = 0; i < this.selectedElements.length; i++) {
1651 var el = this.selectedElements[i];
1652 if (el[prop] === undefined) {
1653 allHave = false;
1654 break;
1655 }
1656 if (el[prop] !== firstValue) {
1657 allSame = false;
1658 }
1659 }
1660
1661 if (!allHave) return undefined;
1662 if (!allSame) return 'mixed';
1663 return firstValue;
1664 };
1665
1666 /**
1667 * Update property for all selected elements
1668 *
1669 * @param {string} prop Property name
1670 * @param {*} value New value
1671 */
1672 SeatingDesigner.prototype.updateSelectedProperty = function(prop, value) {
1673 var self = this;
1674
1675 this.selectedElements.forEach(function(el) {
1676 if (el[prop] !== undefined || prop === 'fill' || prop === 'stroke' ||
1677 prop === 'fillOpacity' || prop === 'strokeOpacity' || prop === 'rotation') {
1678 el[prop] = value;
1679 self.updateSvgElement(el);
1680 }
1681 });
1682
1683 this.markUnsaved();
1684 };
1685
1686 /**
1687 * Update canvas property
1688 *
1689 * @param {string} prop Property name
1690 * @param {*} value New value
1691 */
1692 SeatingDesigner.prototype.updateCanvasProperty = function(prop, value) {
1693 // Handle color properties separately (stored in colors object)
1694 var colorMap = {
1695 colorAvailable: 'available',
1696 colorReserved: 'reserved',
1697 colorBooked: 'booked',
1698 colorSelected: 'selected'
1699 };
1700
1701 if (colorMap[prop]) {
1702 this.config.colors[colorMap[prop]] = value;
1703 this.markUnsaved();
1704 return;
1705 }
1706
1707 this.config[prop] = value;
1708
1709 if (prop === 'canvasWidth' || prop === 'canvasHeight') {
1710 // Update canvas size
1711 this.$canvas.css({
1712 width: this.config.canvasWidth + 'px',
1713 height: this.config.canvasHeight + 'px'
1714 });
1715 this.svg.setAttribute('width', this.config.canvasWidth);
1716 this.svg.setAttribute('height', this.config.canvasHeight);
1717
1718 // Update background rect
1719 var bgRect = this.layers.background.querySelector('.saso-background-rect');
1720 if (bgRect) {
1721 bgRect.setAttribute('width', this.config.canvasWidth);
1722 bgRect.setAttribute('height', this.config.canvasHeight);
1723 }
1724
1725 // Update background image
1726 this.updateBackgroundImageDisplay();
1727 } else if (prop === 'backgroundColor') {
1728 // Update background color
1729 this.$canvas.css('backgroundColor', value);
1730 var bgRect = this.layers.background.querySelector('.saso-background-rect');
1731 if (bgRect) {
1732 bgRect.setAttribute('fill', value);
1733 }
1734 } else if (prop === 'backgroundImageFit' || prop === 'backgroundImageAlign') {
1735 // Update background image fit/align
1736 this.updateBackgroundImageDisplay();
1737 }
1738
1739 this.markUnsaved();
1740 };
1741
1742 /**
1743 * Update background image display based on fit and align settings
1744 */
1745 SeatingDesigner.prototype.updateBackgroundImageDisplay = function() {
1746 var bgImage = this.layers.background.querySelector('.saso-background-image');
1747 if (!bgImage) return;
1748
1749 var fit = this.config.backgroundImageFit || 'contain';
1750 var align = this.config.backgroundImageAlign || 'center';
1751
1752 // Map fit mode to SVG preserveAspectRatio
1753 var aspectRatio;
1754 switch (fit) {
1755 case 'contain':
1756 aspectRatio = this.getPreserveAspectRatio(align, 'meet');
1757 break;
1758 case 'cover':
1759 aspectRatio = this.getPreserveAspectRatio(align, 'slice');
1760 break;
1761 case 'stretch':
1762 aspectRatio = 'none';
1763 break;
1764 case 'original':
1765 aspectRatio = this.getPreserveAspectRatio(align, 'meet');
1766 // For original, we don't stretch at all - handled below
1767 break;
1768 default:
1769 aspectRatio = 'xMidYMid meet';
1770 }
1771
1772 bgImage.setAttribute('preserveAspectRatio', aspectRatio);
1773 bgImage.setAttribute('width', this.config.canvasWidth);
1774 bgImage.setAttribute('height', this.config.canvasHeight);
1775 };
1776
1777 /**
1778 * Get SVG preserveAspectRatio value from alignment
1779 */
1780 SeatingDesigner.prototype.getPreserveAspectRatio = function(align, meetOrSlice) {
1781 var xAlign, yAlign;
1782 switch (align) {
1783 case 'top-left':
1784 xAlign = 'xMin'; yAlign = 'YMin'; break;
1785 case 'top':
1786 xAlign = 'xMid'; yAlign = 'YMin'; break;
1787 case 'top-right':
1788 xAlign = 'xMax'; yAlign = 'YMin'; break;
1789 case 'left':
1790 xAlign = 'xMin'; yAlign = 'YMid'; break;
1791 case 'center':
1792 xAlign = 'xMid'; yAlign = 'YMid'; break;
1793 case 'right':
1794 xAlign = 'xMax'; yAlign = 'YMid'; break;
1795 case 'bottom-left':
1796 xAlign = 'xMin'; yAlign = 'YMax'; break;
1797 case 'bottom':
1798 xAlign = 'xMid'; yAlign = 'YMax'; break;
1799 case 'bottom-right':
1800 xAlign = 'xMax'; yAlign = 'YMax'; break;
1801 default:
1802 xAlign = 'xMid'; yAlign = 'YMid';
1803 }
1804 return xAlign + yAlign + ' ' + meetOrSlice;
1805 };
1806
1807 /**
1808 * Open background image chooser (WordPress media library)
1809 */
1810 SeatingDesigner.prototype.openBackgroundImageChooser = function() {
1811 var self = this;
1812
1813 if (typeof wp === 'undefined' || typeof wp.media === 'undefined') {
1814 alert('Media library not available');
1815 return;
1816 }
1817
1818 var frame = wp.media({
1819 title: this.config.i18n.selectBackgroundImage || 'Select Background Image',
1820 multiple: false,
1821 library: { type: 'image' }
1822 });
1823
1824 frame.on('close', function() {
1825 var selection = frame.state().get('selection');
1826 if (selection.length === 0) return;
1827
1828 var attachment = selection.first().toJSON();
1829 self.setBackgroundImage(attachment.url);
1830 self.config.backgroundImageId = attachment.id;
1831
1832 // Refresh properties panel to show the image
1833 self.showCanvasProperties();
1834 });
1835
1836 frame.open();
1837 };
1838
1839 /**
1840 * Remove background image
1841 */
1842 SeatingDesigner.prototype.removeBackgroundImage = function() {
1843 this.config.backgroundImage = '';
1844 this.config.backgroundImageId = 0;
1845
1846 // Remove from SVG
1847 var existing = this.layers.background.querySelector('.saso-background-image');
1848 if (existing) {
1849 existing.remove();
1850 }
1851
1852 // Refresh properties panel
1853 this.showCanvasProperties();
1854 this.markUnsaved();
1855 };
1856
1857 /**
1858 * Duplicate an element
1859 *
1860 * @param {string} id Element ID to duplicate
1861 */
1862 SeatingDesigner.prototype.duplicateElement = function(id) {
1863 var element = this.findElement(id);
1864 if (!element) return;
1865
1866 // Create a copy with new ID
1867 var newId;
1868 var copy = JSON.parse(JSON.stringify(element));
1869
1870 // Offset position
1871 copy.x = (copy.x || 0) + 20;
1872 copy.y = (copy.y || 0) + 20;
1873
1874 if (element.isSeat) {
1875 var seatNum = this.elements.seats.length + 1;
1876 newId = 'seat_new_' + this.nextId++;
1877 copy.id = newId;
1878 copy.identifier = 'SEAT-' + seatNum;
1879 copy.label = 'Seat ' + seatNum;
1880 copy.dbId = null; // New seat, no DB ID yet
1881 this.elements.seats.push(copy);
1882 } else if (element.type === 'line') {
1883 newId = 'line_' + this.nextId++;
1884 copy.id = newId;
1885 if (copy.x1 !== undefined) {
1886 copy.x1 += 20;
1887 copy.y1 += 20;
1888 copy.x2 += 20;
1889 copy.y2 += 20;
1890 }
1891 this.elements.lines.push(copy);
1892 } else if (element.type === 'text') {
1893 newId = 'label_' + this.nextId++;
1894 copy.id = newId;
1895 this.elements.labels.push(copy);
1896 } else {
1897 newId = 'shape_' + this.nextId++;
1898 copy.id = newId;
1899 this.elements.decorations.push(copy);
1900 }
1901
1902 this.renderElement(copy);
1903 this.selectElement(newId);
1904 this.markUnsaved();
1905 };
1906
1907 // =========================================================================
1908 // Actions Panel
1909 // =========================================================================
1910
1911 /**
1912 * Create actions panel (Save, Publish, Discard)
1913 */
1914 SeatingDesigner.prototype.createActionsPanel = function() {
1915 var self = this;
1916 var $container = $(this.config.container);
1917
1918 var html = '<div class="saso-designer-actions-wrap">';
1919
1920 // Element counts
1921 html += '<div class="saso-element-counts">';
1922 html += '<span class="count-item count-seats"><span class="dashicons dashicons-tickets-alt"></span> <span class="count-value">0</span> ' + (this.config.i18n.seats || 'Seats') + '</span>';
1923 html += '<span class="count-item count-elements"><span class="dashicons dashicons-layout"></span> <span class="count-value">0</span> ' + (this.config.i18n.elements || 'Elements') + '</span>';
1924 html += '</div>';
1925
1926 // Action buttons (Publish is in the unpublished-changes banner)
1927 html += '<div class="saso-designer-actions">';
1928 html += '<label class="saso-sync-option"><input type="checkbox" class="saso-sync-to-pub-checkbox"> ';
1929 html += (this.config.i18n.syncToPubData || 'Sync seats to DB');
1930 html += '</label>';
1931 html += '<button type="button" class="button saso-save-draft">';
1932 html += '<span class="dashicons dashicons-saved"></span> ';
1933 html += (this.config.i18n.saveDraft || 'Save Draft');
1934 html += '</button>';
1935 html += '<button type="button" class="button saso-discard-draft">';
1936 html += (this.config.i18n.discardDraft || 'Discard');
1937 html += '</button>';
1938 html += '</div>';
1939
1940 html += '</div>';
1941
1942 $container.find('.saso-designer-actions-area').html(html);
1943 this.updateElementCounts();
1944 };
1945
1946 /**
1947 * Update element counts in actions panel
1948 */
1949 SeatingDesigner.prototype.updateElementCounts = function() {
1950 var $container = $(this.config.container);
1951 var seatCount = this.elements.seats.length;
1952 var elementCount = this.elements.decorations.length + this.elements.lines.length + this.elements.labels.length;
1953
1954 $container.find('.count-seats .count-value').text(seatCount);
1955 $container.find('.count-elements .count-value').text(elementCount);
1956 };
1957
1958 // =========================================================================
1959 // Event Binding
1960 // =========================================================================
1961
1962 /**
1963 * Bind canvas-specific events (called after canvas creation)
1964 */
1965 SeatingDesigner.prototype.bindCanvasEvents = function() {
1966 var self = this;
1967
1968 if (!this.svg) return;
1969
1970 // Canvas events - use native listeners for SVG
1971 this.svg.addEventListener('mousedown', function(e) {
1972 self.handleCanvasMouseDown(e);
1973 });
1974
1975 this.svg.addEventListener('mousemove', function(e) {
1976 self.handleCanvasMouseMove(e);
1977 });
1978
1979 this.svg.addEventListener('mouseup', function(e) {
1980 self.handleCanvasMouseUp(e);
1981 });
1982
1983 // Mouse leave - end any dragging/panning
1984 this.svg.addEventListener('mouseleave', function(e) {
1985 if (self.isPanning) {
1986 self.endPan();
1987 }
1988 });
1989
1990 // Wheel event for zoom
1991 this.svg.addEventListener('wheel', function(e) {
1992 e.preventDefault();
1993 var rect = self.svg.getBoundingClientRect();
1994 var screenPos = {
1995 x: e.clientX - rect.left,
1996 y: e.clientY - rect.top
1997 };
1998 var delta = e.deltaY < 0 ? 1 : -1;
1999 self.zoomAtPoint(delta, screenPos);
2000 }, { passive: false });
2001
2002 // Context menu prevention (for right-click)
2003 this.svg.addEventListener('contextmenu', function(e) {
2004 e.preventDefault();
2005 });
2006 };
2007
2008 /**
2009 * Bind all events
2010 */
2011 SeatingDesigner.prototype.bindEvents = function() {
2012 var self = this;
2013 var $container = $(this.config.container);
2014
2015 // Tool selection
2016 $container.on('click', '.saso-tool-btn', function(e) {
2017 e.preventDefault();
2018 var tool = $(this).data('tool');
2019 // Delete is an action, not a tool mode
2020 if (tool === 'delete') {
2021 if (self.selectedElements.length > 0) {
2022 self.deleteSelectedElements();
2023 } else {
2024 self.showNotice(self.config.i18n.noElementsSelected || 'No elements selected to delete', 'warning');
2025 }
2026 return;
2027 }
2028 self.setTool(tool);
2029 });
2030
2031 // Grid snap toggle
2032 $container.on('change', '.saso-grid-snap input', function() {
2033 self.config.snapToGrid = $(this).is(':checked');
2034 });
2035
2036 // Help button
2037 $container.on('click', '.saso-help-btn', function(e) {
2038 e.preventDefault();
2039 self.showHelpModal();
2040 });
2041
2042 // Note: Canvas events (mousedown/move/up) are bound in createCanvas()
2043 // via bindCanvasEvents() - this ensures they're rebound when canvas is recreated
2044
2045 // Element click (delegated, so survives canvas recreation)
2046 $container.on('click', '.saso-element', function(e) {
2047 e.stopPropagation();
2048 // Ignore click if we just finished dragging (click fires after mouseup)
2049 if (self.justFinishedDragging) {
2050 self.justFinishedDragging = false;
2051 return;
2052 }
2053 var id = $(this).data('id');
2054 self.selectElement(id);
2055 });
2056
2057 // Action buttons (Publish is now in the unpublished-changes banner)
2058 $container.on('click', '.saso-save-draft', function() {
2059 self.saveDraft();
2060 });
2061
2062 $container.on('click', '.saso-discard-draft', function() {
2063 self.discardDraft();
2064 });
2065
2066 $container.on('click', '.saso-preview', function() {
2067 self.preview();
2068 });
2069
2070 // Back to plans button
2071 $container.on('click', '.saso-back-to-plans', function() {
2072 if (self.hasUnsavedChanges) {
2073 if (!confirm(self.config.i18n.unsavedChanges || 'You have unsaved changes. Are you sure you want to leave?')) {
2074 return;
2075 }
2076 }
2077 if (typeof window.sasoSeatingCloseDesigner === 'function') {
2078 window.sasoSeatingCloseDesigner();
2079 }
2080 });
2081
2082 // Keyboard shortcuts (namespaced to allow removal)
2083 $(document).on('keydown.sasoSeatingDesigner', function(e) {
2084 if (!self.svg) return;
2085
2086 // Delete key - delete all selected elements
2087 if (e.key === 'Delete' || e.key === 'Backspace') {
2088 if (self.selectedElements.length > 0 && !$(e.target).is('input, textarea')) {
2089 e.preventDefault();
2090 self.deleteSelectedElements();
2091 }
2092 }
2093
2094 // Escape - deselect all
2095 if (e.key === 'Escape') {
2096 self.deselectAll();
2097 }
2098
2099 // Ctrl+A - select all
2100 if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
2101 if (!$(e.target).is('input, textarea')) {
2102 e.preventDefault();
2103 self.selectAll();
2104 }
2105 }
2106
2107 // Ctrl+S - save
2108 if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
2109 e.preventDefault();
2110 self.saveDraft();
2111 }
2112
2113 // + / = - zoom in
2114 if ((e.key === '+' || e.key === '=') && !$(e.target).is('input, textarea')) {
2115 e.preventDefault();
2116 self.zoomIn();
2117 }
2118
2119 // - - zoom out
2120 if (e.key === '-' && !$(e.target).is('input, textarea')) {
2121 e.preventDefault();
2122 self.zoomOut();
2123 }
2124
2125 // 0 - reset zoom
2126 if (e.key === '0' && !$(e.target).is('input, textarea')) {
2127 e.preventDefault();
2128 self.resetZoom();
2129 }
2130
2131 // Space key - enable pan mode
2132 if (e.key === ' ' && !$(e.target).is('input, textarea')) {
2133 e.preventDefault();
2134 if (!self.spaceKeyHeld) {
2135 self.spaceKeyHeld = true;
2136 $(self.svg).css('cursor', 'grab');
2137 }
2138 }
2139 });
2140
2141 // Space key release - disable pan mode
2142 $(document).on('keyup.sasoSeatingDesigner', function(e) {
2143 if (e.key === ' ') {
2144 self.spaceKeyHeld = false;
2145 if (!self.isPanning) {
2146 // Restore cursor based on current tool
2147 self.setTool(self.currentTool);
2148 }
2149 }
2150 });
2151
2152 // Zoom button handlers
2153 $container.on('click', '.saso-zoom-in', function(e) {
2154 e.preventDefault();
2155 self.zoomIn();
2156 });
2157
2158 $container.on('click', '.saso-zoom-out', function(e) {
2159 e.preventDefault();
2160 self.zoomOut();
2161 });
2162
2163 $container.on('click', '.saso-fit-view', function(e) {
2164 e.preventDefault();
2165 self.fitToView();
2166 });
2167
2168 $container.on('click', '.saso-reset-zoom', function(e) {
2169 e.preventDefault();
2170 self.resetZoom();
2171 });
2172 };
2173
2174 // =========================================================================
2175 // Tool Management
2176 // =========================================================================
2177
2178 /**
2179 * Set current tool
2180 *
2181 * @param {string} tool Tool ID
2182 */
2183 SeatingDesigner.prototype.setTool = function(tool) {
2184 this.currentTool = tool;
2185
2186 // Update toolbar UI
2187 $(this.config.container).find('.saso-tool-btn').removeClass('active');
2188 $(this.config.container).find('.saso-tool-btn[data-tool="' + tool + '"]').addClass('active');
2189
2190 // Update cursor
2191 var cursor = 'default';
2192 switch (tool) {
2193 case 'select':
2194 cursor = 'default';
2195 break;
2196 case 'seat':
2197 case 'rect':
2198 case 'circle':
2199 case 'text':
2200 case 'line':
2201 case 'row':
2202 cursor = 'crosshair';
2203 break;
2204 }
2205 $(this.svg).css('cursor', cursor);
2206
2207 // Reset line drawing state
2208 if (tool !== 'line') {
2209 this.isDrawingLine = false;
2210 this.lineStart = null;
2211 }
2212
2213 // Reset bulk insert state
2214 if (tool !== 'row') {
2215 this.clearBulkInsertPreview();
2216 this.bulkInsertStart = null;
2217 }
2218 };
2219
2220 // =========================================================================
2221 // Zoom and Pan
2222 // =========================================================================
2223
2224 /**
2225 * Update the SVG viewBox based on current zoom and pan
2226 */
2227 SeatingDesigner.prototype.updateViewBox = function() {
2228 var width = this.config.canvasWidth / this.zoom;
2229 var height = this.config.canvasHeight / this.zoom;
2230
2231 // Ensure pan stays within reasonable bounds
2232 var maxPanX = this.config.canvasWidth - width;
2233 var maxPanY = this.config.canvasHeight - height;
2234
2235 // Allow panning past edges when zoomed in
2236 if (this.zoom > 1) {
2237 this.pan.x = Math.max(0, Math.min(maxPanX, this.pan.x));
2238 this.pan.y = Math.max(0, Math.min(maxPanY, this.pan.y));
2239 } else {
2240 // When zoomed out, center the content
2241 this.pan.x = (this.config.canvasWidth - width) / 2;
2242 this.pan.y = (this.config.canvasHeight - height) / 2;
2243 }
2244
2245 var viewBox = this.pan.x + ' ' + this.pan.y + ' ' + width + ' ' + height;
2246 this.svg.setAttribute('viewBox', viewBox);
2247
2248 // Update zoom level display
2249 $(this.config.container).find('.saso-zoom-level').text(Math.round(this.zoom * 100) + '%');
2250 };
2251
2252 /**
2253 * Set zoom level
2254 *
2255 * @param {number} level Zoom level (1 = 100%)
2256 */
2257 SeatingDesigner.prototype.setZoom = function(level) {
2258 this.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, level));
2259 this.updateViewBox();
2260 };
2261
2262 /**
2263 * Zoom in by a fixed step
2264 */
2265 SeatingDesigner.prototype.zoomIn = function() {
2266 var step = this.zoom < 1 ? 0.25 : 0.5;
2267 this.setZoom(this.zoom + step);
2268 };
2269
2270 /**
2271 * Zoom out by a fixed step
2272 */
2273 SeatingDesigner.prototype.zoomOut = function() {
2274 var step = this.zoom <= 1 ? 0.25 : 0.5;
2275 this.setZoom(this.zoom - step);
2276 };
2277
2278 /**
2279 * Reset zoom to 100%
2280 */
2281 SeatingDesigner.prototype.resetZoom = function() {
2282 this.zoom = 1;
2283 this.pan = { x: 0, y: 0 };
2284 this.updateViewBox();
2285 };
2286
2287 /**
2288 * Zoom at a specific point (for mouse wheel zoom)
2289 *
2290 * @param {number} delta Zoom delta (positive = zoom in, negative = zoom out)
2291 * @param {Object} point {x, y} point to zoom at (canvas coordinates)
2292 */
2293 SeatingDesigner.prototype.zoomAtPoint = function(delta, point) {
2294 var oldZoom = this.zoom;
2295 var factor = delta > 0 ? 1.1 : 0.9;
2296 var newZoom = Math.max(this.minZoom, Math.min(this.maxZoom, oldZoom * factor));
2297
2298 if (newZoom === oldZoom) return;
2299
2300 // Calculate the point in viewBox coordinates before zoom
2301 var viewBoxX = this.pan.x + point.x / oldZoom;
2302 var viewBoxY = this.pan.y + point.y / oldZoom;
2303
2304 // Update zoom
2305 this.zoom = newZoom;
2306
2307 // Adjust pan so the point under the cursor stays in the same place
2308 this.pan.x = viewBoxX - point.x / newZoom;
2309 this.pan.y = viewBoxY - point.y / newZoom;
2310
2311 this.updateViewBox();
2312 };
2313
2314 /**
2315 * Fit all content to view
2316 */
2317 SeatingDesigner.prototype.fitToView = function() {
2318 // Calculate bounding box of all elements
2319 var bounds = this.getContentBounds();
2320
2321 if (!bounds) {
2322 // No content, reset to default
2323 this.resetZoom();
2324 return;
2325 }
2326
2327 // Add some padding
2328 var padding = 40;
2329 bounds.minX -= padding;
2330 bounds.minY -= padding;
2331 bounds.maxX += padding;
2332 bounds.maxY += padding;
2333
2334 var contentWidth = bounds.maxX - bounds.minX;
2335 var contentHeight = bounds.maxY - bounds.minY;
2336
2337 // Calculate zoom to fit content
2338 var zoomX = this.config.canvasWidth / contentWidth;
2339 var zoomY = this.config.canvasHeight / contentHeight;
2340 var fitZoom = Math.min(zoomX, zoomY, this.maxZoom);
2341
2342 // Calculate pan to center content
2343 var viewWidth = this.config.canvasWidth / fitZoom;
2344 var viewHeight = this.config.canvasHeight / fitZoom;
2345 var centerX = (bounds.minX + bounds.maxX) / 2;
2346 var centerY = (bounds.minY + bounds.maxY) / 2;
2347
2348 this.zoom = fitZoom;
2349 this.pan.x = centerX - viewWidth / 2;
2350 this.pan.y = centerY - viewHeight / 2;
2351
2352 this.updateViewBox();
2353 };
2354
2355 /**
2356 * Get bounding box of all content
2357 *
2358 * @return {Object|null} {minX, minY, maxX, maxY} or null if no content
2359 */
2360 SeatingDesigner.prototype.getContentBounds = function() {
2361 var bounds = null;
2362
2363 var updateBounds = function(x, y, w, h) {
2364 w = w || 0;
2365 h = h || 0;
2366 if (!bounds) {
2367 bounds = { minX: x, minY: y, maxX: x + w, maxY: y + h };
2368 } else {
2369 bounds.minX = Math.min(bounds.minX, x);
2370 bounds.minY = Math.min(bounds.minY, y);
2371 bounds.maxX = Math.max(bounds.maxX, x + w);
2372 bounds.maxY = Math.max(bounds.maxY, y + h);
2373 }
2374 };
2375
2376 // Check all element types
2377 var allElements = [].concat(
2378 this.elements.seats,
2379 this.elements.decorations,
2380 this.elements.labels
2381 );
2382
2383 allElements.forEach(function(el) {
2384 if (el.type === 'circle') {
2385 updateBounds(el.x - el.r, el.y - el.r, el.r * 2, el.r * 2);
2386 } else if (el.type === 'text') {
2387 // Approximate text bounds
2388 updateBounds(el.x, el.y - 20, 100, 30);
2389 } else {
2390 updateBounds(el.x, el.y, el.width || 40, el.height || 40);
2391 }
2392 });
2393
2394 // Check lines
2395 this.elements.lines.forEach(function(line) {
2396 updateBounds(Math.min(line.x1, line.x2), Math.min(line.y1, line.y2),
2397 Math.abs(line.x2 - line.x1), Math.abs(line.y2 - line.y1));
2398 });
2399
2400 return bounds;
2401 };
2402
2403 /**
2404 * Start panning
2405 *
2406 * @param {Object} screenPos {x, y} screen position
2407 */
2408 SeatingDesigner.prototype.startPan = function(screenPos) {
2409 this.isPanning = true;
2410 this.panStart = {
2411 x: screenPos.x,
2412 y: screenPos.y,
2413 panX: this.pan.x,
2414 panY: this.pan.y
2415 };
2416 $(this.svg).addClass('panning');
2417 };
2418
2419 /**
2420 * Handle panning during mouse move
2421 *
2422 * @param {Object} screenPos {x, y} screen position
2423 */
2424 SeatingDesigner.prototype.handlePan = function(screenPos) {
2425 if (!this.isPanning) return;
2426
2427 // Calculate delta in screen coordinates, then convert to viewBox coordinates
2428 var deltaX = (screenPos.x - this.panStart.x) / this.zoom;
2429 var deltaY = (screenPos.y - this.panStart.y) / this.zoom;
2430
2431 this.pan.x = this.panStart.panX - deltaX;
2432 this.pan.y = this.panStart.panY - deltaY;
2433
2434 this.updateViewBox();
2435 };
2436
2437 /**
2438 * End panning
2439 */
2440 SeatingDesigner.prototype.endPan = function() {
2441 this.isPanning = false;
2442 $(this.svg).removeClass('panning');
2443 // Restore cursor based on current tool (or grab if space still held)
2444 if (this.spaceKeyHeld) {
2445 $(this.svg).css('cursor', 'grab');
2446 } else {
2447 this.setTool(this.currentTool);
2448 }
2449 };
2450
2451 // =========================================================================
2452 // Canvas Event Handlers
2453 // =========================================================================
2454
2455 /**
2456 * Handle mouse down on canvas
2457 *
2458 * @param {Event} e Mouse event
2459 */
2460 SeatingDesigner.prototype.handleCanvasMouseDown = function(e) {
2461 // Middle mouse (button === 1), right-click (button === 2), or Space+Click - start panning
2462 if (e.button === 1 || e.button === 2 || (this.spaceKeyHeld && e.button === 0)) {
2463 e.preventDefault();
2464 var rect = this.svg.getBoundingClientRect();
2465 this.startPan({
2466 x: e.clientX - rect.left,
2467 y: e.clientY - rect.top
2468 });
2469 return;
2470 }
2471
2472 var pos = this.getCanvasPosition(e);
2473 var target = e.target;
2474 var $el = null;
2475 var clickedOnElement = false;
2476
2477 // Check if clicking on a resize handle
2478 if ($(target).hasClass('saso-resize-handle')) {
2479 var handlePos = $(target).data('handle');
2480 this.startResize(handlePos, pos);
2481 return;
2482 }
2483
2484 // Check if clicking on an existing element
2485 if ($(target).hasClass('saso-element') || $(target).closest('.saso-element').length) {
2486 $el = $(target).hasClass('saso-element') ? $(target) : $(target).closest('.saso-element');
2487 clickedOnElement = true;
2488 }
2489
2490 // If clicking on element with any tool except delete: select it and switch to select tool
2491 if (clickedOnElement && this.currentTool !== 'delete' && this.currentTool !== 'select') {
2492 var id = $el.data('id');
2493 this.setTool('select');
2494 // If already selected, keep selection; otherwise select this element
2495 if (!this.isSelected(id)) {
2496 this.selectElement(id, e.shiftKey);
2497 }
2498 this.startDrag(pos);
2499 return;
2500 }
2501
2502 switch (this.currentTool) {
2503 case 'select':
2504 if (clickedOnElement) {
2505 var id = $el.data('id');
2506 // If element is already selected and no shift: keep selection, just start drag
2507 if (this.isSelected(id) && !e.shiftKey) {
2508 // Already selected - start dragging all selected elements
2509 this.startDrag(pos);
2510 } else {
2511 // Select (or toggle with shift) and start drag
2512 this.selectElement(id, e.shiftKey);
2513 this.startDrag(pos);
2514 }
2515 } else if (!e.shiftKey) {
2516 // Start marquee selection on empty canvas (only if not Shift+Click)
2517 this.startMarqueeSelection(pos);
2518 } else {
2519 // Shift+Click on empty area - do nothing (keep selection)
2520 }
2521 break;
2522
2523 case 'seat':
2524 this.addSeat(pos.x, pos.y);
2525 break;
2526
2527 case 'rect':
2528 this.addShape('rect', pos.x, pos.y);
2529 break;
2530
2531 case 'circle':
2532 this.addShape('circle', pos.x, pos.y);
2533 break;
2534
2535 case 'line':
2536 if (!this.isDrawingLine) {
2537 this.lineStart = pos;
2538 this.isDrawingLine = true;
2539 } else {
2540 this.addLine(this.lineStart.x, this.lineStart.y, pos.x, pos.y);
2541 this.isDrawingLine = false;
2542 this.lineStart = null;
2543 }
2544 break;
2545
2546 case 'text':
2547 this.addText(pos.x, pos.y);
2548 break;
2549
2550 case 'row':
2551 this.showBulkInsertModal(pos.x, pos.y);
2552 break;
2553 }
2554 };
2555
2556 /**
2557 * Handle mouse move on canvas
2558 *
2559 * @param {Event} e Mouse event
2560 */
2561 SeatingDesigner.prototype.handleCanvasMouseMove = function(e) {
2562 // Handle panning (check first, uses screen coordinates)
2563 if (this.isPanning) {
2564 var rect = this.svg.getBoundingClientRect();
2565 this.handlePan({
2566 x: e.clientX - rect.left,
2567 y: e.clientY - rect.top
2568 });
2569 return;
2570 }
2571
2572 var pos = this.getCanvasPosition(e);
2573
2574 // Handle resizing
2575 if (this.isResizing) {
2576 this.handleResize(pos);
2577 return;
2578 }
2579
2580 // Handle marquee selection
2581 if (this.isMarqueeSelecting) {
2582 this.updateMarquee(pos);
2583 return;
2584 }
2585
2586 // Handle dragging (multi or single)
2587 if (this.isDragging && this.selectedElements.length > 0) {
2588 this.dragElements(pos);
2589 }
2590 };
2591
2592 /**
2593 * Handle mouse up on canvas
2594 *
2595 * @param {Event} e Mouse event
2596 */
2597 SeatingDesigner.prototype.handleCanvasMouseUp = function(e) {
2598 if (this.isPanning) {
2599 this.endPan();
2600 }
2601 if (this.isResizing) {
2602 this.endResize();
2603 }
2604 if (this.isMarqueeSelecting) {
2605 this.endMarqueeSelection(e.shiftKey);
2606 }
2607 if (this.isDragging) {
2608 this.endDrag();
2609 }
2610 };
2611
2612 /**
2613 * Get position relative to canvas (accounting for zoom/pan)
2614 *
2615 * @param {Event} e Mouse event
2616 * @return {Object} {x, y} in SVG coordinate space
2617 */
2618 SeatingDesigner.prototype.getCanvasPosition = function(e) {
2619 var rect = this.svg.getBoundingClientRect();
2620
2621 // Screen position relative to SVG element
2622 var screenX = e.clientX - rect.left;
2623 var screenY = e.clientY - rect.top;
2624
2625 // Convert to SVG coordinate space (accounting for viewBox)
2626 // Screen coordinates map to viewBox coordinates
2627 var viewBoxWidth = this.config.canvasWidth / this.zoom;
2628 var viewBoxHeight = this.config.canvasHeight / this.zoom;
2629
2630 // Scale factor from screen to viewBox
2631 var scaleX = viewBoxWidth / rect.width;
2632 var scaleY = viewBoxHeight / rect.height;
2633
2634 // Convert screen position to viewBox position
2635 var x = this.pan.x + (screenX * scaleX);
2636 var y = this.pan.y + (screenY * scaleY);
2637
2638 // Snap to grid
2639 if (this.config.snapToGrid) {
2640 x = Math.round(x / this.config.gridSize) * this.config.gridSize;
2641 y = Math.round(y / this.config.gridSize) * this.config.gridSize;
2642 }
2643
2644 return { x: x, y: y };
2645 };
2646
2647 // =========================================================================
2648 // Element Creation
2649 // =========================================================================
2650
2651 /**
2652 * Add a shape (rect or circle)
2653 *
2654 * @param {string} type 'rect' or 'circle'
2655 * @param {number} x X position
2656 * @param {number} y Y position
2657 */
2658 SeatingDesigner.prototype.addShape = function(type, x, y) {
2659 var id = 'shape_' + this.nextId++;
2660 var shapeNum = this.elements.decorations.length + 1;
2661 var element = {
2662 id: id,
2663 type: type,
2664 x: x,
2665 y: y,
2666 rotation: 0,
2667 width: 40,
2668 height: 40,
2669 r: 20,
2670 fill: '#cccccc',
2671 fillOpacity: 100,
2672 stroke: '#333333',
2673 strokeOpacity: 0,
2674 isSeat: false,
2675 identifier: '',
2676 label: type === 'rect' ? 'Rect ' + shapeNum : 'Circle ' + shapeNum,
2677 labelColor: '#333333',
2678 labelColorOpacity: 100,
2679 labelStroke: '#ffffff',
2680 labelStrokeOpacity: 0,
2681 category: '',
2682 description: ''
2683 };
2684
2685 this.elements.decorations.push(element);
2686 this.renderElement(element);
2687 this.selectElement(id);
2688 this.markUnsaved();
2689 };
2690
2691 /**
2692 * Add a seat directly (dedicated seat tool)
2693 *
2694 * @param {number} x X position
2695 * @param {number} y Y position
2696 */
2697 SeatingDesigner.prototype.addSeat = function(x, y) {
2698 var seatNum = this.elements.seats.length + 1;
2699 var id = 'seat_new_' + this.nextId++;
2700
2701 var element = {
2702 id: id,
2703 type: 'rect',
2704 x: x,
2705 y: y,
2706 rotation: 0,
2707 width: 30,
2708 height: 30,
2709 fill: this.config.colors.available,
2710 fillOpacity: 100,
2711 stroke: '#333333',
2712 strokeOpacity: 0,
2713 isSeat: true,
2714 identifier: 'SEAT-' + seatNum,
2715 label: 'Seat ' + seatNum,
2716 labelColor: '#333333',
2717 labelColorOpacity: 100,
2718 labelStroke: '#ffffff',
2719 labelStrokeOpacity: 0,
2720 category: '',
2721 seat_desc: ''
2722 };
2723
2724 this.elements.seats.push(element);
2725 this.renderElement(element);
2726 this.selectElement(id);
2727 this.markUnsaved();
2728 };
2729
2730 /**
2731 * Add a line
2732 *
2733 * @param {number} x1 Start X
2734 * @param {number} y1 Start Y
2735 * @param {number} x2 End X
2736 * @param {number} y2 End Y
2737 */
2738 SeatingDesigner.prototype.addLine = function(x1, y1, x2, y2) {
2739 var id = 'line_' + this.nextId++;
2740 var element = {
2741 id: id,
2742 type: 'line',
2743 x1: x1,
2744 y1: y1,
2745 x2: x2,
2746 y2: y2,
2747 x: x1,
2748 y: y1,
2749 stroke: '#333333',
2750 strokeWidth: 2,
2751 label: '',
2752 description: ''
2753 };
2754
2755 this.elements.lines.push(element);
2756 this.renderElement(element);
2757 this.selectElement(id);
2758 this.markUnsaved();
2759 };
2760
2761 /**
2762 * Add a text label
2763 *
2764 * @param {number} x X position
2765 * @param {number} y Y position
2766 */
2767 SeatingDesigner.prototype.addText = function(x, y) {
2768 var id = 'label_' + this.nextId++;
2769 var text = prompt(this.config.i18n.enterText || 'Enter text:', 'Label');
2770 if (!text) return;
2771
2772 var element = {
2773 id: id,
2774 type: 'text',
2775 x: x,
2776 y: y,
2777 text: text,
2778 fontSize: 14,
2779 fill: '#333333',
2780 fontWeight: 'normal',
2781 description: ''
2782 };
2783
2784 this.elements.labels.push(element);
2785 this.renderElement(element);
2786 this.selectElement(id);
2787 this.markUnsaved();
2788 };
2789
2790 // =========================================================================
2791 // Element Rendering
2792 // =========================================================================
2793
2794 /**
2795 * Apply rotation transform to SVG element
2796 *
2797 * @param {SVGElement} svgEl SVG element
2798 * @param {Object} element Element data
2799 */
2800 SeatingDesigner.prototype.applyRotation = function(svgEl, element) {
2801 var rotation = parseInt(element.rotation) || 0;
2802 if (rotation !== 0) {
2803 var cx, cy;
2804 if (element.type === 'circle') {
2805 cx = element.x + (element.r || 20);
2806 cy = element.y + (element.r || 20);
2807 } else {
2808 cx = element.x + (element.width || 40) / 2;
2809 cy = element.y + (element.height || 40) / 2;
2810 }
2811 svgEl.setAttribute('transform', 'rotate(' + rotation + ', ' + cx + ', ' + cy + ')');
2812 } else {
2813 svgEl.removeAttribute('transform');
2814 }
2815 };
2816
2817 /**
2818 * Render a single element
2819 *
2820 * @param {Object} element Element data
2821 */
2822 SeatingDesigner.prototype.renderElement = function(element) {
2823 var svgNS = 'http://www.w3.org/2000/svg';
2824 var el;
2825 var labelText = null;
2826
2827 // Get opacity values
2828 var fillOpacity = element.fillOpacity !== undefined ? element.fillOpacity / 100 : 1;
2829 var strokeOpacity = element.strokeOpacity !== undefined ? element.strokeOpacity / 100 : 0;
2830
2831 switch (element.type) {
2832 case 'rect':
2833 case 'seat':
2834 el = document.createElementNS(svgNS, 'rect');
2835 el.setAttribute('x', element.x);
2836 el.setAttribute('y', element.y);
2837 el.setAttribute('width', element.width || 40);
2838 el.setAttribute('height', element.height || 40);
2839 el.setAttribute('fill', element.fill || '#cccccc');
2840 el.setAttribute('fill-opacity', fillOpacity);
2841 el.setAttribute('stroke', element.stroke || '#333333');
2842 el.setAttribute('stroke-opacity', strokeOpacity);
2843 el.setAttribute('stroke-width', strokeOpacity > 0 ? 2 : 0);
2844 el.setAttribute('rx', 3);
2845 // Get label - for seats: label or identifier, for shapes: label
2846 if (element.isSeat) {
2847 labelText = element.label || element.identifier || '';
2848 } else {
2849 labelText = element.label || '';
2850 }
2851 break;
2852
2853 case 'circle':
2854 el = document.createElementNS(svgNS, 'circle');
2855 el.setAttribute('cx', element.x + (element.r || 20));
2856 el.setAttribute('cy', element.y + (element.r || 20));
2857 el.setAttribute('r', element.r || 20);
2858 el.setAttribute('fill', element.fill || '#cccccc');
2859 el.setAttribute('fill-opacity', fillOpacity);
2860 el.setAttribute('stroke', element.stroke || '#333333');
2861 el.setAttribute('stroke-opacity', strokeOpacity);
2862 el.setAttribute('stroke-width', strokeOpacity > 0 ? 2 : 0);
2863 // Get label - for seats: label or identifier, for shapes: label
2864 if (element.isSeat) {
2865 labelText = element.label || element.identifier || '';
2866 } else {
2867 labelText = element.label || '';
2868 }
2869 break;
2870
2871 case 'line':
2872 var lineStrokeOpacity = element.strokeOpacity !== undefined ? element.strokeOpacity / 100 : 1;
2873 el = document.createElementNS(svgNS, 'line');
2874 el.setAttribute('x1', element.x1);
2875 el.setAttribute('y1', element.y1);
2876 el.setAttribute('x2', element.x2);
2877 el.setAttribute('y2', element.y2);
2878 el.setAttribute('stroke', element.stroke || '#333333');
2879 el.setAttribute('stroke-opacity', lineStrokeOpacity);
2880 el.setAttribute('stroke-width', element.strokeWidth || 2);
2881 el.setAttribute('stroke-linecap', 'round');
2882 // Lines can have a label at midpoint
2883 labelText = element.label || '';
2884 break;
2885
2886 case 'text':
2887 el = document.createElementNS(svgNS, 'text');
2888 el.setAttribute('x', element.x);
2889 el.setAttribute('y', element.y);
2890 el.setAttribute('fill', element.fill || '#333333');
2891 el.setAttribute('fill-opacity', fillOpacity);
2892 el.setAttribute('font-size', element.fontSize || 14);
2893 el.setAttribute('font-weight', element.fontWeight || 'normal');
2894 el.textContent = element.text || '';
2895 // Text elements don't need an extra label
2896 break;
2897 }
2898
2899 if (el) {
2900 el.setAttribute('class', 'saso-element' + (element.isSeat ? ' saso-seat' : ''));
2901 el.setAttribute('data-id', element.id);
2902 el.setAttribute('data-type', element.type);
2903
2904 // Apply rotation for shapes
2905 if (element.type === 'rect' || element.type === 'circle' || element.type === 'seat') {
2906 this.applyRotation(el, element);
2907 }
2908
2909 // Add description as title (native tooltip)
2910 var descText = element.seat_desc || element.description || '';
2911 if (descText) {
2912 var title = document.createElementNS(svgNS, 'title');
2913 title.textContent = descText;
2914 el.appendChild(title);
2915 }
2916
2917 // Add to appropriate layer
2918 var layer = this.getLayerForElement(element);
2919 this.layers[layer].appendChild(el);
2920
2921 // Add label text on element (not for text type - it already has text)
2922 if (labelText && element.type !== 'text') {
2923 this.renderElementLabel(element, labelText);
2924 }
2925 }
2926 };
2927
2928 /**
2929 * Render label text on an element
2930 *
2931 * @param {Object} element Element data
2932 * @param {string} text Label text
2933 */
2934 SeatingDesigner.prototype.renderElementLabel = function(element, text) {
2935 var svgNS = 'http://www.w3.org/2000/svg';
2936 var label = document.createElementNS(svgNS, 'text');
2937
2938 // Calculate center position based on element type
2939 var centerX, centerY;
2940 var fillColor = '#333333';
2941 var strokeColor = '#ffffff';
2942 var fontSize = '10';
2943
2944 if (element.type === 'circle') {
2945 centerX = element.x + (element.r || 20);
2946 centerY = element.y + (element.r || 20);
2947 } else if (element.type === 'line') {
2948 // Line: label at midpoint
2949 centerX = (element.x1 + element.x2) / 2;
2950 centerY = (element.y1 + element.y2) / 2;
2951 fillColor = element.stroke || '#333333';
2952 strokeColor = '#ffffff';
2953 fontSize = '9';
2954 } else {
2955 // rect
2956 centerX = element.x + (element.width || 40) / 2;
2957 centerY = element.y + (element.height || 40) / 2;
2958 }
2959
2960 // Use custom label colors if set, otherwise auto-detect based on background
2961 if (element.labelColor || element.labelStroke) {
2962 fillColor = element.labelColor || '#333333';
2963 strokeColor = element.labelStroke || '#ffffff';
2964 } else if (element.type !== 'line') {
2965 // Auto-detect: dark text with light outline on light backgrounds, vice versa
2966 var fill = element.fill || '#cccccc';
2967 if (this.isLightColor(fill)) {
2968 fillColor = '#333333';
2969 strokeColor = '#ffffff';
2970 } else {
2971 fillColor = '#ffffff';
2972 strokeColor = '#333333';
2973 }
2974 }
2975
2976 // Get opacity values (default 100%)
2977 var fillOpacity = element.labelColorOpacity !== undefined ? element.labelColorOpacity / 100 : 1;
2978 var strokeOpacity = element.labelStrokeOpacity !== undefined ? element.labelStrokeOpacity / 100 : 1;
2979
2980 label.setAttribute('x', centerX);
2981 label.setAttribute('y', centerY);
2982 label.setAttribute('text-anchor', 'middle');
2983 label.setAttribute('dominant-baseline', 'middle');
2984 label.setAttribute('fill', fillColor);
2985 label.setAttribute('fill-opacity', fillOpacity);
2986 label.setAttribute('stroke', strokeColor);
2987 label.setAttribute('stroke-opacity', strokeOpacity);
2988 label.setAttribute('stroke-width', '2');
2989 label.setAttribute('paint-order', 'stroke'); // Stroke behind fill
2990 label.setAttribute('font-size', fontSize);
2991 label.setAttribute('font-weight', 'bold');
2992 label.setAttribute('pointer-events', 'none'); // Don't capture clicks
2993 label.setAttribute('class', 'saso-element-label');
2994 label.setAttribute('data-for', element.id);
2995 label.textContent = text;
2996
2997 // Add to labels layer (on top)
2998 this.layers.labels.appendChild(label);
2999 };
3000
3001 /**
3002 * Check if a color is light (for determining text contrast)
3003 *
3004 * @param {string} color Hex color
3005 * @return {boolean} True if light
3006 */
3007 SeatingDesigner.prototype.isLightColor = function(color) {
3008 var hex = color.replace('#', '');
3009 if (hex.length === 3) {
3010 hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
3011 }
3012 var r = parseInt(hex.substr(0, 2), 16);
3013 var g = parseInt(hex.substr(2, 2), 16);
3014 var b = parseInt(hex.substr(4, 2), 16);
3015 // Calculate relative luminance
3016 var luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
3017 return luminance > 0.5;
3018 };
3019
3020 /**
3021 * Get layer name for element type
3022 *
3023 * @param {Object} element Element data
3024 * @return {string} Layer name
3025 */
3026 SeatingDesigner.prototype.getLayerForElement = function(element) {
3027 if (element.type === 'line') return 'lines';
3028 if (element.type === 'text') return 'labels';
3029 if (element.isSeat || element.type === 'seat') return 'seats';
3030 return 'decorations';
3031 };
3032
3033 /**
3034 * Render all elements
3035 */
3036 SeatingDesigner.prototype.renderAllElements = function() {
3037 var self = this;
3038
3039 // Clear layers (except background)
3040 ['lines', 'decorations', 'seats', 'labels'].forEach(function(layer) {
3041 self.layers[layer].innerHTML = '';
3042 });
3043
3044 // Render in order (labels are rendered as part of seats, text labels come last)
3045 this.elements.lines.forEach(function(el) { self.renderElement(el); });
3046 this.elements.decorations.forEach(function(el) { self.renderElement(el); });
3047 this.elements.seats.forEach(function(el) { self.renderElement(el); });
3048 this.elements.labels.forEach(function(el) { self.renderElement(el); });
3049 };
3050
3051 /**
3052 * Update element label position and text
3053 *
3054 * @param {Object} element Element data
3055 */
3056 SeatingDesigner.prototype.updateElementLabel = function(element) {
3057 // Find existing label
3058 var $label = $(this.svg).find('.saso-element-label[data-for="' + element.id + '"]');
3059
3060 // Determine label text
3061 var labelText = '';
3062 if (element.type === 'text') {
3063 // Text elements don't have separate labels
3064 return;
3065 } else if (element.isSeat) {
3066 labelText = element.label || element.identifier || '';
3067 } else {
3068 labelText = element.label || '';
3069 }
3070
3071 // If no label text and label exists, remove it
3072 if (!labelText && $label.length) {
3073 $label.remove();
3074 return;
3075 }
3076
3077 // If label text but no label element, create it
3078 if (labelText && !$label.length) {
3079 this.renderElementLabel(element, labelText);
3080 return;
3081 }
3082
3083 // Update existing label
3084 if (!$label.length) return;
3085
3086 var centerX, centerY;
3087 if (element.type === 'circle') {
3088 centerX = element.x + (element.r || 20);
3089 centerY = element.y + (element.r || 20);
3090 } else if (element.type === 'line') {
3091 centerX = (element.x1 + element.x2) / 2;
3092 centerY = (element.y1 + element.y2) / 2;
3093 } else {
3094 centerX = element.x + (element.width || 40) / 2;
3095 centerY = element.y + (element.height || 40) / 2;
3096 }
3097
3098 $label.attr('x', centerX);
3099 $label.attr('y', centerY);
3100 $label.text(labelText);
3101
3102 // Update text color and stroke - use custom colors if set
3103 if (element.labelColor || element.labelStroke) {
3104 $label.attr('fill', element.labelColor || '#333333');
3105 $label.attr('stroke', element.labelStroke || '#ffffff');
3106 } else if (element.type !== 'line') {
3107 var fill = element.fill || '#cccccc';
3108 var fillColor, strokeColor;
3109 if (this.isLightColor(fill)) {
3110 fillColor = '#333333';
3111 strokeColor = '#ffffff';
3112 } else {
3113 fillColor = '#ffffff';
3114 strokeColor = '#333333';
3115 }
3116 $label.attr('fill', fillColor);
3117 $label.attr('stroke', strokeColor);
3118 }
3119
3120 // Update opacity
3121 var fillOpacity = element.labelColorOpacity !== undefined ? element.labelColorOpacity / 100 : 1;
3122 var strokeOpacity = element.labelStrokeOpacity !== undefined ? element.labelStrokeOpacity / 100 : 1;
3123 $label.attr('fill-opacity', fillOpacity);
3124 $label.attr('stroke-opacity', strokeOpacity);
3125 };
3126
3127 // =========================================================================
3128 // Selection & Drag
3129 // =========================================================================
3130
3131 /**
3132 * Select an element (with optional multi-select via Shift key)
3133 *
3134 * @param {string} id Element ID
3135 * @param {boolean} addToSelection If true, add to existing selection (Shift+Click)
3136 */
3137 SeatingDesigner.prototype.selectElement = function(id, addToSelection) {
3138 var element = this.findElement(id);
3139 if (!element) return;
3140
3141 if (addToSelection) {
3142 // Toggle selection for this element
3143 if (this.isSelected(id)) {
3144 this.removeFromSelection(id);
3145 } else {
3146 this.addToSelection(element);
3147 }
3148 } else {
3149 // Clear and select only this element
3150 this.deselectAll();
3151 this.selectedElement = element;
3152 this.selectedElements = [element];
3153
3154 // Add selection highlight
3155 var $el = $(this.svg).find('.saso-element[data-id="' + id + '"]');
3156 $el.addClass('selected');
3157
3158 // Show resize handles for resizable elements (only for single selection)
3159 if (element.type === 'rect' || element.type === 'circle' || element.type === 'seat') {
3160 this.showResizeHandles(element);
3161 }
3162
3163 // Update properties panel
3164 this.updatePropertiesPanel(element);
3165 }
3166 };
3167
3168 /**
3169 * Check if an element is selected
3170 *
3171 * @param {string} id Element ID
3172 * @return {boolean}
3173 */
3174 SeatingDesigner.prototype.isSelected = function(id) {
3175 return this.selectedElements.some(function(el) {
3176 return el.id === id;
3177 });
3178 };
3179
3180 /**
3181 * Add element to selection
3182 *
3183 * @param {Object} element Element to add
3184 */
3185 SeatingDesigner.prototype.addToSelection = function(element) {
3186 if (this.isSelected(element.id)) return;
3187
3188 this.selectedElements.push(element);
3189 this.selectedElement = element; // Last selected becomes primary
3190
3191 // Add selection highlight
3192 var $el = $(this.svg).find('.saso-element[data-id="' + element.id + '"]');
3193 $el.addClass('selected');
3194
3195 // Hide resize handles when multi-selecting
3196 if (this.selectedElements.length > 1) {
3197 this.hideResizeHandles();
3198 // Add multi-selected class to all selected elements for enhanced styling
3199 var self = this;
3200 this.selectedElements.forEach(function(el) {
3201 $(self.svg).find('.saso-element[data-id="' + el.id + '"]').addClass('multi-selected');
3202 });
3203 }
3204
3205 // Update properties panel for multi-select
3206 this.updatePropertiesPanelMulti();
3207 };
3208
3209 /**
3210 * Remove element from selection
3211 *
3212 * @param {string} id Element ID
3213 */
3214 SeatingDesigner.prototype.removeFromSelection = function(id) {
3215 this.selectedElements = this.selectedElements.filter(function(el) {
3216 return el.id !== id;
3217 });
3218
3219 // Remove highlight
3220 var $el = $(this.svg).find('.saso-element[data-id="' + id + '"]');
3221 $el.removeClass('selected multi-selected');
3222
3223 // Update primary selection
3224 if (this.selectedElements.length > 0) {
3225 this.selectedElement = this.selectedElements[this.selectedElements.length - 1];
3226 if (this.selectedElements.length === 1) {
3227 // Back to single selection - remove multi-selected from remaining element
3228 $(this.svg).find('.saso-element.multi-selected').removeClass('multi-selected');
3229 this.showResizeHandles(this.selectedElement);
3230 this.updatePropertiesPanel(this.selectedElement);
3231 } else {
3232 this.updatePropertiesPanelMulti();
3233 }
3234 } else {
3235 this.selectedElement = null;
3236 this.hideResizeHandles();
3237 this.updatePropertiesPanel(null);
3238 }
3239 };
3240
3241 /**
3242 * Deselect all elements
3243 */
3244 SeatingDesigner.prototype.deselectAll = function() {
3245 this.selectedElement = null;
3246 this.selectedElements = [];
3247 $(this.svg).find('.saso-element').removeClass('selected multi-selected');
3248 this.hideResizeHandles();
3249 this.updatePropertiesPanel(null);
3250 };
3251
3252 /**
3253 * Select all elements
3254 */
3255 SeatingDesigner.prototype.selectAll = function() {
3256 var self = this;
3257 this.deselectAll();
3258
3259 // Collect all elements
3260 var allElements = []
3261 .concat(this.elements.decorations)
3262 .concat(this.elements.lines)
3263 .concat(this.elements.labels)
3264 .concat(this.elements.seats);
3265
3266 allElements.forEach(function(el) {
3267 self.selectedElements.push(el);
3268 $(self.svg).find('.saso-element[data-id="' + el.id + '"]').addClass('selected multi-selected');
3269 });
3270
3271 if (this.selectedElements.length > 0) {
3272 this.selectedElement = this.selectedElements[this.selectedElements.length - 1];
3273 this.updatePropertiesPanelMulti();
3274 }
3275 };
3276
3277 // =========================================================================
3278 // Marquee Selection
3279 // =========================================================================
3280
3281 /**
3282 * Start marquee selection
3283 *
3284 * @param {Object} pos Start position {x, y}
3285 */
3286 SeatingDesigner.prototype.startMarqueeSelection = function(pos) {
3287 this.isMarqueeSelecting = true;
3288 this.marqueeStart = { x: pos.x, y: pos.y };
3289
3290 // Create marquee rectangle in SVG
3291 var svgNS = 'http://www.w3.org/2000/svg';
3292 this.marqueeRect = document.createElementNS(svgNS, 'rect');
3293 this.marqueeRect.setAttribute('class', 'saso-marquee');
3294 this.marqueeRect.setAttribute('x', pos.x);
3295 this.marqueeRect.setAttribute('y', pos.y);
3296 this.marqueeRect.setAttribute('width', 0);
3297 this.marqueeRect.setAttribute('height', 0);
3298 this.marqueeRect.setAttribute('fill', 'rgba(33, 150, 243, 0.2)');
3299 this.marqueeRect.setAttribute('stroke', '#2196F3');
3300 this.marqueeRect.setAttribute('stroke-width', '1');
3301 this.marqueeRect.setAttribute('stroke-dasharray', '4,4');
3302 this.svg.appendChild(this.marqueeRect);
3303 };
3304
3305 /**
3306 * Update marquee rectangle during drag
3307 *
3308 * @param {Object} pos Current position {x, y}
3309 */
3310 SeatingDesigner.prototype.updateMarquee = function(pos) {
3311 if (!this.marqueeRect) return;
3312
3313 var x = Math.min(this.marqueeStart.x, pos.x);
3314 var y = Math.min(this.marqueeStart.y, pos.y);
3315 var width = Math.abs(pos.x - this.marqueeStart.x);
3316 var height = Math.abs(pos.y - this.marqueeStart.y);
3317
3318 this.marqueeRect.setAttribute('x', x);
3319 this.marqueeRect.setAttribute('y', y);
3320 this.marqueeRect.setAttribute('width', width);
3321 this.marqueeRect.setAttribute('height', height);
3322 };
3323
3324 /**
3325 * End marquee selection and select elements within bounds
3326 *
3327 * @param {boolean} addToExisting If true, add to existing selection (Shift held)
3328 */
3329 SeatingDesigner.prototype.endMarqueeSelection = function(addToExisting) {
3330 if (!this.marqueeRect) {
3331 this.isMarqueeSelecting = false;
3332 return;
3333 }
3334
3335 var x = parseFloat(this.marqueeRect.getAttribute('x'));
3336 var y = parseFloat(this.marqueeRect.getAttribute('y'));
3337 var width = parseFloat(this.marqueeRect.getAttribute('width'));
3338 var height = parseFloat(this.marqueeRect.getAttribute('height'));
3339
3340 // Remove marquee rectangle
3341 this.marqueeRect.remove();
3342 this.marqueeRect = null;
3343 this.isMarqueeSelecting = false;
3344
3345 // If marquee is too small, treat as click (deselect)
3346 if (width < 5 && height < 5) {
3347 if (!addToExisting) {
3348 this.deselectAll();
3349 }
3350 return;
3351 }
3352
3353 // Find elements within the marquee bounds
3354 var marqueeBounds = {
3355 left: x,
3356 right: x + width,
3357 top: y,
3358 bottom: y + height
3359 };
3360
3361 // Clear selection if not adding to existing
3362 if (!addToExisting) {
3363 this.deselectAll();
3364 }
3365
3366 var self = this;
3367
3368 // Check all elements
3369 var allElements = []
3370 .concat(this.elements.decorations)
3371 .concat(this.elements.lines)
3372 .concat(this.elements.labels)
3373 .concat(this.elements.seats);
3374
3375 allElements.forEach(function(el) {
3376 if (self.elementIntersectsMarquee(el, marqueeBounds)) {
3377 if (!self.isSelected(el.id)) {
3378 self.addToSelection(el);
3379 }
3380 }
3381 });
3382
3383 // Update UI
3384 if (this.selectedElements.length === 1) {
3385 this.showResizeHandles(this.selectedElement);
3386 this.updatePropertiesPanel(this.selectedElement);
3387 } else if (this.selectedElements.length > 1) {
3388 this.updatePropertiesPanelMulti();
3389 }
3390 };
3391
3392 /**
3393 * Check if element intersects with marquee bounds
3394 *
3395 * @param {Object} el Element
3396 * @param {Object} bounds Marquee bounds {left, right, top, bottom}
3397 * @return {boolean}
3398 */
3399 SeatingDesigner.prototype.elementIntersectsMarquee = function(el, bounds) {
3400 var elBounds = this.getElementBounds(el);
3401
3402 // Check intersection
3403 return !(elBounds.right < bounds.left ||
3404 elBounds.left > bounds.right ||
3405 elBounds.bottom < bounds.top ||
3406 elBounds.top > bounds.bottom);
3407 };
3408
3409 /**
3410 * Get bounding box for an element
3411 *
3412 * @param {Object} el Element
3413 * @return {Object} {left, right, top, bottom}
3414 */
3415 SeatingDesigner.prototype.getElementBounds = function(el) {
3416 var x = el.x || 0;
3417 var y = el.y || 0;
3418
3419 if (el.type === 'circle' || el.type === 'seat') {
3420 var r = el.r || el.width / 2 || 15;
3421 return {
3422 left: x - r,
3423 right: x + r,
3424 top: y - r,
3425 bottom: y + r
3426 };
3427 } else if (el.type === 'rect') {
3428 return {
3429 left: x,
3430 right: x + (el.width || 50),
3431 top: y,
3432 bottom: y + (el.height || 50)
3433 };
3434 } else if (el.type === 'line') {
3435 var x1 = el.x1 || 0;
3436 var y1 = el.y1 || 0;
3437 var x2 = el.x2 || 0;
3438 var y2 = el.y2 || 0;
3439 return {
3440 left: Math.min(x1, x2),
3441 right: Math.max(x1, x2),
3442 top: Math.min(y1, y2),
3443 bottom: Math.max(y1, y2)
3444 };
3445 } else if (el.type === 'label') {
3446 // Labels - approximate based on text length
3447 var textLen = (el.text || '').length * 6;
3448 return {
3449 left: x,
3450 right: x + textLen,
3451 top: y - 10,
3452 bottom: y + 10
3453 };
3454 }
3455
3456 // Default fallback
3457 return {
3458 left: x - 20,
3459 right: x + 20,
3460 top: y - 20,
3461 bottom: y + 20
3462 };
3463 };
3464
3465 // =========================================================================
3466 // Resize Handles
3467 // =========================================================================
3468
3469 /**
3470 * Show resize handles for an element
3471 *
3472 * @param {Object} element Element data
3473 */
3474 SeatingDesigner.prototype.showResizeHandles = function(element) {
3475 this.hideResizeHandles();
3476
3477 var svgNS = 'http://www.w3.org/2000/svg';
3478 var handleSize = 8;
3479 var handles = [];
3480
3481 if (element.type === 'circle') {
3482 // Circle: 4 handles at cardinal points
3483 var cx = element.x + (element.r || 20);
3484 var cy = element.y + (element.r || 20);
3485 var r = element.r || 20;
3486
3487 handles = [
3488 { pos: 'n', x: cx - handleSize/2, y: cy - r - handleSize/2 },
3489 { pos: 'e', x: cx + r - handleSize/2, y: cy - handleSize/2 },
3490 { pos: 's', x: cx - handleSize/2, y: cy + r - handleSize/2 },
3491 { pos: 'w', x: cx - r - handleSize/2, y: cy - handleSize/2 }
3492 ];
3493 } else {
3494 // Rectangle: 8 handles at corners and edges
3495 var x = element.x;
3496 var y = element.y;
3497 var w = element.width || 40;
3498 var h = element.height || 40;
3499
3500 handles = [
3501 { pos: 'nw', x: x - handleSize/2, y: y - handleSize/2 },
3502 { pos: 'n', x: x + w/2 - handleSize/2, y: y - handleSize/2 },
3503 { pos: 'ne', x: x + w - handleSize/2, y: y - handleSize/2 },
3504 { pos: 'e', x: x + w - handleSize/2, y: y + h/2 - handleSize/2 },
3505 { pos: 'se', x: x + w - handleSize/2, y: y + h - handleSize/2 },
3506 { pos: 's', x: x + w/2 - handleSize/2, y: y + h - handleSize/2 },
3507 { pos: 'sw', x: x - handleSize/2, y: y + h - handleSize/2 },
3508 { pos: 'w', x: x - handleSize/2, y: y + h/2 - handleSize/2 }
3509 ];
3510 }
3511
3512 var self = this;
3513 handles.forEach(function(handle) {
3514 var rect = document.createElementNS(svgNS, 'rect');
3515 rect.setAttribute('x', handle.x);
3516 rect.setAttribute('y', handle.y);
3517 rect.setAttribute('width', handleSize);
3518 rect.setAttribute('height', handleSize);
3519 rect.setAttribute('fill', '#2271b1');
3520 rect.setAttribute('stroke', '#ffffff');
3521 rect.setAttribute('stroke-width', '1');
3522 rect.setAttribute('class', 'saso-resize-handle');
3523 rect.setAttribute('data-handle', handle.pos);
3524 rect.style.cursor = self.getResizeCursor(handle.pos);
3525
3526 self.svg.appendChild(rect);
3527 });
3528 };
3529
3530 /**
3531 * Hide resize handles
3532 */
3533 SeatingDesigner.prototype.hideResizeHandles = function() {
3534 $(this.svg).find('.saso-resize-handle').remove();
3535 };
3536
3537 /**
3538 * Update resize handles position
3539 */
3540 SeatingDesigner.prototype.updateResizeHandles = function() {
3541 if (this.selectedElement) {
3542 this.showResizeHandles(this.selectedElement);
3543 }
3544 };
3545
3546 /**
3547 * Get cursor style for resize handle
3548 *
3549 * @param {string} pos Handle position
3550 * @return {string} CSS cursor value
3551 */
3552 SeatingDesigner.prototype.getResizeCursor = function(pos) {
3553 var cursors = {
3554 'nw': 'nwse-resize',
3555 'n': 'ns-resize',
3556 'ne': 'nesw-resize',
3557 'e': 'ew-resize',
3558 'se': 'nwse-resize',
3559 's': 'ns-resize',
3560 'sw': 'nesw-resize',
3561 'w': 'ew-resize'
3562 };
3563 return cursors[pos] || 'pointer';
3564 };
3565
3566 /**
3567 * Start resizing
3568 *
3569 * @param {string} handle Handle position
3570 * @param {Object} pos Mouse position
3571 */
3572 SeatingDesigner.prototype.startResize = function(handle, pos) {
3573 if (!this.selectedElement) return;
3574
3575 this.isResizing = true;
3576 this.resizeHandle = handle;
3577
3578 var el = this.selectedElement;
3579 this.resizeStart = {
3580 x: el.x,
3581 y: el.y,
3582 width: el.width || 40,
3583 height: el.height || 40,
3584 r: el.r || 20,
3585 mouseX: pos.x,
3586 mouseY: pos.y
3587 };
3588 };
3589
3590 /**
3591 * Handle resize drag
3592 *
3593 * @param {Object} pos Mouse position
3594 */
3595 SeatingDesigner.prototype.handleResize = function(pos) {
3596 if (!this.isResizing || !this.selectedElement) return;
3597
3598 var el = this.selectedElement;
3599 var start = this.resizeStart;
3600 var dx = pos.x - start.mouseX;
3601 var dy = pos.y - start.mouseY;
3602 var minSize = 20;
3603
3604 if (el.type === 'circle') {
3605 // Circle: resize radius based on distance from center
3606 var newR;
3607 if (this.resizeHandle === 'n' || this.resizeHandle === 's') {
3608 newR = Math.max(minSize/2, start.r + (this.resizeHandle === 's' ? dy : -dy));
3609 } else {
3610 newR = Math.max(minSize/2, start.r + (this.resizeHandle === 'e' ? dx : -dx));
3611 }
3612
3613 // Snap to grid
3614 if (this.config.snapToGrid) {
3615 newR = Math.round(newR / this.config.gridSize) * this.config.gridSize;
3616 }
3617
3618 el.r = newR;
3619 // Adjust position to keep center stable for n/w handles
3620 if (this.resizeHandle === 'n') {
3621 el.y = start.y + start.r - newR;
3622 } else if (this.resizeHandle === 'w') {
3623 el.x = start.x + start.r - newR;
3624 }
3625 } else {
3626 // Rectangle: resize based on handle position
3627 var newX = start.x;
3628 var newY = start.y;
3629 var newW = start.width;
3630 var newH = start.height;
3631
3632 // Handle horizontal resize
3633 if (this.resizeHandle.includes('e')) {
3634 newW = Math.max(minSize, start.width + dx);
3635 } else if (this.resizeHandle.includes('w')) {
3636 newW = Math.max(minSize, start.width - dx);
3637 newX = start.x + start.width - newW;
3638 }
3639
3640 // Handle vertical resize
3641 if (this.resizeHandle.includes('s')) {
3642 newH = Math.max(minSize, start.height + dy);
3643 } else if (this.resizeHandle.includes('n')) {
3644 newH = Math.max(minSize, start.height - dy);
3645 newY = start.y + start.height - newH;
3646 }
3647
3648 // Snap to grid
3649 if (this.config.snapToGrid) {
3650 newX = Math.round(newX / this.config.gridSize) * this.config.gridSize;
3651 newY = Math.round(newY / this.config.gridSize) * this.config.gridSize;
3652 newW = Math.round(newW / this.config.gridSize) * this.config.gridSize;
3653 newH = Math.round(newH / this.config.gridSize) * this.config.gridSize;
3654 }
3655
3656 el.x = newX;
3657 el.y = newY;
3658 el.width = newW;
3659 el.height = newH;
3660 }
3661
3662 // Update SVG and handles
3663 this.updateSvgElement(el);
3664 this.updateResizeHandles();
3665
3666 // Update properties panel
3667 this.updatePropertiesPanel(el);
3668 };
3669
3670 /**
3671 * End resizing
3672 */
3673 SeatingDesigner.prototype.endResize = function() {
3674 if (this.isResizing) {
3675 this.isResizing = false;
3676 this.resizeHandle = null;
3677 this.markUnsaved();
3678 }
3679 };
3680
3681 /**
3682 * Find element by ID
3683 *
3684 * @param {string} id Element ID
3685 * @return {Object|null} Element data
3686 */
3687 SeatingDesigner.prototype.findElement = function(id) {
3688 var all = [].concat(
3689 this.elements.seats,
3690 this.elements.decorations,
3691 this.elements.lines,
3692 this.elements.labels
3693 );
3694
3695 for (var i = 0; i < all.length; i++) {
3696 if (all[i].id === id) return all[i];
3697 }
3698 return null;
3699 };
3700
3701 /**
3702 * Start dragging
3703 *
3704 * @param {Object} pos Start position
3705 * @param {boolean} addedToSelection If element was just added to selection (shift+click)
3706 */
3707 SeatingDesigner.prototype.startDrag = function(pos, addedToSelection) {
3708 if (this.selectedElements.length === 0) return;
3709
3710 this.isDragging = true;
3711
3712 // Store initial offsets for all selected elements
3713 this.dragOffsets = [];
3714 var self = this;
3715 this.selectedElements.forEach(function(el) {
3716 self.dragOffsets.push({
3717 id: el.id,
3718 offsetX: pos.x - (el.x || 0),
3719 offsetY: pos.y - (el.y || 0)
3720 });
3721 });
3722 };
3723
3724 /**
3725 * Drag all selected elements to new positions
3726 *
3727 * @param {Object} pos Current position
3728 */
3729 SeatingDesigner.prototype.dragElements = function(pos) {
3730 if (this.selectedElements.length === 0 || !this.dragOffsets) return;
3731
3732 var self = this;
3733
3734 // Calculate delta from first element (the one clicked)
3735 var primaryOffset = this.dragOffsets[0];
3736 var deltaX = pos.x - primaryOffset.offsetX - (this.selectedElements[0].x || 0);
3737 var deltaY = pos.y - primaryOffset.offsetY - (this.selectedElements[0].y || 0);
3738
3739 // Snap delta to grid
3740 if (this.config.snapToGrid) {
3741 deltaX = Math.round(deltaX / this.config.gridSize) * this.config.gridSize;
3742 deltaY = Math.round(deltaY / this.config.gridSize) * this.config.gridSize;
3743 }
3744
3745 // If no movement, skip
3746 if (deltaX === 0 && deltaY === 0) return;
3747
3748 // Move all selected elements
3749 this.selectedElements.forEach(function(el, index) {
3750 var offset = self.dragOffsets[index];
3751 var newX = (el.x || 0) + deltaX;
3752 var newY = (el.y || 0) + deltaY;
3753
3754 // Constrain to canvas bounds
3755 var elWidth = el.width || (el.r ? el.r * 2 : 40);
3756 var elHeight = el.height || (el.r ? el.r * 2 : 40);
3757 newX = Math.max(0, Math.min(newX, self.config.canvasWidth - elWidth));
3758 newY = Math.max(0, Math.min(newY, self.config.canvasHeight - elHeight));
3759
3760 // Update position
3761 el.x = newX;
3762 el.y = newY;
3763 self.updateSvgElement(el);
3764
3765 // Update offset for next movement
3766 self.dragOffsets[index].offsetX = pos.x - newX;
3767 self.dragOffsets[index].offsetY = pos.y - newY;
3768 });
3769
3770 // Update properties panel (show primary element's position)
3771 if (this.selectedElements.length === 1) {
3772 this.updatePositionInPanel(this.selectedElements[0].x, this.selectedElements[0].y);
3773 }
3774 };
3775
3776 /**
3777 * Update position inputs in properties panel (live during drag)
3778 *
3779 * @param {number} x X position
3780 * @param {number} y Y position
3781 */
3782 SeatingDesigner.prototype.updatePositionInPanel = function(x, y) {
3783 var $panel = this.$propsPanel;
3784 if (!$panel) return;
3785
3786 $panel.find('.prop-input[data-prop="x"]').val(Math.round(x));
3787 $panel.find('.prop-input[data-prop="y"]').val(Math.round(y));
3788 };
3789
3790 /**
3791 * End dragging
3792 */
3793 SeatingDesigner.prototype.endDrag = function() {
3794 // Set flag to ignore the click event that follows mouseup
3795 if (this.isDragging) {
3796 this.justFinishedDragging = true;
3797 }
3798
3799 this.isDragging = false;
3800 this.dragOffsets = null;
3801
3802 if (this.selectedElements.length > 0) {
3803 this.markUnsaved();
3804 // Update resize handles if single selection
3805 if (this.selectedElements.length === 1) {
3806 this.updateResizeHandles();
3807 }
3808 }
3809 };
3810
3811 // =========================================================================
3812 // Element Updates
3813 // =========================================================================
3814
3815 /**
3816 * Update element property
3817 *
3818 * @param {string} id Element ID
3819 * @param {string} prop Property name
3820 * @param {*} value New value
3821 */
3822 SeatingDesigner.prototype.updateElementProperty = function(id, prop, value) {
3823 var element = this.findElement(id);
3824 if (!element) return;
3825
3826 element[prop] = value;
3827
3828 // Special handling for isSeat toggle
3829 if (prop === 'isSeat') {
3830 this.handleSeatToggle(element);
3831 }
3832
3833 // Update SVG element
3834 this.updateSvgElement(element);
3835
3836 // Refresh properties panel if this is the selected element
3837 if (this.selectedElement && this.selectedElement.id === id && prop === 'isSeat') {
3838 this.updatePropertiesPanel(element);
3839 }
3840
3841 this.markUnsaved();
3842 };
3843
3844 /**
3845 * Handle toggling isSeat property
3846 *
3847 * @param {Object} element Element data
3848 */
3849 SeatingDesigner.prototype.handleSeatToggle = function(element) {
3850 // Move between decorations and seats arrays
3851 if (element.isSeat) {
3852 // Remove from decorations, add to seats
3853 var idx = this.elements.decorations.indexOf(element);
3854 if (idx > -1) {
3855 this.elements.decorations.splice(idx, 1);
3856 this.elements.seats.push(element);
3857 }
3858 // Update color to available
3859 element.fill = this.config.colors.available;
3860
3861 // Assign default identifier if not set
3862 if (!element.identifier) {
3863 var seatNum = this.elements.seats.length;
3864 element.identifier = 'SEAT-' + seatNum;
3865 element.label = 'Seat ' + seatNum;
3866 }
3867 } else {
3868 // Remove from seats, add to decorations
3869 var idx = this.elements.seats.indexOf(element);
3870 if (idx > -1) {
3871 this.elements.seats.splice(idx, 1);
3872 this.elements.decorations.push(element);
3873 }
3874 // Reset color
3875 element.fill = '#cccccc';
3876 // Clear seat properties
3877 element.identifier = '';
3878 element.label = '';
3879 element.category = '';
3880 }
3881
3882 // Re-render to move to correct layer
3883 this.renderAllElements();
3884 this.selectElement(element.id);
3885 };
3886
3887 /**
3888 * Update SVG element from data
3889 *
3890 * @param {Object} element Element data
3891 */
3892 SeatingDesigner.prototype.updateSvgElement = function(element) {
3893 var $el = $(this.svg).find('.saso-element[data-id="' + element.id + '"]');
3894 if (!$el.length) return;
3895
3896 var svgEl = $el[0];
3897 var fillOpacity = element.fillOpacity !== undefined ? element.fillOpacity / 100 : 1;
3898 var strokeOpacity = element.strokeOpacity !== undefined ? element.strokeOpacity / 100 : 0;
3899
3900 switch (element.type) {
3901 case 'rect':
3902 case 'seat':
3903 svgEl.setAttribute('x', element.x);
3904 svgEl.setAttribute('y', element.y);
3905 svgEl.setAttribute('width', element.width || 40);
3906 svgEl.setAttribute('height', element.height || 40);
3907 svgEl.setAttribute('fill', element.fill || '#cccccc');
3908 svgEl.setAttribute('fill-opacity', fillOpacity);
3909 svgEl.setAttribute('stroke', element.stroke || '#333333');
3910 svgEl.setAttribute('stroke-opacity', strokeOpacity);
3911 svgEl.setAttribute('stroke-width', strokeOpacity > 0 ? 2 : 0);
3912 break;
3913
3914 case 'circle':
3915 svgEl.setAttribute('cx', element.x + (element.r || 20));
3916 svgEl.setAttribute('cy', element.y + (element.r || 20));
3917 svgEl.setAttribute('r', element.r || 20);
3918 svgEl.setAttribute('fill', element.fill || '#cccccc');
3919 svgEl.setAttribute('fill-opacity', fillOpacity);
3920 svgEl.setAttribute('stroke', element.stroke || '#333333');
3921 svgEl.setAttribute('stroke-opacity', strokeOpacity);
3922 svgEl.setAttribute('stroke-width', strokeOpacity > 0 ? 2 : 0);
3923 break;
3924
3925 case 'line':
3926 var lineStrokeOpacity = element.strokeOpacity !== undefined ? element.strokeOpacity / 100 : 1;
3927 svgEl.setAttribute('x1', element.x1 || element.x);
3928 svgEl.setAttribute('y1', element.y1 || element.y);
3929 svgEl.setAttribute('x2', element.x2);
3930 svgEl.setAttribute('y2', element.y2);
3931 svgEl.setAttribute('stroke', element.stroke || '#333333');
3932 svgEl.setAttribute('stroke-opacity', lineStrokeOpacity);
3933 svgEl.setAttribute('stroke-width', element.strokeWidth || 2);
3934 break;
3935
3936 case 'text':
3937 svgEl.setAttribute('x', element.x);
3938 svgEl.setAttribute('y', element.y);
3939 svgEl.setAttribute('fill', element.fill || '#333333');
3940 svgEl.setAttribute('fill-opacity', fillOpacity);
3941 svgEl.setAttribute('font-size', element.fontSize || 14);
3942 svgEl.textContent = element.text || '';
3943 break;
3944 }
3945
3946 // Apply rotation transform for rect, circle, seat
3947 if (element.type === 'rect' || element.type === 'circle' || element.type === 'seat') {
3948 this.applyRotation(svgEl, element);
3949 }
3950
3951 // Update label for all elements (except text which uses text content)
3952 if (element.type !== 'text') {
3953 this.updateElementLabel(element);
3954 }
3955 };
3956
3957 /**
3958 * Delete element
3959 *
3960 * @param {string} id Element ID
3961 */
3962 SeatingDesigner.prototype.deleteElement = function(id) {
3963 var element = this.findElement(id);
3964 if (!element) return;
3965
3966 // Check if seat has tickets (would need AJAX check in real implementation)
3967 // For now, just delete
3968
3969 // Remove from array (find by ID, not reference)
3970 var arrays = ['seats', 'decorations', 'lines', 'labels'];
3971 for (var i = 0; i < arrays.length; i++) {
3972 var arr = this.elements[arrays[i]];
3973 for (var j = arr.length - 1; j >= 0; j--) {
3974 if (arr[j].id === id) {
3975 arr.splice(j, 1);
3976 break;
3977 }
3978 }
3979 }
3980
3981 // Remove SVG element AND its label
3982 $(this.svg).find('[data-id="' + id + '"]').remove();
3983 $(this.svg).find('.saso-element-label[data-for="' + id + '"]').remove();
3984
3985 // Remove from selection if was selected
3986 this.removeFromSelection(id);
3987
3988 this.updateElementCounts();
3989 this.markUnsaved();
3990 };
3991
3992 /**
3993 * Delete all selected elements
3994 */
3995 SeatingDesigner.prototype.deleteSelectedElements = function() {
3996 var self = this;
3997 var toDelete = this.selectedElements.slice(); // Copy array
3998
3999 if (toDelete.length === 0) return;
4000
4001 toDelete.forEach(function(el) {
4002 // Remove from arrays (find by ID, not reference)
4003 var arrays = ['seats', 'decorations', 'lines', 'labels'];
4004 for (var i = 0; i < arrays.length; i++) {
4005 var arr = self.elements[arrays[i]];
4006 for (var j = arr.length - 1; j >= 0; j--) {
4007 if (arr[j].id === el.id) {
4008 arr.splice(j, 1);
4009 break;
4010 }
4011 }
4012 }
4013
4014 // Remove SVG element AND its label
4015 $(self.svg).find('[data-id="' + el.id + '"]').remove();
4016 $(self.svg).find('.saso-element-label[data-for="' + el.id + '"]').remove();
4017 });
4018
4019 this.selectedElements = [];
4020 this.selectedElement = null;
4021 this.hideResizeHandles();
4022 this.updatePropertiesPanel(null);
4023 this.updateElementCounts();
4024 this.markUnsaved();
4025 };
4026
4027 // =========================================================================
4028 // Save / Load / Publish
4029 // =========================================================================
4030
4031 /**
4032 * Load data from server
4033 */
4034 SeatingDesigner.prototype.loadData = function() {
4035 var self = this;
4036
4037 if (!this.config.planId) {
4038 console.warn('No plan ID set');
4039 return;
4040 }
4041
4042 $.ajax({
4043 url: this.config.ajaxUrl,
4044 type: 'POST',
4045 data: {
4046 action: this.config.ajaxAction,
4047 a: 'getDesignerData',
4048 plan_id: this.config.planId,
4049 nonce: this.config.nonce
4050 },
4051 success: function(response) {
4052 if (response.success && response.data) {
4053 self.applyLoadedData(response.data);
4054 }
4055 },
4056 error: function(xhr, status, error) {
4057 console.error('Failed to load designer data:', error);
4058 }
4059 });
4060 };
4061
4062 /**
4063 * Apply loaded data
4064 *
4065 * @param {Object} data Server response data
4066 */
4067 SeatingDesigner.prototype.applyLoadedData = function(data, usePublished) {
4068 var self = this;
4069 var maxId = 0;
4070
4071 // Determine if we received full response or just plan object
4072 // Full response: {plan: {...}, config: {...}} (from getDesignerPage)
4073 // Plan object: {id, draft, published, seats, ...} (from switchToVersion)
4074 var plan;
4075 if (data.plan) {
4076 plan = data.plan;
4077 } else if (data.draft !== undefined || data.published !== undefined) {
4078 plan = data;
4079 } else {
4080 console.error('applyLoadedData: invalid data structure', data);
4081 return;
4082 }
4083
4084 // Store the plan object
4085 this.plan = plan;
4086
4087 // Which meta to use: draft or published
4088 var meta = usePublished ? plan.published : plan.draft;
4089 this.viewingPublished = !!usePublished;
4090
4091 // Helper to ensure element has ID and track max ID
4092 function ensureId(element, prefix) {
4093 if (!element.id) {
4094 element.id = prefix + '_' + self.nextId++;
4095 } else {
4096 // Extract number from existing ID to track max
4097 var match = element.id.match(/\d+$/);
4098 if (match) {
4099 maxId = Math.max(maxId, parseInt(match[0], 10));
4100 }
4101 }
4102 return element;
4103 }
4104
4105 // Apply canvas settings from meta (draft or published)
4106 if (meta) {
4107 this.config.canvasWidth = meta.canvas_width || 800;
4108 this.config.canvasHeight = meta.canvas_height || 600;
4109 this.config.backgroundColor = meta.background_color || '#ffffff';
4110 this.config.backgroundImage = meta.background_image || '';
4111
4112 if (meta.colors) {
4113 this.config.colors = meta.colors;
4114 }
4115
4116 // Load decorations
4117 this.elements.decorations = (meta.decorations || []).map(function(el) {
4118 var copy = Object.assign({}, el);
4119 return ensureId(copy, 'shape');
4120 });
4121
4122 // Load lines
4123 this.elements.lines = (meta.lines || []).map(function(el) {
4124 var copy = Object.assign({}, el);
4125 copy = ensureId(copy, 'line');
4126 if (copy.x1 !== undefined && copy.x === undefined) {
4127 copy.x = copy.x1;
4128 copy.y = copy.y1;
4129 }
4130 return copy;
4131 });
4132
4133 // Load labels
4134 this.elements.labels = (meta.labels || []).map(function(el) {
4135 var copy = Object.assign({}, el);
4136 return ensureId(copy, 'label');
4137 });
4138 }
4139
4140 // Load seats from seats array
4141 if (plan.seats && plan.seats.length > 0) {
4142 this.elements.seats = plan.seats.map(function(seat) {
4143 var seatMeta = seat.meta || {};
4144 var shapeConfig = seatMeta.shape_config || {};
4145 // Track max seat ID for nextId calculation
4146 var seatDbId = parseInt(seat.id, 10) || 0;
4147 if (seatDbId > maxId) {
4148 maxId = seatDbId;
4149 }
4150 return {
4151 id: 'seat_' + seat.id,
4152 dbId: seat.id,
4153 type: seatMeta.shape_type || 'rect',
4154 x: parseFloat(seatMeta.pos_x) || 0,
4155 y: parseFloat(seatMeta.pos_y) || 0,
4156 rotation: parseInt(seatMeta.rotation) || 0,
4157 width: shapeConfig.width || 30,
4158 height: shapeConfig.height || 30,
4159 r: shapeConfig.width ? shapeConfig.width / 2 : 15,
4160 fill: seatMeta.color || self.config.colors.available,
4161 stroke: '#999999',
4162 strokeWidth: 1,
4163 isSeat: true,
4164 identifier: seat.seat_identifier || '',
4165 label: seatMeta.seat_label || '',
4166 category: seatMeta.seat_category || '',
4167 seat_desc: seatMeta.seat_desc || ''
4168 };
4169 });
4170 } else {
4171 this.elements.seats = [];
4172 }
4173
4174 // Update nextId to be higher than any loaded ID (including seat DB IDs)
4175 this.nextId = Math.max(this.nextId, maxId + 1);
4176
4177 // Ensure all elements have unique IDs (fix any duplicates)
4178 this.ensureUniqueElementIds();
4179
4180 // Update canvas
4181 this.createCanvas();
4182 this.renderAllElements();
4183
4184 // Update element counts
4185 this.updateElementCounts();
4186
4187 // Show warnings if needed (only on initial load, not on version switch)
4188 if (!usePublished) {
4189 if (plan.active_sales && plan.active_sales.has_active_sales) {
4190 this.showActiveSalesWarning(plan.active_sales);
4191 }
4192
4193 if (plan.has_unpublished_changes) {
4194 this.showUnpublishedBanner(plan.publish_info);
4195 }
4196 }
4197 };
4198
4199 /**
4200 * Ensure all element IDs are unique across all element arrays
4201 * Fixes any duplicates by assigning new unique IDs
4202 */
4203 SeatingDesigner.prototype.ensureUniqueElementIds = function() {
4204 var self = this;
4205 var seenIds = {};
4206 var duplicatesFound = [];
4207
4208 // Helper to check and fix ID
4209 function checkAndFixId(element, arrayName) {
4210 if (!element.id) {
4211 // No ID - assign one
4212 element.id = arrayName + '_' + self.nextId++;
4213 return;
4214 }
4215
4216 if (seenIds[element.id]) {
4217 // Duplicate found!
4218 var oldId = element.id;
4219 var newId = arrayName + '_unique_' + self.nextId++;
4220 duplicatesFound.push({
4221 oldId: oldId,
4222 newId: newId,
4223 array: arrayName,
4224 label: element.label || element.identifier || ''
4225 });
4226 element.id = newId;
4227 } else {
4228 seenIds[element.id] = true;
4229 }
4230 }
4231
4232 // Check all element arrays
4233 this.elements.seats.forEach(function(el) { checkAndFixId(el, 'seat'); });
4234 this.elements.decorations.forEach(function(el) { checkAndFixId(el, 'shape'); });
4235 this.elements.lines.forEach(function(el) { checkAndFixId(el, 'line'); });
4236 this.elements.labels.forEach(function(el) { checkAndFixId(el, 'label'); });
4237
4238 // Log if duplicates were found
4239 if (duplicatesFound.length > 0) {
4240 console.warn('SeatingDesigner: Fixed ' + duplicatesFound.length + ' duplicate element IDs:', duplicatesFound);
4241 }
4242 };
4243
4244 /**
4245 * Save draft to server
4246 */
4247 SeatingDesigner.prototype.saveDraft = function() {
4248 var self = this;
4249
4250 var draftData = {
4251 canvas_width: this.config.canvasWidth,
4252 canvas_height: this.config.canvasHeight,
4253 background_color: this.config.backgroundColor,
4254 background_image: this.config.backgroundImage,
4255 background_image_id: this.config.backgroundImageId || 0,
4256 background_image_fit: this.config.backgroundImageFit || 'contain',
4257 background_image_align: this.config.backgroundImageAlign || 'center',
4258 colors: this.config.colors,
4259 decorations: this.elements.decorations,
4260 lines: this.elements.lines,
4261 labels: this.elements.labels
4262 };
4263
4264 // Check if sync to published data is requested
4265 var $container = $(this.config.container);
4266 var syncToPubData = $container.find('.saso-sync-to-pub-checkbox').is(':checked');
4267
4268 // Seats are saved separately via the seats endpoint
4269 var seatsData = this.elements.seats.map(function(seat) {
4270 // For circles: always use r*2 (diameter), ignore stale width/height
4271 // For rects: use width/height
4272 var seatWidth, seatHeight;
4273 if (seat.type === 'circle') {
4274 seatWidth = seatHeight = (seat.r || 15) * 2;
4275 } else {
4276 seatWidth = seat.width || 30;
4277 seatHeight = seat.height || 30;
4278 }
4279 return {
4280 id: seat.dbId || null,
4281 identifier: seat.identifier,
4282 label: seat.label,
4283 category: seat.category,
4284 pos_x: seat.x,
4285 pos_y: seat.y,
4286 rotation: seat.rotation || 0,
4287 shape_type: seat.type,
4288 shape_config: {
4289 width: seatWidth,
4290 height: seatHeight
4291 },
4292 color: seat.fill,
4293 seat_desc: seat.seat_desc,
4294 syncToPubData: syncToPubData
4295 };
4296 });
4297
4298 $.ajax({
4299 url: this.config.ajaxUrl,
4300 type: 'POST',
4301 data: {
4302 action: this.config.ajaxAction,
4303 a: 'saveDraft',
4304 plan_id: this.config.planId,
4305 nonce: this.config.nonce,
4306 decorations: JSON.stringify(draftData.decorations),
4307 lines: JSON.stringify(draftData.lines),
4308 labels: JSON.stringify(draftData.labels),
4309 canvas_width: draftData.canvas_width,
4310 canvas_height: draftData.canvas_height,
4311 background_color: draftData.background_color,
4312 background_image: draftData.background_image,
4313 background_image_id: draftData.background_image_id,
4314 background_image_fit: draftData.background_image_fit,
4315 background_image_align: draftData.background_image_align,
4316 colors: JSON.stringify(draftData.colors),
4317 seats: JSON.stringify(seatsData)
4318 },
4319 success: function(response) {
4320 if (response.success) {
4321 self.hasUnsavedChanges = false;
4322 // Reset sync checkbox after save
4323 $(self.config.container).find('.saso-sync-to-pub-checkbox').prop('checked', false);
4324 self.showNotice('success', self.config.i18n.draftSaved || 'Draft saved successfully');
4325 // Show unpublished banner if not already visible
4326 if (response.data && response.data.has_unpublished_changes) {
4327 var $existing = $(self.config.container).find('.saso-unpublished-banner');
4328 if ($existing.length === 0) {
4329 self.showUnpublishedBanner(response.data.publish_info);
4330 }
4331 }
4332 // Update header badges
4333 self.updateHeaderBadges(response.data);
4334 } else {
4335 self.showNotice('error', response.data.error || 'Save failed');
4336 }
4337 },
4338 error: function(xhr, status, error) {
4339 self.showNotice('error', 'Save failed: ' + error);
4340 }
4341 });
4342 };
4343
4344 /**
4345 * Publish plan
4346 */
4347 SeatingDesigner.prototype.publish = SeatingDesigner.prototype.publishPlan = function() {
4348 var self = this;
4349
4350 if (!confirm(this.config.i18n.confirmPublish || 'Publish changes? This will make them visible to customers.')) {
4351 return;
4352 }
4353
4354 // Save draft first, then publish
4355 this.saveDraft();
4356
4357 setTimeout(function() {
4358 $.ajax({
4359 url: self.config.ajaxUrl,
4360 type: 'POST',
4361 data: {
4362 action: self.config.ajaxAction,
4363 a: 'publishPlan',
4364 plan_id: self.config.planId,
4365 nonce: self.config.nonce
4366 },
4367 success: function(response) {
4368 if (response.success && response.data.success) {
4369 self.showNotice('success', self.config.i18n.planPublished || 'Seating plan published successfully');
4370 // Hide unpublished banner
4371 $(self.config.container).find('.saso-unpublished-banner').remove();
4372 // Update header badges
4373 self.updateHeaderBadges(response.data);
4374 } else {
4375 // Show conflicts
4376 if (response.data.conflicts) {
4377 self.showConflictsModal(response.data.conflicts);
4378 } else {
4379 self.showNotice('error', response.data.message || 'Publish failed');
4380 }
4381 }
4382 },
4383 error: function(xhr, status, error) {
4384 self.showNotice('error', 'Publish failed: ' + error);
4385 }
4386 });
4387 }, 500);
4388 };
4389
4390 /**
4391 * Discard draft changes
4392 */
4393 SeatingDesigner.prototype.discardDraft = function() {
4394 var self = this;
4395
4396 if (!confirm(this.config.i18n.confirmDiscard || 'Discard all unsaved changes and revert to published version?')) {
4397 return;
4398 }
4399
4400 $.ajax({
4401 url: this.config.ajaxUrl,
4402 type: 'POST',
4403 data: {
4404 action: this.config.ajaxAction,
4405 a: 'discardDraft',
4406 plan_id: this.config.planId,
4407 nonce: this.config.nonce
4408 },
4409 success: function(response) {
4410 if (response.success) {
4411 self.showNotice('success', self.config.i18n.draftDiscarded || 'Draft changes discarded');
4412 self.loadData(); // Reload
4413 } else {
4414 self.showNotice('error', response.data.error || 'Discard failed');
4415 }
4416 },
4417 error: function(xhr, status, error) {
4418 self.showNotice('error', 'Discard failed: ' + error);
4419 }
4420 });
4421 };
4422
4423 /**
4424 * Preview (open in new tab)
4425 */
4426 SeatingDesigner.prototype.preview = function() {
4427 // For now, just save and alert
4428 this.saveDraft();
4429 alert(this.config.i18n.previewNotAvailable || 'Preview will open the customer view in a new tab. (Not implemented yet)');
4430 };
4431
4432 // =========================================================================
4433 // UI Helpers
4434 // =========================================================================
4435
4436 /**
4437 * Mark as having unsaved changes
4438 */
4439 SeatingDesigner.prototype.markUnsaved = function() {
4440 this.hasUnsavedChanges = true;
4441 $(this.config.container).find('.saso-save-draft').addClass('has-changes');
4442 this.updateElementCounts();
4443 };
4444
4445 /**
4446 * Show notice as toast (fixed position, doesn't affect layout)
4447 *
4448 * @param {string} type 'success', 'error', 'warning'
4449 * @param {string} message Message text
4450 */
4451 SeatingDesigner.prototype.showNotice = function(type, message) {
4452 // Get or create toast container
4453 var $container = $('.saso-toast-container');
4454 if ($container.length === 0) {
4455 $container = $('<div class="saso-toast-container"></div>');
4456 $('body').append($container);
4457 }
4458
4459 var $notice = $('<div class="saso-notice saso-notice-' + type + '">' + message + '</div>');
4460 $container.append($notice);
4461
4462 setTimeout(function() {
4463 $notice.fadeOut(function() { $(this).remove(); });
4464 }, 3000);
4465 };
4466
4467 /**
4468 * Update header badges (Published, Draft)
4469 *
4470 * @param {Object} data Response data with audit_info, publish_info, has_unpublished_changes
4471 */
4472 SeatingDesigner.prototype.updateHeaderBadges = function(data) {
4473 var $headerRight = $(this.config.container).find('.saso-designer-header .header-right');
4474 if ($headerRight.length === 0) return;
4475
4476 var auditInfo = data.audit_info || {};
4477 var publishInfo = data.publish_info;
4478 var hasUnpublishedChanges = data.has_unpublished_changes;
4479
4480 // Keep the active/inactive badge, rebuild the rest
4481 var $activeStatus = $headerRight.find('.saso-plan-status.active, .saso-plan-status.inactive');
4482 var activeHtml = $activeStatus.length ? $activeStatus[0].outerHTML : '';
4483
4484 var badges = activeHtml;
4485
4486 // Store for later reference
4487 this.lastPublishInfo = publishInfo;
4488 this.lastAuditInfo = auditInfo;
4489
4490 // Published badge (clickable to view published version)
4491 if (publishInfo && publishInfo.published_at) {
4492 var publishedActive = this.viewingPublished ? ' viewing' : '';
4493 badges += '<span class="saso-plan-status published clickable' + publishedActive + '" ' +
4494 'title="' + (this.config.i18n.clickToViewPublished || 'Click to view published version') + '">' +
4495 '<span class="dashicons dashicons-yes-alt"></span> ' +
4496 (this.config.i18n.published || 'Published') +
4497 '<span class="saso-status-date">' + this.formatDate(publishInfo.published_at) + '</span>' +
4498 '</span>';
4499 }
4500
4501 // Draft badge (clickable to view/edit draft)
4502 if (hasUnpublishedChanges) {
4503 var draftDate = auditInfo.updated_at || '';
4504 var draftActive = !this.viewingPublished ? ' viewing' : '';
4505 badges += '<span class="saso-plan-status draft clickable' + draftActive + '" ' +
4506 'title="' + (this.config.i18n.clickToViewDraft || 'Click to view/edit draft') + '">' +
4507 '<span class="dashicons dashicons-edit"></span> ' +
4508 (this.config.i18n.draft || 'Draft') +
4509 (draftDate ? '<span class="saso-status-date">' + this.formatDate(draftDate) + '</span>' : '') +
4510 '</span>';
4511 }
4512
4513 $headerRight.html(badges);
4514 };
4515
4516 /**
4517 * Bind version toggle click handlers (called once during init)
4518 */
4519 SeatingDesigner.prototype.bindVersionToggleHandlers = function() {
4520 var self = this;
4521 var $container = $(this.config.container);
4522
4523 // Use event delegation for badges that get rebuilt
4524 $container.on('click', '.saso-plan-status.published.clickable', function(e) {
4525 e.preventDefault();
4526 e.stopPropagation();
4527 console.log('Published badge clicked');
4528 self.switchToVersion(true);
4529 });
4530
4531 $container.on('click', '.saso-plan-status.draft.clickable', function(e) {
4532 e.preventDefault();
4533 e.stopPropagation();
4534 console.log('Draft badge clicked');
4535 self.switchToVersion(false);
4536 });
4537 };
4538
4539 /**
4540 * Format date string for display
4541 *
4542 * @param {string} dateStr Date string
4543 * @return {string} Formatted date
4544 */
4545 SeatingDesigner.prototype.formatDate = function(dateStr) {
4546 if (!dateStr) return '';
4547 try {
4548 var date = new Date(dateStr);
4549 var day = String(date.getDate()).padStart(2, '0');
4550 var month = String(date.getMonth() + 1).padStart(2, '0');
4551 var year = String(date.getFullYear()).slice(-2);
4552 var hours = String(date.getHours()).padStart(2, '0');
4553 var mins = String(date.getMinutes()).padStart(2, '0');
4554 return day + '.' + month + '.' + year + ' ' + hours + ':' + mins;
4555 } catch (e) {
4556 return dateStr;
4557 }
4558 };
4559
4560 /**
4561 * Switch between draft and published view
4562 *
4563 * @param {boolean} showPublished Whether to show published version
4564 */
4565 SeatingDesigner.prototype.switchToVersion = function(showPublished) {
4566 if (this.viewingPublished === showPublished) {
4567 return;
4568 }
4569
4570 if (showPublished && this.hasUnsavedChanges) {
4571 if (!confirm(this.config.i18n.unsavedChangesPreview || 'You have unsaved changes. Switch to published view anyway?')) {
4572 return;
4573 }
4574 }
4575
4576 if (showPublished && (!this.plan.published || Object.keys(this.plan.published).length === 0)) {
4577 this.showNotice('warning', this.config.i18n.noPublishedVersion || 'No published version available');
4578 return;
4579 }
4580
4581 // Neu laden mit dem gewünschten Meta (draft oder published)
4582 this.applyLoadedData(this.plan, showPublished);
4583 this.showNotice('info', showPublished
4584 ? (this.config.i18n.viewingPublished || 'Viewing published version')
4585 : (this.config.i18n.viewingDraft || 'Back to draft version'));
4586 };
4587
4588 /**
4589 * Show unpublished changes banner
4590 *
4591 * @param {Object} publishInfo Last publish info
4592 */
4593 SeatingDesigner.prototype.showUnpublishedBanner = function(publishInfo) {
4594 var self = this;
4595 var $container = $(this.config.container).find('.saso-designer-notices');
4596
4597 // Remove existing banner first
4598 $container.find('.saso-unpublished-banner').remove();
4599
4600 var $banner = $('<div class="saso-unpublished-banner">' +
4601 '<div class="banner-content">' +
4602 '<div class="banner-text">' +
4603 '<span class="dashicons dashicons-warning"></span>' +
4604 '<span>' + (this.config.i18n.unpublishedChanges || 'You have unpublished changes') + '</span>' +
4605 '</div>');
4606
4607 if (publishInfo) {
4608 $banner.find('.banner-text').append('<span class="publish-info">' +
4609 (this.config.i18n.lastPublished || 'Last published:') + ' ' +
4610 publishInfo.published_at + ' ' + (this.config.i18n.by || 'by') + ' ' +
4611 publishInfo.published_by_name +
4612 '</span>');
4613 }
4614
4615 // Add publish button right-aligned
4616 var $publishBtn = $('<button type="button" class="button button-primary saso-banner-publish">' +
4617 '<span class="dashicons dashicons-yes-alt"></span> ' +
4618 (this.config.i18n.publish || 'Publish') +
4619 '</button>');
4620
4621 $publishBtn.on('click', function() {
4622 self.publishPlan();
4623 });
4624
4625 $banner.append('<div class="banner-actions"></div>');
4626 $banner.find('.banner-actions').append($publishBtn);
4627 $banner.append('</div></div>');
4628
4629 $container.prepend($banner);
4630 };
4631
4632 /**
4633 * Show active sales warning
4634 *
4635 * @param {Object} salesInfo Active sales info
4636 */
4637 SeatingDesigner.prototype.showActiveSalesWarning = function(salesInfo) {
4638 var $container = $(this.config.container).find('.saso-designer-notices');
4639
4640 // Remove existing warning first
4641 $container.find('.saso-active-sales-warning').remove();
4642
4643 var html = '<div class="saso-active-sales-warning">' +
4644 '<span class="dashicons dashicons-warning"></span>' +
4645 '<div class="warning-content">' +
4646 '<div class="warning-title">' + (this.config.i18n.activeSalesWarning || 'Warning: Active ticket sales') + '</div>' +
4647 '<div class="warning-details">' +
4648 salesInfo.total_tickets + ' ' + (this.config.i18n.ticketsSold || 'tickets sold') +
4649 '<ul class="product-list">';
4650
4651 salesInfo.products.forEach(function(product) {
4652 html += '<li>' + product.name + ' (' + product.ticket_count + ')</li>';
4653 });
4654
4655 html += '</ul></div></div></div>';
4656
4657 $container.prepend(html);
4658 };
4659
4660 /**
4661 * Show conflicts modal
4662 *
4663 * @param {Object} conflicts Seat conflicts
4664 */
4665 SeatingDesigner.prototype.showConflictsModal = function(conflicts) {
4666 var html = '<div class="saso-conflicts-list">' +
4667 '<p>' + (this.config.i18n.publishConflicts || 'Cannot publish: The following seats have sold tickets and cannot be deleted:') + '</p>' +
4668 '<ul>';
4669
4670 for (var identifier in conflicts) {
4671 html += '<li><strong>' + identifier + '</strong>: ' + conflicts[identifier] + ' ' + (this.config.i18n.tickets || 'tickets') + '</li>';
4672 }
4673
4674 html += '</ul></div>';
4675
4676 // Simple alert for now - could be a proper modal
4677 alert($(html).text());
4678 };
4679
4680 // =========================================================================
4681 // Destroy
4682 // =========================================================================
4683
4684 /**
4685 * Destroy designer instance
4686 */
4687 SeatingDesigner.prototype.destroy = function() {
4688 if (this.svg) {
4689 $(this.svg).off();
4690 }
4691 $(this.config.container).off();
4692 $(document).off('keydown.sasoSeatingDesigner');
4693 $(document).off('keyup.sasoSeatingDesigner');
4694 $(this.config.container).empty();
4695 this.svg = null;
4696 this.layers = {};
4697 this.elements = { seats: [], decorations: [], lines: [], labels: [] };
4698 };
4699
4700 // =========================================================================
4701 // Export
4702 // =========================================================================
4703
4704 /**
4705 * Initialize designer
4706 *
4707 * @param {Object} config Configuration
4708 * @return {SeatingDesigner} Designer instance
4709 */
4710 window.initSeatingDesigner = function(config) {
4711 if (window.SasoSeatingDesigner) {
4712 window.SasoSeatingDesigner.destroy();
4713 }
4714 window.SasoSeatingDesigner = new SeatingDesigner(config);
4715 return window.SasoSeatingDesigner;
4716 };
4717
4718 })(jQuery);
4719