WebOrbiton
v1.0.0.1

StocketBase

1,615 lines · 55.3 KB
  1. (function (global) {
  2. 'use strict';
  3. ​
  4. const BLOCK_LABELS = {
  5. paragraph: 'Text',
  6. heading2: 'Heading 2',
  7. heading3: 'Heading 3',
  8. image: 'Image',
  9. video: 'Video',
  10. quote: 'Quote',
  11. list: 'List',
  12. code: 'Code',
  13. table: 'Table',
  14. checklist: 'Checklist',
  15. callout: 'Highlighted note',
  16. separator: 'Separator',
  17. embed: 'Embed',
  18. contact_form: 'Contact form',
  19. hero: 'Welcome banner',
  20. home_featured: 'Featured products',
  21. home_products: 'Product list',
  22. };
  23. ​
  24. const SYSTEM_TYPES = ['home_featured', 'home_products'];
  25. ​
  26. const HOME_ONLY_TYPES = ['hero', 'home_featured', 'home_products'];
  27. ​
  28. const SINGLE_USE_TYPES = ['contact_form', 'home_featured', 'home_products'];
  29. ​
  30. const BLOCK_ICONS = {
  31. paragraph: 'text',
  32. heading2: 'heading2',
  33. heading3: 'heading3',
  34. image: 'image',
  35. video: 'video',
  36. quote: 'quote',
  37. list: 'list',
  38. code: 'code',
  39. table: 'table',
  40. checklist: 'checklist',
  41. callout: 'callout',
  42. separator: 'separator',
  43. embed: 'embed',
  44. contact_form: 'mail',
  45. hero: 'layout',
  46. home_featured: 'star',
  47. home_products: 'grid',
  48. };
  49. ​
  50. function iconSvg(name, cls) {
  51. return '<svg class="' + (cls || 'icon icon-sm') + '" aria-hidden="true"><use href="#icon-' + name + '"></use></svg>';
  52. }
  53. ​
  54. function uid() {
  55. return 'b' + Math.random().toString(16).slice(2) + Date.now().toString(16);
  56. }
  57. ​
  58. const BLOCK_ICONS_KEY = 'stocketbase_dashboard_block_icons';
  59. ​
  60. function blockIconsEnabled() {
  61. try {
  62. return global.localStorage.getItem(BLOCK_ICONS_KEY) !== '0';
  63. } catch (error) {
  64. return true;
  65. }
  66. }
  67. ​
  68. const SPELLCHECK_KEY = 'stocketbase_dashboard_spellcheck';
  69. ​
  70. function spellcheckEnabled() {
  71. try {
  72. return global.localStorage.getItem(SPELLCHECK_KEY) !== '0';
  73. } catch (error) {
  74. return true;
  75. }
  76. }
  77. ​
  78. const PASTE_CLEANUP_KEY = 'stocketbase_dashboard_paste_cleanup';
  79. ​
  80. function pasteCleanupEnabled() {
  81. try {
  82. return global.localStorage.getItem(PASTE_CLEANUP_KEY) === '1';
  83. } catch (error) {
  84. return false;
  85. }
  86. }
  87. ​
  88. let forceCleanPaste = false;
  89. document.addEventListener('keydown', (event) => {
  90. if ((event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === 'V' || event.key === 'v')) {
  91. forceCleanPaste = true;
  92. }
  93. });
  94. ​
  95. function attachCleanPaste(el) {
  96. el.addEventListener('paste', (event) => {
  97. const shouldClean = pasteCleanupEnabled() || forceCleanPaste;
  98. forceCleanPaste = false;
  99. if (!shouldClean) {
  100. return;
  101. }
  102. event.preventDefault();
  103. const clipboard = event.clipboardData || global.clipboardData;
  104. const text = clipboard ? clipboard.getData('text/plain') : '';
  105. document.execCommand('insertText', false, text);
  106. });
  107. }
  108. ​
  109. const RECENT_COLORS_KEY = 'stocketbase_editor_recent_colors';
  110. const PRESET_COLORS = ['#ef4444', '#f59e0b', '#10b981', '#0ea5e9', '#3862f5', '#e85fd0', '#f8fafc', '#8b93b0', '#03040d'];
  111. ​
  112. function loadRecentColors() {
  113. try {
  114. const raw = JSON.parse(global.localStorage.getItem(RECENT_COLORS_KEY) || '[]');
  115. return Array.isArray(raw) ? raw.filter((color) => typeof color === 'string').slice(0, 8) : [];
  116. } catch (error) {
  117. return [];
  118. }
  119. }
  120. ​
  121. function saveRecentColor(color) {
  122. try {
  123. const list = loadRecentColors().filter((existing) => existing.toLowerCase() !== color.toLowerCase());
  124. list.unshift(color);
  125. global.localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(list.slice(0, 8)));
  126. } catch (error) {
  127. }
  128. }
  129. ​
  130. function clamp01(value) {
  131. return Math.min(1, Math.max(0, value));
  132. }
  133. ​
  134. function hexToRgb(hex) {
  135. const clean = hex.replace('#', '');
  136. const num = parseInt(clean, 16);
  137. return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
  138. }
  139. ​
  140. function rgbToHex(r, g, b) {
  141. return '#' + [r, g, b].map((channel) => {
  142. return Math.round(clamp01(channel / 255) * 255).toString(16).padStart(2, '0');
  143. }).join('');
  144. }
  145. ​
  146. function rgbToHsv(r, g, b) {
  147. r /= 255;
  148. g /= 255;
  149. b /= 255;
  150. const max = Math.max(r, g, b);
  151. const min = Math.min(r, g, b);
  152. const delta = max - min;
  153. let h = 0;
  154. if (delta !== 0) {
  155. if (max === r) {
  156. h = ((g - b) / delta) % 6;
  157. } else if (max === g) {
  158. h = (b - r) / delta + 2;
  159. } else {
  160. h = (r - g) / delta + 4;
  161. }
  162. h *= 60;
  163. if (h < 0) {
  164. h += 360;
  165. }
  166. }
  167. return { h, s: max === 0 ? 0 : delta / max, v: max };
  168. }
  169. ​
  170. function hsvToRgb(h, s, v) {
  171. const c = v * s;
  172. const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  173. const m = v - c;
  174. let r = 0;
  175. let g = 0;
  176. let b = 0;
  177. if (h < 60) {
  178. r = c; g = x; b = 0;
  179. } else if (h < 120) {
  180. r = x; g = c; b = 0;
  181. } else if (h < 180) {
  182. r = 0; g = c; b = x;
  183. } else if (h < 240) {
  184. r = 0; g = x; b = c;
  185. } else if (h < 300) {
  186. r = x; g = 0; b = c;
  187. } else {
  188. r = c; g = 0; b = x;
  189. }
  190. return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
  191. }
  192. ​
  193. function positionFloating(el, anchorRect, options) {
  194. const opts = options || {};
  195. const margin = 8;
  196. const width = el.offsetWidth;
  197. const height = el.offsetHeight;
  198. ​
  199. let left = window.scrollX + anchorRect.left;
  200. const maxLeft = window.scrollX + window.innerWidth - width - margin;
  201. const minLeft = window.scrollX + margin;
  202. left = Math.min(left, maxLeft);
  203. left = Math.max(left, minLeft);
  204. ​
  205. let top;
  206. if (opts.placement === 'above') {
  207. top = window.scrollY + anchorRect.top - height - (opts.gap || 0);
  208. if (anchorRect.top - height - (opts.gap || 0) < margin) {
  209. top = window.scrollY + anchorRect.bottom + (opts.gap || 0);
  210. }
  211. } else {
  212. top = window.scrollY + anchorRect.bottom + (opts.gap || 0);
  213. const viewportBottom = window.scrollY + window.innerHeight;
  214. if (top + height > viewportBottom - margin && anchorRect.top - height - (opts.gap || 0) >= margin) {
  215. top = window.scrollY + anchorRect.top - height - (opts.gap || 0);
  216. }
  217. }
  218. ​
  219. el.style.left = left + 'px';
  220. el.style.top = top + 'px';
  221. }
  222. ​
  223. function uploadFile(file, onSuccess, onError) {
  224. const csrfInput = document.querySelector('input[name="csrf_token"]');
  225. const formData = new FormData();
  226. formData.append('file', file);
  227. if (csrfInput) {
  228. formData.append('csrf_token', csrfInput.value);
  229. }
  230. ​
  231. fetch('media-upload.php', { method: 'POST', body: formData })
  232. .then((response) => response.json())
  233. .then((data) => {
  234. if (data.success) {
  235. onSuccess(data.url);
  236. } else {
  237. onError(data.error || 'The upload didn\'t work. Please try again.');
  238. }
  239. })
  240. .catch(() => onError('The upload didn\'t work. Please try again.'));
  241. }
  242. ​
  243. function defaultDataFor(type) {
  244. switch (type) {
  245. case 'paragraph':
  246. case 'quote':
  247. return { text: '' };
  248. case 'heading2':
  249. case 'heading3':
  250. return { text: '' };
  251. case 'image':
  252. return { src: '', alt: '', caption: '' };
  253. case 'video':
  254. return { src: '', caption: '' };
  255. case 'list':
  256. return { style: 'unordered', items: [''] };
  257. case 'code':
  258. return { language: 'text', code: '' };
  259. case 'table':
  260. return { rows: [['', ''], ['', '']] };
  261. case 'checklist':
  262. return { items: [{ text: '', checked: false }] };
  263. case 'callout':
  264. return { style: 'info', text: '' };
  265. case 'embed':
  266. return { url: '' };
  267. case 'separator':
  268. return {};
  269. case 'contact_form':
  270. return { title: '' };
  271. case 'hero':
  272. return { eyebrow: '', title: '', subtitle: '', show_eyebrow: true };
  273. default:
  274. return {};
  275. }
  276. }
  277. ​
  278. function keepSingleUseBlocks(blocks) {
  279. const seen = {};
  280. return blocks.filter((block) => {
  281. if (!block || SINGLE_USE_TYPES.indexOf(block.type) === -1) {
  282. return true;
  283. }
  284. if (seen[block.type]) {
  285. return false;
  286. }
  287. seen[block.type] = true;
  288. return true;
  289. });
  290. }
  291. ​
  292. class BlockEditor {
  293. constructor(rootElement) {
  294. this.root = rootElement;
  295. this.blocks = [];
  296. this.allowContact = rootElement.dataset.allowContact === '1';
  297. this.isHome = rootElement.dataset.home === '1';
  298. this.hiddenInput = document.getElementById('blocks_json_input');
  299. this.root.classList.toggle('be-hide-block-icons', !blockIconsEnabled());
  300. this.draftTimer = null;
  301. ​
  302. const form = this.root.closest('form');
  303. this.form = form || null;
  304. const idField = this.form ? this.form.querySelector('[name="product_id"], [name="page_id"], [name="article_id"]') : null;
  305. this.draftKey = 'stocketbase_draft_' + (idField ? idField.name : 'unknown') + '_' + (idField && idField.value ? idField.value : '0');
  306. ​
  307. this.statsBar = document.createElement('div');
  308. this.statsBar.className = 'be-stats-bar';
  309. this.statsText = document.createElement('span');
  310. this.statsBar.appendChild(this.statsText);
  311. this.focusToggleBtn = document.createElement('button');
  312. this.focusToggleBtn.type = 'button';
  313. this.focusToggleBtn.className = 'be-focus-toggle-btn';
  314. this.focusToggleBtn.textContent = 'Focus mode';
  315. this.focusToggleBtn.addEventListener('click', () => this.toggleFocusMode());
  316. this.statsBar.appendChild(this.focusToggleBtn);
  317. this.root.insertAdjacentElement('afterend', this.statsBar);
  318. ​
  319. document.addEventListener('keydown', (event) => {
  320. if (event.key === 'Escape' && document.body.classList.contains('be-focus-mode')) {
  321. this.toggleFocusMode();
  322. }
  323. });
  324. ​
  325. this.loadInitial();
  326. this.offerDraftRecovery();
  327. this.isInitialRender = true;
  328. this.render();
  329. this.isInitialRender = false;
  330. ​
  331. if (this.form) {
  332. this.form.addEventListener('submit', () => {
  333. this.syncHiddenInput();
  334. try {
  335. global.localStorage.removeItem(this.draftKey);
  336. } catch (error) {
  337. }
  338. });
  339. }
  340. ​
  341. this.attachShortcuts();
  342. }
  343. ​
  344. toggleFocusMode() {
  345. const active = document.body.classList.toggle('be-focus-mode');
  346. this.focusToggleBtn.textContent = active ? 'Exit focus mode' : 'Focus mode';
  347. }
  348. ​
  349. scheduleDraftSave() {
  350. if (this.isInitialRender) {
  351. return;
  352. }
  353. if (this.draftTimer) {
  354. clearTimeout(this.draftTimer);
  355. }
  356. this.draftTimer = setTimeout(() => {
  357. try {
  358. global.localStorage.setItem(this.draftKey, JSON.stringify({ blocks: this.blocks, savedAt: Date.now() }));
  359. } catch (error) {
  360. }
  361. }, 1000);
  362. }
  363. ​
  364. offerDraftRecovery() {
  365. let saved = null;
  366. try {
  367. saved = JSON.parse(global.localStorage.getItem(this.draftKey) || 'null');
  368. } catch (error) {
  369. saved = null;
  370. }
  371. if (!saved || !Array.isArray(saved.blocks) || !saved.blocks.length) {
  372. return;
  373. }
  374. ​
  375. const banner = document.createElement('div');
  376. banner.className = 'be-draft-banner';
  377. ​
  378. const label = document.createElement('span');
  379. label.textContent = 'You have unsaved changes from ' + new Date(saved.savedAt).toLocaleString();
  380. banner.appendChild(label);
  381. ​
  382. const restoreBtn = document.createElement('button');
  383. restoreBtn.type = 'button';
  384. restoreBtn.textContent = 'Restore';
  385. restoreBtn.addEventListener('click', () => {
  386. this.blocks = this.normalizeBlocks(saved.blocks);
  387. this.render();
  388. banner.remove();
  389. });
  390. banner.appendChild(restoreBtn);
  391. ​
  392. const discardBtn = document.createElement('button');
  393. discardBtn.type = 'button';
  394. discardBtn.textContent = 'Throw away';
  395. discardBtn.addEventListener('click', () => {
  396. try {
  397. global.localStorage.removeItem(this.draftKey);
  398. } catch (error) {
  399. }
  400. banner.remove();
  401. });
  402. banner.appendChild(discardBtn);
  403. ​
  404. this.root.insertAdjacentElement('beforebegin', banner);
  405. }
  406. ​
  407. getActiveBlockId() {
  408. const active = document.activeElement;
  409. const wrapper = active && active.closest ? active.closest('.be-block') : null;
  410. if (wrapper) {
  411. return wrapper.dataset.blockId;
  412. }
  413. return this.blocks.length ? this.blocks[this.blocks.length - 1].id : null;
  414. }
  415. ​
  416. getBlocks() {
  417. return JSON.parse(JSON.stringify(this.blocks));
  418. }
  419. ​
  420. replaceBlocks(blocks) {
  421. const next = this.normalizeBlocks(Array.isArray(blocks) ? JSON.parse(JSON.stringify(blocks)) : []);
  422. if (next.length === 0) {
  423. next.push({ id: uid(), type: 'paragraph', data: defaultDataFor('paragraph') });
  424. }
  425. this.blocks = next;
  426. this.render();
  427. }
  428. ​
  429. attachShortcuts() {
  430. if (!global.StocketBaseShortcuts) {
  431. return;
  432. }
  433. ​
  434. document.addEventListener('keydown', (event) => {
  435. if (!this.root.isConnected) {
  436. return;
  437. }
  438. ​
  439. const activeEl = document.activeElement;
  440. const withinEditor = activeEl && this.root.contains(activeEl);
  441. const withinForm = this.form && activeEl && this.form.contains(activeEl);
  442. ​
  443. const shortcuts = global.StocketBaseShortcuts.getShortcuts();
  444. const action = global.StocketBaseShortcuts.findMatchingAction(event, shortcuts);
  445. if (!action) {
  446. return;
  447. }
  448. ​
  449. switch (action) {
  450. case 'bold':
  451. case 'italic':
  452. case 'underline':
  453. case 'strikethrough':
  454. if (!withinEditor) {
  455. return;
  456. }
  457. event.preventDefault();
  458. this.applyFormatShortcut(action);
  459. break;
  460. ​
  461. case 'add_block': {
  462. if (!withinEditor) {
  463. return;
  464. }
  465. event.preventDefault();
  466. const blockId = this.getActiveBlockId();
  467. const index = this.blocks.findIndex((b) => b.id === blockId);
  468. this.addBlock(index === -1 ? this.blocks.length - 1 : index, 'paragraph');
  469. break;
  470. }
  471. ​
  472. case 'remove_block': {
  473. if (!withinEditor) {
  474. return;
  475. }
  476. event.preventDefault();
  477. const blockId = this.getActiveBlockId();
  478. if (blockId) {
  479. this.removeBlock(blockId);
  480. }
  481. break;
  482. }
  483. ​
  484. case 'move_block_up': {
  485. if (!withinEditor) {
  486. return;
  487. }
  488. event.preventDefault();
  489. const blockId = this.getActiveBlockId();
  490. if (blockId) {
  491. this.moveBlock(blockId, -1);
  492. }
  493. break;
  494. }
  495. ​
  496. case 'move_block_down': {
  497. if (!withinEditor) {
  498. return;
  499. }
  500. event.preventDefault();
  501. const blockId = this.getActiveBlockId();
  502. if (blockId) {
  503. this.moveBlock(blockId, 1);
  504. }
  505. break;
  506. }
  507. ​
  508. case 'save':
  509. if (!withinForm && !withinEditor) {
  510. return;
  511. }
  512. event.preventDefault();
  513. if (this.form) {
  514. this.syncHiddenInput();
  515. if (this.form.requestSubmit) {
  516. this.form.requestSubmit();
  517. } else {
  518. this.form.submit();
  519. }
  520. }
  521. break;
  522. ​
  523. default:
  524. break;
  525. }
  526. });
  527. }
  528. ​
  529. applyFormatShortcut(action) {
  530. const commandMap = {
  531. bold: 'bold',
  532. italic: 'italic',
  533. underline: 'underline',
  534. strikethrough: 'strikeThrough',
  535. };
  536. const command = commandMap[action];
  537. if (!command) {
  538. return;
  539. }
  540. const selection = window.getSelection();
  541. if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
  542. return;
  543. }
  544. document.execCommand(command, false, null);
  545. }
  546. ​
  547. loadInitial() {
  548. const raw = this.root.dataset.initialBlocks || '{"blocks":[]}';
  549. try {
  550. const parsed = JSON.parse(raw);
  551. this.blocks = Array.isArray(parsed.blocks) ? parsed.blocks : [];
  552. } catch (error) {
  553. this.blocks = [];
  554. }
  555. this.blocks = this.normalizeBlocks(this.blocks);
  556. ​
  557. if (this.blocks.length === 0) {
  558. this.blocks.push({ id: uid(), type: 'paragraph', data: defaultDataFor('paragraph') });
  559. }
  560. }
  561. ​
  562. syncHiddenInput() {
  563. if (this.hiddenInput) {
  564. this.hiddenInput.value = JSON.stringify({ blocks: this.blocks });
  565. }
  566. this.updateStats();
  567. this.scheduleDraftSave();
  568. }
  569. ​
  570. updateStats() {
  571. if (!this.statsText) {
  572. return;
  573. }
  574. const stripHtml = (html) => {
  575. const tmp = document.createElement('div');
  576. tmp.innerHTML = html || '';
  577. return tmp.textContent || '';
  578. };
  579. const parts = [];
  580. this.blocks.forEach((block) => {
  581. const data = block.data || {};
  582. if (typeof data.text === 'string') {
  583. parts.push(stripHtml(data.text));
  584. }
  585. if (typeof data.caption === 'string') {
  586. parts.push(data.caption);
  587. }
  588. if (Array.isArray(data.items)) {
  589. data.items.forEach((item) => {
  590. parts.push(typeof item === 'string' ? item : (item.text || ''));
  591. });
  592. }
  593. if (Array.isArray(data.rows)) {
  594. data.rows.forEach((row) => {
  595. row.forEach((cell) => parts.push(cell || ''));
  596. });
  597. }
  598. });
  599. const text = parts.join(' ').trim();
  600. const words = text ? text.split(/\s+/).length : 0;
  601. const chars = text.replace(/\s+/g, '').length;
  602. const minutes = Math.max(1, Math.round(words / 200));
  603. this.statsText.textContent = words + ' words, ' + chars + ' characters, about ' + minutes + ' min to read';
  604. }
  605. ​
  606. normalizeBlocks(blocks) {
  607. let list = (Array.isArray(blocks) ? blocks : []).filter((block) => block && typeof block === 'object' && typeof block.type === 'string');
  608. if (!this.isHome) {
  609. list = list.filter((block) => HOME_ONLY_TYPES.indexOf(block.type) === -1);
  610. }
  611. list = keepSingleUseBlocks(list);
  612. if (this.isHome) {
  613. SYSTEM_TYPES.forEach((type) => {
  614. if (!list.some((block) => block.type === type)) {
  615. list.push({ id: uid(), type: type, data: {} });
  616. }
  617. });
  618. }
  619. list.forEach((block) => {
  620. if (!block.data || typeof block.data !== 'object') {
  621. block.data = defaultDataFor(block.type);
  622. }
  623. });
  624. return list;
  625. }
  626. ​
  627. isSystemBlock(id) {
  628. const block = this.blocks.find((b) => b.id === id);
  629. return !!block && SYSTEM_TYPES.indexOf(block.type) !== -1;
  630. }
  631. ​
  632. canUseType(type, replacingId) {
  633. if (SYSTEM_TYPES.indexOf(type) !== -1) {
  634. return false;
  635. }
  636. if (type === 'hero' && !this.isHome) {
  637. return false;
  638. }
  639. if (type === 'contact_form' && !this.allowContact) {
  640. return false;
  641. }
  642. if (SINGLE_USE_TYPES.indexOf(type) === -1) {
  643. return true;
  644. }
  645. return !this.blocks.some((b) => b.type === type && b.id !== replacingId);
  646. }
  647. ​
  648. addBlock(afterIndex, type) {
  649. if (!this.canUseType(type, null)) {
  650. return;
  651. }
  652. const block = { id: uid(), type: type, data: defaultDataFor(type) };
  653. this.blocks.splice(afterIndex + 1, 0, block);
  654. this.render();
  655. this.focusBlock(block.id);
  656. }
  657. ​
  658. transformBlock(id, type) {
  659. const index = this.blocks.findIndex((b) => b.id === id);
  660. if (index === -1 || !this.canUseType(type, id) || this.isSystemBlock(id)) {
  661. return;
  662. }
  663. const block = { id: uid(), type: type, data: defaultDataFor(type) };
  664. this.blocks.splice(index, 1, block);
  665. this.render();
  666. this.focusBlock(block.id);
  667. }
  668. ​
  669. removeBlock(id) {
  670. if (this.blocks.length <= 1 || this.isSystemBlock(id)) {
  671. return;
  672. }
  673. this.blocks = this.blocks.filter((b) => b.id !== id);
  674. this.render();
  675. }
  676. ​
  677. moveBlock(id, direction) {
  678. const index = this.blocks.findIndex((b) => b.id === id);
  679. const targetIndex = index + direction;
  680. if (index === -1 || targetIndex < 0 || targetIndex >= this.blocks.length) {
  681. return;
  682. }
  683. const [block] = this.blocks.splice(index, 1);
  684. this.blocks.splice(targetIndex, 0, block);
  685. this.render();
  686. }
  687. ​
  688. focusBlock(id) {
  689. requestAnimationFrame(() => {
  690. const el = this.root.querySelector('[data-block-id="' + id + '"] [data-focus-target]');
  691. if (el) {
  692. el.focus();
  693. }
  694. });
  695. }
  696. ​
  697. attachInlineToolbar(el, onChange) {
  698. let toolbar = null;
  699. let savedRange = null;
  700. let colorSpan = null;
  701. let colorPopover = null;
  702. let colorPopoverOutsideClick = null;
  703. let lastAppliedColor = '';
  704. ​
  705. const closeColorPopover = () => {
  706. if (colorPopoverOutsideClick) {
  707. document.removeEventListener('mousedown', colorPopoverOutsideClick, true);
  708. colorPopoverOutsideClick = null;
  709. }
  710. if (colorPopover) {
  711. colorPopover.remove();
  712. colorPopover = null;
  713. }
  714. };
  715. ​
  716. const removeToolbar = () => {
  717. if (toolbar) {
  718. toolbar.remove();
  719. toolbar = null;
  720. }
  721. colorSpan = null;
  722. closeColorPopover();
  723. };
  724. ​
  725. const selectionInsideEl = () => {
  726. const selection = window.getSelection();
  727. if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
  728. return null;
  729. }
  730. const range = selection.getRangeAt(0);
  731. if (!el.contains(range.commonAncestorContainer)) {
  732. return null;
  733. }
  734. return range;
  735. };
  736. ​
  737. const wrapSelection = (range, tagName, styleValue) => {
  738. const wrapper = document.createElement(tagName);
  739. if (styleValue) {
  740. wrapper.setAttribute('style', styleValue);
  741. }
  742. try {
  743. range.surroundContents(wrapper);
  744. } catch (error) {
  745. wrapper.appendChild(range.extractContents());
  746. range.insertNode(wrapper);
  747. }
  748. const newSelection = window.getSelection();
  749. newSelection.removeAllRanges();
  750. const newRange = document.createRange();
  751. newRange.selectNodeContents(wrapper);
  752. newSelection.addRange(newRange);
  753. return wrapper;
  754. };
  755. ​
  756. const applyFormat = (command) => {
  757. const range = selectionInsideEl();
  758. if (!range) {
  759. return;
  760. }
  761. document.execCommand(command, false, null);
  762. el.focus();
  763. onChange();
  764. showToolbar();
  765. };
  766. ​
  767. const clearFormat = () => {
  768. const range = selectionInsideEl();
  769. if (!range) {
  770. return;
  771. }
  772. document.execCommand('removeFormat', false, null);
  773. document.execCommand('unlink', false, null);
  774. el.focus();
  775. onChange();
  776. colorSpan = null;
  777. lastAppliedColor = '';
  778. showToolbar();
  779. };
  780. ​
  781. const applyColor = (color) => {
  782. lastAppliedColor = color;
  783. if (colorSpan && colorSpan.isConnected) {
  784. colorSpan.style.color = color;
  785. onChange();
  786. return;
  787. }
  788. if (!savedRange) {
  789. return;
  790. }
  791. colorSpan = wrapSelection(savedRange, 'span', 'color:' + color);
  792. onChange();
  793. };
  794. ​
  795. const resetColor = () => {
  796. if (colorSpan && colorSpan.isConnected) {
  797. const parent = colorSpan.parentNode;
  798. while (colorSpan.firstChild) {
  799. parent.insertBefore(colorSpan.firstChild, colorSpan);
  800. }
  801. parent.removeChild(colorSpan);
  802. colorSpan = null;
  803. onChange();
  804. }
  805. lastAppliedColor = '';
  806. };
  807. ​
  808. const openColorPopover = (anchorEl) => {
  809. closeColorPopover();
  810. ​
  811. const popover = document.createElement('div');
  812. popover.className = 'be-color-popover';
  813. ​
  814. const buildSwatchRow = (label, colors, onPick) => {
  815. if (!colors.length) {
  816. return null;
  817. }
  818. const row = document.createElement('div');
  819. row.className = 'be-color-popover-row';
  820. const rowLabel = document.createElement('span');
  821. rowLabel.className = 'be-color-popover-label';
  822. rowLabel.textContent = label;
  823. row.appendChild(rowLabel);
  824. const swatches = document.createElement('div');
  825. swatches.className = 'be-color-popover-swatches';
  826. colors.forEach((color) => {
  827. const swatch = document.createElement('button');
  828. swatch.type = 'button';
  829. swatch.className = 'be-color-swatch';
  830. swatch.style.background = color;
  831. swatch.title = color;
  832. swatch.addEventListener('mousedown', (event) => event.preventDefault());
  833. swatch.addEventListener('click', () => onPick(color));
  834. swatches.appendChild(swatch);
  835. });
  836. row.appendChild(swatches);
  837. return row;
  838. };
  839. ​
  840. let hue = 0;
  841. let sat = 0;
  842. let val = 0;
  843. let suppressSync = false;
  844. ​
  845. if (lastAppliedColor) {
  846. const startRgb = hexToRgb(lastAppliedColor);
  847. const startHsv = rgbToHsv(startRgb.r, startRgb.g, startRgb.b);
  848. hue = startHsv.h;
  849. sat = startHsv.s;
  850. val = startHsv.v;
  851. }
  852. ​
  853. const pickColor = (color, remember) => {
  854. applyColor(color);
  855. if (remember !== false) {
  856. saveRecentColor(color);
  857. }
  858. hexInput.value = color;
  859. hexPreview.style.background = color;
  860. const bar = anchorEl.querySelector('.be-color-trigger-bar');
  861. if (bar) {
  862. bar.style.background = color;
  863. }
  864. if (!suppressSync) {
  865. const rgb = hexToRgb(color);
  866. const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
  867. hue = hsv.h;
  868. sat = hsv.s;
  869. val = hsv.v;
  870. positionPickerHandles();
  871. }
  872. };
  873. ​
  874. const recentRow = buildSwatchRow('Recent', loadRecentColors(), pickColor);
  875. if (recentRow) {
  876. popover.appendChild(recentRow);
  877. }
  878. const presetsRow = buildSwatchRow('Presets', PRESET_COLORS, pickColor);
  879. const customTrigger = document.createElement('button');
  880. customTrigger.type = 'button';
  881. customTrigger.className = 'be-color-swatch be-color-swatch-custom';
  882. customTrigger.title = 'Custom color';
  883. customTrigger.addEventListener('mousedown', (event) => event.preventDefault());
  884. customTrigger.addEventListener('click', () => {
  885. customPanel.classList.toggle('open');
  886. });
  887. presetsRow.querySelector('.be-color-popover-swatches').appendChild(customTrigger);
  888. popover.appendChild(presetsRow);
  889. ​
  890. const hexRow = document.createElement('div');
  891. hexRow.className = 'be-color-popover-hex-row';
  892. ​
  893. const hexPreview = document.createElement('span');
  894. hexPreview.className = 'be-color-swatch be-color-swatch-preview';
  895. hexPreview.style.background = lastAppliedColor || 'transparent';
  896. ​
  897. const hexInput = document.createElement('input');
  898. hexInput.type = 'text';
  899. hexInput.className = 'be-color-hex-input';
  900. hexInput.placeholder = '#000000';
  901. hexInput.value = lastAppliedColor;
  902. hexInput.maxLength = 7;
  903. hexInput.spellcheck = false;
  904. ​
  905. const commitHex = () => {
  906. const value = hexInput.value.trim();
  907. if (/^#[0-9a-fA-F]{6}$/.test(value)) {
  908. pickColor(value);
  909. }
  910. };
  911. hexInput.addEventListener('input', () => {
  912. const value = hexInput.value.trim();
  913. hexPreview.style.background = /^#[0-9a-fA-F]{6}$/.test(value) ? value : 'transparent';
  914. });
  915. hexInput.addEventListener('change', commitHex);
  916. hexInput.addEventListener('keydown', (event) => {
  917. if (event.key === 'Enter') {
  918. event.preventDefault();
  919. commitHex();
  920. }
  921. });
  922. ​
  923. hexRow.appendChild(hexPreview);
  924. hexRow.appendChild(hexInput);
  925. popover.appendChild(hexRow);
  926. ​
  927. const customPanel = document.createElement('div');
  928. customPanel.className = 'be-color-custom-panel';
  929. ​
  930. const svArea = document.createElement('div');
  931. svArea.className = 'be-color-sv-area';
  932. const svCursor = document.createElement('div');
  933. svCursor.className = 'be-color-sv-cursor';
  934. svArea.appendChild(svCursor);
  935. ​
  936. const hueSlider = document.createElement('div');
  937. hueSlider.className = 'be-color-hue-slider';
  938. const hueHandle = document.createElement('div');
  939. hueHandle.className = 'be-color-hue-handle';
  940. hueSlider.appendChild(hueHandle);
  941. ​
  942. customPanel.appendChild(svArea);
  943. customPanel.appendChild(hueSlider);
  944. popover.appendChild(customPanel);
  945. ​
  946. const positionPickerHandles = () => {
  947. const rgb = hsvToRgb(hue, sat, val);
  948. svArea.style.background = 'linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(' + hue + ',100%,50%))';
  949. svCursor.style.left = (sat * 100) + '%';
  950. svCursor.style.top = ((1 - val) * 100) + '%';
  951. svCursor.style.background = rgbToHex(rgb.r, rgb.g, rgb.b);
  952. hueHandle.style.left = (hue / 360 * 100) + '%';
  953. hueHandle.style.background = 'hsl(' + hue + ',100%,50%)';
  954. };
  955. ​
  956. const updateFromPicker = (remember) => {
  957. suppressSync = true;
  958. const rgb = hsvToRgb(hue, sat, val);
  959. const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
  960. pickColor(hex, remember);
  961. suppressSync = false;
  962. positionPickerHandles();
  963. };
  964. ​
  965. const dragHandler = (area, onMove) => (startEvent) => {
  966. startEvent.preventDefault();
  967. area.setPointerCapture(startEvent.pointerId);
  968. const move = (event) => onMove(event);
  969. const stop = (event) => {
  970. onMove(event);
  971. area.removeEventListener('pointermove', move);
  972. area.removeEventListener('pointerup', stop);
  973. saveRecentColor(hexInput.value);
  974. };
  975. onMove(startEvent);
  976. area.addEventListener('pointermove', move);
  977. area.addEventListener('pointerup', stop);
  978. };
  979. ​
  980. svArea.addEventListener('pointerdown', dragHandler(svArea, (event) => {
  981. const rect = svArea.getBoundingClientRect();
  982. sat = clamp01((event.clientX - rect.left) / rect.width);
  983. val = 1 - clamp01((event.clientY - rect.top) / rect.height);
  984. updateFromPicker(false);
  985. }));
  986. ​
  987. hueSlider.addEventListener('pointerdown', dragHandler(hueSlider, (event) => {
  988. const rect = hueSlider.getBoundingClientRect();
  989. hue = clamp01((event.clientX - rect.left) / rect.width) * 360;
  990. updateFromPicker(false);
  991. }));
  992. ​
  993. positionPickerHandles();
  994. ​
  995. const resetBtn = document.createElement('button');
  996. resetBtn.type = 'button';
  997. resetBtn.className = 'be-color-reset-btn';
  998. resetBtn.textContent = 'Reset color';
  999. resetBtn.addEventListener('mousedown', (event) => event.preventDefault());
  1000. resetBtn.addEventListener('click', () => {
  1001. resetColor();
  1002. hexInput.value = '';
  1003. hexPreview.style.background = 'transparent';
  1004. const bar = anchorEl.querySelector('.be-color-trigger-bar');
  1005. if (bar) {
  1006. bar.style.background = 'currentColor';
  1007. }
  1008. closeColorPopover();
  1009. });
  1010. popover.appendChild(resetBtn);
  1011. ​
  1012. document.body.appendChild(popover);
  1013. const rect = anchorEl.getBoundingClientRect();
  1014. positionFloating(popover, rect, { placement: 'below', gap: 6 });
  1015. colorPopover = popover;
  1016. ​
  1017. colorPopoverOutsideClick = (event) => {
  1018. if (!popover.contains(event.target) && event.target !== anchorEl) {
  1019. closeColorPopover();
  1020. }
  1021. };
  1022. setTimeout(() => document.addEventListener('mousedown', colorPopoverOutsideClick, true), 0);
  1023. };
  1024. ​
  1025. const showToolbar = () => {
  1026. const range = selectionInsideEl();
  1027. removeToolbar();
  1028. if (!range) {
  1029. savedRange = null;
  1030. return;
  1031. }
  1032. savedRange = range.cloneRange();
  1033. ​
  1034. toolbar = document.createElement('div');
  1035. toolbar.className = 'be-inline-toolbar';
  1036. ​
  1037. const shortcutTitle = (action, fallback) => {
  1038. if (global.StocketBaseShortcuts) {
  1039. const combo = global.StocketBaseShortcuts.getShortcuts()[action];
  1040. if (combo) {
  1041. return fallback + ' (' + combo.combo + ')';
  1042. }
  1043. }
  1044. return fallback;
  1045. };
  1046. ​
  1047. const boldBtn = document.createElement('button');
  1048. boldBtn.type = 'button';
  1049. boldBtn.textContent = 'B';
  1050. boldBtn.title = shortcutTitle('bold', 'Bold');
  1051. boldBtn.addEventListener('mousedown', (event) => {
  1052. event.preventDefault();
  1053. applyFormat('bold');
  1054. });
  1055. ​
  1056. const italicBtn = document.createElement('button');
  1057. italicBtn.type = 'button';
  1058. italicBtn.textContent = 'I';
  1059. italicBtn.title = shortcutTitle('italic', 'Italic');
  1060. italicBtn.style.fontStyle = 'italic';
  1061. italicBtn.addEventListener('mousedown', (event) => {
  1062. event.preventDefault();
  1063. applyFormat('italic');
  1064. });
  1065. ​
  1066. const underlineBtn = document.createElement('button');
  1067. underlineBtn.type = 'button';
  1068. underlineBtn.textContent = 'U';
  1069. underlineBtn.title = shortcutTitle('underline', 'Underline');
  1070. underlineBtn.style.textDecoration = 'underline';
  1071. underlineBtn.addEventListener('mousedown', (event) => {
  1072. event.preventDefault();
  1073. applyFormat('underline');
  1074. });
  1075. ​
  1076. const strikeBtn = document.createElement('button');
  1077. strikeBtn.type = 'button';
  1078. strikeBtn.textContent = 'S';
  1079. strikeBtn.title = shortcutTitle('strikethrough', 'Strikethrough');
  1080. strikeBtn.style.textDecoration = 'line-through';
  1081. strikeBtn.addEventListener('mousedown', (event) => {
  1082. event.preventDefault();
  1083. applyFormat('strikeThrough');
  1084. });
  1085. ​
  1086. const clearFormatBtn = document.createElement('button');
  1087. clearFormatBtn.type = 'button';
  1088. clearFormatBtn.className = 'be-clear-format-btn';
  1089. clearFormatBtn.textContent = 'Tx';
  1090. clearFormatBtn.title = shortcutTitle('clearFormat', 'Clear formatting');
  1091. clearFormatBtn.addEventListener('mousedown', (event) => {
  1092. event.preventDefault();
  1093. clearFormat();
  1094. });
  1095. ​
  1096. const colorTriggerBtn = document.createElement('button');
  1097. colorTriggerBtn.type = 'button';
  1098. colorTriggerBtn.className = 'be-color-trigger';
  1099. colorTriggerBtn.title = 'Text color';
  1100. colorTriggerBtn.innerHTML = '<span class="be-color-trigger-letter">A</span><span class="be-color-trigger-bar" style="background:' + (lastAppliedColor || 'currentColor') + '"></span>';
  1101. colorTriggerBtn.addEventListener('mousedown', (event) => event.preventDefault());
  1102. colorTriggerBtn.addEventListener('click', () => openColorPopover(colorTriggerBtn));
  1103. ​
  1104. toolbar.appendChild(boldBtn);
  1105. toolbar.appendChild(italicBtn);
  1106. toolbar.appendChild(underlineBtn);
  1107. toolbar.appendChild(strikeBtn);
  1108. toolbar.appendChild(clearFormatBtn);
  1109. toolbar.appendChild(colorTriggerBtn);
  1110. ​
  1111. document.body.appendChild(toolbar);
  1112. const rect = range.getBoundingClientRect();
  1113. positionFloating(toolbar, rect, { placement: 'above', gap: 6 });
  1114. };
  1115. ​
  1116. el.addEventListener('mouseup', () => setTimeout(showToolbar, 0));
  1117. el.addEventListener('keyup', (event) => {
  1118. if (event.shiftKey || event.key === 'Shift') {
  1119. setTimeout(showToolbar, 0);
  1120. }
  1121. });
  1122. el.addEventListener('blur', () => {
  1123. setTimeout(() => {
  1124. if (toolbar && toolbar.contains(document.activeElement)) {
  1125. return;
  1126. }
  1127. if (colorPopover && colorPopover.contains(document.activeElement)) {
  1128. return;
  1129. }
  1130. removeToolbar();
  1131. }, 150);
  1132. });
  1133. }
  1134. ​
  1135. render() {
  1136. this.root.innerHTML = '';
  1137. this.blocks.forEach((block, index) => {
  1138. this.root.appendChild(this.renderBlock(block, index));
  1139. });
  1140. ​
  1141. const addRow = document.createElement('div');
  1142. addRow.className = 'be-add-row';
  1143. const addButton = document.createElement('button');
  1144. addButton.type = 'button';
  1145. addButton.className = 'dash-btn-small be-add-button';
  1146. addButton.innerHTML = iconSvg('plus') + 'Add block';
  1147. addButton.addEventListener('click', () => this.openBlockMenu(addButton, this.blocks.length - 1));
  1148. addRow.appendChild(addButton);
  1149. this.root.appendChild(addRow);
  1150. ​
  1151. this.syncHiddenInput();
  1152. }
  1153. ​
  1154. renderBlock(block, index) {
  1155. const wrapper = document.createElement('div');
  1156. const isSystem = SYSTEM_TYPES.indexOf(block.type) !== -1;
  1157. wrapper.className = isSystem ? 'be-block be-block-system' : 'be-block';
  1158. wrapper.dataset.blockId = block.id;
  1159. ​
  1160. const typeIcon = document.createElement('span');
  1161. typeIcon.className = 'be-block-icon-badge';
  1162. typeIcon.title = BLOCK_LABELS[block.type] || '';
  1163. typeIcon.innerHTML = iconSvg(BLOCK_ICONS[block.type] || 'text');
  1164. wrapper.appendChild(typeIcon);
  1165. ​
  1166. const toolbar = document.createElement('div');
  1167. toolbar.className = 'be-block-toolbar';
  1168. const titleFor = (action, fallback) => {
  1169. if (global.StocketBaseShortcuts) {
  1170. const entry = global.StocketBaseShortcuts.getShortcuts()[action];
  1171. if (entry) {
  1172. return fallback + ' (' + entry.combo + ')';
  1173. }
  1174. }
  1175. return fallback;
  1176. };
  1177. toolbar.innerHTML =
  1178. '<span class="be-block-type">' + iconSvg(BLOCK_ICONS[block.type] || 'text') + BLOCK_LABELS[block.type] + '</span>' +
  1179. '<button type="button" data-action="up" title="' + titleFor('move_block_up', 'Move up') + '">' + iconSvg('arrow-up') + '</button>' +
  1180. '<button type="button" data-action="down" title="' + titleFor('move_block_down', 'Move down') + '">' + iconSvg('arrow-down') + '</button>' +
  1181. '<button type="button" data-action="add" title="' + titleFor('add_block', 'Add block below') + '">' + iconSvg('plus') + '</button>' +
  1182. (isSystem ? '' : '<button type="button" data-action="remove" title="' + titleFor('remove_block', 'Remove block') + '">' + iconSvg('x') + '</button>');
  1183. ​
  1184. toolbar.querySelector('[data-action="up"]').addEventListener('click', () => this.moveBlock(block.id, -1));
  1185. toolbar.querySelector('[data-action="down"]').addEventListener('click', () => this.moveBlock(block.id, 1));
  1186. const removeButton = toolbar.querySelector('[data-action="remove"]');
  1187. if (removeButton) {
  1188. removeButton.addEventListener('click', () => this.removeBlock(block.id));
  1189. }
  1190. toolbar.querySelector('[data-action="add"]').addEventListener('click', (event) => {
  1191. this.openBlockMenu(event.currentTarget, index);
  1192. });
  1193. ​
  1194. wrapper.appendChild(toolbar);
  1195. wrapper.appendChild(this.renderBlockBody(block));
  1196. ​
  1197. return wrapper;
  1198. }
  1199. ​
  1200. openBlockMenu(anchorEl, afterIndex, onPick, replacingId) {
  1201. const existing = document.querySelector('.be-block-menu');
  1202. if (existing) {
  1203. existing.remove();
  1204. }
  1205. ​
  1206. const menu = document.createElement('div');
  1207. menu.className = 'be-block-menu';
  1208. Object.keys(BLOCK_LABELS).forEach((type) => {
  1209. if (SYSTEM_TYPES.indexOf(type) !== -1 || (type === 'hero' && !this.isHome) || (type === 'contact_form' && (!this.allowContact || this.isHome))) {
  1210. return;
  1211. }
  1212. const item = document.createElement('button');
  1213. item.type = 'button';
  1214. item.className = 'be-block-menu-item';
  1215. item.innerHTML = iconSvg(BLOCK_ICONS[type] || 'text') + '<span>' + BLOCK_LABELS[type] + '</span>';
  1216. if (!this.canUseType(type, replacingId || null)) {
  1217. item.disabled = true;
  1218. item.classList.add('be-block-menu-item-disabled');
  1219. item.title = 'You can only have one ' + BLOCK_LABELS[type].toLowerCase() + ' per page.';
  1220. menu.appendChild(item);
  1221. return;
  1222. }
  1223. item.addEventListener('click', () => {
  1224. if (onPick) {
  1225. onPick(type);
  1226. } else {
  1227. this.addBlock(afterIndex, type);
  1228. }
  1229. menu.remove();
  1230. });
  1231. menu.appendChild(item);
  1232. });
  1233. ​
  1234. document.body.appendChild(menu);
  1235. const rect = anchorEl.getBoundingClientRect();
  1236. positionFloating(menu, rect, { placement: 'below', gap: 4 });
  1237. ​
  1238. const closeHandler = (event) => {
  1239. if (!menu.contains(event.target) && event.target !== anchorEl) {
  1240. menu.remove();
  1241. document.removeEventListener('click', closeHandler);
  1242. }
  1243. };
  1244. setTimeout(() => document.addEventListener('click', closeHandler), 0);
  1245. }
  1246. ​
  1247. renderBlockBody(block) {
  1248. const container = document.createElement('div');
  1249. container.className = 'be-block-body';
  1250. ​
  1251. const update = (patch) => {
  1252. Object.assign(block.data, patch);
  1253. this.syncHiddenInput();
  1254. };
  1255. ​
  1256. switch (block.type) {
  1257. case 'paragraph':
  1258. case 'quote': {
  1259. const el = document.createElement('div');
  1260. el.className = 'be-editable';
  1261. el.contentEditable = 'true';
  1262. el.spellcheck = spellcheckEnabled();
  1263. el.dataset.focusTarget = '1';
  1264. el.innerHTML = block.data.text || '';
  1265. el.addEventListener('input', () => {
  1266. update({ text: el.innerHTML });
  1267. if (el.textContent === '/') {
  1268. this.openBlockMenu(el, -1, (type) => this.transformBlock(block.id, type), block.id);
  1269. }
  1270. });
  1271. this.attachInlineToolbar(el, () => update({ text: el.innerHTML }));
  1272. attachCleanPaste(el);
  1273. container.appendChild(el);
  1274. break;
  1275. }
  1276. case 'heading2':
  1277. case 'heading3': {
  1278. const el = document.createElement('div');
  1279. el.className = 'be-editable be-heading';
  1280. el.contentEditable = 'true';
  1281. el.spellcheck = spellcheckEnabled();
  1282. el.dataset.focusTarget = '1';
  1283. el.textContent = block.data.text || '';
  1284. el.addEventListener('input', () => update({ text: el.textContent }));
  1285. attachCleanPaste(el);
  1286. container.appendChild(el);
  1287. break;
  1288. }
  1289. case 'image': {
  1290. container.appendChild(this.buildInput('Image link', block.data.src, (val) => update({ src: val }), true));
  1291. container.appendChild(this.buildUploadButton('image', (url) => {
  1292. block.data.src = url;
  1293. this.render();
  1294. }));
  1295. container.appendChild(this.buildInput('Describe the image (helps blind visitors and Google)', block.data.alt, (val) => update({ alt: val })));
  1296. container.appendChild(this.buildInput('Caption', block.data.caption, (val) => update({ caption: val })));
  1297. if (block.data.src) {
  1298. const preview = document.createElement('img');
  1299. preview.src = block.data.src;
  1300. preview.className = 'be-preview-image';
  1301. container.appendChild(preview);
  1302. }
  1303. break;
  1304. }
  1305. case 'video': {
  1306. container.appendChild(this.buildInput('Video link', block.data.src, (val) => update({ src: val }), true));
  1307. container.appendChild(this.buildUploadButton('video', (url) => {
  1308. block.data.src = url;
  1309. this.render();
  1310. }));
  1311. container.appendChild(this.buildInput('Caption', block.data.caption, (val) => update({ caption: val })));
  1312. if (block.data.src) {
  1313. const preview = document.createElement('video');
  1314. preview.src = block.data.src;
  1315. preview.controls = true;
  1316. preview.className = 'be-preview-video';
  1317. container.appendChild(preview);
  1318. }
  1319. break;
  1320. }
  1321. case 'list': {
  1322. const styleSelect = document.createElement('select');
  1323. ['unordered', 'ordered'].forEach((style) => {
  1324. const option = document.createElement('option');
  1325. option.value = style;
  1326. option.textContent = style === 'unordered' ? 'Bullet list' : 'Numbered list';
  1327. option.selected = block.data.style === style;
  1328. styleSelect.appendChild(option);
  1329. });
  1330. styleSelect.addEventListener('change', () => update({ style: styleSelect.value }));
  1331. container.appendChild(styleSelect);
  1332. ​
  1333. const itemsWrap = document.createElement('div');
  1334. (block.data.items || ['']).forEach((item, itemIndex) => {
  1335. const row = document.createElement('div');
  1336. row.className = 'be-list-row';
  1337. const input = document.createElement('input');
  1338. input.type = 'text';
  1339. input.value = item;
  1340. input.addEventListener('input', () => {
  1341. block.data.items[itemIndex] = input.value;
  1342. this.syncHiddenInput();
  1343. });
  1344. const removeBtn = document.createElement('button');
  1345. removeBtn.type = 'button';
  1346. removeBtn.textContent = '−';
  1347. removeBtn.addEventListener('click', () => {
  1348. block.data.items.splice(itemIndex, 1);
  1349. this.render();
  1350. });
  1351. row.appendChild(input);
  1352. row.appendChild(removeBtn);
  1353. itemsWrap.appendChild(row);
  1354. });
  1355. const addItemBtn = document.createElement('button');
  1356. addItemBtn.type = 'button';
  1357. addItemBtn.textContent = '+ Add item';
  1358. addItemBtn.addEventListener('click', () => {
  1359. block.data.items.push('');
  1360. this.render();
  1361. });
  1362. container.appendChild(itemsWrap);
  1363. container.appendChild(addItemBtn);
  1364. break;
  1365. }
  1366. case 'code': {
  1367. container.appendChild(this.buildInput('Code language (like html or php)', block.data.language, (val) => update({ language: val })));
  1368. const textarea = document.createElement('textarea');
  1369. textarea.className = 'be-code';
  1370. textarea.value = block.data.code || '';
  1371. textarea.spellcheck = false;
  1372. textarea.addEventListener('input', () => update({ code: textarea.value }));
  1373. container.appendChild(textarea);
  1374. break;
  1375. }
  1376. case 'table': {
  1377. const table = document.createElement('table');
  1378. table.className = 'be-table';
  1379. const rows = block.data.rows || [['', '']];
  1380. rows.forEach((row, rowIndex) => {
  1381. const tr = document.createElement('tr');
  1382. row.forEach((cell, cellIndex) => {
  1383. const td = document.createElement('td');
  1384. const input = document.createElement('input');
  1385. input.type = 'text';
  1386. input.value = cell;
  1387. input.addEventListener('input', () => {
  1388. block.data.rows[rowIndex][cellIndex] = input.value;
  1389. this.syncHiddenInput();
  1390. });
  1391. td.appendChild(input);
  1392. tr.appendChild(td);
  1393. });
  1394. table.appendChild(tr);
  1395. });
  1396. const tableScroll = document.createElement('div');
  1397. tableScroll.className = 'be-table-scroll';
  1398. tableScroll.appendChild(table);
  1399. container.appendChild(tableScroll);
  1400. ​
  1401. const controls = document.createElement('div');
  1402. controls.className = 'be-table-controls';
  1403. const addRowBtn = document.createElement('button');
  1404. addRowBtn.type = 'button';
  1405. addRowBtn.textContent = '+ Row';
  1406. addRowBtn.addEventListener('click', () => {
  1407. const cols = (block.data.rows[0] || ['', '']).length;
  1408. block.data.rows.push(new Array(cols).fill(''));
  1409. this.render();
  1410. });
  1411. const addColBtn = document.createElement('button');
  1412. addColBtn.type = 'button';
  1413. addColBtn.textContent = '+ Column';
  1414. addColBtn.addEventListener('click', () => {
  1415. block.data.rows.forEach((row) => row.push(''));
  1416. this.render();
  1417. });
  1418. controls.appendChild(addRowBtn);
  1419. controls.appendChild(addColBtn);
  1420. container.appendChild(controls);
  1421. break;
  1422. }
  1423. case 'checklist': {
  1424. const itemsWrap = document.createElement('div');
  1425. (block.data.items || []).forEach((item, itemIndex) => {
  1426. const row = document.createElement('div');
  1427. row.className = 'be-checklist-row';
  1428. const checkbox = document.createElement('input');
  1429. checkbox.type = 'checkbox';
  1430. checkbox.checked = !!item.checked;
  1431. checkbox.addEventListener('change', () => {
  1432. block.data.items[itemIndex].checked = checkbox.checked;
  1433. this.syncHiddenInput();
  1434. });
  1435. const input = document.createElement('input');
  1436. input.type = 'text';
  1437. input.value = item.text;
  1438. input.addEventListener('input', () => {
  1439. block.data.items[itemIndex].text = input.value;
  1440. this.syncHiddenInput();
  1441. });
  1442. row.appendChild(checkbox);
  1443. row.appendChild(input);
  1444. itemsWrap.appendChild(row);
  1445. });
  1446. const addBtn = document.createElement('button');
  1447. addBtn.type = 'button';
  1448. addBtn.textContent = '+ Add item';
  1449. addBtn.addEventListener('click', () => {
  1450. block.data.items.push({ text: '', checked: false });
  1451. this.render();
  1452. });
  1453. container.appendChild(itemsWrap);
  1454. container.appendChild(addBtn);
  1455. break;
  1456. }
  1457. case 'callout': {
  1458. const styleSelect = document.createElement('select');
  1459. ['info', 'warning', 'success', 'danger'].forEach((style) => {
  1460. const option = document.createElement('option');
  1461. option.value = style;
  1462. option.textContent = style;
  1463. option.selected = block.data.style === style;
  1464. styleSelect.appendChild(option);
  1465. });
  1466. styleSelect.addEventListener('change', () => update({ style: styleSelect.value }));
  1467. container.appendChild(styleSelect);
  1468. ​
  1469. const text = document.createElement('div');
  1470. text.className = 'be-editable';
  1471. text.contentEditable = 'true';
  1472. text.spellcheck = spellcheckEnabled();
  1473. text.dataset.focusTarget = '1';
  1474. text.textContent = block.data.text || '';
  1475. text.addEventListener('input', () => update({ text: text.textContent }));
  1476. attachCleanPaste(text);
  1477. container.appendChild(text);
  1478. break;
  1479. }
  1480. case 'embed': {
  1481. container.appendChild(this.buildInput('Link to embed (like a YouTube video)', block.data.url, (val) => update({ url: val }), true));
  1482. break;
  1483. }
  1484. case 'separator':
  1485. container.appendChild(document.createElement('hr'));
  1486. break;
  1487. case 'contact_form': {
  1488. container.appendChild(this.buildInput('Heading above the form (optional), like Contact us', block.data.title || '', (val) => update({ title: val }), true));
  1489. const note = document.createElement('div');
  1490. note.className = 'be-contact-note';
  1491. note.innerHTML = iconSvg('mail') + '<span>Visitors see a message form here. '
  1492. + (this.allowContact
  1493. ? 'Messages go to the email you set in Settings.'
  1494. : 'The contact form is turned off in Settings, so visitors won\'t see it yet.')
  1495. + ' You can have one per page.</span>';
  1496. container.appendChild(note);
  1497. break;
  1498. }
  1499. case 'hero': {
  1500. container.appendChild(this.buildInput('Small text above the title (leave empty for "Home")', block.data.eyebrow || '', (val) => update({ eyebrow: val }), true));
  1501. container.appendChild(this.buildInput('Title (leave empty for your store name)', block.data.title || '', (val) => update({ title: val })));
  1502. container.appendChild(this.buildInput('Subtitle (leave empty for your store description)', block.data.subtitle || '', (val) => update({ subtitle: val })));
  1503. const toggle = document.createElement('label');
  1504. toggle.className = 'be-hero-toggle';
  1505. const checkbox = document.createElement('input');
  1506. checkbox.type = 'checkbox';
  1507. checkbox.checked = block.data.show_eyebrow !== false;
  1508. checkbox.addEventListener('change', () => update({ show_eyebrow: checkbox.checked }));
  1509. toggle.appendChild(checkbox);
  1510. toggle.appendChild(document.createTextNode(' Show the small label'));
  1511. container.appendChild(toggle);
  1512. break;
  1513. }
  1514. case 'home_featured':
  1515. case 'home_products': {
  1516. const note = document.createElement('div');
  1517. note.className = 'be-contact-note be-system-note';
  1518. note.innerHTML = iconSvg(BLOCK_ICONS[block.type]) + '<span>'
  1519. + (block.type === 'home_featured'
  1520. ? 'Your featured products go here. You choose them in Settings, Layout.'
  1521. : 'All your products go here. You can change how they load in Settings, Layout.')
  1522. + ' You can move this block, but it always stays on the page.</span>';
  1523. container.appendChild(note);
  1524. break;
  1525. }
  1526. default:
  1527. break;
  1528. }
  1529. ​
  1530. return container;
  1531. }
  1532. ​
  1533. buildUploadButton(kind, onUploaded) {
  1534. const wrapper = document.createElement('div');
  1535. wrapper.className = 'be-upload-wrapper';
  1536. ​
  1537. const fileInput = document.createElement('input');
  1538. fileInput.type = 'file';
  1539. fileInput.accept = kind === 'image' ? 'image/jpeg,image/png,image/gif,image/webp' : 'video/mp4,video/webm';
  1540. fileInput.style.display = 'none';
  1541. ​
  1542. const button = document.createElement('button');
  1543. button.type = 'button';
  1544. button.textContent = 'Upload ' + kind;
  1545. button.addEventListener('click', () => {
  1546. if (window.StocketBaseMedia) {
  1547. window.StocketBaseMedia.open({ type: kind === 'image' ? 'image' : 'video', onSelect: onUploaded });
  1548. } else {
  1549. fileInput.click();
  1550. }
  1551. });
  1552. ​
  1553. const status = document.createElement('span');
  1554. status.className = 'be-upload-status';
  1555. ​
  1556. fileInput.addEventListener('change', () => {
  1557. const file = fileInput.files[0];
  1558. if (!file) {
  1559. return;
  1560. }
  1561. status.textContent = 'Uploading…';
  1562. uploadFile(
  1563. file,
  1564. (url) => {
  1565. status.textContent = '';
  1566. onUploaded(url);
  1567. },
  1568. (error) => {
  1569. status.textContent = error;
  1570. }
  1571. );
  1572. });
  1573. ​
  1574. wrapper.appendChild(button);
  1575. wrapper.appendChild(fileInput);
  1576. wrapper.appendChild(status);
  1577. return wrapper;
  1578. }
  1579. ​
  1580. buildInput(placeholder, value, onInput, autofocus) {
  1581. const input = document.createElement('input');
  1582. input.type = 'text';
  1583. input.placeholder = placeholder;
  1584. input.value = value || '';
  1585. if (autofocus) {
  1586. input.dataset.focusTarget = '1';
  1587. }
  1588. input.addEventListener('input', () => onInput(input.value));
  1589. return input;
  1590. }
  1591. }
  1592. ​
  1593. function ensureBlockEditorIconStyles() {
  1594. if (document.getElementById('be-icon-styles')) {
  1595. return;
  1596. }
  1597. const style = document.createElement('style');
  1598. style.id = 'be-icon-styles';
  1599. style.textContent =
  1600. '.be-block-type{display:inline-flex;align-items:center;gap:6px;}' +
  1601. '.be-block-toolbar button svg.icon{display:block;}' +
  1602. '.be-block-menu-item{display:flex;align-items:center;gap:10px;width:100%;text-align:left;}' +
  1603. '.be-block-menu-item svg.icon{flex-shrink:0;}';
  1604. document.head.appendChild(style);
  1605. }
  1606. ​
  1607. document.addEventListener('DOMContentLoaded', function () {
  1608. const root = document.getElementById('block-editor-root');
  1609. if (root) {
  1610. ensureBlockEditorIconStyles();
  1611. window.stocketbaseBlockEditor = new BlockEditor(root);
  1612. }
  1613. });
  1614. })(window);
  1615. ​