@obelism/content 0.12.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/README.md +27 -0
- package/dist/bin/content.js +2 -1
- package/dist/bridge.d.mts +42 -1
- package/dist/bridge.d.mts.map +1 -1
- package/dist/bridge.d.ts +42 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.global.js +56 -2
- package/dist/bridge.js +653 -37
- package/dist/bridge.mjs +653 -37
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/site.js +10 -1
- package/dist/site.mjs +10 -1
- package/dist/{types-hmpsikmo.d.ts → types-m3j4tltp.d.ts} +2 -2
- package/dist/{types-hmpsikmo.d.ts.map → types-m3j4tltp.d.ts.map} +1 -1
- package/package.json +1 -1
package/dist/bridge.mjs
CHANGED
|
@@ -222,10 +222,21 @@ const createEmitter = ()=>{
|
|
|
222
222
|
};
|
|
223
223
|
const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
|
|
224
224
|
const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
|
|
225
|
+
const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in to edit on the site'));
|
|
225
226
|
// What every bridge without a feature does for it.
|
|
226
227
|
const INERT_METHODS = {
|
|
227
228
|
prompt: inertPrompt,
|
|
228
229
|
createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
|
|
230
|
+
view: onlyWhileEditingSite,
|
|
231
|
+
applyJson: onlyWhileEditingSite,
|
|
232
|
+
openForm: (_container, options = {})=>{
|
|
233
|
+
queueMicrotask(()=>options.onError?.('Only while signed in to edit on the site'));
|
|
234
|
+
return {
|
|
235
|
+
close: ()=>{}
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
pages: onlyWhileEditingSite,
|
|
239
|
+
openPage: ()=>{},
|
|
229
240
|
undo: ()=>{},
|
|
230
241
|
save: onlyOnSite,
|
|
231
242
|
publish: onlyOnSite,
|
|
@@ -510,7 +521,19 @@ const DEFAULT_PROMPT_LABELS = {
|
|
|
510
521
|
path: 'Path',
|
|
511
522
|
pathPlaceholder: '/pricing',
|
|
512
523
|
creating: 'Writing the page…',
|
|
513
|
-
created: 'Opening the new page…'
|
|
524
|
+
created: 'Opening the new page…',
|
|
525
|
+
modes: 'Edit with',
|
|
526
|
+
modePrompt: 'Prompt',
|
|
527
|
+
form: 'Form',
|
|
528
|
+
formTitle: 'The content form',
|
|
529
|
+
formLoading: 'Opening the form…',
|
|
530
|
+
pages: 'Pages',
|
|
531
|
+
pagesSearch: 'Find a page',
|
|
532
|
+
pagesLoading: 'Loading pages…',
|
|
533
|
+
pagesEmpty: 'No pages match',
|
|
534
|
+
pageCurrent: 'This page',
|
|
535
|
+
pageDraft: 'Draft',
|
|
536
|
+
pageChanged: 'Changed'
|
|
514
537
|
};
|
|
515
538
|
// Icons (Lucide), drawn in the text colour.
|
|
516
539
|
const ICONS = {
|
|
@@ -519,6 +542,9 @@ const ICONS = {
|
|
|
519
542
|
block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
|
|
520
543
|
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
|
521
544
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
545
|
+
chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
|
546
|
+
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
547
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
522
548
|
send: '<path d="M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z"/><path d="M6 12h16"/>'
|
|
523
549
|
};
|
|
524
550
|
// Obelism's design tokens (obelism.studio/design.md), under their own names:
|
|
@@ -620,6 +646,13 @@ textarea {
|
|
|
620
646
|
font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
|
|
621
647
|
}
|
|
622
648
|
textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
649
|
+
/* Form: the CMS's entry form, edited live. */
|
|
650
|
+
.form { padding: 12px 12px 0; }
|
|
651
|
+
.form iframe {
|
|
652
|
+
display: block; width: 100%; height: min(560px, 60vh); border: none; border-radius: 12px;
|
|
653
|
+
background: rgb(var(--theme-background-actions-rgb) / .45);
|
|
654
|
+
}
|
|
655
|
+
.form.loading iframe { visibility: hidden; }
|
|
623
656
|
/* New page: where it goes, above what it is for. */
|
|
624
657
|
.path {
|
|
625
658
|
display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
|
|
@@ -663,6 +696,21 @@ button.chip:disabled { opacity: .5; }
|
|
|
663
696
|
.icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
|
|
664
697
|
.icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
|
|
665
698
|
.icon-button.primary:disabled { opacity: .35; }
|
|
699
|
+
/* Prompt or Form: two small pills in one track. */
|
|
700
|
+
.modes {
|
|
701
|
+
display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
|
|
702
|
+
border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
|
|
703
|
+
}
|
|
704
|
+
.modes button {
|
|
705
|
+
height: 28px; padding: 0 12px; border-radius: var(--border-radius-pill); font-size: 13px;
|
|
706
|
+
color: rgb(var(--theme-foreground-muted-rgb));
|
|
707
|
+
transition: background-color .25s var(--ease-roll), color .25s var(--ease-roll);
|
|
708
|
+
}
|
|
709
|
+
.modes button:hover { color: rgb(var(--theme-foreground-rgb)); }
|
|
710
|
+
.modes button[aria-pressed='true'] {
|
|
711
|
+
background: rgb(var(--theme-background-alt-rgb)); color: rgb(var(--theme-foreground-rgb));
|
|
712
|
+
box-shadow: 0 1px 2px rgb(var(--color-black-rgb) / .08);
|
|
713
|
+
}
|
|
666
714
|
.divider { width: 1px; height: 20px; background: rgb(var(--theme-border-rgb) / .3); margin: 0 4px; }
|
|
667
715
|
|
|
668
716
|
/* On the site: storing the draft, and the way out. */
|
|
@@ -679,6 +727,38 @@ button.chip:disabled { opacity: .5; }
|
|
|
679
727
|
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
|
680
728
|
background: rgb(var(--theme-accent-rgb)); margin-right: 8px;
|
|
681
729
|
}
|
|
730
|
+
|
|
731
|
+
/* The pages: a search above a list, in place of the prompt while open. */
|
|
732
|
+
.page-chip { min-width: 0; max-width: 50%; gap: 6px; }
|
|
733
|
+
.page-chip > span:not(.icon) { overflow: hidden; text-overflow: ellipsis; }
|
|
734
|
+
.page-chip .icon:last-child { color: rgb(var(--theme-foreground-muted-rgb)); margin-right: -4px; }
|
|
735
|
+
.page-chip .icon:last-child svg { width: 14px; height: 14px; }
|
|
736
|
+
.pages { display: grid; gap: 8px; padding: 16px 12px 12px; }
|
|
737
|
+
.search {
|
|
738
|
+
display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; margin: 0 4px;
|
|
739
|
+
border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
|
|
740
|
+
background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-muted-rgb));
|
|
741
|
+
transition: border-color .25s var(--ease-roll);
|
|
742
|
+
}
|
|
743
|
+
.search:focus-within { border-color: rgb(var(--theme-accent-rgb)); }
|
|
744
|
+
.search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: rgb(var(--theme-foreground-rgb)); }
|
|
745
|
+
.search input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
746
|
+
.options { list-style: none; padding: 0; max-height: min(300px, 40vh); overflow-y: auto; display: grid; gap: 2px; }
|
|
747
|
+
.option {
|
|
748
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; cursor: pointer;
|
|
749
|
+
transition: background-color .15s var(--ease-roll);
|
|
750
|
+
}
|
|
751
|
+
.option:hover, .option.active { background: rgb(var(--theme-background-actions-rgb) / .7); }
|
|
752
|
+
.option-text { flex: 1; min-width: 0; display: grid; }
|
|
753
|
+
.option-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
754
|
+
.option-path { font-size: 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
755
|
+
.badge {
|
|
756
|
+
flex: none; font-size: 12px; padding: 1px 8px; border-radius: var(--border-radius-pill);
|
|
757
|
+
border: 1px solid rgb(var(--theme-border-rgb) / .3); color: rgb(var(--theme-foreground-muted-rgb));
|
|
758
|
+
}
|
|
759
|
+
.option .icon { color: rgb(var(--theme-accent-rgb)); }
|
|
760
|
+
.empty { padding: 8px 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
761
|
+
.empty.error { color: var(--theme-error); }
|
|
682
762
|
[hidden] { display: none !important; }
|
|
683
763
|
`;
|
|
684
764
|
const element = (doc, tag, props = {}, ...children)=>{
|
|
@@ -742,6 +822,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
742
822
|
rows: 1
|
|
743
823
|
});
|
|
744
824
|
input.setAttribute('aria-label', labels.open);
|
|
825
|
+
// Form mode (on the site): the CMS's form, mounted while shown.
|
|
826
|
+
const formBox = element(doc, 'div', {
|
|
827
|
+
className: 'form'
|
|
828
|
+
});
|
|
745
829
|
// What the prompt acts on: the whole page, or the selected block with a
|
|
746
830
|
// way back to the page.
|
|
747
831
|
const pageIcon = icon(doc, 'page');
|
|
@@ -760,6 +844,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
760
844
|
className: 'action chip',
|
|
761
845
|
type: 'button'
|
|
762
846
|
}, icon(doc, 'undo'), labels.undo);
|
|
847
|
+
// Prompt or Form (on the site).
|
|
848
|
+
const modeButton = (text)=>{
|
|
849
|
+
const button = element(doc, 'button', {
|
|
850
|
+
type: 'button',
|
|
851
|
+
textContent: text
|
|
852
|
+
});
|
|
853
|
+
button.setAttribute('aria-pressed', 'false');
|
|
854
|
+
return button;
|
|
855
|
+
};
|
|
856
|
+
const promptMode = modeButton(labels.modePrompt);
|
|
857
|
+
const formMode = modeButton(labels.form);
|
|
858
|
+
const modes = element(doc, 'span', {
|
|
859
|
+
className: 'modes'
|
|
860
|
+
}, promptMode, formMode);
|
|
861
|
+
modes.setAttribute('role', 'group');
|
|
862
|
+
modes.setAttribute('aria-label', labels.modes);
|
|
763
863
|
// New page (on the site, for people who may add pages): the scope becomes
|
|
764
864
|
// the new page, with a way back, and a path field shows above the prompt.
|
|
765
865
|
const newPage = element(doc, 'button', {
|
|
@@ -802,6 +902,52 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
802
902
|
textContent: labels.send
|
|
803
903
|
}), icon(doc, 'send'));
|
|
804
904
|
send.title = labels.send;
|
|
905
|
+
const divider = element(doc, 'span', {
|
|
906
|
+
className: 'divider'
|
|
907
|
+
});
|
|
908
|
+
const toolbar = element(doc, 'div', {
|
|
909
|
+
className: 'toolbar'
|
|
910
|
+
}, scope, newScope, modes, undo, newPage, element(doc, 'span', {
|
|
911
|
+
className: 'spacer'
|
|
912
|
+
}), close, divider, send);
|
|
913
|
+
// The pages (on the site): a chip naming this page, opening a search
|
|
914
|
+
// and a list of every page in place of the prompt.
|
|
915
|
+
const pageChipText = element(doc, 'span', {
|
|
916
|
+
textContent: labels.pages
|
|
917
|
+
});
|
|
918
|
+
const pageChip = element(doc, 'button', {
|
|
919
|
+
className: 'chip page-chip',
|
|
920
|
+
type: 'button',
|
|
921
|
+
title: labels.pages
|
|
922
|
+
}, icon(doc, 'page'), pageChipText, icon(doc, 'chevrons'));
|
|
923
|
+
pageChip.setAttribute('aria-haspopup', 'listbox');
|
|
924
|
+
pageChip.setAttribute('aria-expanded', 'false');
|
|
925
|
+
pageChip.setAttribute('aria-controls', 'obelism-pages');
|
|
926
|
+
const search = element(doc, 'input', {
|
|
927
|
+
type: 'text',
|
|
928
|
+
placeholder: labels.pagesSearch,
|
|
929
|
+
autocomplete: 'off',
|
|
930
|
+
spellcheck: false
|
|
931
|
+
});
|
|
932
|
+
search.setAttribute('role', 'combobox');
|
|
933
|
+
search.setAttribute('aria-label', labels.pagesSearch);
|
|
934
|
+
search.setAttribute('aria-autocomplete', 'list');
|
|
935
|
+
search.setAttribute('aria-expanded', 'true');
|
|
936
|
+
search.setAttribute('aria-controls', 'obelism-pages');
|
|
937
|
+
const optionList = element(doc, 'ul', {
|
|
938
|
+
className: 'options',
|
|
939
|
+
id: 'obelism-pages'
|
|
940
|
+
});
|
|
941
|
+
optionList.setAttribute('role', 'listbox');
|
|
942
|
+
optionList.setAttribute('aria-label', labels.pages);
|
|
943
|
+
const pagesNote = element(doc, 'p', {
|
|
944
|
+
className: 'empty'
|
|
945
|
+
});
|
|
946
|
+
const pagesSection = element(doc, 'div', {
|
|
947
|
+
className: 'pages'
|
|
948
|
+
}, element(doc, 'label', {
|
|
949
|
+
className: 'search'
|
|
950
|
+
}, icon(doc, 'search'), search), optionList, pagesNote);
|
|
805
951
|
const unsaved = element(doc, 'span', {
|
|
806
952
|
className: 'unsaved'
|
|
807
953
|
});
|
|
@@ -826,18 +972,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
826
972
|
});
|
|
827
973
|
const footer = element(doc, 'div', {
|
|
828
974
|
className: 'footer'
|
|
829
|
-
}, unsaved, element(doc, 'span', {
|
|
975
|
+
}, pageChip, unsaved, element(doc, 'span', {
|
|
830
976
|
className: 'spacer'
|
|
831
977
|
}), signOut, save, publish);
|
|
832
978
|
const panel = element(doc, 'form', {
|
|
833
979
|
className: 'panel'
|
|
834
|
-
}, status, pathRow, input,
|
|
835
|
-
className: 'toolbar'
|
|
836
|
-
}, scope, newScope, undo, newPage, element(doc, 'span', {
|
|
837
|
-
className: 'spacer'
|
|
838
|
-
}), close, element(doc, 'span', {
|
|
839
|
-
className: 'divider'
|
|
840
|
-
}), send), footer);
|
|
980
|
+
}, status, pathRow, input, formBox, toolbar, pagesSection, footer);
|
|
841
981
|
const root = element(doc, 'div', {
|
|
842
982
|
className: 'root'
|
|
843
983
|
}, element(doc, 'div', {
|
|
@@ -849,6 +989,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
849
989
|
let open = false;
|
|
850
990
|
let creating = false;
|
|
851
991
|
let message = null;
|
|
992
|
+
// Form mode, and the form while mounted: a fresh one (and one-time
|
|
993
|
+
// address) every time it shows.
|
|
994
|
+
let form = false;
|
|
995
|
+
let formHandle = null;
|
|
996
|
+
let formLoading = false;
|
|
997
|
+
// The pages, fetched once the panel opens and kept; `active` is the
|
|
998
|
+
// option arrow keys are on.
|
|
999
|
+
let picking = false;
|
|
1000
|
+
let pages = null;
|
|
1001
|
+
let pagesLoad = null;
|
|
1002
|
+
let pagesError = null;
|
|
1003
|
+
let active = 0;
|
|
1004
|
+
const failure = (error)=>({
|
|
1005
|
+
text: error instanceof Error ? error.message : String(error),
|
|
1006
|
+
error: true
|
|
1007
|
+
});
|
|
852
1008
|
const render = (state)=>{
|
|
853
1009
|
const available = state.features.includes('prompt');
|
|
854
1010
|
const storing = state.saving || state.publishing;
|
|
@@ -864,21 +1020,41 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
864
1020
|
const onlyNew = pageless && canCreate;
|
|
865
1021
|
if (!canCreate) creating = false;
|
|
866
1022
|
if (onlyNew) creating = true;
|
|
1023
|
+
if (creating || pageless || !site) form = false;
|
|
1024
|
+
// The form is there only while shown in the open panel.
|
|
1025
|
+
const showForm = open && available && form;
|
|
1026
|
+
if (showForm && !formHandle) mountForm();
|
|
1027
|
+
else if (!showForm && formHandle) unmountForm();
|
|
867
1028
|
pillText.textContent = onlyNew ? labels.newPage : labels.open;
|
|
868
1029
|
cancelNew.hidden = onlyNew;
|
|
869
1030
|
scope.hidden = creating;
|
|
870
1031
|
newScope.hidden = !creating;
|
|
871
|
-
newPage.hidden = creating || !canCreate;
|
|
1032
|
+
newPage.hidden = creating || form || !canCreate;
|
|
872
1033
|
newPage.disabled = busy;
|
|
873
|
-
pathRow.hidden = !creating;
|
|
1034
|
+
pathRow.hidden = !creating || picking;
|
|
874
1035
|
pathInput.disabled = busy;
|
|
1036
|
+
input.hidden = form || picking;
|
|
1037
|
+
formBox.hidden = !form || picking;
|
|
1038
|
+
formBox.classList.toggle('loading', formLoading);
|
|
1039
|
+
toolbar.hidden = picking;
|
|
1040
|
+
pagesSection.hidden = !picking;
|
|
875
1041
|
input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
|
|
876
1042
|
scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
|
|
877
1043
|
clearBlock.hidden = !state.selectedUid;
|
|
878
1044
|
pageIcon.hidden = !!state.selectedUid;
|
|
879
1045
|
blockIcon.hidden = !state.selectedUid;
|
|
1046
|
+
// A CMS from before the form doesn't offer it.
|
|
1047
|
+
modes.hidden = !site || creating || pageless || !state.features.includes('form');
|
|
1048
|
+
promptMode.setAttribute('aria-pressed', String(!form));
|
|
1049
|
+
formMode.setAttribute('aria-pressed', String(form));
|
|
1050
|
+
modes.querySelectorAll('button').forEach((button)=>{
|
|
1051
|
+
button.disabled = busy;
|
|
1052
|
+
});
|
|
880
1053
|
undo.hidden = creating || !state.canUndo;
|
|
881
1054
|
undo.disabled = busy;
|
|
1055
|
+
// The form applies edits as they are made.
|
|
1056
|
+
send.hidden = form;
|
|
1057
|
+
divider.hidden = form;
|
|
882
1058
|
send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
|
|
883
1059
|
input.disabled = busy;
|
|
884
1060
|
save.hidden = !state.features.includes('save') || pageless;
|
|
@@ -890,17 +1066,60 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
890
1066
|
signOut.hidden = !site;
|
|
891
1067
|
footer.hidden = !site;
|
|
892
1068
|
unsaved.hidden = !state.unsaved;
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1069
|
+
pageChip.hidden = !site;
|
|
1070
|
+
pageChip.setAttribute('aria-expanded', String(picking));
|
|
1071
|
+
pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
|
|
1072
|
+
const loadingForm = form && formLoading && !picking;
|
|
1073
|
+
const working = state.prompting || loadingForm;
|
|
1074
|
+
const note = state.prompting ? creating ? labels.creating : labels.working : loadingForm ? labels.formLoading : message?.text ?? (site && !creating && !picking && !state.selectedUid ? labels.selectHint : '');
|
|
1075
|
+
status.textContent = note;
|
|
1076
|
+
status.hidden = !note;
|
|
1077
|
+
status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
|
|
897
1078
|
};
|
|
898
1079
|
const refresh = ()=>render(bridge.getState());
|
|
899
1080
|
const grow = ()=>{
|
|
900
1081
|
input.style.height = 'auto';
|
|
901
1082
|
input.style.height = `${input.scrollHeight}px`;
|
|
902
1083
|
};
|
|
1084
|
+
// Callbacks come later (the address is fetched first), never during a
|
|
1085
|
+
// render.
|
|
1086
|
+
const mountForm = ()=>{
|
|
1087
|
+
formLoading = true;
|
|
1088
|
+
formBox.classList.add('loading');
|
|
1089
|
+
const handle = bridge.openForm(formBox, {
|
|
1090
|
+
title: labels.formTitle,
|
|
1091
|
+
onReady: ()=>{
|
|
1092
|
+
if (formHandle !== handle) return;
|
|
1093
|
+
formLoading = false;
|
|
1094
|
+
message = null;
|
|
1095
|
+
refresh();
|
|
1096
|
+
},
|
|
1097
|
+
onError: (text)=>{
|
|
1098
|
+
if (formHandle !== handle) return;
|
|
1099
|
+
formLoading = false;
|
|
1100
|
+
message = {
|
|
1101
|
+
text,
|
|
1102
|
+
error: true
|
|
1103
|
+
};
|
|
1104
|
+
refresh();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
formHandle = handle;
|
|
1108
|
+
};
|
|
1109
|
+
const unmountForm = ()=>{
|
|
1110
|
+
formHandle?.close();
|
|
1111
|
+
formHandle = null;
|
|
1112
|
+
formLoading = false;
|
|
1113
|
+
};
|
|
1114
|
+
const setForm = (next)=>{
|
|
1115
|
+
if (form === next) return;
|
|
1116
|
+
form = next;
|
|
1117
|
+
message = null;
|
|
1118
|
+
refresh();
|
|
1119
|
+
if (!form) input.focus();
|
|
1120
|
+
};
|
|
903
1121
|
const submit = async ()=>{
|
|
1122
|
+
if (form) return;
|
|
904
1123
|
const prompt = input.value.trim();
|
|
905
1124
|
if (!prompt || bridge.getState().prompting) return;
|
|
906
1125
|
message = null;
|
|
@@ -910,6 +1129,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
910
1129
|
if (!path) return pathInput.focus();
|
|
911
1130
|
// The bridge opens the new page; this shows until it does.
|
|
912
1131
|
await bridge.createPage(path, prompt);
|
|
1132
|
+
pages = null;
|
|
1133
|
+
pagesLoad = null;
|
|
913
1134
|
message = {
|
|
914
1135
|
text: labels.created,
|
|
915
1136
|
error: false
|
|
@@ -924,18 +1145,101 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
924
1145
|
input.value = '';
|
|
925
1146
|
grow();
|
|
926
1147
|
} catch (error) {
|
|
927
|
-
message =
|
|
928
|
-
text: error instanceof Error ? error.message : String(error),
|
|
929
|
-
error: true
|
|
930
|
-
};
|
|
1148
|
+
message = failure(error);
|
|
931
1149
|
}
|
|
932
1150
|
refresh();
|
|
933
1151
|
input.focus();
|
|
934
1152
|
};
|
|
1153
|
+
const fetchPages = ()=>{
|
|
1154
|
+
pagesLoad ??= bridge.pages().then((list)=>{
|
|
1155
|
+
pages = list;
|
|
1156
|
+
pagesError = null;
|
|
1157
|
+
renderPages();
|
|
1158
|
+
refresh();
|
|
1159
|
+
}, (error)=>{
|
|
1160
|
+
pagesLoad = null;
|
|
1161
|
+
pagesError = failure(error).text;
|
|
1162
|
+
renderPages();
|
|
1163
|
+
});
|
|
1164
|
+
return pagesLoad;
|
|
1165
|
+
};
|
|
1166
|
+
const matching = ()=>{
|
|
1167
|
+
const query = search.value.trim().toLowerCase();
|
|
1168
|
+
return (pages ?? []).filter((page)=>!query || page.name.toLowerCase().includes(query) || page.url.toLowerCase().includes(query) || `/${page.path}`.toLowerCase().includes(query));
|
|
1169
|
+
};
|
|
1170
|
+
const renderPages = ()=>{
|
|
1171
|
+
const list = matching();
|
|
1172
|
+
active = Math.max(0, Math.min(active, list.length - 1));
|
|
1173
|
+
optionList.textContent = '';
|
|
1174
|
+
list.forEach((page, index)=>{
|
|
1175
|
+
const current = page.entryId === bridge.entryId;
|
|
1176
|
+
const option = element(doc, 'li', {
|
|
1177
|
+
className: `option${index === active ? ' active' : ''}`,
|
|
1178
|
+
id: `obelism-page-${index}`
|
|
1179
|
+
}, element(doc, 'span', {
|
|
1180
|
+
className: 'option-text'
|
|
1181
|
+
}, element(doc, 'span', {
|
|
1182
|
+
textContent: page.name
|
|
1183
|
+
}), element(doc, 'span', {
|
|
1184
|
+
className: 'option-path',
|
|
1185
|
+
textContent: page.url
|
|
1186
|
+
})));
|
|
1187
|
+
option.setAttribute('role', 'option');
|
|
1188
|
+
option.setAttribute('aria-selected', String(index === active));
|
|
1189
|
+
if (page.status !== 'published') {
|
|
1190
|
+
option.appendChild(element(doc, 'span', {
|
|
1191
|
+
className: 'badge',
|
|
1192
|
+
textContent: page.status === 'draft' ? labels.pageDraft : labels.pageChanged
|
|
1193
|
+
}));
|
|
1194
|
+
}
|
|
1195
|
+
if (current) {
|
|
1196
|
+
option.setAttribute('aria-current', 'page');
|
|
1197
|
+
const check = icon(doc, 'check');
|
|
1198
|
+
check.removeAttribute('aria-hidden');
|
|
1199
|
+
check.setAttribute('role', 'img');
|
|
1200
|
+
check.setAttribute('aria-label', labels.pageCurrent);
|
|
1201
|
+
option.appendChild(check);
|
|
1202
|
+
}
|
|
1203
|
+
option.addEventListener('click', ()=>choose(page));
|
|
1204
|
+
optionList.appendChild(option);
|
|
1205
|
+
});
|
|
1206
|
+
const activeOption = optionList.children[active];
|
|
1207
|
+
if (activeOption) {
|
|
1208
|
+
search.setAttribute('aria-activedescendant', activeOption.id);
|
|
1209
|
+
activeOption.scrollIntoView?.({
|
|
1210
|
+
block: 'nearest'
|
|
1211
|
+
});
|
|
1212
|
+
} else {
|
|
1213
|
+
search.removeAttribute('aria-activedescendant');
|
|
1214
|
+
}
|
|
1215
|
+
const note = pagesError ?? (pages ? '' : labels.pagesLoading);
|
|
1216
|
+
pagesNote.textContent = note || (list.length === 0 ? labels.pagesEmpty : '');
|
|
1217
|
+
pagesNote.hidden = !pagesNote.textContent;
|
|
1218
|
+
pagesNote.className = `empty${pagesError ? ' error' : ''}`;
|
|
1219
|
+
};
|
|
1220
|
+
const setPicking = (next)=>{
|
|
1221
|
+
picking = next;
|
|
1222
|
+
message = null;
|
|
1223
|
+
if (picking) {
|
|
1224
|
+
search.value = '';
|
|
1225
|
+
active = Math.max(0, (pages ?? []).findIndex(({ entryId })=>entryId === bridge.entryId));
|
|
1226
|
+
void fetchPages();
|
|
1227
|
+
renderPages();
|
|
1228
|
+
}
|
|
1229
|
+
refresh();
|
|
1230
|
+
(picking ? search : pageChip).focus();
|
|
1231
|
+
};
|
|
1232
|
+
const choose = (page)=>{
|
|
1233
|
+
if (page.entryId === bridge.entryId) return setPicking(false);
|
|
1234
|
+
// Asks first when changes aren't saved; stays here if told no.
|
|
1235
|
+
bridge.openPage(page);
|
|
1236
|
+
};
|
|
935
1237
|
const setOpen = (next)=>{
|
|
936
1238
|
open = next;
|
|
1239
|
+
if (!open) picking = false;
|
|
937
1240
|
refresh();
|
|
938
1241
|
if (site) bridge.setSelecting(open && !creating);
|
|
1242
|
+
if (open && site) void fetchPages();
|
|
939
1243
|
};
|
|
940
1244
|
const setCreating = (next)=>{
|
|
941
1245
|
creating = next;
|
|
@@ -954,16 +1258,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
954
1258
|
error: false
|
|
955
1259
|
};
|
|
956
1260
|
} catch (error) {
|
|
957
|
-
message =
|
|
958
|
-
text: error instanceof Error ? error.message : String(error),
|
|
959
|
-
error: true
|
|
960
|
-
};
|
|
1261
|
+
message = failure(error);
|
|
961
1262
|
}
|
|
962
1263
|
refresh();
|
|
963
1264
|
};
|
|
964
1265
|
pill.addEventListener('click', ()=>{
|
|
965
1266
|
setOpen(true);
|
|
966
|
-
(creating ? pathInput : input).focus();
|
|
1267
|
+
if (!form) (creating ? pathInput : input).focus();
|
|
967
1268
|
});
|
|
968
1269
|
close.addEventListener('click', ()=>setOpen(false));
|
|
969
1270
|
save.addEventListener('click', ()=>void store('save'));
|
|
@@ -973,6 +1274,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
973
1274
|
clearBlock.addEventListener('click', ()=>bridge.select(null));
|
|
974
1275
|
newPage.addEventListener('click', ()=>setCreating(true));
|
|
975
1276
|
cancelNew.addEventListener('click', ()=>setCreating(false));
|
|
1277
|
+
promptMode.addEventListener('click', ()=>setForm(false));
|
|
1278
|
+
formMode.addEventListener('click', ()=>setForm(true));
|
|
1279
|
+
pageChip.addEventListener('click', ()=>setPicking(!picking));
|
|
976
1280
|
pathInput.addEventListener('input', refresh);
|
|
977
1281
|
pathInput.addEventListener('keydown', (event)=>{
|
|
978
1282
|
if (event.key === 'Enter' && !event.isComposing) {
|
|
@@ -982,6 +1286,26 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
982
1286
|
setOpen(false);
|
|
983
1287
|
}
|
|
984
1288
|
});
|
|
1289
|
+
search.addEventListener('input', ()=>{
|
|
1290
|
+
active = 0;
|
|
1291
|
+
renderPages();
|
|
1292
|
+
});
|
|
1293
|
+
search.addEventListener('keydown', (event)=>{
|
|
1294
|
+
const count = optionList.children.length;
|
|
1295
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
1296
|
+
event.preventDefault();
|
|
1297
|
+
if (count === 0) return;
|
|
1298
|
+
active = (active + (event.key === 'ArrowDown' ? 1 : -1) + count) % count;
|
|
1299
|
+
renderPages();
|
|
1300
|
+
} else if (event.key === 'Enter' && !event.isComposing) {
|
|
1301
|
+
event.preventDefault();
|
|
1302
|
+
const page = matching()[active];
|
|
1303
|
+
if (page) choose(page);
|
|
1304
|
+
} else if (event.key === 'Escape') {
|
|
1305
|
+
event.preventDefault();
|
|
1306
|
+
setPicking(false);
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
985
1309
|
undo.addEventListener('click', ()=>{
|
|
986
1310
|
message = null;
|
|
987
1311
|
bridge.undo();
|
|
@@ -1007,10 +1331,87 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1007
1331
|
doc.body.appendChild(host);
|
|
1008
1332
|
return ()=>{
|
|
1009
1333
|
unsubscribe();
|
|
1334
|
+
unmountForm();
|
|
1010
1335
|
host.remove();
|
|
1011
1336
|
};
|
|
1012
1337
|
};
|
|
1013
1338
|
|
|
1339
|
+
// The CMS's entry form in an iframe, for editing on the site (the prompt
|
|
1340
|
+
// box's Form mode). The CMS renders the form at a one-time URL (`/site/form#
|
|
1341
|
+
// code=…`, from the site's `/cms/api/form`); this keeps it in step with the
|
|
1342
|
+
// bridge's in-memory content.
|
|
1343
|
+
//
|
|
1344
|
+
// Messages are only exchanged with the URL's origin, and only with the
|
|
1345
|
+
// iframe's window:
|
|
1346
|
+
// - form → bridge: { source: 'obelism-content-form', type: 'ready' |
|
|
1347
|
+
// 'change' | 'error', content?, message? } — `change` carries the whole
|
|
1348
|
+
// entry content after an edit
|
|
1349
|
+
// - bridge → form: { source: 'obelism-content-bridge', type: 'content',
|
|
1350
|
+
// entryId, content, locale, selectedUid }
|
|
1351
|
+
const FORM_SOURCE = 'obelism-content-form';
|
|
1352
|
+
// How long the form may take to say it is ready.
|
|
1353
|
+
const FORM_READY_TIMEOUT_MS = 30_000;
|
|
1354
|
+
const isObject = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
|
|
1355
|
+
// Mounts the form at `url` in `container`. The URL is single use: mount a
|
|
1356
|
+
// fresh one every time.
|
|
1357
|
+
const mountFormFrame = (win, container, url, title, handlers)=>{
|
|
1358
|
+
let parsed = null;
|
|
1359
|
+
try {
|
|
1360
|
+
parsed = new URL(url);
|
|
1361
|
+
} catch {}
|
|
1362
|
+
if (!parsed || !/^https?:$/.test(parsed.protocol)) {
|
|
1363
|
+
throw new Error('Obelism Content sent no form to open');
|
|
1364
|
+
}
|
|
1365
|
+
const origin = parsed.origin;
|
|
1366
|
+
const iframe = container.ownerDocument.createElement('iframe');
|
|
1367
|
+
iframe.title = title;
|
|
1368
|
+
iframe.src = url;
|
|
1369
|
+
let ready = false;
|
|
1370
|
+
let latest = null;
|
|
1371
|
+
const post = ()=>{
|
|
1372
|
+
if (!ready || !latest) return;
|
|
1373
|
+
iframe.contentWindow?.postMessage({
|
|
1374
|
+
source: BRIDGE_SOURCE,
|
|
1375
|
+
type: 'content',
|
|
1376
|
+
...latest
|
|
1377
|
+
}, origin);
|
|
1378
|
+
};
|
|
1379
|
+
const timer = setTimeout(()=>{
|
|
1380
|
+
if (!ready) handlers.onError('The form did not open');
|
|
1381
|
+
}, FORM_READY_TIMEOUT_MS);
|
|
1382
|
+
const onMessage = (event)=>{
|
|
1383
|
+
if (event.origin !== origin || !iframe.contentWindow || event.source !== iframe.contentWindow) {
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const data = event.data;
|
|
1387
|
+
if (!data || data.source !== FORM_SOURCE) return;
|
|
1388
|
+
if (data.type === 'ready') {
|
|
1389
|
+
// Again after the form reloads itself: it gets the content again.
|
|
1390
|
+
ready = true;
|
|
1391
|
+
clearTimeout(timer);
|
|
1392
|
+
handlers.onReady();
|
|
1393
|
+
post();
|
|
1394
|
+
} else if (data.type === 'change' && isObject(data.content)) {
|
|
1395
|
+
handlers.onChange(data.content, typeof data.entryId === 'string' ? data.entryId : undefined);
|
|
1396
|
+
} else if (data.type === 'error') {
|
|
1397
|
+
handlers.onError(typeof data.message === 'string' && data.message ? data.message : 'The form failed');
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
win.addEventListener('message', onMessage);
|
|
1401
|
+
container.appendChild(iframe);
|
|
1402
|
+
return {
|
|
1403
|
+
show (content) {
|
|
1404
|
+
latest = content;
|
|
1405
|
+
post();
|
|
1406
|
+
},
|
|
1407
|
+
destroy () {
|
|
1408
|
+
clearTimeout(timer);
|
|
1409
|
+
win.removeEventListener('message', onMessage);
|
|
1410
|
+
iframe.remove();
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1014
1415
|
// The bridge on a site signed in to edit (`/cms/login`, from
|
|
1015
1416
|
// @obelism/content/site), outside the visual editor. It plays the editor's
|
|
1016
1417
|
// part itself: it keeps the changed content in memory, asks the site's
|
|
@@ -1020,6 +1421,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1020
1421
|
//
|
|
1021
1422
|
// What it edits is the page's entry — the outermost element marked with
|
|
1022
1423
|
// `editable(entry)` — or, for a selected block, the entry around that block.
|
|
1424
|
+
// Besides prompts it shows the CMS's own entry form (`openForm`, an iframe
|
|
1425
|
+
// kept in step with the content here, see formFrame.ts), edits JSON (`view`,
|
|
1426
|
+
// then `applyJson`: the raw alternative), and lists the space's pages to go
|
|
1427
|
+
// to one.
|
|
1023
1428
|
// The base path of the site's handler while signed in to edit, else null.
|
|
1024
1429
|
const readSiteEditing = (doc)=>{
|
|
1025
1430
|
for (const part of doc.cookie.split(';')){
|
|
@@ -1035,6 +1440,11 @@ const readSiteEditing = (doc)=>{
|
|
|
1035
1440
|
return null;
|
|
1036
1441
|
};
|
|
1037
1442
|
const same = (a, b)=>JSON.stringify(a) === JSON.stringify(b);
|
|
1443
|
+
// Edits in the form further apart than this are separate undo steps.
|
|
1444
|
+
const FORM_BURST_MS = 1000;
|
|
1445
|
+
// How long the page waits for the form's edits to settle before it shows
|
|
1446
|
+
// them.
|
|
1447
|
+
const FORM_RENDER_MS = 300;
|
|
1038
1448
|
const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
|
|
1039
1449
|
const doc = win.document;
|
|
1040
1450
|
const emitter = createEmitter();
|
|
@@ -1045,6 +1455,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1045
1455
|
// Entries changed here since the last publish.
|
|
1046
1456
|
const touched = new Set();
|
|
1047
1457
|
let session = null;
|
|
1458
|
+
// The open form's, if any; and when the form last changed content, for
|
|
1459
|
+
// undo steps (-Infinity: the next edit starts a new step).
|
|
1460
|
+
let closeForm = null;
|
|
1461
|
+
let lastFormEdit = -Infinity;
|
|
1462
|
+
const renderTimers = new Map();
|
|
1048
1463
|
let state = INITIAL_STATE;
|
|
1049
1464
|
const setState = (next)=>{
|
|
1050
1465
|
state = {
|
|
@@ -1098,17 +1513,39 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1098
1513
|
const base = lang.split('-')[0] ?? '';
|
|
1099
1514
|
return codes.includes(base) ? base : session.space.defaultLocale;
|
|
1100
1515
|
};
|
|
1516
|
+
// One request per entry at a time: a second would overwrite edits made
|
|
1517
|
+
// while the first was out.
|
|
1518
|
+
const loading = new Map();
|
|
1101
1519
|
const load = async (entryId)=>{
|
|
1102
1520
|
const loaded = working.get(entryId);
|
|
1103
1521
|
if (loaded) return loaded;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1522
|
+
let pending = loading.get(entryId);
|
|
1523
|
+
if (!pending) {
|
|
1524
|
+
pending = api(`entries/${encodeURIComponent(entryId)}`).then((entry)=>{
|
|
1525
|
+
const fresh = working.get(entryId) ?? {
|
|
1526
|
+
content: entry.content,
|
|
1527
|
+
saved: entry.content,
|
|
1528
|
+
revision: entry.draftRevision
|
|
1529
|
+
};
|
|
1530
|
+
working.set(entryId, fresh);
|
|
1531
|
+
return fresh;
|
|
1532
|
+
}).finally(()=>loading.delete(entryId));
|
|
1533
|
+
loading.set(entryId, pending);
|
|
1534
|
+
}
|
|
1535
|
+
return pending;
|
|
1536
|
+
};
|
|
1537
|
+
// The entry a prompt or JSON acts on, for the block `uid` (the selected
|
|
1538
|
+
// one when left out).
|
|
1539
|
+
const target = (option)=>{
|
|
1540
|
+
const uid = option === undefined ? state.selectedUid : option;
|
|
1541
|
+
const entryId = entryFor(uid);
|
|
1542
|
+
if (!entryId) {
|
|
1543
|
+
throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
|
|
1544
|
+
}
|
|
1545
|
+
return {
|
|
1546
|
+
uid,
|
|
1547
|
+
entryId
|
|
1109
1548
|
};
|
|
1110
|
-
working.set(entryId, fresh);
|
|
1111
|
-
return fresh;
|
|
1112
1549
|
};
|
|
1113
1550
|
const highlight = (uid)=>{
|
|
1114
1551
|
doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
|
|
@@ -1134,6 +1571,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1134
1571
|
});
|
|
1135
1572
|
win.requestAnimationFrame(()=>highlight(state.selectedUid));
|
|
1136
1573
|
};
|
|
1574
|
+
const logError = (error)=>console.error('[@obelism/content bridge]', error);
|
|
1575
|
+
// Renders once the form's edits settle.
|
|
1576
|
+
const renderSoon = (entryId)=>{
|
|
1577
|
+
clearTimeout(renderTimers.get(entryId));
|
|
1578
|
+
renderTimers.set(entryId, setTimeout(()=>{
|
|
1579
|
+
renderTimers.delete(entryId);
|
|
1580
|
+
void render(entryId).catch(logError);
|
|
1581
|
+
}, FORM_RENDER_MS));
|
|
1582
|
+
};
|
|
1137
1583
|
const afterStore = (event)=>{
|
|
1138
1584
|
emitter.emit(event, {});
|
|
1139
1585
|
if (emitter.listeners.update.size === 0) win.location.reload();
|
|
@@ -1150,6 +1596,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1150
1596
|
touched.add(entryId);
|
|
1151
1597
|
}
|
|
1152
1598
|
history.length = 0;
|
|
1599
|
+
lastFormEdit = -Infinity;
|
|
1153
1600
|
refreshUnsaved();
|
|
1154
1601
|
};
|
|
1155
1602
|
const has = (feature)=>state.features.includes(feature);
|
|
@@ -1166,11 +1613,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1166
1613
|
const text = prompt.trim();
|
|
1167
1614
|
if (!text) throw new Error('Say what to change');
|
|
1168
1615
|
if (!has('prompt')) throw new Error('You may not edit content here');
|
|
1169
|
-
const
|
|
1170
|
-
const entryId = entryFor(uid);
|
|
1171
|
-
if (!entryId) {
|
|
1172
|
-
throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
|
|
1173
|
-
}
|
|
1616
|
+
const { uid, entryId } = target(options.uid);
|
|
1174
1617
|
setState({
|
|
1175
1618
|
prompting: true
|
|
1176
1619
|
});
|
|
@@ -1186,6 +1629,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1186
1629
|
entryId,
|
|
1187
1630
|
content: entry.content
|
|
1188
1631
|
});
|
|
1632
|
+
lastFormEdit = -Infinity;
|
|
1189
1633
|
entry.content = result.content;
|
|
1190
1634
|
refreshUnsaved();
|
|
1191
1635
|
await render(entryId);
|
|
@@ -1231,13 +1675,182 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1231
1675
|
});
|
|
1232
1676
|
}
|
|
1233
1677
|
},
|
|
1678
|
+
async view (options = {}) {
|
|
1679
|
+
const { uid, entryId } = target(options.uid);
|
|
1680
|
+
const entry = await load(entryId);
|
|
1681
|
+
const { view } = await api(`entries/${encodeURIComponent(entryId)}/view`, {
|
|
1682
|
+
content: entry.content,
|
|
1683
|
+
locale: locale(),
|
|
1684
|
+
uid
|
|
1685
|
+
});
|
|
1686
|
+
return view;
|
|
1687
|
+
},
|
|
1688
|
+
async applyJson (changes, options = {}) {
|
|
1689
|
+
if (!has('prompt')) throw new Error('You may not edit content here');
|
|
1690
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
|
|
1691
|
+
throw new Error('Send the fields to change as an object');
|
|
1692
|
+
}
|
|
1693
|
+
const { uid, entryId } = target(options.uid);
|
|
1694
|
+
setState({
|
|
1695
|
+
prompting: true
|
|
1696
|
+
});
|
|
1697
|
+
try {
|
|
1698
|
+
const entry = await load(entryId);
|
|
1699
|
+
const result = await api(`entries/${encodeURIComponent(entryId)}/apply`, {
|
|
1700
|
+
changes,
|
|
1701
|
+
content: entry.content,
|
|
1702
|
+
locale: locale(),
|
|
1703
|
+
uid
|
|
1704
|
+
});
|
|
1705
|
+
// Nothing to undo when nothing changed.
|
|
1706
|
+
if (!same(result.content, entry.content)) {
|
|
1707
|
+
history.push({
|
|
1708
|
+
entryId,
|
|
1709
|
+
content: entry.content
|
|
1710
|
+
});
|
|
1711
|
+
lastFormEdit = -Infinity;
|
|
1712
|
+
entry.content = result.content;
|
|
1713
|
+
refreshUnsaved();
|
|
1714
|
+
await render(entryId);
|
|
1715
|
+
}
|
|
1716
|
+
return {
|
|
1717
|
+
changed: result.changed ?? [],
|
|
1718
|
+
...result.view && {
|
|
1719
|
+
view: result.view
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
} finally{
|
|
1723
|
+
setState({
|
|
1724
|
+
prompting: false
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
},
|
|
1728
|
+
openForm (container, options = {}) {
|
|
1729
|
+
const { title = 'Content form', onReady, onError } = options;
|
|
1730
|
+
closeForm?.();
|
|
1731
|
+
let closed = false;
|
|
1732
|
+
let frame = null;
|
|
1733
|
+
// What the form was last sent, or sent back itself.
|
|
1734
|
+
let shown = null;
|
|
1735
|
+
let unsubscribe = ()=>{};
|
|
1736
|
+
const fail = (error)=>{
|
|
1737
|
+
if (!closed) onError?.(error instanceof Error ? error.message : String(error));
|
|
1738
|
+
};
|
|
1739
|
+
const close = ()=>{
|
|
1740
|
+
if (closed) return;
|
|
1741
|
+
closed = true;
|
|
1742
|
+
unsubscribe();
|
|
1743
|
+
frame?.destroy();
|
|
1744
|
+
if (closeForm === close) closeForm = null;
|
|
1745
|
+
};
|
|
1746
|
+
closeForm = close;
|
|
1747
|
+
// Sends the form the entry for the selection, when anything of it
|
|
1748
|
+
// changed: prompts, undo, a reload after saving, the selection.
|
|
1749
|
+
const show = ()=>{
|
|
1750
|
+
if (closed || !frame) return;
|
|
1751
|
+
const entryId = entryFor(state.selectedUid);
|
|
1752
|
+
if (!entryId) return;
|
|
1753
|
+
const entry = working.get(entryId);
|
|
1754
|
+
if (!entry) {
|
|
1755
|
+
load(entryId).then(show, fail);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const next = {
|
|
1759
|
+
entryId,
|
|
1760
|
+
content: entry.content,
|
|
1761
|
+
locale: locale(),
|
|
1762
|
+
selectedUid: state.selectedUid
|
|
1763
|
+
};
|
|
1764
|
+
if (shown && shown.entryId === next.entryId && shown.content === next.content && shown.locale === next.locale && shown.selectedUid === next.selectedUid) {
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
shown = next;
|
|
1768
|
+
frame.show(next);
|
|
1769
|
+
};
|
|
1770
|
+
const change = (content, from)=>{
|
|
1771
|
+
const entryId = from ?? shown?.entryId;
|
|
1772
|
+
const entry = entryId === undefined ? undefined : working.get(entryId);
|
|
1773
|
+
if (closed || entryId === undefined || !entry || same(content, entry.content)) {
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const now = Date.now();
|
|
1777
|
+
if (now - lastFormEdit >= FORM_BURST_MS) {
|
|
1778
|
+
history.push({
|
|
1779
|
+
entryId,
|
|
1780
|
+
content: entry.content
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
lastFormEdit = now;
|
|
1784
|
+
entry.content = content;
|
|
1785
|
+
// Not sent back: the form has it.
|
|
1786
|
+
if (shown?.entryId === entryId) shown = {
|
|
1787
|
+
...shown,
|
|
1788
|
+
content
|
|
1789
|
+
};
|
|
1790
|
+
refreshUnsaved();
|
|
1791
|
+
renderSoon(entryId);
|
|
1792
|
+
};
|
|
1793
|
+
const start = async ()=>{
|
|
1794
|
+
if (!has('form')) throw new Error('You may not edit content here');
|
|
1795
|
+
await load(target(undefined).entryId);
|
|
1796
|
+
// A fresh one every time: the address works once, for a minute.
|
|
1797
|
+
const { url } = await api('form', {});
|
|
1798
|
+
if (closed) return;
|
|
1799
|
+
const address = typeof url === 'string' ? url : '';
|
|
1800
|
+
frame = mountFormFrame(win, container, address, title, {
|
|
1801
|
+
onReady: ()=>onReady?.(),
|
|
1802
|
+
onChange: change,
|
|
1803
|
+
onError: (message)=>onError?.(message)
|
|
1804
|
+
});
|
|
1805
|
+
unsubscribe = emitter.on('change', show);
|
|
1806
|
+
show();
|
|
1807
|
+
};
|
|
1808
|
+
start().catch(fail);
|
|
1809
|
+
return {
|
|
1810
|
+
close
|
|
1811
|
+
};
|
|
1812
|
+
},
|
|
1813
|
+
async pages () {
|
|
1814
|
+
const { pages } = await api('pages');
|
|
1815
|
+
const preferred = [
|
|
1816
|
+
locale(),
|
|
1817
|
+
session?.space.defaultLocale
|
|
1818
|
+
];
|
|
1819
|
+
return pages.flatMap((page)=>{
|
|
1820
|
+
// The page's path in this locale, else the default's, else any.
|
|
1821
|
+
const code = preferred.find((code)=>code !== undefined && page.paths[code] !== undefined) ?? Object.keys(page.paths)[0];
|
|
1822
|
+
const path = code === undefined ? undefined : page.paths[code];
|
|
1823
|
+
if (code === undefined || path === undefined) return [];
|
|
1824
|
+
return [
|
|
1825
|
+
{
|
|
1826
|
+
entryId: page.entryId,
|
|
1827
|
+
name: page.name,
|
|
1828
|
+
contentType: page.contentType,
|
|
1829
|
+
status: page.status,
|
|
1830
|
+
path,
|
|
1831
|
+
url: pagePath(path, code)
|
|
1832
|
+
}
|
|
1833
|
+
];
|
|
1834
|
+
});
|
|
1835
|
+
},
|
|
1836
|
+
openPage (page) {
|
|
1837
|
+
if (state.unsaved && !win.confirm('Leave this page and lose the changes you have not saved?')) {
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
// Asked already: no second question from `beforeunload`.
|
|
1841
|
+
working.clear();
|
|
1842
|
+
history.length = 0;
|
|
1843
|
+
refreshUnsaved();
|
|
1844
|
+
win.location.assign(page.url);
|
|
1845
|
+
},
|
|
1234
1846
|
undo () {
|
|
1235
1847
|
const last = history.pop();
|
|
1236
1848
|
const entry = last && working.get(last.entryId);
|
|
1237
1849
|
if (!last || !entry) return;
|
|
1850
|
+
lastFormEdit = -Infinity;
|
|
1238
1851
|
entry.content = last.content;
|
|
1239
1852
|
refreshUnsaved();
|
|
1240
|
-
void render(last.entryId).catch(
|
|
1853
|
+
void render(last.entryId).catch(logError);
|
|
1241
1854
|
},
|
|
1242
1855
|
async save () {
|
|
1243
1856
|
if (!has('save')) throw new Error('You may not edit content here');
|
|
@@ -1300,6 +1913,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1300
1913
|
});
|
|
1301
1914
|
},
|
|
1302
1915
|
destroy () {
|
|
1916
|
+
closeForm?.();
|
|
1917
|
+
renderTimers.forEach((timer)=>clearTimeout(timer));
|
|
1918
|
+
renderTimers.clear();
|
|
1303
1919
|
cleanup.splice(0).forEach((undo)=>undo());
|
|
1304
1920
|
}
|
|
1305
1921
|
};
|