WebOrbiton
v1.0.0.8

Publisium

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