leksy-editor 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tab.js ADDED
@@ -0,0 +1,623 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { SVG } from "./constant";
3
+ import { insertOutlinesItems } from './utilities';
4
+
5
+ const initTabs = (core, options) => {
6
+ if (!options.showTabs) return;
7
+
8
+ const tabsContainer = document.createElement('div');
9
+ tabsContainer.className = `tabs-container`;
10
+ core.elements.tabsContainer = tabsContainer;
11
+ core.elements.placeholder.style.visibility = 'hidden';
12
+ core.elements.iframeWindow.body.style.display = 'flex';
13
+ core.elements.iframeWindow.body.prepend(tabsContainer);
14
+ renderTabs(core, options);
15
+ }
16
+
17
+ const findTabLocation = (list, tabId, parent = null) => {
18
+ for (let i = 0; i < list.length; i++) {
19
+ if (list[i].tabId === tabId) {
20
+ return { list, index: i, parent };
21
+ }
22
+ if (list[i].children) {
23
+ const result = findTabLocation(list[i].children, tabId, list[i]);
24
+ if (result) return result;
25
+ }
26
+ }
27
+ return null;
28
+ };
29
+
30
+ const findTab = (tabs, tabId) => {
31
+ for (const tab of tabs) {
32
+ if (tab.tabId === tabId) return tab;
33
+ if (tab.children) {
34
+ const found = findTab(tab.children, tabId);
35
+ if (found) return found;
36
+ }
37
+ }
38
+ return null;
39
+ };
40
+
41
+ const isDescendant = (tabs, parentId, childId) => {
42
+ const parent = findTab(tabs, parentId);
43
+ if (!parent?.children) return false;
44
+ return findTab(parent.children, childId) !== null;
45
+ };
46
+
47
+ const renderTabs = (core, options) => {
48
+ if (!core.elements.tabsContainer) return;
49
+
50
+ core.onChange(core.html);
51
+ core.elements.tabsContainer.innerHTML = '';
52
+
53
+ const renderLevel = (tabs, container, level) => {
54
+ tabs.forEach(tab => {
55
+ const tabItem = document.createElement('div');
56
+ tabItem.className = `tab-item`;
57
+ tabItem.classList.toggle('active', tab.tabId === core.state.currentTabId)
58
+
59
+ tabItem.draggable = true;
60
+
61
+ tabItem.ondragstart = (e) => {
62
+ e.stopPropagation();
63
+ core.state.draggedTabId = tab.tabId;
64
+ e.dataTransfer.effectAllowed = 'move';
65
+ tabItem.style.opacity = '0.5';
66
+ };
67
+
68
+ tabItem.ondragend = (e) => {
69
+ e.stopPropagation();
70
+ delete core.state.draggedTabId;
71
+ tabItem.style.opacity = '1';
72
+ renderTabs(core, options);
73
+ };
74
+
75
+ tabItem.ondragover = (e) => {
76
+ e.preventDefault();
77
+ e.stopPropagation();
78
+ const draggedId = core.state.draggedTabId;
79
+ if (!draggedId || draggedId === tab.tabId) return;
80
+ if (isDescendant(core.state.tabs, draggedId, tab.tabId)) return;
81
+
82
+ const rect = tabItem.getBoundingClientRect();
83
+ const relY = e.clientY - rect.top;
84
+ const height = rect.height;
85
+
86
+ tabItem.style.borderTop = '';
87
+ tabItem.style.borderBottom = '1px solid #eee';
88
+ tabItem.style.background = '';
89
+
90
+ if (relY < height * 0.25) {
91
+ tabItem.style.borderTop = '2px solid var(--primary)';
92
+ } else if (relY > height * 0.75) {
93
+ tabItem.style.borderBottom = '2px solid var(--primary)';
94
+ } else {
95
+ tabItem.style.background = 'rgba(0,0,0,0.05)';
96
+ }
97
+ };
98
+
99
+ tabItem.ondragleave = (e) => {
100
+ e.stopPropagation();
101
+ tabItem.style.borderTop = '';
102
+ tabItem.style.borderBottom = '1px solid #eee';
103
+ tabItem.style.background = '';
104
+ };
105
+
106
+ tabItem.ondrop = (e) => {
107
+ e.preventDefault();
108
+ e.stopPropagation();
109
+ const draggedId = core.state.draggedTabId;
110
+ if (!draggedId || draggedId === tab.tabId) return;
111
+ if (isDescendant(core.state.tabs, draggedId, tab.tabId) || level > 1) return;
112
+
113
+ const rect = tabItem.getBoundingClientRect();
114
+ const relY = e.clientY - rect.top;
115
+ const height = rect.height;
116
+
117
+ if (relY < height * 0.25) {
118
+ moveTabRelative(core, draggedId, tab.tabId, 'before', options);
119
+ } else if (relY > height * 0.75) {
120
+ moveTabRelative(core, draggedId, tab.tabId, 'after', options);
121
+ } else {
122
+ moveTabInto(core, draggedId, tab.tabId, options);
123
+ }
124
+ };
125
+
126
+ let clickTimer;
127
+
128
+ tabItem.onclick = () => {
129
+ clearTimeout(clickTimer);
130
+ clickTimer = setTimeout(() => {
131
+ if (tab.tabId === core.state.currentTabId) {
132
+ tab.showOutlines = !tab.showOutlines;
133
+ renderTabs(core, options);
134
+ }
135
+ switchTab(core, tab.tabId, options);
136
+ }, 200);
137
+ };
138
+
139
+ tabItem.ondblclick = () => {
140
+ clearTimeout(clickTimer);
141
+ renameTab(core, tab.tabId, titleInput, options);
142
+ };
143
+
144
+ const leftSection = document.createElement('div');
145
+ leftSection.className = 'left'
146
+ leftSection.style.paddingLeft = `${level * 12}px`
147
+
148
+ const hasChildren = tab.children && tab.children.length > 0;
149
+ const arrow = document.createElement('span');
150
+ arrow.style.width = '20px'
151
+ arrow.style.height = '20px';
152
+ arrow.style.marginRight = '4px';
153
+ arrow.style.cursor = 'pointer';
154
+ arrow.style.transition = 'transform 0.2s';
155
+
156
+ if (hasChildren) {
157
+ arrow.innerHTML = SVG.ARROW_DROP_DOWN_FILL;
158
+ arrow.style.transform = tab.isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)';
159
+ arrow.onclick = (e) => {
160
+ e.stopPropagation();
161
+ tab.isExpanded = !tab.isExpanded;
162
+ renderTabs(core, options);
163
+ };
164
+ }
165
+
166
+ const titleInput = document.createElement('input');
167
+ titleInput.value = tab.tabTitle || 'Untitled Tab';
168
+ titleInput.disabled = true;
169
+ titleInput.className = 'tab-input';
170
+
171
+ const menuBtn = document.createElement('span');
172
+ menuBtn.className = 'tab-icons tab-dropdown';
173
+ menuBtn.innerHTML = SVG.MORE;
174
+
175
+ menuBtn.onclick = (e) => {
176
+ e.stopPropagation();
177
+ showContextMenu(core, tab, e, level, options);
178
+ };
179
+
180
+
181
+ const span = document.createElement('span');
182
+ span.className = 'tab-icons';
183
+ span.innerHTML = SVG.TABLE_OF_CONTENT;
184
+
185
+ leftSection.appendChild(arrow);
186
+ leftSection.appendChild(span);
187
+ leftSection.appendChild(titleInput);
188
+ tabItem.appendChild(leftSection);
189
+ tabItem.appendChild(menuBtn);
190
+
191
+ const tabItemContainer = document.createElement('div');
192
+ tabItemContainer.id = tab.tabId;
193
+ tabItemContainer.appendChild(tabItem);
194
+
195
+ const outlineContainer = document.createElement('div');
196
+ outlineContainer.className = 'outline-items';
197
+ outlineContainer.style.paddingLeft = `${32 + level * 12}px`
198
+ tabItemContainer.appendChild(outlineContainer);
199
+ insertOutlinesItems(core, options, tab, outlineContainer)
200
+ container.appendChild(tabItemContainer);
201
+ core.elements.tabs[tab.tabId] = tabItemContainer
202
+
203
+ if (tab.children && tab.children.length > 0 && tab.isExpanded) {
204
+ const childrenContainer = document.createElement('div');
205
+ renderLevel(tab.children, childrenContainer, level + 1);
206
+ container.appendChild(childrenContainer);
207
+ }
208
+ });
209
+ };
210
+
211
+ const tabListContainer = document.createElement('div');
212
+ tabListContainer.className = 'tab-items'
213
+ renderLevel(core.state.tabs, tabListContainer, 0);
214
+ core.elements.tabsContainer.appendChild(tabListContainer);
215
+
216
+ const tabHeader = document.createElement('span');
217
+ tabHeader.className = 'tab-header';
218
+
219
+ const headerTitle = document.createElement('span');
220
+ headerTitle.innerHTML = "Tabs";
221
+
222
+ const addBtn = document.createElement('button');
223
+ addBtn.className = 'tab-add-button'
224
+ addBtn.type = 'button';
225
+ addBtn.innerHTML = SVG.PLUS;
226
+ addBtn.onclick = () => createTab(core, options);
227
+ tabHeader.appendChild(headerTitle);
228
+ tabHeader.appendChild(addBtn);
229
+ core.elements.tabsContainer.prepend(tabHeader);
230
+ }
231
+
232
+ const showContextMenu = (core, tab, event, level, options) => {
233
+ const existingMenu = core.elements.iframeWindow.getElementById('leksy-editor-tab-context-menu');
234
+ if (existingMenu) existingMenu.remove();
235
+
236
+ const menu = document.createElement('div');
237
+ menu.id = 'leksy-editor-tab-context-menu';
238
+ menu.style.left = `${event.clientX}px`;
239
+ menu.style.top = `${event.clientY}px`;
240
+
241
+ const closeMenu = (e) => {
242
+ if (!menu.contains(e.target)) {
243
+ menu.remove();
244
+ core.elements.iframeWindow.removeEventListener('click', closeMenu);
245
+ }
246
+ };
247
+
248
+ const createOption = (text, icon, onClick) => {
249
+ const item = document.createElement('div');
250
+ item.innerHTML = icon + text;
251
+ item.className = 'tab-menu-item';
252
+ item.onclick = (e) => {
253
+ e.stopPropagation();
254
+ menu.remove();
255
+ core.elements.iframeWindow.removeEventListener('click', closeMenu);
256
+ onClick();
257
+ };
258
+ return item;
259
+ };
260
+
261
+ const createSubmenuOption = (text, items) => {
262
+ const item = document.createElement('div');
263
+ item.className = 'tab-menu-item';
264
+ item.style.position = 'relative';
265
+
266
+ const span = document.createElement('span');
267
+ span.innerHTML = SVG.PLAY_LIST + text;
268
+ const arrow = document.createElement('span');
269
+ arrow.innerHTML = SVG.ARROW_DROP_RIGHT;
270
+ arrow.style.width = '20px';
271
+ arrow.style.height = '20px';
272
+ arrow.style.marginLeft = '4px';
273
+ arrow.style.display = 'flex';
274
+ arrow.style.alignItems = 'center';
275
+
276
+ item.appendChild(span);
277
+ item.appendChild(arrow);
278
+
279
+ let submenu;
280
+
281
+ item.onmouseenter = () => {
282
+ submenu = document.createElement('div');
283
+ submenu.className = 'tab-submenu';
284
+
285
+ items.forEach(subItem => {
286
+ const subOption = createOption(subItem.tabTitle, SVG.TABLE_OF_CONTENT, () => {
287
+ moveTabInto(core, tab.tabId, subItem.tabId);
288
+ });
289
+ if (subItem.level > 0) {
290
+ subOption.style.paddingLeft = `${16 + subItem.level * 10}px`;
291
+ }
292
+ submenu.appendChild(subOption);
293
+ });
294
+ item.appendChild(submenu);
295
+ };
296
+
297
+ item.onmouseleave = () => {
298
+ if (submenu) submenu.remove();
299
+ submenu = null;
300
+ };
301
+
302
+ return item;
303
+ };
304
+
305
+ const location = findTabLocation(core.state.tabs, tab.tabId);
306
+
307
+ if (level < 2) {
308
+ menu.appendChild(createOption('Add subtab', SVG.PLUS, () => addSublist(core, tab, options)));
309
+ }
310
+
311
+ menu.appendChild(createOption('Duplicate', SVG.COPY, () => duplicateTab(core, tab, options)));
312
+
313
+ menu.appendChild(createOption('Rename', SVG.PENCIL_LINE, () => {
314
+ const tabItem = event.target.closest(`.tab-item`);
315
+ if (tabItem) {
316
+ const input = tabItem.querySelector('input');
317
+ if (input) renameTab(core, tab.tabId, input, options);
318
+ }
319
+ }));
320
+
321
+ menu.appendChild(createOption('Delete', SVG.DELETE, () => deleteTab(core, tab.tabId, options)));
322
+
323
+ menu.appendChild(createOption('Move up', SVG.ARROW_UP, () => moveTab(core, tab.tabId, 'up', options)));
324
+ menu.appendChild(createOption('Move down', SVG.ARROW_DOWN_LONG, () => moveTab(core, tab.tabId, 'down', options)));
325
+
326
+ const getTargets = (list, currentTabId, level = 0) => {
327
+ let targets = [];
328
+ for (const t of list) {
329
+ if (t.tabId === currentTabId || level > 1) continue;
330
+ targets.push({ tabId: t.tabId, tabTitle: t.tabTitle, level });
331
+ if (t.children) targets = targets.concat(getTargets(t.children, currentTabId, level + 1));
332
+ }
333
+ return targets;
334
+ };
335
+ const targets = getTargets(core.state.tabs, tab.tabId);
336
+ if (targets.length > 0) {
337
+ menu.appendChild(createSubmenuOption('Move into', targets));
338
+ }
339
+
340
+ if (location?.parent) {
341
+ menu.appendChild(createOption('Move out', SVG.DELETE, () => outdentTab(core, tab.tabId, options)));
342
+ }
343
+
344
+ menu.appendChild(createOption(tab.showOutlines ? 'Hide outline' : 'Show outline', SVG.LIST_BULLETS, () => {
345
+ tab.showOutlines = !tab.showOutlines;
346
+ renderTabs(core, options);
347
+ if (tab.showOutlines) switchTab(core, tab.tabId, options);
348
+ }));
349
+
350
+ core.elements.iframeWindow.body.appendChild(menu);
351
+ core.elements.iframeWindow.addEventListener('click', closeMenu);
352
+ };
353
+
354
+ const createTab = (core, options) => {
355
+ const newTab = {
356
+ tabTitle: `Tab ${core.state.tabs.length + 1}`,
357
+ tabId: uuidv4(),
358
+ content: '<div><br></div>',
359
+ children: []
360
+ };
361
+
362
+ core.state.tabs.push(newTab);
363
+ core.history.tabs[newTab.tabId] = { stack: [], currentIndex: -1 };
364
+
365
+ switchTab(core, newTab.tabId, options);
366
+ }
367
+
368
+ const switchTab = (core, tabId, options) => {
369
+ if (core.state.currentTabId === tabId) return;
370
+
371
+ const currentTab = findTab(core.state.tabs, core.state.currentTabId);
372
+ if (currentTab) {
373
+ currentTab.content = core.elements.editor.innerHTML;
374
+ }
375
+
376
+ const nextTab = findTab(core.state.tabs, tabId);
377
+ if (nextTab) {
378
+ core.state.currentTabId = nextTab.tabId;
379
+ core.elements.editor.innerHTML = nextTab.content;
380
+ renderTabs(core, options);
381
+
382
+ core.updateCaretPosition();
383
+ core.elements.editor.focus();
384
+ }
385
+ }
386
+
387
+ const deleteTab = (core, tabId, options) => {
388
+ const deleteFromList = (list) => {
389
+ const index = list.findIndex(t => t.tabId === tabId);
390
+ if (index > -1) {
391
+ list.splice(index, 1);
392
+ return true;
393
+ }
394
+ for (const tab of list) {
395
+ if (tab.children && deleteFromList(tab.children)) return true;
396
+ }
397
+ return false;
398
+ };
399
+
400
+ const tabToDelete = findTab(core.state.tabs, tabId);
401
+ if (!tabToDelete) return;
402
+
403
+ const isCurrentTabAffected = (tab) => {
404
+ if (tab.tabId === core.state.currentTabId) return true;
405
+ if (tab.children) {
406
+ return tab.children.some(child => isCurrentTabAffected(child));
407
+ }
408
+ return false;
409
+ };
410
+
411
+ const shouldSwitch = isCurrentTabAffected(tabToDelete);
412
+
413
+ deleteFromList(core.state.tabs);
414
+ delete core.history.tabs[tabId];
415
+
416
+ if (shouldSwitch) {
417
+ if (core.state.tabs.length > 0) {
418
+ switchTab(core, core.state.tabs[0].tabId, options);
419
+ } else {
420
+ createTab(core, options);
421
+ }
422
+ } else {
423
+ renderTabs(core, options);
424
+ }
425
+ }
426
+
427
+ const duplicateTab = (core, tab, options) => {
428
+ const newTab = structuredClone(tab);
429
+ newTab.tabId = uuidv4();
430
+ newTab.tabTitle = `${newTab.tabTitle} (Copy)`;
431
+
432
+ const updateIds = (t) => {
433
+ t.tabId = uuidv4();
434
+ core.history.tabs[t.tabId] = { stack: [], currentIndex: -1 };
435
+ if (t.children) t.children.forEach(updateIds);
436
+ };
437
+ if (newTab.children) newTab.children.forEach(updateIds);
438
+
439
+ core.history.tabs[newTab.tabId] = { stack: [], currentIndex: -1 };
440
+
441
+ const insertIntoList = (list) => {
442
+ const index = list.findIndex(t => t.tabId === tab.tabId);
443
+ if (index > -1) {
444
+ list.splice(index + 1, 0, newTab);
445
+ return true;
446
+ }
447
+ for (const t of list) {
448
+ if (t.children && insertIntoList(t.children)) return true;
449
+ }
450
+ return false;
451
+ };
452
+
453
+ insertIntoList(core.state.tabs);
454
+ renderTabs(core, options);
455
+ };
456
+
457
+ const addSublist = (core, parentTab, options) => {
458
+ const newTab = {
459
+ tabTitle: `Subtab ${(parentTab.children?.length || 0) + 1}`,
460
+ tabId: uuidv4(),
461
+ content: '<div><br></div>',
462
+ children: []
463
+ };
464
+ core.history.tabs[newTab.tabId] = { stack: [], currentIndex: -1 };
465
+
466
+ if (!parentTab.children) parentTab.children = [];
467
+ parentTab.children.push(newTab);
468
+ parentTab.isExpanded = true;
469
+
470
+ renderTabs(core, options);
471
+ switchTab(core, newTab.tabId, options);
472
+ };
473
+
474
+ const moveTab = (core, tabId, direction, options) => {
475
+ const moveInList = (list) => {
476
+ const index = list.findIndex(t => t.tabId === tabId);
477
+ if (index > -1) {
478
+ if (direction === 'up' && index > 0) {
479
+ [list[index - 1], list[index]] = [list[index], list[index - 1]];
480
+ return true;
481
+ }
482
+ if (direction === 'down' && index < list.length - 1) {
483
+ [list[index], list[index + 1]] = [list[index + 1], list[index]];
484
+ return true;
485
+ }
486
+ return false;
487
+ }
488
+ for (const tab of list) {
489
+ if (tab.children && moveInList(tab.children)) return true;
490
+ }
491
+ return false;
492
+ };
493
+
494
+ if (moveInList(core.state.tabs)) {
495
+ renderTabs(core, options);
496
+ }
497
+ };
498
+
499
+ const moveTabRelative = (core, tabId, targetTabId, position, options) => {
500
+ const sourceLoc = findTabLocation(core.state.tabs, tabId);
501
+ if (!sourceLoc) return;
502
+
503
+ const [tab] = sourceLoc.list.splice(sourceLoc.index, 1);
504
+ const targetLoc = findTabLocation(core.state.tabs, targetTabId);
505
+
506
+ if (targetLoc) {
507
+ if (position === 'before') {
508
+ targetLoc.list.splice(targetLoc.index, 0, tab);
509
+ } else {
510
+ targetLoc.list.splice(targetLoc.index + 1, 0, tab);
511
+ }
512
+ } else {
513
+ sourceLoc.list.splice(sourceLoc.index, 0, tab);
514
+ }
515
+ renderTabs(core, options);
516
+ };
517
+
518
+ const moveTabInto = (core, tabId, targetTabId, options) => {
519
+ const sourceLoc = findTabLocation(core.state.tabs, tabId);
520
+ if (!sourceLoc) return;
521
+
522
+ const [tab] = sourceLoc.list.splice(sourceLoc.index, 1);
523
+ const targetTab = findTab(core.state.tabs, targetTabId);
524
+
525
+ if (targetTab) {
526
+ if (!targetTab.children) targetTab.children = [];
527
+ targetTab.children.push(tab);
528
+ targetTab.isExpanded = true;
529
+ }
530
+ renderTabs(core, options);
531
+ };
532
+
533
+ const outdentTab = (core, tabId, options) => {
534
+ const location = findTabLocation(core.state.tabs, tabId);
535
+ if (!location) return;
536
+ const { list, index, parent } = location;
537
+
538
+ if (parent) {
539
+ const parentLocation = findTabLocation(core.state.tabs, parent.tabId);
540
+ if (!parentLocation) return;
541
+
542
+ const { list: parentList, index: parentIndex } = parentLocation;
543
+ const tab = list[index];
544
+
545
+ list.splice(index, 1);
546
+ parentList.splice(parentIndex + 1, 0, tab);
547
+
548
+ renderTabs(core, options);
549
+ }
550
+ };
551
+
552
+ const renameTab = (core, tabId, titleInput, options) => {
553
+ const tab = findTab(core.state.tabs, tabId);
554
+ if (!tab) return;
555
+
556
+ titleInput.disabled = false;
557
+ titleInput.style.pointerEvents = 'auto';
558
+ titleInput.style.outline = '1px solid gray';
559
+
560
+ titleInput.onclick = (e) => e.stopPropagation();
561
+
562
+ let saved = false;
563
+ const save = () => {
564
+ if (saved) return;
565
+ saved = true;
566
+ if (titleInput.value.trim()) {
567
+ tab.tabTitle = titleInput.value.trim();
568
+ }
569
+ renderTabs(core, options);
570
+ };
571
+
572
+ titleInput.onblur = save;
573
+ titleInput.onkeydown = (e) => {
574
+ if (e.key === 'Enter') {
575
+ save();
576
+ }
577
+ };
578
+
579
+ titleInput.focus();
580
+ titleInput.select();
581
+ }
582
+
583
+ const validateAndFixTabId = (tabs) => {
584
+ const ids = new Set();
585
+ const traverse = (list) => {
586
+ list.forEach(tab => {
587
+ if (!tab.tabId || ids.has(tab.tabId)) {
588
+ tab.tabId = uuidv4();
589
+ }
590
+ if (!tab.content) tab.content = '<div><br></div>';
591
+ if (!tab.tabTitle) tab.tabTitle = 'Untitled Tab';
592
+
593
+ ids.add(tab.tabId);
594
+ if (tab.children && tab.children.length > 0) {
595
+ traverse(tab.children);
596
+ }
597
+ });
598
+ };
599
+ traverse(tabs);
600
+ }
601
+
602
+ const prepareTabs = (value) => {
603
+ let initialTabs = [];
604
+ if (Array.isArray(value) && value.length) {
605
+ validateAndFixTabId(value);
606
+ initialTabs = value;
607
+ } else {
608
+ initialTabs = [{
609
+ tabTitle: 'Tab 1',
610
+ tabId: uuidv4(),
611
+ content: typeof value === 'string' ? value : '<div><br></div>',
612
+ children: []
613
+ }];
614
+ }
615
+ return initialTabs
616
+ }
617
+
618
+ export {
619
+ initTabs,
620
+ renderTabs,
621
+ findTab,
622
+ prepareTabs,
623
+ }