@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.js
CHANGED
|
@@ -224,10 +224,21 @@ const createEmitter = ()=>{
|
|
|
224
224
|
};
|
|
225
225
|
const inertPrompt = ()=>Promise.reject(new Error('Prompts only work inside the visual editor'));
|
|
226
226
|
const onlyOnSite = ()=>Promise.reject(new Error('Save and publish in the visual editor itself'));
|
|
227
|
+
const onlyWhileEditingSite = ()=>Promise.reject(new Error('Only while signed in to edit on the site'));
|
|
227
228
|
// What every bridge without a feature does for it.
|
|
228
229
|
const INERT_METHODS = {
|
|
229
230
|
prompt: inertPrompt,
|
|
230
231
|
createPage: ()=>Promise.reject(new Error('New pages are made on the site itself')),
|
|
232
|
+
view: onlyWhileEditingSite,
|
|
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
|
+
},
|
|
240
|
+
pages: onlyWhileEditingSite,
|
|
241
|
+
openPage: ()=>{},
|
|
231
242
|
undo: ()=>{},
|
|
232
243
|
save: onlyOnSite,
|
|
233
244
|
publish: onlyOnSite,
|
|
@@ -512,7 +523,19 @@ const DEFAULT_PROMPT_LABELS = {
|
|
|
512
523
|
path: 'Path',
|
|
513
524
|
pathPlaceholder: '/pricing',
|
|
514
525
|
creating: 'Writing the page…',
|
|
515
|
-
created: 'Opening the new page…'
|
|
526
|
+
created: 'Opening the new page…',
|
|
527
|
+
modes: 'Edit with',
|
|
528
|
+
modePrompt: 'Prompt',
|
|
529
|
+
form: 'Form',
|
|
530
|
+
formTitle: 'The content form',
|
|
531
|
+
formLoading: 'Opening the form…',
|
|
532
|
+
pages: 'Pages',
|
|
533
|
+
pagesSearch: 'Find a page',
|
|
534
|
+
pagesLoading: 'Loading pages…',
|
|
535
|
+
pagesEmpty: 'No pages match',
|
|
536
|
+
pageCurrent: 'This page',
|
|
537
|
+
pageDraft: 'Draft',
|
|
538
|
+
pageChanged: 'Changed'
|
|
516
539
|
};
|
|
517
540
|
// Icons (Lucide), drawn in the text colour.
|
|
518
541
|
const ICONS = {
|
|
@@ -521,6 +544,9 @@ const ICONS = {
|
|
|
521
544
|
block: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/>',
|
|
522
545
|
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
|
523
546
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
547
|
+
chevrons: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
|
548
|
+
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
549
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
524
550
|
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"/>'
|
|
525
551
|
};
|
|
526
552
|
// Obelism's design tokens (obelism.studio/design.md), under their own names:
|
|
@@ -622,6 +648,13 @@ textarea {
|
|
|
622
648
|
font-size: 16px; line-height: 1.6; color: rgb(var(--theme-foreground-rgb));
|
|
623
649
|
}
|
|
624
650
|
textarea::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
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);
|
|
656
|
+
}
|
|
657
|
+
.form.loading iframe { visibility: hidden; }
|
|
625
658
|
/* New page: where it goes, above what it is for. */
|
|
626
659
|
.path {
|
|
627
660
|
display: flex; align-items: center; gap: 10px; padding: 16px 22px 0;
|
|
@@ -665,6 +698,21 @@ button.chip:disabled { opacity: .5; }
|
|
|
665
698
|
.icon-button.primary { background: rgb(var(--theme-accent-rgb)); color: #fff; }
|
|
666
699
|
.icon-button.primary:hover:not(:disabled) { background: rgb(var(--theme-accent-rgb)); color: #fff; opacity: .88; }
|
|
667
700
|
.icon-button.primary:disabled { opacity: .35; }
|
|
701
|
+
/* Prompt or Form: two small pills in one track. */
|
|
702
|
+
.modes {
|
|
703
|
+
display: inline-flex; flex: none; gap: 2px; padding: 2px; border-radius: var(--border-radius-pill);
|
|
704
|
+
border: 1px solid rgb(var(--theme-border-rgb) / .2); background: rgb(var(--theme-background-actions-rgb) / .6);
|
|
705
|
+
}
|
|
706
|
+
.modes button {
|
|
707
|
+
height: 28px; padding: 0 12px; border-radius: var(--border-radius-pill); font-size: 13px;
|
|
708
|
+
color: rgb(var(--theme-foreground-muted-rgb));
|
|
709
|
+
transition: background-color .25s var(--ease-roll), color .25s var(--ease-roll);
|
|
710
|
+
}
|
|
711
|
+
.modes button:hover { color: rgb(var(--theme-foreground-rgb)); }
|
|
712
|
+
.modes button[aria-pressed='true'] {
|
|
713
|
+
background: rgb(var(--theme-background-alt-rgb)); color: rgb(var(--theme-foreground-rgb));
|
|
714
|
+
box-shadow: 0 1px 2px rgb(var(--color-black-rgb) / .08);
|
|
715
|
+
}
|
|
668
716
|
.divider { width: 1px; height: 20px; background: rgb(var(--theme-border-rgb) / .3); margin: 0 4px; }
|
|
669
717
|
|
|
670
718
|
/* On the site: storing the draft, and the way out. */
|
|
@@ -681,6 +729,38 @@ button.chip:disabled { opacity: .5; }
|
|
|
681
729
|
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
|
682
730
|
background: rgb(var(--theme-accent-rgb)); margin-right: 8px;
|
|
683
731
|
}
|
|
732
|
+
|
|
733
|
+
/* The pages: a search above a list, in place of the prompt while open. */
|
|
734
|
+
.page-chip { min-width: 0; max-width: 50%; gap: 6px; }
|
|
735
|
+
.page-chip > span:not(.icon) { overflow: hidden; text-overflow: ellipsis; }
|
|
736
|
+
.page-chip .icon:last-child { color: rgb(var(--theme-foreground-muted-rgb)); margin-right: -4px; }
|
|
737
|
+
.page-chip .icon:last-child svg { width: 14px; height: 14px; }
|
|
738
|
+
.pages { display: grid; gap: 8px; padding: 16px 12px 12px; }
|
|
739
|
+
.search {
|
|
740
|
+
display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; margin: 0 4px;
|
|
741
|
+
border: 1px solid rgb(var(--theme-border-rgb) / .25); border-radius: var(--border-radius-pill);
|
|
742
|
+
background: rgb(var(--theme-background-actions-rgb) / .45); color: rgb(var(--theme-foreground-muted-rgb));
|
|
743
|
+
transition: border-color .25s var(--ease-roll);
|
|
744
|
+
}
|
|
745
|
+
.search:focus-within { border-color: rgb(var(--theme-accent-rgb)); }
|
|
746
|
+
.search input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: rgb(var(--theme-foreground-rgb)); }
|
|
747
|
+
.search input::placeholder { color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
748
|
+
.options { list-style: none; padding: 0; max-height: min(300px, 40vh); overflow-y: auto; display: grid; gap: 2px; }
|
|
749
|
+
.option {
|
|
750
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; cursor: pointer;
|
|
751
|
+
transition: background-color .15s var(--ease-roll);
|
|
752
|
+
}
|
|
753
|
+
.option:hover, .option.active { background: rgb(var(--theme-background-actions-rgb) / .7); }
|
|
754
|
+
.option-text { flex: 1; min-width: 0; display: grid; }
|
|
755
|
+
.option-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
756
|
+
.option-path { font-size: 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
757
|
+
.badge {
|
|
758
|
+
flex: none; font-size: 12px; padding: 1px 8px; border-radius: var(--border-radius-pill);
|
|
759
|
+
border: 1px solid rgb(var(--theme-border-rgb) / .3); color: rgb(var(--theme-foreground-muted-rgb));
|
|
760
|
+
}
|
|
761
|
+
.option .icon { color: rgb(var(--theme-accent-rgb)); }
|
|
762
|
+
.empty { padding: 8px 12px; color: rgb(var(--theme-foreground-muted-rgb)); }
|
|
763
|
+
.empty.error { color: var(--theme-error); }
|
|
684
764
|
[hidden] { display: none !important; }
|
|
685
765
|
`;
|
|
686
766
|
const element = (doc, tag, props = {}, ...children)=>{
|
|
@@ -744,6 +824,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
744
824
|
rows: 1
|
|
745
825
|
});
|
|
746
826
|
input.setAttribute('aria-label', labels.open);
|
|
827
|
+
// Form mode (on the site): the CMS's form, mounted while shown.
|
|
828
|
+
const formBox = element(doc, 'div', {
|
|
829
|
+
className: 'form'
|
|
830
|
+
});
|
|
747
831
|
// What the prompt acts on: the whole page, or the selected block with a
|
|
748
832
|
// way back to the page.
|
|
749
833
|
const pageIcon = icon(doc, 'page');
|
|
@@ -762,6 +846,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
762
846
|
className: 'action chip',
|
|
763
847
|
type: 'button'
|
|
764
848
|
}, icon(doc, 'undo'), labels.undo);
|
|
849
|
+
// Prompt or Form (on the site).
|
|
850
|
+
const modeButton = (text)=>{
|
|
851
|
+
const button = element(doc, 'button', {
|
|
852
|
+
type: 'button',
|
|
853
|
+
textContent: text
|
|
854
|
+
});
|
|
855
|
+
button.setAttribute('aria-pressed', 'false');
|
|
856
|
+
return button;
|
|
857
|
+
};
|
|
858
|
+
const promptMode = modeButton(labels.modePrompt);
|
|
859
|
+
const formMode = modeButton(labels.form);
|
|
860
|
+
const modes = element(doc, 'span', {
|
|
861
|
+
className: 'modes'
|
|
862
|
+
}, promptMode, formMode);
|
|
863
|
+
modes.setAttribute('role', 'group');
|
|
864
|
+
modes.setAttribute('aria-label', labels.modes);
|
|
765
865
|
// New page (on the site, for people who may add pages): the scope becomes
|
|
766
866
|
// the new page, with a way back, and a path field shows above the prompt.
|
|
767
867
|
const newPage = element(doc, 'button', {
|
|
@@ -804,6 +904,52 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
804
904
|
textContent: labels.send
|
|
805
905
|
}), icon(doc, 'send'));
|
|
806
906
|
send.title = labels.send;
|
|
907
|
+
const divider = element(doc, 'span', {
|
|
908
|
+
className: 'divider'
|
|
909
|
+
});
|
|
910
|
+
const toolbar = element(doc, 'div', {
|
|
911
|
+
className: 'toolbar'
|
|
912
|
+
}, scope, newScope, modes, undo, newPage, element(doc, 'span', {
|
|
913
|
+
className: 'spacer'
|
|
914
|
+
}), close, divider, send);
|
|
915
|
+
// The pages (on the site): a chip naming this page, opening a search
|
|
916
|
+
// and a list of every page in place of the prompt.
|
|
917
|
+
const pageChipText = element(doc, 'span', {
|
|
918
|
+
textContent: labels.pages
|
|
919
|
+
});
|
|
920
|
+
const pageChip = element(doc, 'button', {
|
|
921
|
+
className: 'chip page-chip',
|
|
922
|
+
type: 'button',
|
|
923
|
+
title: labels.pages
|
|
924
|
+
}, icon(doc, 'page'), pageChipText, icon(doc, 'chevrons'));
|
|
925
|
+
pageChip.setAttribute('aria-haspopup', 'listbox');
|
|
926
|
+
pageChip.setAttribute('aria-expanded', 'false');
|
|
927
|
+
pageChip.setAttribute('aria-controls', 'obelism-pages');
|
|
928
|
+
const search = element(doc, 'input', {
|
|
929
|
+
type: 'text',
|
|
930
|
+
placeholder: labels.pagesSearch,
|
|
931
|
+
autocomplete: 'off',
|
|
932
|
+
spellcheck: false
|
|
933
|
+
});
|
|
934
|
+
search.setAttribute('role', 'combobox');
|
|
935
|
+
search.setAttribute('aria-label', labels.pagesSearch);
|
|
936
|
+
search.setAttribute('aria-autocomplete', 'list');
|
|
937
|
+
search.setAttribute('aria-expanded', 'true');
|
|
938
|
+
search.setAttribute('aria-controls', 'obelism-pages');
|
|
939
|
+
const optionList = element(doc, 'ul', {
|
|
940
|
+
className: 'options',
|
|
941
|
+
id: 'obelism-pages'
|
|
942
|
+
});
|
|
943
|
+
optionList.setAttribute('role', 'listbox');
|
|
944
|
+
optionList.setAttribute('aria-label', labels.pages);
|
|
945
|
+
const pagesNote = element(doc, 'p', {
|
|
946
|
+
className: 'empty'
|
|
947
|
+
});
|
|
948
|
+
const pagesSection = element(doc, 'div', {
|
|
949
|
+
className: 'pages'
|
|
950
|
+
}, element(doc, 'label', {
|
|
951
|
+
className: 'search'
|
|
952
|
+
}, icon(doc, 'search'), search), optionList, pagesNote);
|
|
807
953
|
const unsaved = element(doc, 'span', {
|
|
808
954
|
className: 'unsaved'
|
|
809
955
|
});
|
|
@@ -828,18 +974,12 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
828
974
|
});
|
|
829
975
|
const footer = element(doc, 'div', {
|
|
830
976
|
className: 'footer'
|
|
831
|
-
}, unsaved, element(doc, 'span', {
|
|
977
|
+
}, pageChip, unsaved, element(doc, 'span', {
|
|
832
978
|
className: 'spacer'
|
|
833
979
|
}), signOut, save, publish);
|
|
834
980
|
const panel = element(doc, 'form', {
|
|
835
981
|
className: 'panel'
|
|
836
|
-
}, status, pathRow, input,
|
|
837
|
-
className: 'toolbar'
|
|
838
|
-
}, scope, newScope, undo, newPage, element(doc, 'span', {
|
|
839
|
-
className: 'spacer'
|
|
840
|
-
}), close, element(doc, 'span', {
|
|
841
|
-
className: 'divider'
|
|
842
|
-
}), send), footer);
|
|
982
|
+
}, status, pathRow, input, formBox, toolbar, pagesSection, footer);
|
|
843
983
|
const root = element(doc, 'div', {
|
|
844
984
|
className: 'root'
|
|
845
985
|
}, element(doc, 'div', {
|
|
@@ -851,6 +991,22 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
851
991
|
let open = false;
|
|
852
992
|
let creating = false;
|
|
853
993
|
let message = null;
|
|
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;
|
|
999
|
+
// The pages, fetched once the panel opens and kept; `active` is the
|
|
1000
|
+
// option arrow keys are on.
|
|
1001
|
+
let picking = false;
|
|
1002
|
+
let pages = null;
|
|
1003
|
+
let pagesLoad = null;
|
|
1004
|
+
let pagesError = null;
|
|
1005
|
+
let active = 0;
|
|
1006
|
+
const failure = (error)=>({
|
|
1007
|
+
text: error instanceof Error ? error.message : String(error),
|
|
1008
|
+
error: true
|
|
1009
|
+
});
|
|
854
1010
|
const render = (state)=>{
|
|
855
1011
|
const available = state.features.includes('prompt');
|
|
856
1012
|
const storing = state.saving || state.publishing;
|
|
@@ -866,21 +1022,41 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
866
1022
|
const onlyNew = pageless && canCreate;
|
|
867
1023
|
if (!canCreate) creating = false;
|
|
868
1024
|
if (onlyNew) creating = true;
|
|
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();
|
|
869
1030
|
pillText.textContent = onlyNew ? labels.newPage : labels.open;
|
|
870
1031
|
cancelNew.hidden = onlyNew;
|
|
871
1032
|
scope.hidden = creating;
|
|
872
1033
|
newScope.hidden = !creating;
|
|
873
|
-
newPage.hidden = creating || !canCreate;
|
|
1034
|
+
newPage.hidden = creating || form || !canCreate;
|
|
874
1035
|
newPage.disabled = busy;
|
|
875
|
-
pathRow.hidden = !creating;
|
|
1036
|
+
pathRow.hidden = !creating || picking;
|
|
876
1037
|
pathInput.disabled = busy;
|
|
1038
|
+
input.hidden = form || picking;
|
|
1039
|
+
formBox.hidden = !form || picking;
|
|
1040
|
+
formBox.classList.toggle('loading', formLoading);
|
|
1041
|
+
toolbar.hidden = picking;
|
|
1042
|
+
pagesSection.hidden = !picking;
|
|
877
1043
|
input.placeholder = creating ? labels.newPagePlaceholder : labels.placeholder;
|
|
878
1044
|
scopeText.textContent = state.selectedUid ? labels.scopeBlock : labels.scopeEntry;
|
|
879
1045
|
clearBlock.hidden = !state.selectedUid;
|
|
880
1046
|
pageIcon.hidden = !!state.selectedUid;
|
|
881
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));
|
|
1052
|
+
modes.querySelectorAll('button').forEach((button)=>{
|
|
1053
|
+
button.disabled = busy;
|
|
1054
|
+
});
|
|
882
1055
|
undo.hidden = creating || !state.canUndo;
|
|
883
1056
|
undo.disabled = busy;
|
|
1057
|
+
// The form applies edits as they are made.
|
|
1058
|
+
send.hidden = form;
|
|
1059
|
+
divider.hidden = form;
|
|
884
1060
|
send.disabled = busy || input.value.trim() === '' || creating && pathInput.value.trim() === '';
|
|
885
1061
|
input.disabled = busy;
|
|
886
1062
|
save.hidden = !state.features.includes('save') || pageless;
|
|
@@ -892,17 +1068,60 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
892
1068
|
signOut.hidden = !site;
|
|
893
1069
|
footer.hidden = !site;
|
|
894
1070
|
unsaved.hidden = !state.unsaved;
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1071
|
+
pageChip.hidden = !site;
|
|
1072
|
+
pageChip.setAttribute('aria-expanded', String(picking));
|
|
1073
|
+
pageChipText.textContent = pages?.find(({ entryId })=>entryId === bridge.entryId)?.name ?? labels.pages;
|
|
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 : '');
|
|
1077
|
+
status.textContent = note;
|
|
1078
|
+
status.hidden = !note;
|
|
1079
|
+
status.className = `status${working ? ' working' : ''}${!busy && !loadingForm && message?.error ? ' error' : ''}`;
|
|
899
1080
|
};
|
|
900
1081
|
const refresh = ()=>render(bridge.getState());
|
|
901
1082
|
const grow = ()=>{
|
|
902
1083
|
input.style.height = 'auto';
|
|
903
1084
|
input.style.height = `${input.scrollHeight}px`;
|
|
904
1085
|
};
|
|
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
|
+
};
|
|
1106
|
+
refresh();
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
formHandle = handle;
|
|
1110
|
+
};
|
|
1111
|
+
const unmountForm = ()=>{
|
|
1112
|
+
formHandle?.close();
|
|
1113
|
+
formHandle = null;
|
|
1114
|
+
formLoading = false;
|
|
1115
|
+
};
|
|
1116
|
+
const setForm = (next)=>{
|
|
1117
|
+
if (form === next) return;
|
|
1118
|
+
form = next;
|
|
1119
|
+
message = null;
|
|
1120
|
+
refresh();
|
|
1121
|
+
if (!form) input.focus();
|
|
1122
|
+
};
|
|
905
1123
|
const submit = async ()=>{
|
|
1124
|
+
if (form) return;
|
|
906
1125
|
const prompt = input.value.trim();
|
|
907
1126
|
if (!prompt || bridge.getState().prompting) return;
|
|
908
1127
|
message = null;
|
|
@@ -912,6 +1131,8 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
912
1131
|
if (!path) return pathInput.focus();
|
|
913
1132
|
// The bridge opens the new page; this shows until it does.
|
|
914
1133
|
await bridge.createPage(path, prompt);
|
|
1134
|
+
pages = null;
|
|
1135
|
+
pagesLoad = null;
|
|
915
1136
|
message = {
|
|
916
1137
|
text: labels.created,
|
|
917
1138
|
error: false
|
|
@@ -926,18 +1147,101 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
926
1147
|
input.value = '';
|
|
927
1148
|
grow();
|
|
928
1149
|
} catch (error) {
|
|
929
|
-
message =
|
|
930
|
-
text: error instanceof Error ? error.message : String(error),
|
|
931
|
-
error: true
|
|
932
|
-
};
|
|
1150
|
+
message = failure(error);
|
|
933
1151
|
}
|
|
934
1152
|
refresh();
|
|
935
1153
|
input.focus();
|
|
936
1154
|
};
|
|
1155
|
+
const fetchPages = ()=>{
|
|
1156
|
+
pagesLoad ??= bridge.pages().then((list)=>{
|
|
1157
|
+
pages = list;
|
|
1158
|
+
pagesError = null;
|
|
1159
|
+
renderPages();
|
|
1160
|
+
refresh();
|
|
1161
|
+
}, (error)=>{
|
|
1162
|
+
pagesLoad = null;
|
|
1163
|
+
pagesError = failure(error).text;
|
|
1164
|
+
renderPages();
|
|
1165
|
+
});
|
|
1166
|
+
return pagesLoad;
|
|
1167
|
+
};
|
|
1168
|
+
const matching = ()=>{
|
|
1169
|
+
const query = search.value.trim().toLowerCase();
|
|
1170
|
+
return (pages ?? []).filter((page)=>!query || page.name.toLowerCase().includes(query) || page.url.toLowerCase().includes(query) || `/${page.path}`.toLowerCase().includes(query));
|
|
1171
|
+
};
|
|
1172
|
+
const renderPages = ()=>{
|
|
1173
|
+
const list = matching();
|
|
1174
|
+
active = Math.max(0, Math.min(active, list.length - 1));
|
|
1175
|
+
optionList.textContent = '';
|
|
1176
|
+
list.forEach((page, index)=>{
|
|
1177
|
+
const current = page.entryId === bridge.entryId;
|
|
1178
|
+
const option = element(doc, 'li', {
|
|
1179
|
+
className: `option${index === active ? ' active' : ''}`,
|
|
1180
|
+
id: `obelism-page-${index}`
|
|
1181
|
+
}, element(doc, 'span', {
|
|
1182
|
+
className: 'option-text'
|
|
1183
|
+
}, element(doc, 'span', {
|
|
1184
|
+
textContent: page.name
|
|
1185
|
+
}), element(doc, 'span', {
|
|
1186
|
+
className: 'option-path',
|
|
1187
|
+
textContent: page.url
|
|
1188
|
+
})));
|
|
1189
|
+
option.setAttribute('role', 'option');
|
|
1190
|
+
option.setAttribute('aria-selected', String(index === active));
|
|
1191
|
+
if (page.status !== 'published') {
|
|
1192
|
+
option.appendChild(element(doc, 'span', {
|
|
1193
|
+
className: 'badge',
|
|
1194
|
+
textContent: page.status === 'draft' ? labels.pageDraft : labels.pageChanged
|
|
1195
|
+
}));
|
|
1196
|
+
}
|
|
1197
|
+
if (current) {
|
|
1198
|
+
option.setAttribute('aria-current', 'page');
|
|
1199
|
+
const check = icon(doc, 'check');
|
|
1200
|
+
check.removeAttribute('aria-hidden');
|
|
1201
|
+
check.setAttribute('role', 'img');
|
|
1202
|
+
check.setAttribute('aria-label', labels.pageCurrent);
|
|
1203
|
+
option.appendChild(check);
|
|
1204
|
+
}
|
|
1205
|
+
option.addEventListener('click', ()=>choose(page));
|
|
1206
|
+
optionList.appendChild(option);
|
|
1207
|
+
});
|
|
1208
|
+
const activeOption = optionList.children[active];
|
|
1209
|
+
if (activeOption) {
|
|
1210
|
+
search.setAttribute('aria-activedescendant', activeOption.id);
|
|
1211
|
+
activeOption.scrollIntoView?.({
|
|
1212
|
+
block: 'nearest'
|
|
1213
|
+
});
|
|
1214
|
+
} else {
|
|
1215
|
+
search.removeAttribute('aria-activedescendant');
|
|
1216
|
+
}
|
|
1217
|
+
const note = pagesError ?? (pages ? '' : labels.pagesLoading);
|
|
1218
|
+
pagesNote.textContent = note || (list.length === 0 ? labels.pagesEmpty : '');
|
|
1219
|
+
pagesNote.hidden = !pagesNote.textContent;
|
|
1220
|
+
pagesNote.className = `empty${pagesError ? ' error' : ''}`;
|
|
1221
|
+
};
|
|
1222
|
+
const setPicking = (next)=>{
|
|
1223
|
+
picking = next;
|
|
1224
|
+
message = null;
|
|
1225
|
+
if (picking) {
|
|
1226
|
+
search.value = '';
|
|
1227
|
+
active = Math.max(0, (pages ?? []).findIndex(({ entryId })=>entryId === bridge.entryId));
|
|
1228
|
+
void fetchPages();
|
|
1229
|
+
renderPages();
|
|
1230
|
+
}
|
|
1231
|
+
refresh();
|
|
1232
|
+
(picking ? search : pageChip).focus();
|
|
1233
|
+
};
|
|
1234
|
+
const choose = (page)=>{
|
|
1235
|
+
if (page.entryId === bridge.entryId) return setPicking(false);
|
|
1236
|
+
// Asks first when changes aren't saved; stays here if told no.
|
|
1237
|
+
bridge.openPage(page);
|
|
1238
|
+
};
|
|
937
1239
|
const setOpen = (next)=>{
|
|
938
1240
|
open = next;
|
|
1241
|
+
if (!open) picking = false;
|
|
939
1242
|
refresh();
|
|
940
1243
|
if (site) bridge.setSelecting(open && !creating);
|
|
1244
|
+
if (open && site) void fetchPages();
|
|
941
1245
|
};
|
|
942
1246
|
const setCreating = (next)=>{
|
|
943
1247
|
creating = next;
|
|
@@ -956,16 +1260,13 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
956
1260
|
error: false
|
|
957
1261
|
};
|
|
958
1262
|
} catch (error) {
|
|
959
|
-
message =
|
|
960
|
-
text: error instanceof Error ? error.message : String(error),
|
|
961
|
-
error: true
|
|
962
|
-
};
|
|
1263
|
+
message = failure(error);
|
|
963
1264
|
}
|
|
964
1265
|
refresh();
|
|
965
1266
|
};
|
|
966
1267
|
pill.addEventListener('click', ()=>{
|
|
967
1268
|
setOpen(true);
|
|
968
|
-
(creating ? pathInput : input).focus();
|
|
1269
|
+
if (!form) (creating ? pathInput : input).focus();
|
|
969
1270
|
});
|
|
970
1271
|
close.addEventListener('click', ()=>setOpen(false));
|
|
971
1272
|
save.addEventListener('click', ()=>void store('save'));
|
|
@@ -975,6 +1276,9 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
975
1276
|
clearBlock.addEventListener('click', ()=>bridge.select(null));
|
|
976
1277
|
newPage.addEventListener('click', ()=>setCreating(true));
|
|
977
1278
|
cancelNew.addEventListener('click', ()=>setCreating(false));
|
|
1279
|
+
promptMode.addEventListener('click', ()=>setForm(false));
|
|
1280
|
+
formMode.addEventListener('click', ()=>setForm(true));
|
|
1281
|
+
pageChip.addEventListener('click', ()=>setPicking(!picking));
|
|
978
1282
|
pathInput.addEventListener('input', refresh);
|
|
979
1283
|
pathInput.addEventListener('keydown', (event)=>{
|
|
980
1284
|
if (event.key === 'Enter' && !event.isComposing) {
|
|
@@ -984,6 +1288,26 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
984
1288
|
setOpen(false);
|
|
985
1289
|
}
|
|
986
1290
|
});
|
|
1291
|
+
search.addEventListener('input', ()=>{
|
|
1292
|
+
active = 0;
|
|
1293
|
+
renderPages();
|
|
1294
|
+
});
|
|
1295
|
+
search.addEventListener('keydown', (event)=>{
|
|
1296
|
+
const count = optionList.children.length;
|
|
1297
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
1298
|
+
event.preventDefault();
|
|
1299
|
+
if (count === 0) return;
|
|
1300
|
+
active = (active + (event.key === 'ArrowDown' ? 1 : -1) + count) % count;
|
|
1301
|
+
renderPages();
|
|
1302
|
+
} else if (event.key === 'Enter' && !event.isComposing) {
|
|
1303
|
+
event.preventDefault();
|
|
1304
|
+
const page = matching()[active];
|
|
1305
|
+
if (page) choose(page);
|
|
1306
|
+
} else if (event.key === 'Escape') {
|
|
1307
|
+
event.preventDefault();
|
|
1308
|
+
setPicking(false);
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
987
1311
|
undo.addEventListener('click', ()=>{
|
|
988
1312
|
message = null;
|
|
989
1313
|
bridge.undo();
|
|
@@ -1009,10 +1333,87 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1009
1333
|
doc.body.appendChild(host);
|
|
1010
1334
|
return ()=>{
|
|
1011
1335
|
unsubscribe();
|
|
1336
|
+
unmountForm();
|
|
1012
1337
|
host.remove();
|
|
1013
1338
|
};
|
|
1014
1339
|
};
|
|
1015
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
|
+
|
|
1016
1417
|
// The bridge on a site signed in to edit (`/cms/login`, from
|
|
1017
1418
|
// @obelism/content/site), outside the visual editor. It plays the editor's
|
|
1018
1419
|
// part itself: it keeps the changed content in memory, asks the site's
|
|
@@ -1022,6 +1423,10 @@ const mountPromptBox = (doc, bridge, options = {})=>{
|
|
|
1022
1423
|
//
|
|
1023
1424
|
// What it edits is the page's entry — the outermost element marked with
|
|
1024
1425
|
// `editable(entry)` — or, for a selected block, the entry around that block.
|
|
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.
|
|
1025
1430
|
// The base path of the site's handler while signed in to edit, else null.
|
|
1026
1431
|
const readSiteEditing = (doc)=>{
|
|
1027
1432
|
for (const part of doc.cookie.split(';')){
|
|
@@ -1037,6 +1442,11 @@ const readSiteEditing = (doc)=>{
|
|
|
1037
1442
|
return null;
|
|
1038
1443
|
};
|
|
1039
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;
|
|
1040
1450
|
const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})=>{
|
|
1041
1451
|
const doc = win.document;
|
|
1042
1452
|
const emitter = createEmitter();
|
|
@@ -1047,6 +1457,11 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1047
1457
|
// Entries changed here since the last publish.
|
|
1048
1458
|
const touched = new Set();
|
|
1049
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();
|
|
1050
1465
|
let state = INITIAL_STATE;
|
|
1051
1466
|
const setState = (next)=>{
|
|
1052
1467
|
state = {
|
|
@@ -1100,17 +1515,39 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1100
1515
|
const base = lang.split('-')[0] ?? '';
|
|
1101
1516
|
return codes.includes(base) ? base : session.space.defaultLocale;
|
|
1102
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();
|
|
1103
1521
|
const load = async (entryId)=>{
|
|
1104
1522
|
const loaded = working.get(entryId);
|
|
1105
1523
|
if (loaded) return loaded;
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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;
|
|
1538
|
+
};
|
|
1539
|
+
// The entry a prompt or JSON acts on, for the block `uid` (the selected
|
|
1540
|
+
// one when left out).
|
|
1541
|
+
const target = (option)=>{
|
|
1542
|
+
const uid = option === undefined ? state.selectedUid : option;
|
|
1543
|
+
const entryId = entryFor(uid);
|
|
1544
|
+
if (!entryId) {
|
|
1545
|
+
throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
|
|
1546
|
+
}
|
|
1547
|
+
return {
|
|
1548
|
+
uid,
|
|
1549
|
+
entryId
|
|
1111
1550
|
};
|
|
1112
|
-
working.set(entryId, fresh);
|
|
1113
|
-
return fresh;
|
|
1114
1551
|
};
|
|
1115
1552
|
const highlight = (uid)=>{
|
|
1116
1553
|
doc.querySelectorAll('[data-cms-selected]').forEach((element)=>{
|
|
@@ -1136,6 +1573,15 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1136
1573
|
});
|
|
1137
1574
|
win.requestAnimationFrame(()=>highlight(state.selectedUid));
|
|
1138
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
|
+
};
|
|
1139
1585
|
const afterStore = (event)=>{
|
|
1140
1586
|
emitter.emit(event, {});
|
|
1141
1587
|
if (emitter.listeners.update.size === 0) win.location.reload();
|
|
@@ -1152,6 +1598,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1152
1598
|
touched.add(entryId);
|
|
1153
1599
|
}
|
|
1154
1600
|
history.length = 0;
|
|
1601
|
+
lastFormEdit = -Infinity;
|
|
1155
1602
|
refreshUnsaved();
|
|
1156
1603
|
};
|
|
1157
1604
|
const has = (feature)=>state.features.includes(feature);
|
|
@@ -1168,11 +1615,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1168
1615
|
const text = prompt.trim();
|
|
1169
1616
|
if (!text) throw new Error('Say what to change');
|
|
1170
1617
|
if (!has('prompt')) throw new Error('You may not edit content here');
|
|
1171
|
-
const
|
|
1172
|
-
const entryId = entryFor(uid);
|
|
1173
|
-
if (!entryId) {
|
|
1174
|
-
throw new Error('Nothing on this page is marked as an entry: add editable(entry)');
|
|
1175
|
-
}
|
|
1618
|
+
const { uid, entryId } = target(options.uid);
|
|
1176
1619
|
setState({
|
|
1177
1620
|
prompting: true
|
|
1178
1621
|
});
|
|
@@ -1188,6 +1631,7 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1188
1631
|
entryId,
|
|
1189
1632
|
content: entry.content
|
|
1190
1633
|
});
|
|
1634
|
+
lastFormEdit = -Infinity;
|
|
1191
1635
|
entry.content = result.content;
|
|
1192
1636
|
refreshUnsaved();
|
|
1193
1637
|
await render(entryId);
|
|
@@ -1233,13 +1677,182 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1233
1677
|
});
|
|
1234
1678
|
}
|
|
1235
1679
|
},
|
|
1680
|
+
async view (options = {}) {
|
|
1681
|
+
const { uid, entryId } = target(options.uid);
|
|
1682
|
+
const entry = await load(entryId);
|
|
1683
|
+
const { view } = await api(`entries/${encodeURIComponent(entryId)}/view`, {
|
|
1684
|
+
content: entry.content,
|
|
1685
|
+
locale: locale(),
|
|
1686
|
+
uid
|
|
1687
|
+
});
|
|
1688
|
+
return view;
|
|
1689
|
+
},
|
|
1690
|
+
async applyJson (changes, options = {}) {
|
|
1691
|
+
if (!has('prompt')) throw new Error('You may not edit content here');
|
|
1692
|
+
if (!changes || typeof changes !== 'object' || Array.isArray(changes)) {
|
|
1693
|
+
throw new Error('Send the fields to change as an object');
|
|
1694
|
+
}
|
|
1695
|
+
const { uid, entryId } = target(options.uid);
|
|
1696
|
+
setState({
|
|
1697
|
+
prompting: true
|
|
1698
|
+
});
|
|
1699
|
+
try {
|
|
1700
|
+
const entry = await load(entryId);
|
|
1701
|
+
const result = await api(`entries/${encodeURIComponent(entryId)}/apply`, {
|
|
1702
|
+
changes,
|
|
1703
|
+
content: entry.content,
|
|
1704
|
+
locale: locale(),
|
|
1705
|
+
uid
|
|
1706
|
+
});
|
|
1707
|
+
// Nothing to undo when nothing changed.
|
|
1708
|
+
if (!same(result.content, entry.content)) {
|
|
1709
|
+
history.push({
|
|
1710
|
+
entryId,
|
|
1711
|
+
content: entry.content
|
|
1712
|
+
});
|
|
1713
|
+
lastFormEdit = -Infinity;
|
|
1714
|
+
entry.content = result.content;
|
|
1715
|
+
refreshUnsaved();
|
|
1716
|
+
await render(entryId);
|
|
1717
|
+
}
|
|
1718
|
+
return {
|
|
1719
|
+
changed: result.changed ?? [],
|
|
1720
|
+
...result.view && {
|
|
1721
|
+
view: result.view
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
} finally{
|
|
1725
|
+
setState({
|
|
1726
|
+
prompting: false
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
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
|
+
},
|
|
1815
|
+
async pages () {
|
|
1816
|
+
const { pages } = await api('pages');
|
|
1817
|
+
const preferred = [
|
|
1818
|
+
locale(),
|
|
1819
|
+
session?.space.defaultLocale
|
|
1820
|
+
];
|
|
1821
|
+
return pages.flatMap((page)=>{
|
|
1822
|
+
// The page's path in this locale, else the default's, else any.
|
|
1823
|
+
const code = preferred.find((code)=>code !== undefined && page.paths[code] !== undefined) ?? Object.keys(page.paths)[0];
|
|
1824
|
+
const path = code === undefined ? undefined : page.paths[code];
|
|
1825
|
+
if (code === undefined || path === undefined) return [];
|
|
1826
|
+
return [
|
|
1827
|
+
{
|
|
1828
|
+
entryId: page.entryId,
|
|
1829
|
+
name: page.name,
|
|
1830
|
+
contentType: page.contentType,
|
|
1831
|
+
status: page.status,
|
|
1832
|
+
path,
|
|
1833
|
+
url: pagePath(path, code)
|
|
1834
|
+
}
|
|
1835
|
+
];
|
|
1836
|
+
});
|
|
1837
|
+
},
|
|
1838
|
+
openPage (page) {
|
|
1839
|
+
if (state.unsaved && !win.confirm('Leave this page and lose the changes you have not saved?')) {
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
// Asked already: no second question from `beforeunload`.
|
|
1843
|
+
working.clear();
|
|
1844
|
+
history.length = 0;
|
|
1845
|
+
refreshUnsaved();
|
|
1846
|
+
win.location.assign(page.url);
|
|
1847
|
+
},
|
|
1236
1848
|
undo () {
|
|
1237
1849
|
const last = history.pop();
|
|
1238
1850
|
const entry = last && working.get(last.entryId);
|
|
1239
1851
|
if (!last || !entry) return;
|
|
1852
|
+
lastFormEdit = -Infinity;
|
|
1240
1853
|
entry.content = last.content;
|
|
1241
1854
|
refreshUnsaved();
|
|
1242
|
-
void render(last.entryId).catch(
|
|
1855
|
+
void render(last.entryId).catch(logError);
|
|
1243
1856
|
},
|
|
1244
1857
|
async save () {
|
|
1245
1858
|
if (!has('save')) throw new Error('You may not edit content here');
|
|
@@ -1302,6 +1915,9 @@ const createSiteBridge = (win, basePath, { pagePath = (path)=>`/${path}` } = {})
|
|
|
1302
1915
|
});
|
|
1303
1916
|
},
|
|
1304
1917
|
destroy () {
|
|
1918
|
+
closeForm?.();
|
|
1919
|
+
renderTimers.forEach((timer)=>clearTimeout(timer));
|
|
1920
|
+
renderTimers.clear();
|
|
1305
1921
|
cleanup.splice(0).forEach((undo)=>undo());
|
|
1306
1922
|
}
|
|
1307
1923
|
};
|