@cancia/toolbar 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cancia.js CHANGED
@@ -28,6 +28,20 @@ function setPending(key, lang, value) {
28
28
  const full = pendingKey(key, lang);
29
29
  state.pending.set(full, { key, lang, value });
30
30
  }
31
+ function parseLinkOverlay(raw) {
32
+ if (!raw) return { label: "", href: "" };
33
+ const trimmed = raw.trim();
34
+ if (trimmed.startsWith("{")) {
35
+ try {
36
+ const p = JSON.parse(trimmed);
37
+ if (p && typeof p === "object") {
38
+ return { label: String(p.label ?? ""), href: String(p.href ?? "") };
39
+ }
40
+ } catch {
41
+ }
42
+ }
43
+ return { label: raw, href: "" };
44
+ }
31
45
  function applyOverlay() {
32
46
  document.querySelectorAll("[data-cms]").forEach((el) => {
33
47
  if (el.dataset.cmsList !== void 0) return;
@@ -39,6 +53,14 @@ function applyOverlay() {
39
53
  el.src = savedValue;
40
54
  return;
41
55
  }
56
+ if (el.dataset.cmsType === "link" || el.tagName === "A") {
57
+ const link = parseLinkOverlay(savedValue);
58
+ if (link.href && el.tagName === "A") el.setAttribute("href", link.href);
59
+ const labelEl = el.querySelector("[data-cms-label]");
60
+ if (labelEl) labelEl.textContent = link.label;
61
+ else if (el.childElementCount === 0) el.textContent = link.label;
62
+ return;
63
+ }
42
64
  if (el.childElementCount > 0) {
43
65
  console.warn(
44
66
  `Cancia: skipping overlay for "${key}" \u2014 element has child elements (textContent would destroy them).`
@@ -234,7 +256,10 @@ function accent() {
234
256
  }
235
257
  function fieldType(el) {
236
258
  if (el.dataset.cmsType === "image") return "image";
237
- return el.tagName === "IMG" ? "image" : "text";
259
+ if (el.dataset.cmsType === "link") return "link";
260
+ if (el.tagName === "IMG") return "image";
261
+ if (el.tagName === "A") return "link";
262
+ return "text";
238
263
  }
239
264
  function elementMode(el) {
240
265
  return el.dataset.cmsList ? "list" : "field";
@@ -319,6 +344,10 @@ var IMAGE_ICON = `<svg width="10" height="10" viewBox="0 0 14 14" fill="none" st
319
344
  <circle cx="4.5" cy="4.5" r="1.2"/>
320
345
  <path d="M1 9.5l3.5-3 2.5 2.5 2-1.5 3 3.5"/>
321
346
  </svg>`;
347
+ var LINK_ICON = `<svg width="10" height="10" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
348
+ <path d="M6 8a2.5 2.5 0 003.6.3l2.2-2.2a2.5 2.5 0 00-3.5-3.5L7.2 3.6"/>
349
+ <path d="M8 6a2.5 2.5 0 00-3.6-.3L2.2 7.9a2.5 2.5 0 003.5 3.5l1.1-1.1"/>
350
+ </svg>`;
322
351
  var LIST_ICON = `<svg width="10" height="10" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
323
352
  <rect x="1" y="2" width="3" height="3" rx="0.5"/>
324
353
  <rect x="1" y="7.5" width="3" height="3" rx="0.5"/>
@@ -348,7 +377,7 @@ function positionOverlay(el, animate = false) {
348
377
  tooltipText = `list: ${el.dataset.cmsList}`;
349
378
  } else {
350
379
  const type = fieldType(el);
351
- badgeIcon = type === "image" ? IMAGE_ICON : FIELD_ICON;
380
+ badgeIcon = type === "image" ? IMAGE_ICON : type === "link" ? LINK_ICON : FIELD_ICON;
352
381
  badgeLabel = type;
353
382
  tooltipText = el.dataset.cms ?? "";
354
383
  }
@@ -434,9 +463,27 @@ function handleClick(e) {
434
463
  onSelect?.({ kind: "field", el: target, key, fieldType: fieldType(target) });
435
464
  }
436
465
  }
466
+ function warnUnhoverableRegions() {
467
+ const els = document.querySelectorAll(CMS_SELECTOR);
468
+ els.forEach((el) => {
469
+ const rect = el.getBoundingClientRect();
470
+ if (rect.width > 0 && rect.height > 0) return;
471
+ const cs = getComputedStyle(el);
472
+ const selfInflicted = cs.display === "contents" || cs.position === "absolute" || cs.clipPath !== "none";
473
+ if (!selfInflicted) return;
474
+ const name = el.dataset.cmsList ? `list "${el.dataset.cmsList}"` : `field "${el.dataset.cms}"`;
475
+ console.warn(
476
+ `[cancia] ${name} is annotated but has no layout box (${Math.round(rect.width)}\xD7${Math.round(
477
+ rect.height
478
+ )}) \u2014 it cannot be hovered or clicked in the editor. Avoid sr-only / display:contents on CMS regions; give the container a real box.`,
479
+ el
480
+ );
481
+ });
482
+ }
437
483
  function attachHighlight(selectCallback) {
438
484
  injectStyles();
439
485
  onSelect = selectCallback;
486
+ warnUnhoverableRegions();
440
487
  document.addEventListener("mouseover", handleMouseOver, true);
441
488
  document.addEventListener("mouseout", handleMouseOut, true);
442
489
  document.addEventListener("click", handleClick, true);
@@ -659,6 +706,175 @@ function buildTextPopup(key, anchorEl, onClose) {
659
706
  wrap.appendChild(footer);
660
707
  return wrap;
661
708
  }
709
+ function isSafeHrefValue(href) {
710
+ const trimmed = href.trim();
711
+ if (trimmed === "") return true;
712
+ if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return true;
713
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
714
+ if (schemeMatch) {
715
+ const firstSep = trimmed.search(/[/?#]/);
716
+ if (firstSep === -1 || schemeMatch[1].length < firstSep) return false;
717
+ }
718
+ return true;
719
+ }
720
+ function parseLink(raw) {
721
+ if (!raw) return { label: "", href: "" };
722
+ const trimmed = raw.trim();
723
+ if (trimmed.startsWith("{")) {
724
+ try {
725
+ const p = JSON.parse(trimmed);
726
+ if (p && typeof p === "object") {
727
+ return { label: String(p.label ?? ""), href: String(p.href ?? "") };
728
+ }
729
+ } catch {
730
+ }
731
+ }
732
+ return { label: raw, href: "" };
733
+ }
734
+ function buildLinkPopup(key, anchorEl, onClose) {
735
+ const langs = state.config?.languages ?? ["en"];
736
+ let activeLang = state.activeLang || langs[0];
737
+ const wrap = document.createElement("div");
738
+ wrap.dataset.canciaPopup = "1";
739
+ applyPopupStyles(wrap);
740
+ wrap.appendChild(buildHeader(key, onClose));
741
+ const labelNode = anchorEl.querySelector("[data-cms-label]") ?? anchorEl;
742
+ const domLabel = labelNode.textContent?.trim() ?? "";
743
+ const domHref = anchorEl.getAttribute("href") ?? "";
744
+ const readCurrent = (lang) => {
745
+ const stored = parseLink(getValue(key, lang));
746
+ return {
747
+ label: stored.label || domLabel,
748
+ // The href is shared across languages (see the note below), so fall back
749
+ // to the default language's stored value before the DOM.
750
+ href: stored.href || parseLink(getValue(key, langs[0])).href || domHref
751
+ };
752
+ };
753
+ const inputStyle = `
754
+ width: 100%; box-sizing: border-box;
755
+ background: rgba(255,255,255,0.03); color: rgba(255,255,255,0.9);
756
+ border: 1px solid rgba(255,255,255,0.07); border-radius: 10px;
757
+ padding: 9px 12px;
758
+ font-size: 13px; font-family: inherit; outline: none;
759
+ transition: border-color 0.18s, background 0.18s;
760
+ caret-color: ${accent2()};
761
+ `;
762
+ const makeLabelled = (text, input) => {
763
+ const field = document.createElement("div");
764
+ field.style.cssText = `display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px;`;
765
+ const lab = document.createElement("div");
766
+ lab.textContent = text;
767
+ lab.style.cssText = `font-size:10px;letter-spacing:0.08em;text-transform:uppercase;color:rgba(255,255,255,0.35);`;
768
+ input.style.cssText = inputStyle;
769
+ input.addEventListener("focus", () => {
770
+ input.style.borderColor = `${accent2()}66`;
771
+ input.style.background = "rgba(255,255,255,0.05)";
772
+ });
773
+ input.addEventListener("blur", () => {
774
+ input.style.borderColor = "rgba(255,255,255,0.07)";
775
+ input.style.background = "rgba(255,255,255,0.03)";
776
+ });
777
+ field.appendChild(lab);
778
+ field.appendChild(input);
779
+ return field;
780
+ };
781
+ const labelInput = document.createElement("input");
782
+ labelInput.type = "text";
783
+ labelInput.placeholder = "Book a call";
784
+ const hrefInput = document.createElement("input");
785
+ hrefInput.type = "text";
786
+ hrefInput.inputMode = "url";
787
+ hrefInput.placeholder = "/start or https://\u2026";
788
+ const current = readCurrent(activeLang);
789
+ labelInput.value = current.label;
790
+ hrefInput.value = current.href;
791
+ if (langs.length > 1) {
792
+ const tabs = document.createElement("div");
793
+ tabs.style.cssText = `display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.06);`;
794
+ const renderTabs2 = () => {
795
+ tabs.innerHTML = "";
796
+ langs.forEach((lang) => {
797
+ const tab = document.createElement("button");
798
+ tab.textContent = lang.toUpperCase();
799
+ const isActive = lang === activeLang;
800
+ tab.style.cssText = `
801
+ padding: 5px 10px 6px; border: none; border-bottom: 2px solid;
802
+ margin-bottom: -1px;
803
+ font-size: 11px; font-weight: 600; cursor: pointer; letter-spacing: 0.06em;
804
+ background: transparent;
805
+ border-bottom-color: ${isActive ? accent2() : "transparent"};
806
+ color: ${isActive ? "#fff" : "rgba(255,255,255,0.3)"};
807
+ transition: color 0.15s, border-color 0.15s;
808
+ `;
809
+ tab.addEventListener("click", () => {
810
+ stage(activeLang);
811
+ activeLang = lang;
812
+ state.activeLang = lang;
813
+ applyOverlay();
814
+ labelInput.value = readCurrent(lang).label;
815
+ renderTabs2();
816
+ });
817
+ tabs.appendChild(tab);
818
+ });
819
+ };
820
+ renderTabs2();
821
+ wrap.appendChild(tabs);
822
+ }
823
+ wrap.appendChild(makeLabelled("Label", labelInput));
824
+ wrap.appendChild(makeLabelled("URL", hrefInput));
825
+ const hint = document.createElement("div");
826
+ hint.style.cssText = `font-size:11px;color:rgba(255,255,255,0.28);margin:-4px 0 10px;line-height:1.45;`;
827
+ hint.textContent = langs.length > 1 ? "Relative (/start), #anchor, mailto: and tel: all work. The URL is shared across languages." : "Relative (/start), #anchor, mailto: and tel: all work.";
828
+ wrap.appendChild(hint);
829
+ const warn = document.createElement("div");
830
+ warn.style.cssText = `font-size:11px;color:#f0a; margin:-4px 0 10px; display:none;`;
831
+ wrap.appendChild(warn);
832
+ const paint = () => {
833
+ labelNode.textContent = labelInput.value;
834
+ if (isSafeHrefValue(hrefInput.value)) anchorEl.setAttribute("href", hrefInput.value);
835
+ };
836
+ const stage = (lang) => {
837
+ const value = {
838
+ label: labelInput.value,
839
+ href: hrefInput.value.trim()
840
+ };
841
+ const existing = parseLink(getValue(key, lang));
842
+ if (existing.label !== value.label || existing.href !== value.href) {
843
+ setPending(key, lang, JSON.stringify(value));
844
+ }
845
+ };
846
+ const validate = () => {
847
+ const ok = isSafeHrefValue(hrefInput.value);
848
+ warn.style.display = ok ? "none" : "block";
849
+ warn.textContent = ok ? "" : "That URL scheme isn\u2019t allowed and won\u2019t be saved.";
850
+ return ok;
851
+ };
852
+ labelInput.addEventListener("input", () => {
853
+ paint();
854
+ onPendingChange();
855
+ });
856
+ hrefInput.addEventListener("input", () => {
857
+ validate();
858
+ paint();
859
+ onPendingChange();
860
+ });
861
+ setTimeout(() => labelInput.focus(), 80);
862
+ const footer = document.createElement("div");
863
+ footer.dataset.canciaFooter = "1";
864
+ footer.style.cssText = `display: flex; justify-content: flex-end; margin-top: 10px;`;
865
+ const saveBtn = makePrimaryButton("Save", accent2());
866
+ saveBtn.dataset.canciaSave = "1";
867
+ saveBtn.title = "Save (\u2318S)";
868
+ saveBtn.addEventListener("click", () => {
869
+ if (!validate()) return;
870
+ stage(activeLang);
871
+ onPendingChange();
872
+ onClose();
873
+ });
874
+ footer.appendChild(saveBtn);
875
+ wrap.appendChild(footer);
876
+ return wrap;
877
+ }
662
878
  function buildImagePopup(key, anchorEl, onClose) {
663
879
  const wrap = document.createElement("div");
664
880
  wrap.dataset.canciaPopup = "1";
@@ -880,13 +1096,11 @@ function applyPopupStyles(el) {
880
1096
  }
881
1097
  function openPopup(key, fieldType2, anchorEl, onClose) {
882
1098
  closePopup();
883
- const popup = fieldType2 === "image" ? buildImagePopup(key, anchorEl, () => {
884
- onClose();
885
- closePopup();
886
- }) : buildTextPopup(key, anchorEl, () => {
1099
+ const done = () => {
887
1100
  onClose();
888
1101
  closePopup();
889
- });
1102
+ };
1103
+ const popup = fieldType2 === "image" ? buildImagePopup(key, anchorEl, done) : fieldType2 === "link" ? buildLinkPopup(key, anchorEl, done) : buildTextPopup(key, anchorEl, done);
890
1104
  document.body.appendChild(popup);
891
1105
  popupEl = popup;
892
1106
  const { top, left, origin } = getPopupPosition(anchorEl);