@obelism/content 0.28.0 → 0.28.1

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/bridge.js CHANGED
@@ -4,18 +4,21 @@ var preview = require('./preview-c4giufnr.js');
4
4
  var site = require('./site.js');
5
5
  var config = require('./config-ci2hxmas.js');
6
6
 
7
- // Click rates on the page: in the visual editor, a small label on each block
8
- // that numbers has clicks for ("24% clicked"), from the editor's `stats`
9
- // message. The editor's "Show click rates" switch turns it on and off. Plain
10
- // DOM in a shadow root, like the prompt box, in a fixed layer that follows
11
- // the blocks as the page scrolls, resizes and re-renders.
12
- //
13
- // Sites tag a block's clickable element with `data-numbers-id={block._uid}`
14
- // and load Obelism numbers' script; the CMS reads the rates from numbers
15
- // (the CMS's content/docs/guides/canvas.mdx, "Click rates").
16
7
  const DEFAULT_STATS_LABELS = {
17
8
  clicked: '{rate}% clicked',
18
- title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)'
9
+ clickedShort: '{rate}%',
10
+ title: '{clickers} of {viewers} visitors of its page clicked it in the last 30 days ({clicks} clicks)',
11
+ noClicks: 'No clicks yet in the last 30 days',
12
+ counting: '{clicks} clicks so far: a click rate once {needed} visitors saw its page ({viewers} yet)'
13
+ };
14
+ // Fewer visitors than this make a rate noise.
15
+ const MIN_VIEWERS = 20;
16
+ const TAG_ATTRIBUTE = 'data-numbers-id';
17
+ const statsStatus = (block)=>!block || block.clicks === 0 ? 'none' : block.viewers < MIN_VIEWERS ? 'counting' : 'rate';
18
+ // Lucide's x and hourglass.
19
+ const ICONS$1 = {
20
+ none: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
21
+ counting: '<path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/>'
19
22
  };
20
23
  const fill$1 = (template, values)=>template.replace(/\{(\w+)\}/g, (match, key)=>key in values ? String(values[key]) : match);
21
24
  // The same tokens as the prompt box: an ink pill, paper text, the orange
@@ -41,37 +44,42 @@ const CSS$2 = `
41
44
  content: ''; width: 8px; height: 8px; border-radius: 50%;
42
45
  background: rgb(212 130 30);
43
46
  }
44
- .badge[data-place='beside'] { transform: translateY(-50%); }
45
- .badge[data-place='above'] { transform: translate(-100%, -100%); }
46
- .badge:focus-visible { outline: 2px solid rgb(212 130 30); outline-offset: 2px; }
47
+ .badge:focus-visible, .tag:focus-visible { outline: 2px solid rgb(212 130 30); outline-offset: 2px; }
48
+ .tag {
49
+ position: absolute; transform: translate(-50%, -50%);
50
+ display: grid; place-items: center; width: 20px; height: 20px; padding: 0;
51
+ cursor: pointer; pointer-events: auto; border: 0; border-radius: 50%;
52
+ color: rgb(242 240 236); background: rgb(23 22 20 / .88);
53
+ box-shadow: 0 4px 10px -6px rgb(23 22 20 / .5);
54
+ }
55
+ .tag::after { content: ''; position: absolute; inset: -6px; }
56
+ .tag[data-status='counting'] { color: rgb(212 130 30); }
57
+ .tag[data-status='rate'] {
58
+ width: auto; height: 20px; padding: 0 7px; border-radius: 5em;
59
+ font-size: 11px; transform: translate(-70%, -50%);
60
+ }
61
+ .tag svg {
62
+ width: 12px; height: 12px; fill: none; stroke: currentColor;
63
+ stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round;
64
+ }
47
65
  `;
48
- // Inset from a block's top-right corner, or gap beside a small element.
49
66
  const INSET = 8;
50
- // Below this an element is a button or a link, not a section: its label goes
51
- // beside it (or above, without room on the right) so it doesn't cover it.
67
+ // Below this an element is a button or a link, not a section: a tag on its
68
+ // corner rather than a label over it.
52
69
  const SMALL_HEIGHT = 64;
53
- // Room a label needs beside an element.
54
- const LABEL_WIDTH = 140;
55
- // Where a label goes for an element's box: inside the top-right corner of a
56
- // block, beside a small element, or above one at the right edge.
57
- const placeLabel = (rect, viewportWidth)=>{
58
- if (rect.height >= SMALL_HEIGHT) return {
59
- place: 'inside',
70
+ // A block's label: inside its top-right corner, in view when it starts above
71
+ // the screen.
72
+ const placeLabel = (rect)=>({
60
73
  top: Math.max(rect.top, 0) + INSET,
61
74
  left: rect.right - INSET
62
- };
63
- if (rect.right + INSET + LABEL_WIDTH <= viewportWidth) return {
64
- place: 'beside',
65
- top: rect.top + rect.height / 2,
66
- left: rect.right + INSET
67
- };
68
- return {
69
- place: 'above',
70
- top: rect.top - INSET / 2,
71
- left: rect.right
72
- };
73
- };
74
- const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=>{
75
+ });
76
+ // A tag's: on the element's top-right corner, like a notification badge,
77
+ // kept on screen.
78
+ const placeTag = (rect, viewport)=>({
79
+ top: Math.min(Math.max(rect.top, 10), viewport.height - 10),
80
+ left: Math.min(rect.right, viewport.width - 10)
81
+ });
82
+ const mountStatsOverlay = (win, { selector, onSelect, checked, labels: labelOverrides })=>{
75
83
  const doc = win.document;
76
84
  const labels = {
77
85
  ...DEFAULT_STATS_LABELS,
@@ -91,37 +99,71 @@ const mountStatsOverlay = (win, { selector, onSelect, labels: labelOverrides })=
91
99
  let stats = {};
92
100
  let visible = false;
93
101
  let frame = null;
102
+ const draw = (id, element, blocks)=>{
103
+ const rect = element.getBoundingClientRect();
104
+ // Not rendered, or off screen.
105
+ if (!rect.width && !rect.height) return;
106
+ if (rect.bottom < 0 || rect.top > win.innerHeight) return;
107
+ const block = stats[id];
108
+ const status = statsStatus(block);
109
+ const label = doc.createElement('button');
110
+ label.type = 'button';
111
+ label.dataset.uid = id;
112
+ label.dataset.status = status;
113
+ const rate = block ? Math.round(block.rate * 100) : 0;
114
+ label.title = status === 'rate' ? fill$1(labels.title, {
115
+ clickers: block.clickers,
116
+ viewers: block.viewers,
117
+ clicks: block.clicks
118
+ }) : status === 'none' ? labels.noClicks : fill$1(labels.counting, {
119
+ clicks: block.clicks,
120
+ viewers: block.viewers,
121
+ needed: MIN_VIEWERS
122
+ });
123
+ const { top, left } = status === 'rate' && rect.height >= SMALL_HEIGHT ? placeLabel(rect) : placeTag(rect, {
124
+ width: win.innerWidth,
125
+ height: win.innerHeight
126
+ });
127
+ if (status === 'rate' && rect.height >= SMALL_HEIGHT) {
128
+ label.className = 'badge';
129
+ label.textContent = fill$1(labels.clicked, {
130
+ rate
131
+ });
132
+ } else {
133
+ label.className = 'tag';
134
+ if (status === 'rate') label.textContent = fill$1(labels.clickedShort, {
135
+ rate
136
+ });
137
+ else label.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS$1[status]}</svg>`;
138
+ label.setAttribute('aria-label', label.title);
139
+ }
140
+ label.style.top = `${top}px`;
141
+ label.style.left = `${left}px`;
142
+ // Its id names a block by convention; else the block around it.
143
+ const uid = blocks.has(id) ? id : element.closest(`[${preview.UID_ATTRIBUTE}]`)?.getAttribute(preview.UID_ATTRIBUTE);
144
+ if (uid) label.addEventListener('click', ()=>onSelect(uid));
145
+ layer.appendChild(label);
146
+ };
94
147
  const render = ()=>{
95
148
  frame = null;
96
149
  layer.replaceChildren();
97
150
  layer.hidden = !visible;
98
151
  if (!visible) return;
99
- const height = win.innerHeight;
100
- for (const [uid, block] of Object.entries(stats)){
101
- const element = doc.querySelector(selector(uid));
102
- if (!element) continue;
103
- const rect = element.getBoundingClientRect();
104
- // Not rendered, or off screen.
105
- if (!rect.width && !rect.height) continue;
106
- if (rect.bottom < 0 || rect.top > height) continue;
107
- const badge = doc.createElement('button');
108
- badge.type = 'button';
109
- badge.className = 'badge';
110
- badge.dataset.uid = uid;
111
- badge.textContent = fill$1(labels.clicked, {
112
- rate: Math.round(block.rate * 100)
113
- });
114
- badge.title = fill$1(labels.title, {
115
- clickers: block.clickers,
116
- viewers: block.viewers,
117
- clicks: block.clicks
118
- });
119
- const { place, top, left } = placeLabel(rect, win.innerWidth);
120
- badge.dataset.place = place;
121
- badge.style.top = `${top}px`;
122
- badge.style.left = `${left}px`;
123
- badge.addEventListener('click', ()=>onSelect(uid));
124
- layer.appendChild(badge);
152
+ const blocks = new Set();
153
+ for (const element of doc.querySelectorAll(`[${preview.UID_ATTRIBUTE}]`))blocks.add(element.getAttribute(preview.UID_ATTRIBUTE) ?? '');
154
+ const isChecked = checked ?? ((id)=>blocks.has(id));
155
+ const tagged = new Set();
156
+ for (const element of doc.querySelectorAll(`[${TAG_ATTRIBUTE}]`)){
157
+ const id = element.getAttribute(TAG_ATTRIBUTE);
158
+ if (!id || !stats[id] && !isChecked(id)) continue;
159
+ tagged.add(id);
160
+ draw(id, element, blocks);
161
+ }
162
+ // Rates of blocks the site doesn't tag an element of.
163
+ for (const id of Object.keys(stats)){
164
+ if (tagged.has(id) || !blocks.has(id)) continue;
165
+ const element = doc.querySelector(selector(id));
166
+ if (element) draw(id, element, blocks);
125
167
  }
126
168
  };
127
169
  // At most once a frame, however many scroll and mutation events come in.
@@ -786,6 +828,7 @@ const DEFAULT_SITE_EDITOR_LABELS = {
786
828
  envProduction: 'live',
787
829
  scopeNoteBlock: 'Working on {name}. Click another block to switch.',
788
830
  scopeNotePage: 'Working on the whole page. Click a block to change only that block.',
831
+ scopeUp: 'Up to {name}',
789
832
  block: 'Block',
790
833
  previewChip: '{name} · preview',
791
834
  askIntro: 'Describe a change and Obelism shows it on the page. Nothing is stored until you keep it and save.',
@@ -1950,6 +1993,10 @@ const mountFormFrame = (win, container, url, title, handlers)=>{
1950
1993
  // kept in step with the content here, see formFrame.ts), edits JSON (`view`,
1951
1994
  // then `applyJson`: the raw alternative), and lists the space's pages to go
1952
1995
  // to one.
1996
+ // The CMS's limit on ids in one stats request.
1997
+ const MAX_STATS_IDS = 500;
1998
+ // After the page changes, before asking for the ids it newly tags.
1999
+ const STATS_DELAY_MS = 1000;
1953
2000
  // Plain path segments only: the URL parser drops tabs and newlines and reads
1954
2001
  // \ as /, so anything looser can resolve to another origin.
1955
2002
  const BASE_PATH = /^(?:\/[\w.~-]+)+$/;
@@ -2536,8 +2583,10 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2536
2583
  if (selecting) doc.documentElement.setAttribute('data-cms-selecting', '');
2537
2584
  else doc.documentElement.removeAttribute('data-cms-selecting');
2538
2585
  setState({
2539
- selecting
2586
+ selecting,
2587
+ statsVisible: selecting
2540
2588
  });
2589
+ showStats();
2541
2590
  },
2542
2591
  destroy () {
2543
2592
  closeForm?.();
@@ -2571,11 +2620,72 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path.replace(/
2571
2620
  };
2572
2621
  win.addEventListener('beforeunload', onBeforeUnload);
2573
2622
  cleanup.push(()=>win.removeEventListener('beforeunload', onBeforeUnload));
2623
+ // The tags on the page's links (statsOverlay.ts) while blocks are
2624
+ // outlined: the rates of the ids the page tags, asked for as they appear.
2625
+ const asked = new Set();
2626
+ const answered = new Set();
2627
+ let statsOff = false;
2628
+ let overlay = null;
2629
+ let statsTimer = null;
2630
+ const showStats = ()=>overlay?.update(state.stats, state.statsVisible);
2631
+ const loadStats = async ()=>{
2632
+ if (statsOff) return;
2633
+ const ids = [
2634
+ ...new Set([
2635
+ ...doc.querySelectorAll(`[${TAG_ATTRIBUTE}]`)
2636
+ ].map((element)=>element.getAttribute(TAG_ATTRIBUTE) ?? ''))
2637
+ ].filter((id)=>id && !asked.has(id));
2638
+ const batch = ids.slice(0, MAX_STATS_IDS);
2639
+ if (!batch.length) return;
2640
+ batch.forEach((id)=>asked.add(id));
2641
+ try {
2642
+ const { stats } = await api('stats', {
2643
+ uids: batch
2644
+ });
2645
+ batch.forEach((id)=>answered.add(id));
2646
+ setState({
2647
+ stats: {
2648
+ ...state.stats,
2649
+ ...readStats(stats)
2650
+ }
2651
+ });
2652
+ } catch {
2653
+ // No tags rather than wrong ones, and no asking again.
2654
+ statsOff = true;
2655
+ return;
2656
+ }
2657
+ overlay ??= mountStatsOverlay(win, {
2658
+ selector: blockSelector,
2659
+ onSelect: (uid)=>highlight(uid),
2660
+ checked: (id)=>answered.has(id)
2661
+ });
2662
+ showStats();
2663
+ if (ids.length > batch.length) scheduleStats();
2664
+ };
2665
+ const scheduleStats = ()=>{
2666
+ if (statsTimer !== null || statsOff) return;
2667
+ statsTimer = setTimeout(()=>{
2668
+ statsTimer = null;
2669
+ void loadStats();
2670
+ }, STATS_DELAY_MS);
2671
+ };
2672
+ const Observer = win.MutationObserver;
2673
+ const statsObserver = typeof Observer === 'function' ? new Observer(scheduleStats) : null;
2674
+ cleanup.push(()=>{
2675
+ statsObserver?.disconnect();
2676
+ if (statsTimer !== null) clearTimeout(statsTimer);
2677
+ overlay?.destroy();
2678
+ });
2574
2679
  api('session').then((info)=>{
2575
2680
  session = info;
2576
2681
  setState({
2577
2682
  features: info.features
2578
2683
  });
2684
+ void loadStats();
2685
+ statsObserver?.observe(doc.body, {
2686
+ childList: true,
2687
+ subtree: true
2688
+ });
2579
2689
  }).catch((error)=>console.error('[@obelism/content bridge]', error));
2580
2690
  return bridge;
2581
2691
  };
@@ -2759,6 +2869,7 @@ button:disabled { cursor: not-allowed; }
2759
2869
  flex: none; font-size: 12px; font-weight: 500; text-decoration: underline; text-underline-offset: 2px;
2760
2870
  }
2761
2871
  .note button::after { content: ''; position: absolute; inset: -13px -6px; }
2872
+ .scroll > .note { position: sticky; top: 0; z-index: 1; }
2762
2873
  .intro { color: rgb(var(--muted)); font-size: 13px; }
2763
2874
  .gate {
2764
2875
  display: grid; gap: 8px; margin: 10px 14px 0; padding: 10px 12px; border-radius: 12px;
@@ -3067,11 +3178,11 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3067
3178
  }, gateKeep, gateUndo));
3068
3179
  gate.setAttribute('role', 'alert');
3069
3180
  const noteText = element(doc, 'span');
3070
- const wholePage = button('', labels.scopeEntry);
3181
+ const up = button('', labels.scopeEntry);
3071
3182
  const cancelNew = button('', labels.newPageCancel);
3072
3183
  const note = element(doc, 'div', {
3073
3184
  className: 'note'
3074
- }, icon('scope'), noteText, wholePage, cancelNew);
3185
+ }, icon('scope'), noteText, up, cancelNew);
3075
3186
  const intro = element(doc, 'p', {
3076
3187
  className: 'intro',
3077
3188
  textContent: labels.askIntro
@@ -3137,10 +3248,10 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3137
3248
  }, formNote);
3138
3249
  // The same way back as the ask view's note, above the form it changes.
3139
3250
  const fieldsNoteText = element(doc, 'span');
3140
- const wholePageFields = button('', labels.scopeEntry);
3251
+ const upFields = button('', labels.scopeEntry);
3141
3252
  const fieldsNote = element(doc, 'div', {
3142
3253
  className: 'note'
3143
- }, icon('scope'), fieldsNoteText, wholePageFields);
3254
+ }, icon('scope'), fieldsNoteText, upFields);
3144
3255
  const fields = element(doc, 'div', {
3145
3256
  className: 'fields',
3146
3257
  id: 'obelism-fields'
@@ -3220,6 +3331,7 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3220
3331
  let hoverUid = null;
3221
3332
  // Block names by uid, from the views; null when it couldn't be read.
3222
3333
  const names = new Map();
3334
+ const parents = new Map();
3223
3335
  let pageNames = 'idle';
3224
3336
  let destroyed = false;
3225
3337
  const formSlot = createFormSlot(bridge, formBox, labels.formTitle, {
@@ -3255,13 +3367,21 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3255
3367
  }
3256
3368
  return names.get(uid) ?? labels.block;
3257
3369
  };
3258
- const readNames = (value)=>{
3259
- if (Array.isArray(value)) value.forEach(readNames);
3370
+ const readNames = (value, parent = null)=>{
3371
+ if (Array.isArray(value)) value.forEach((item)=>readNames(item, parent));
3260
3372
  else if (isRecord(value)) {
3261
- if (typeof value._uid === 'string' && typeof value._type === 'string') names.set(value._uid, humanize(value._type));
3262
- Object.values(value).forEach(readNames);
3373
+ let uid = parent;
3374
+ if (typeof value._uid === 'string' && typeof value._type === 'string') {
3375
+ uid = value._uid;
3376
+ parents.set(uid, parent);
3377
+ names.set(uid, humanize(value._type));
3378
+ }
3379
+ Object.values(value).forEach((item)=>readNames(item, uid));
3263
3380
  }
3264
3381
  };
3382
+ // The block around `uid`, null at the top of the page: by the page as it
3383
+ // is now, else by its view, for a block whose wrapper the site doesn't mark.
3384
+ const parentOf = (uid)=>doc.querySelector(blockSelector(uid))?.parentElement?.closest(`[${preview.UID_ATTRIBUTE}]`)?.getAttribute(preview.UID_ATTRIBUTE) ?? parents.get(uid) ?? null;
3265
3385
  const readPageNames = ()=>{
3266
3386
  if (pageNames !== 'idle' || bridge.entryId === null) return;
3267
3387
  pageNames = 'loading';
@@ -3555,9 +3675,17 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3555
3675
  } else {
3556
3676
  noteText.textContent = labels.scopeNotePage;
3557
3677
  }
3678
+ const parent = state.selectedUid ? parentOf(state.selectedUid) : null;
3679
+ const upText = parent ? fill(labels.scopeUp, {
3680
+ name: nameOf(parent)
3681
+ }) : labels.scopeEntry;
3682
+ for (const link of [
3683
+ up,
3684
+ upFields
3685
+ ])if (link.textContent !== upText) link.textContent = upText;
3558
3686
  fieldsNote.hidden = creating || !state.selectedUid;
3559
3687
  note.hidden = offerShop || pageless && !creating && !options.promptWithoutPage;
3560
- wholePage.hidden = creating || !state.selectedUid;
3688
+ up.hidden = creating || !state.selectedUid;
3561
3689
  cancelNew.hidden = !creating || onlyNew;
3562
3690
  intro.hidden = answers.length > 0 || offerShop || creating;
3563
3691
  shop.hidden = !offerShop;
@@ -3667,13 +3795,19 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3667
3795
  refresh();
3668
3796
  (picking ? picker.search : pageButton).focus();
3669
3797
  };
3670
- // Escape, on the page or in the form: from the pages, from a block to the
3671
- // whole page. True when it did something.
3798
+ // True once back at the page.
3799
+ const stepUp = ()=>{
3800
+ const { selectedUid } = bridge.getState();
3801
+ if (selectedUid) bridge.select(parentOf(selectedUid));
3802
+ return !bridge.getState().selectedUid;
3803
+ };
3804
+ // Escape, on the page or in the form: from the pages, from a block a level
3805
+ // up. True when it did something.
3672
3806
  const stepBack = ()=>{
3673
3807
  const state = bridge.getState();
3674
3808
  if (!panelShown(state)) return false;
3675
3809
  if (picking) setPicking(false);
3676
- else if (state.selectedUid) bridge.select(null);
3810
+ else if (state.selectedUid) stepUp();
3677
3811
  else return false;
3678
3812
  return true;
3679
3813
  };
@@ -3787,13 +3921,11 @@ const mountSiteEditor = (doc, bridge, options = {})=>{
3787
3921
  pageButton.addEventListener('click', ()=>setPicking(!picking));
3788
3922
  newPage.addEventListener('click', ()=>setCreating(true));
3789
3923
  cancelNew.addEventListener('click', ()=>setCreating(false));
3790
- wholePage.addEventListener('click', ()=>{
3791
- bridge.select(null);
3792
- input.focus();
3924
+ up.addEventListener('click', ()=>{
3925
+ if (stepUp()) input.focus();
3793
3926
  });
3794
- wholePageFields.addEventListener('click', ()=>{
3795
- bridge.select(null);
3796
- focusForm();
3927
+ upFields.addEventListener('click', ()=>{
3928
+ if (stepUp()) focusForm();
3797
3929
  });
3798
3930
  shopButton.addEventListener('click', async ()=>{
3799
3931
  shopMessage = null;