WebOrbiton
v1.0.0.0

StocketBase

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