@obelism/content 0.13.0 → 0.14.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/bridge.js CHANGED
@@ -231,6 +231,12 @@ const INERT_METHODS = {
231
231
  createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
232
232
  view: onlyWhileEditingSite,
233
233
  applyJson: onlyWhileEditingSite,
234
+ openForm: (_container, options = {})=>{
235
+ queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
236
+ return {
237
+ close: ()=>{}
238
+ };
239
+ },
234
240
  pages: onlyWhileEditingSite,
235
241
  openPage: ()=>{},
236
242
  undo: ()=>{},
@@ -520,16 +526,9 @@ const DEFAULT_PROMPT_LABELS = {
520
526
  created: 'Opening the new page…',
521
527
  modes: 'Edit with',
522
528
  modePrompt: 'Prompt',
523
- modeJson: 'JSON',
524
- json: 'The content as JSON',
525
- jsonLoading: 'Loading the JSON…',
526
- jsonReload: 'Reload the JSON',
527
- jsonKept: 'Kept your unapplied JSON. Reload to see the current content.',
528
- jsonInvalidAt: 'Not valid JSON at line {line}, column {column}',
529
- jsonInvalid: 'Not valid JSON',
530
- jsonNotObject: 'The JSON has to be an object of fields, like { "title": … }',
531
- applied: 'Applied',
532
- unchanged: 'Nothing changed',
529
+ form: 'Form',
530
+ formTitle: 'The content form',
531
+ formLoading: 'Opening the form…',
533
532
  pages: 'Pages',
534
533
  pagesSearch: 'Find a page',
535
534
  pagesLoading: 'Loading pages…',
@@ -545,7 +544,6 @@ const ICONS = {
545
544
  block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
546
545
  undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
547
546
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
548
- reload: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
549
547
  chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
550
548
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
551
549
  check: '<path d="M20 6 9 17l-5-5"/>',
@@ -650,13 +648,13 @@ textarea {
650
648
  font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
651
649
  }
652
650
  textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
653
- /* JSON: a plain code editor, taller, scrolling instead of wrapping. */
654
- textarea.code {
655
- height: 40vh; min-height: 160px; max-height: 50vh; overflow: auto; resize: vertical;
656
- white-space: pre; overflow-wrap: normal; tab-size: 2;
657
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
658
- font-size: 13px; line-height: 1.55;
651
+ /* Form: the CMS's entry form, edited live. */
652
+ .form { padding: 12px 12px 0; }
653
+ .form iframe {
654
+ display: block; width: 100%; height: min(560px, 60vh); border: none; border-radius: 12px;
655
+ background: rgb(var(--theme-background-actions-rgb) / .45);
659
656
  }
657
+ .form.loading iframe { visibility: hidden; }
660
658
  /* New page: where it goes, above what it is for. */
661
659
  .path {
662
660
  display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
@@ -700,7 +698,7 @@ button.chip:disabled { opacity: .5; }
700
698
  .icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
701
699
  .icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
702
700
  .icon-button.primary:disabled { opacity: .35; }
703
- /* Prompt or JSON: two small pills in one track. */
701
+ /* Prompt or Form: two small pills in one track. */
704
702
  .modes {
705
703
  display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
706
704
  border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
@@ -783,101 +781,6 @@ const icon = (doc, name)=>{
783
781
  span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name]}</svg>`;
784
782
  return span;
785
783
  };
786
- // Where `text` stops being JSON: the offset of the first character that
787
- // doesn't fit, or -1 when it is JSON. JSON.parse's errors don't all say
788
- // where (V8's "Unexpected token" doesn't, Safari's never do).
789
- const jsonErrorAt = (text)=>{
790
- let i = 0;
791
- const fail = ()=>{
792
- throw i;
793
- };
794
- const space = ()=>{
795
- while(i < text.length && ' \t\n\r'.includes(text[i]))i++;
796
- };
797
- const string = ()=>{
798
- i++;
799
- while(i < text.length){
800
- const char = text[i];
801
- if (char === '"') {
802
- i++;
803
- return;
804
- }
805
- if (char === '\\') {
806
- const next = text[i + 1] ?? '';
807
- if (next === 'u' && /^[\da-f]{4}$/i.test(text.slice(i + 2, i + 6))) {
808
- i += 6;
809
- } else if (next !== '' && '"\\/bfnrt'.includes(next)) {
810
- i += 2;
811
- } else {
812
- i++;
813
- fail();
814
- }
815
- } else if (char < ' ') {
816
- fail();
817
- } else {
818
- i++;
819
- }
820
- }
821
- fail();
822
- };
823
- const items = (close, item)=>{
824
- i++;
825
- space();
826
- if (text[i] === close) {
827
- i++;
828
- return;
829
- }
830
- for(;;){
831
- item();
832
- space();
833
- if (text[i] === close) {
834
- i++;
835
- return;
836
- }
837
- if (text[i] !== ',') fail();
838
- i++;
839
- }
840
- };
841
- const value = ()=>{
842
- space();
843
- const char = text[i] ?? '';
844
- if (char === '{') {
845
- return items('}', ()=>{
846
- space();
847
- if (text[i] !== '"') fail();
848
- string();
849
- space();
850
- if (text[i] !== ':') fail();
851
- i++;
852
- value();
853
- });
854
- }
855
- if (char === '[') return items(']', value);
856
- if (char === '"') return string();
857
- for (const word of [
858
- 'true',
859
- 'false',
860
- 'null'
861
- ]){
862
- if (text.startsWith(word, i)) {
863
- i += word.length;
864
- return;
865
- }
866
- }
867
- const number = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i));
868
- if (!number) return fail();
869
- i += number[0].length;
870
- };
871
- try {
872
- value();
873
- space();
874
- if (i < text.length) fail();
875
- return -1;
876
- } catch (at) {
877
- if (typeof at !== 'number') throw at;
878
- return Math.min(at, text.length);
879
- }
880
- };
881
784
  // Mounts the box on the page; returns a function that removes it.
882
785
  const mountPromptBox = (doc, bridge, options = {})=>{
883
786
  const labels = {
@@ -921,14 +824,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
921
824
  rows: 1
922
825
  });
923
826
  input.setAttribute('aria-label', labels.open);
924
- // JSON mode (on the site): the entry or block as JSON, edited by hand.
925
- const code = element(doc, 'textarea', {
926
- className: 'code',
927
- spellcheck: false,
928
- autocomplete: 'off'
827
+ // Form mode (on the site): the CMS's form, mounted while shown.
828
+ const formBox = element(doc, 'div', {
829
+ className: 'form'
929
830
  });
930
- code.setAttribute('aria-label', labels.json);
931
- code.setAttribute('autocapitalize', 'off');
932
831
  // What the prompt acts on: the whole page, or the selected block with a
933
832
  // way back to the page.
934
833
  const pageIcon = icon(doc, 'page');
@@ -947,7 +846,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
947
846
  className: 'action chip',
948
847
  type: 'button'
949
848
  }, icon(doc, 'undo'), labels.undo);
950
- // Prompt or JSON (on the site).
849
+ // Prompt or Form (on the site).
951
850
  const modeButton = (text)=>{
952
851
  const button = element(doc, 'button', {
953
852
  type: 'button',
@@ -957,18 +856,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
957
856
  return button;
958
857
  };
959
858
  const promptMode = modeButton(labels.modePrompt);
960
- const jsonMode = modeButton(labels.modeJson);
859
+ const formMode = modeButton(labels.form);
961
860
  const modes = element(doc, 'span', {
962
861
  className: 'modes'
963
- }, promptMode, jsonMode);
862
+ }, promptMode, formMode);
964
863
  modes.setAttribute('role', 'group');
965
864
  modes.setAttribute('aria-label', labels.modes);
966
- const reload = element(doc, 'button', {
967
- className: 'icon-button',
968
- type: 'button',
969
- title: labels.jsonReload
970
- }, icon(doc, 'reload'));
971
- reload.setAttribute('aria-label', labels.jsonReload);
972
865
  // New page (on the site, for people who may add pages): the scope becomes
973
866
  // the new page, with a way back, and a path field shows above the prompt.
974
867
  const newPage = element(doc, 'button', {
@@ -1011,13 +904,14 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1011
904
  textContent: labels.send
1012
905
  }), icon(doc, 'send'));
1013
906
  send.title = labels.send;
907
+ const divider = element(doc, 'span', {
908
+ className: 'divider'
909
+ });
1014
910
  const toolbar = element(doc, 'div', {
1015
911
  className: 'toolbar'
1016
912
  }, scope, newScope, modes, undo, newPage, element(doc, 'span', {
1017
913
  className: 'spacer'
1018
- }), reload, close, element(doc, 'span', {
1019
- className: 'divider'
1020
- }), send);
914
+ }), close, divider, send);
1021
915
  // The pages (on the site): a chip naming this page, opening a search
1022
916
  // and a list of every page in place of the prompt.
1023
917
  const pageChipText = element(doc, 'span', {
@@ -1085,7 +979,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1085
979
  }), signOut, save, publish);
1086
980
  const panel = element(doc, 'form', {
1087
981
  className: 'panel'
1088
- }, status, pathRow, input, code, toolbar, pagesSection, footer);
982
+ }, status, pathRow, input, formBox, toolbar, pagesSection, footer);
1089
983
  const root = element(doc, 'div', {
1090
984
  className: 'root'
1091
985
  }, element(doc, 'div', {
@@ -1097,15 +991,11 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1097
991
  let open = false;
1098
992
  let creating = false;
1099
993
  let message = null;
1100
- // JSON mode: the text as last loaded, and what it is of (the block's
1101
- // uid, null for the whole entry). `seenUid` is the selection last loaded
1102
- // for: a new one loads its JSON, and undefined loads it again.
1103
- let json = false;
1104
- let loaded = '';
1105
- let viewUid = null;
1106
- let seenUid;
1107
- let loadingView = false;
1108
- let viewRequest = 0;
994
+ // Form mode, and the form while mounted: a fresh one (and one-time
995
+ // address) every time it shows.
996
+ let form = false;
997
+ let formHandle = null;
998
+ let formLoading = false;
1109
999
  // The pages, fetched once the panel opens and kept; `active` is the
1110
1000
  // option arrow keys are on.
1111
1001
  let picking = false;
@@ -1132,40 +1022,43 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1132
1022
  const onlyNew = pageless && canCreate;
1133
1023
  if (!canCreate) creating = false;
1134
1024
  if (onlyNew) creating = true;
1135
- if (creating || pageless) json = false;
1025
+ if (creating || pageless || !site) form = false;
1026
+ // The form is there only while shown in the open panel.
1027
+ const showForm = open && available && form;
1028
+ if (showForm && !formHandle) mountForm();
1029
+ else if (!showForm && formHandle) unmountForm();
1136
1030
  pillText.textContent = onlyNew ? labels.newPage : labels.open;
1137
1031
  cancelNew.hidden = onlyNew;
1138
1032
  scope.hidden = creating;
1139
1033
  newScope.hidden = !creating;
1140
- newPage.hidden = creating || json || !canCreate;
1034
+ newPage.hidden = creating || form || !canCreate;
1141
1035
  newPage.disabled = busy;
1142
1036
  pathRow.hidden = !creating || picking;
1143
1037
  pathInput.disabled = busy;
1144
- input.hidden = json || picking;
1145
- code.hidden = !json || picking;
1038
+ input.hidden = form || picking;
1039
+ formBox.hidden = !form || picking;
1040
+ formBox.classList.toggle('loading', formLoading);
1146
1041
  toolbar.hidden = picking;
1147
1042
  pagesSection.hidden = !picking;
1148
1043
  input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
1149
- // In JSON mode the scope is what the JSON is of.
1150
- const scopeUid = json ? viewUid : state.selectedUid;
1151
- scopeText.textContent = scopeUid ? labels.scopeBlock : labels.scopeEntry;
1044
+ scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
1152
1045
  clearBlock.hidden = !state.selectedUid;
1153
- pageIcon.hidden = !!scopeUid;
1154
- blockIcon.hidden = !scopeUid;
1155
- modes.hidden = !site || creating || pageless;
1156
- promptMode.setAttribute('aria-pressed', String(!json));
1157
- jsonMode.setAttribute('aria-pressed', String(json));
1046
+ pageIcon.hidden = !!state.selectedUid;
1047
+ blockIcon.hidden = !state.selectedUid;
1048
+ // A CMS from before the form doesn't offer it.
1049
+ modes.hidden = !site || creating || pageless || !state.features.includes('form');
1050
+ promptMode.setAttribute('aria-pressed', String(!form));
1051
+ formMode.setAttribute('aria-pressed', String(form));
1158
1052
  modes.querySelectorAll('button').forEach((button)=>{
1159
- button.disabled = busy || loadingView;
1053
+ button.disabled = busy;
1160
1054
  });
1161
- reload.hidden = !json;
1162
- reload.disabled = busy || loadingView;
1163
1055
  undo.hidden = creating || !state.canUndo;
1164
- undo.disabled = busy || loadingView;
1165
- const text = json ? code.value : input.value;
1166
- send.disabled = busy || loadingView || text.trim() === '' || creating && pathInput.value.trim() === '';
1056
+ undo.disabled = busy;
1057
+ // The form applies edits as they are made.
1058
+ send.hidden = form;
1059
+ divider.hidden = form;
1060
+ send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
1167
1061
  input.disabled = busy;
1168
- code.disabled = busy || loadingView;
1169
1062
  save.hidden = !state.features.includes('save') || pageless;
1170
1063
  save.disabled = busy || !state.unsaved;
1171
1064
  save.textContent = state.saving ? labels.saving : labels.save;
@@ -1178,141 +1071,57 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1178
1071
  pageChip.hidden = !site;
1179
1072
  pageChip.setAttribute('aria-expanded', String(picking));
1180
1073
  pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
1181
- const working = state.prompting || loadingView;
1182
- const note = state.prompting ? creating ? labels.creating : labels.working : loadingView ? labels.jsonLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1074
+ const loadingForm = form && formLoading && !picking;
1075
+ const working = state.prompting || loadingForm;
1076
+ const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
1183
1077
  status.textContent = note;
1184
1078
  status.hidden = !note;
1185
- status.className = `status${working ? ' working' : ''}${!busy && !loadingView && message?.error ? ' error' : ''}`;
1186
- // A new selection while editing JSON (or a reload asked for): its
1187
- // JSON, after this render.
1188
- if (open && json && !picking && state.selectedUid !== seenUid) {
1189
- seenUid = state.selectedUid;
1190
- queueMicrotask(()=>void loadView(false));
1191
- }
1079
+ status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
1192
1080
  };
1193
1081
  const refresh = ()=>render(bridge.getState());
1194
1082
  const grow = ()=>{
1195
1083
  input.style.height = 'auto';
1196
1084
  input.style.height = `${input.scrollHeight}px`;
1197
1085
  };
1198
- // Loads the JSON of the selection. Unless `force`, keeps text edited
1199
- // since the last load, with a note. True once loaded.
1200
- const loadView = async (force)=>{
1201
- const uid = bridge.getState().selectedUid;
1202
- seenUid = uid;
1203
- if (!force && code.value !== loaded) {
1204
- message = {
1205
- text: labels.jsonKept,
1206
- error: false
1207
- };
1208
- refresh();
1209
- return false;
1210
- }
1211
- const request = ++viewRequest;
1212
- loadingView = true;
1213
- message = null;
1214
- refresh();
1215
- try {
1216
- const view = await bridge.view({
1217
- uid
1218
- });
1219
- if (request !== viewRequest) return false;
1220
- loaded = `${JSON.stringify(view, null, 2)}\n`;
1221
- code.value = loaded;
1222
- code.scrollTop = 0;
1223
- viewUid = uid;
1224
- return true;
1225
- } catch (error) {
1226
- if (request === viewRequest) message = failure(error);
1227
- return false;
1228
- } finally{
1229
- if (request === viewRequest) {
1230
- loadingView = false;
1086
+ // Callbacks come later (the address is fetched first), never during a
1087
+ // render.
1088
+ const mountForm = ()=>{
1089
+ formLoading = true;
1090
+ formBox.classList.add('loading');
1091
+ const handle = bridge.openForm(formBox, {
1092
+ title: labels.formTitle,
1093
+ onReady: ()=>{
1094
+ if (formHandle !== handle) return;
1095
+ formLoading = false;
1096
+ message = null;
1097
+ refresh();
1098
+ },
1099
+ onError: (text)=>{
1100
+ if (formHandle !== handle) return;
1101
+ formLoading = false;
1102
+ message = {
1103
+ text,
1104
+ error: true
1105
+ };
1231
1106
  refresh();
1232
1107
  }
1233
- }
1234
- };
1235
- // Loads the JSON again once rendered, keeping edited text.
1236
- const requestView = ()=>{
1237
- seenUid = undefined;
1238
- refresh();
1239
- };
1240
- const setJson = (next)=>{
1241
- if (json === next) return;
1242
- json = next;
1243
- message = null;
1244
- requestView();
1245
- (json ? code : input).focus();
1108
+ });
1109
+ formHandle = handle;
1246
1110
  };
1247
- // Where the text stops being JSON, as a line and column.
1248
- const parseProblem = (text, error)=>{
1249
- const offset = jsonErrorAt(text);
1250
- if (offset < 0) {
1251
- const reason = error instanceof Error ? error.message : String(error);
1252
- return {
1253
- text: `${labels.jsonInvalid}: ${reason}`
1254
- };
1255
- }
1256
- const before = text.slice(0, offset);
1257
- const line = before.split('\n').length;
1258
- const column = offset - before.lastIndexOf('\n');
1259
- return {
1260
- text: labels.jsonInvalidAt.replace('{line}', String(line)).replace('{column}', String(column)),
1261
- offset
1262
- };
1111
+ const unmountForm = ()=>{
1112
+ formHandle?.close();
1113
+ formHandle = null;
1114
+ formLoading = false;
1263
1115
  };
1264
- const applyJson = async ()=>{
1265
- const text = code.value;
1266
- if (!text.trim() || bridge.getState().prompting || loadingView) return;
1267
- let changes;
1268
- try {
1269
- changes = JSON.parse(text);
1270
- } catch (error) {
1271
- const problem = parseProblem(text, error);
1272
- message = {
1273
- text: problem.text,
1274
- error: true
1275
- };
1276
- refresh();
1277
- code.focus();
1278
- if (problem.offset !== undefined) {
1279
- code.setSelectionRange(problem.offset, problem.offset);
1280
- }
1281
- return;
1282
- }
1283
- if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
1284
- message = {
1285
- text: labels.jsonNotObject,
1286
- error: true
1287
- };
1288
- return refresh();
1289
- }
1116
+ const setForm = (next)=>{
1117
+ if (form === next) return;
1118
+ form = next;
1290
1119
  message = null;
1291
- try {
1292
- const { changed, view } = await bridge.applyJson(changes, {
1293
- uid: viewUid
1294
- });
1295
- // What the CMS made of it, from now on what's edited: in the
1296
- // answer, or asked for (a CMS from before `view` in it).
1297
- if (view) {
1298
- ++viewRequest;
1299
- loaded = `${JSON.stringify(view, null, 2)}\n`;
1300
- code.value = loaded;
1301
- }
1302
- if (view || await loadView(true)) {
1303
- message = {
1304
- text: changed.length > 0 ? labels.applied : labels.unchanged,
1305
- error: false
1306
- };
1307
- }
1308
- } catch (error) {
1309
- message = failure(error);
1310
- }
1311
1120
  refresh();
1312
- code.focus();
1121
+ if (!form) input.focus();
1313
1122
  };
1314
1123
  const submit = async ()=>{
1315
- if (json) return applyJson();
1124
+ if (form) return;
1316
1125
  const prompt = input.value.trim();
1317
1126
  if (!prompt || bridge.getState().prompting) return;
1318
1127
  message = null;
@@ -1433,7 +1242,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1433
1242
  refresh();
1434
1243
  if (site) bridge.setSelecting(open && !creating);
1435
1244
  if (open && site) void fetchPages();
1436
- if (open && json) requestView();
1437
1245
  };
1438
1246
  const setCreating = (next)=>{
1439
1247
  creating = next;
@@ -1458,7 +1266,7 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1458
1266
  };
1459
1267
  pill.addEventListener('click', ()=>{
1460
1268
  setOpen(true);
1461
- (creating ? pathInput : json ? code : input).focus();
1269
+ if (!form) (creating ? pathInput : input).focus();
1462
1270
  });
1463
1271
  close.addEventListener('click', ()=>setOpen(false));
1464
1272
  save.addEventListener('click', ()=>void store('save'));
@@ -1468,12 +1276,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1468
1276
  clearBlock.addEventListener('click', ()=>bridge.select(null));
1469
1277
  newPage.addEventListener('click', ()=>setCreating(true));
1470
1278
  cancelNew.addEventListener('click', ()=>setCreating(false));
1471
- promptMode.addEventListener('click', ()=>setJson(false));
1472
- jsonMode.addEventListener('click', ()=>setJson(true));
1473
- reload.addEventListener('click', ()=>{
1474
- void loadView(true);
1475
- code.focus();
1476
- });
1279
+ promptMode.addEventListener('click', ()=>setForm(false));
1280
+ formMode.addEventListener('click', ()=>setForm(true));
1477
1281
  pageChip.addEventListener('click', ()=>setPicking(!picking));
1478
1282
  pathInput.addEventListener('input', refresh);
1479
1283
  pathInput.addEventListener('keydown', (event)=>{
@@ -1507,7 +1311,6 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1507
1311
  undo.addEventListener('click', ()=>{
1508
1312
  message = null;
1509
1313
  bridge.undo();
1510
- if (json) requestView();
1511
1314
  });
1512
1315
  panel.addEventListener('submit', (event)=>{
1513
1316
  event.preventDefault();
@@ -1525,37 +1328,92 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1525
1328
  setOpen(false);
1526
1329
  }
1527
1330
  });
1528
- code.addEventListener('input', refresh);
1529
- code.addEventListener('keydown', (event)=>{
1530
- // Enter is a new line here; Ctrl or Cmd + Enter applies.
1531
- if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !event.isComposing) {
1532
- event.preventDefault();
1533
- void submit();
1534
- } else if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
1535
- // A tab, not the next button (Shift + Tab still leaves). Through
1536
- // execCommand where it works, so the browser's undo has it.
1537
- event.preventDefault();
1538
- let inserted = false;
1539
- try {
1540
- inserted = doc.execCommand('insertText', false, '\t');
1541
- } catch {}
1542
- if (!inserted) {
1543
- code.setRangeText('\t', code.selectionStart, code.selectionEnd, 'end');
1544
- }
1545
- refresh();
1546
- } else if (event.key === 'Escape') {
1547
- setOpen(false);
1548
- }
1549
- });
1550
1331
  const unsubscribe = bridge.on('change', render);
1551
1332
  refresh();
1552
1333
  doc.body.appendChild(host);
1553
1334
  return ()=>{
1554
1335
  unsubscribe();
1336
+ unmountForm();
1555
1337
  host.remove();
1556
1338
  };
1557
1339
  };
1558
1340
 
1341
+ // The CMS's entry form in an iframe, for editing on the site (the prompt
1342
+ // box's Form mode). The CMS renders the form at a one-time URL (`/site/form#
1343
+ // code=…`, from the site's `/cms/api/form`); this keeps it in step with the
1344
+ // bridge's in-memory content.
1345
+ //
1346
+ // Messages are only exchanged with the URL's origin, and only with the
1347
+ // iframe's window:
1348
+ // - form → bridge: { source: 'obelism-content-form', type: 'ready' |
1349
+ // 'change' | 'error', content?, message? } — `change` carries the whole
1350
+ // entry content after an edit
1351
+ // - bridge → form: { source: 'obelism-content-bridge', type: 'content',
1352
+ // entryId, content, locale, selectedUid }
1353
+ const FORM_SOURCE = 'obelism-content-form';
1354
+ // How long the form may take to say it is ready.
1355
+ const FORM_READY_TIMEOUT_MS = 30_000;
1356
+ const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
1357
+ // Mounts the form at `url` in `container`. The URL is single use: mount a
1358
+ // fresh one every time.
1359
+ const mountFormFrame = (win, container, url, title, handlers)=>{
1360
+ let parsed = null;
1361
+ try {
1362
+ parsed = new URL(url);
1363
+ } catch {}
1364
+ if (!parsed || !/^https?:$/.test(parsed.protocol)) {
1365
+ throw new Error('Obelism Content sent no form to open');
1366
+ }
1367
+ const origin = parsed.origin;
1368
+ const iframe = container.ownerDocument.createElement('iframe');
1369
+ iframe.title = title;
1370
+ iframe.src = url;
1371
+ let ready = false;
1372
+ let latest = null;
1373
+ const post = ()=>{
1374
+ if (!ready || !latest) return;
1375
+ iframe.contentWindow?.postMessage({
1376
+ source: BRIDGE_SOURCE,
1377
+ type: 'content',
1378
+ ...latest
1379
+ }, origin);
1380
+ };
1381
+ const timer = setTimeout(()=>{
1382
+ if (!ready) handlers.onError('The form did not open');
1383
+ }, FORM_READY_TIMEOUT_MS);
1384
+ const onMessage = (event)=>{
1385
+ if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
1386
+ return;
1387
+ }
1388
+ const data = event.data;
1389
+ if (!data || data.source !== FORM_SOURCE) return;
1390
+ if (data.type === 'ready') {
1391
+ // Again after the form reloads itself: it gets the content again.
1392
+ ready = true;
1393
+ clearTimeout(timer);
1394
+ handlers.onReady();
1395
+ post();
1396
+ } else if (data.type === 'change' && isObject(data.content)) {
1397
+ handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
1398
+ } else if (data.type === 'error') {
1399
+ handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
1400
+ }
1401
+ };
1402
+ win.addEventListener('message', onMessage);
1403
+ container.appendChild(iframe);
1404
+ return {
1405
+ show (content) {
1406
+ latest = content;
1407
+ post();
1408
+ },
1409
+ destroy () {
1410
+ clearTimeout(timer);
1411
+ win.removeEventListener('message', onMessage);
1412
+ iframe.remove();
1413
+ }
1414
+ };
1415
+ };
1416
+
1559
1417
  // The bridge on a site signed in to edit (`/cms/login`, from
1560
1418
  // @obelism/content/site), outside the visual editor. It plays the editor's
1561
1419
  // part itself: it keeps the changed content in memory, asks the site's
@@ -1565,9 +1423,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
1565
1423
  //
1566
1424
  // What it edits is the page's entry — the outermost element marked with
1567
1425
  // `editable(entry)` — or, for a selected block, the entry around that block.
1568
- // Besides prompts it edits JSON (`view`, then `applyJson`: the entry as a
1569
- // language model sees it, changed by hand), and lists the space's pages to
1570
- // go to one.
1426
+ // Besides prompts it shows the CMS's own entry form (`openForm`, an iframe
1427
+ // kept in step with the content here, see formFrame.ts), edits JSON (`view`,
1428
+ // then `applyJson`: the raw alternative), and lists the space's pages to go
1429
+ // to one.
1571
1430
  // The base path of the site's handler while signed in to edit, else null.
1572
1431
  const readSiteEditing = (doc)=>{
1573
1432
  for (const part of doc.cookie.split(';')){
@@ -1583,6 +1442,11 @@ const readSiteEditing = (doc)=>{
1583
1442
  return null;
1584
1443
  };
1585
1444
  const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
1445
+ // Edits in the form further apart than this are separate undo steps.
1446
+ const FORM_BURST_MS = 1000;
1447
+ // How long the page waits for the form's edits to settle before it shows
1448
+ // them.
1449
+ const FORM_RENDER_MS = 300;
1586
1450
  const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
1587
1451
  const doc = win.document;
1588
1452
  const emitter = createEmitter();
@@ -1593,6 +1457,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1593
1457
  // Entries changed here since the last publish.
1594
1458
  const touched = new Set();
1595
1459
  let session = null;
1460
+ // The open form's, if any; and when the form last changed content, for
1461
+ // undo steps (-Infinity: the next edit starts a new step).
1462
+ let closeForm = null;
1463
+ let lastFormEdit = -Infinity;
1464
+ const renderTimers = new Map();
1596
1465
  let state = INITIAL_STATE;
1597
1466
  const setState = (next)=>{
1598
1467
  state = {
@@ -1646,17 +1515,26 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1646
1515
  const base = lang.split('-')[0] ?? '';
1647
1516
  return codes.includes(base) ? base : session.space.defaultLocale;
1648
1517
  };
1518
+ // One request per entry at a time: a second would overwrite edits made
1519
+ // while the first was out.
1520
+ const loading = new Map();
1649
1521
  const load = async (entryId)=>{
1650
1522
  const loaded = working.get(entryId);
1651
1523
  if (loaded) return loaded;
1652
- const entry = await api(`entries/${encodeURIComponent(entryId)}`);
1653
- const fresh = {
1654
- content: entry.content,
1655
- saved: entry.content,
1656
- revision: entry.draftRevision
1657
- };
1658
- working.set(entryId, fresh);
1659
- return fresh;
1524
+ let pending = loading.get(entryId);
1525
+ if (!pending) {
1526
+ pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
1527
+ const fresh = working.get(entryId) ?? {
1528
+ content: entry.content,
1529
+ saved: entry.content,
1530
+ revision: entry.draftRevision
1531
+ };
1532
+ working.set(entryId, fresh);
1533
+ return fresh;
1534
+ }).finally(()=>loading.delete(entryId));
1535
+ loading.set(entryId, pending);
1536
+ }
1537
+ return pending;
1660
1538
  };
1661
1539
  // The entry a prompt or JSON acts on, for the block `uid` (the selected
1662
1540
  // one when left out).
@@ -1695,6 +1573,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1695
1573
  });
1696
1574
  win.requestAnimationFrame(()=>highlight(state.selectedUid));
1697
1575
  };
1576
+ const logError = (error)=>console.error('[@obelism/content bridge]', error);
1577
+ // Renders once the form's edits settle.
1578
+ const renderSoon = (entryId)=>{
1579
+ clearTimeout(renderTimers.get(entryId));
1580
+ renderTimers.set(entryId, setTimeout(()=>{
1581
+ renderTimers.delete(entryId);
1582
+ void render(entryId).catch(logError);
1583
+ }, FORM_RENDER_MS));
1584
+ };
1698
1585
  const afterStore = (event)=>{
1699
1586
  emitter.emit(event, {});
1700
1587
  if (emitter.listeners.update.size === 0) win.location.reload();
@@ -1711,6 +1598,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1711
1598
  touched.add(entryId);
1712
1599
  }
1713
1600
  history.length = 0;
1601
+ lastFormEdit = -Infinity;
1714
1602
  refreshUnsaved();
1715
1603
  };
1716
1604
  const has = (feature)=>state.features.includes(feature);
@@ -1743,6 +1631,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1743
1631
  entryId,
1744
1632
  content: entry.content
1745
1633
  });
1634
+ lastFormEdit = -Infinity;
1746
1635
  entry.content = result.content;
1747
1636
  refreshUnsaved();
1748
1637
  await render(entryId);
@@ -1821,6 +1710,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1821
1710
  entryId,
1822
1711
  content: entry.content
1823
1712
  });
1713
+ lastFormEdit = -Infinity;
1824
1714
  entry.content = result.content;
1825
1715
  refreshUnsaved();
1826
1716
  await render(entryId);
@@ -1837,6 +1727,91 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1837
1727
  });
1838
1728
  }
1839
1729
  },
1730
+ openForm (container, options = {}) {
1731
+ const { title = 'Content form', onReady, onError } = options;
1732
+ closeForm?.();
1733
+ let closed = false;
1734
+ let frame = null;
1735
+ // What the form was last sent, or sent back itself.
1736
+ let shown = null;
1737
+ let unsubscribe = ()=>{};
1738
+ const fail = (error)=>{
1739
+ if (!closed) onError?.(error instanceof Error ? error.message : String(error));
1740
+ };
1741
+ const close = ()=>{
1742
+ if (closed) return;
1743
+ closed = true;
1744
+ unsubscribe();
1745
+ frame?.destroy();
1746
+ if (closeForm === close) closeForm = null;
1747
+ };
1748
+ closeForm = close;
1749
+ // Sends the form the entry for the selection, when anything of it
1750
+ // changed: prompts, undo, a reload after saving, the selection.
1751
+ const show = ()=>{
1752
+ if (closed || !frame) return;
1753
+ const entryId = entryFor(state.selectedUid);
1754
+ if (!entryId) return;
1755
+ const entry = working.get(entryId);
1756
+ if (!entry) {
1757
+ load(entryId).then(show, fail);
1758
+ return;
1759
+ }
1760
+ const next = {
1761
+ entryId,
1762
+ content: entry.content,
1763
+ locale: locale(),
1764
+ selectedUid: state.selectedUid
1765
+ };
1766
+ if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
1767
+ return;
1768
+ }
1769
+ shown = next;
1770
+ frame.show(next);
1771
+ };
1772
+ const change = (content, from)=>{
1773
+ const entryId = from ?? shown?.entryId;
1774
+ const entry = entryId === undefined ? undefined : working.get(entryId);
1775
+ if (closed || entryId === undefined || !entry || same(content, entry.content)) {
1776
+ return;
1777
+ }
1778
+ const now = Date.now();
1779
+ if (now - lastFormEdit >= FORM_BURST_MS) {
1780
+ history.push({
1781
+ entryId,
1782
+ content: entry.content
1783
+ });
1784
+ }
1785
+ lastFormEdit = now;
1786
+ entry.content = content;
1787
+ // Not sent back: the form has it.
1788
+ if (shown?.entryId === entryId) shown = {
1789
+ ...shown,
1790
+ content
1791
+ };
1792
+ refreshUnsaved();
1793
+ renderSoon(entryId);
1794
+ };
1795
+ const start = async ()=>{
1796
+ if (!has('form')) throw new Error('You may not edit content here');
1797
+ await load(target(undefined).entryId);
1798
+ // A fresh one every time: the address works once, for a minute.
1799
+ const { url } = await api('form', {});
1800
+ if (closed) return;
1801
+ const address = typeof url === 'string' ? url : '';
1802
+ frame = mountFormFrame(win, container, address, title, {
1803
+ onReady: ()=>onReady?.(),
1804
+ onChange: change,
1805
+ onError: (message)=>onError?.(message)
1806
+ });
1807
+ unsubscribe = emitter.on('change', show);
1808
+ show();
1809
+ };
1810
+ start().catch(fail);
1811
+ return {
1812
+ close
1813
+ };
1814
+ },
1840
1815
  async pages () {
1841
1816
  const { pages } = await api('pages');
1842
1817
  const preferred = [
@@ -1874,9 +1849,10 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1874
1849
  const last = history.pop();
1875
1850
  const entry = last && working.get(last.entryId);
1876
1851
  if (!last || !entry) return;
1852
+ lastFormEdit = -Infinity;
1877
1853
  entry.content = last.content;
1878
1854
  refreshUnsaved();
1879
- void render(last.entryId).catch((error)=>console.error('[@obelism/content bridge]', error));
1855
+ void render(last.entryId).catch(logError);
1880
1856
  },
1881
1857
  async save () {
1882
1858
  if (!has('save')) throw new Error('You may not edit content here');
@@ -1939,6 +1915,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
1939
1915
  });
1940
1916
  },
1941
1917
  destroy () {
1918
+ closeForm?.();
1919
+ renderTimers.forEach((timer)=>clearTimeout(timer));
1920
+ renderTimers.clear();
1942
1921
  cleanup.splice(0).forEach((undo)=>undo());
1943
1922
  }
1944
1923
  };