@panphora/clayjs 1.16.0 → 1.17.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/all.js +1 -1
- package/dist/clay-dom.js +1 -1
- package/dist/clay-events.js +1 -1
- package/dist/clay-internals.js +1 -1
- package/dist/clay-options.js +1 -1
- package/dist/clay-router.js +1 -1
- package/dist/clay-ui.js +1 -1
- package/dist/clay-utils.js +1 -1
- package/dist/clay.js +1 -1
- package/dist/clay.standalone.js +7917 -5621
- package/dist/m/attrs/refetch-on-save-ZUE7PSCI.js +1 -0
- package/dist/m/attrs/save-freeze-MYNVXREN.js +1 -0
- package/dist/m/c-2YYDMLNZ.js +1 -0
- package/dist/m/c-336SGU5W.js +1 -0
- package/dist/m/c-3H6BZDNJ.js +6 -0
- package/dist/m/{c-LR4CGEPT.js → c-3RUU4TPX.js} +1 -1
- package/dist/m/c-3XIRD4NU.js +1 -0
- package/dist/m/c-4DFEJ2PO.js +3 -0
- package/dist/m/c-56QRZTZH.js +1 -0
- package/dist/m/c-5J4E7O4Y.js +4 -0
- package/dist/m/c-5LKDWQTL.js +19 -0
- package/dist/m/c-5QWQ7A5L.js +1 -0
- package/dist/m/c-5RJFTLSF.js +1 -0
- package/dist/m/c-6AZOGAPE.js +1 -0
- package/dist/m/{c-Y6USGTS5.js → c-6CRUUXOX.js} +5 -5
- package/dist/m/{c-54RCDCBU.js → c-6KEVEQ7L.js} +1 -1
- package/dist/m/{c-LTGMXXKO.js → c-6NC5I2TV.js} +1 -1
- package/dist/m/{c-YDH7Q3NY.js → c-74MGYWZF.js} +1 -1
- package/dist/m/{c-Z36J63PP.js → c-7OLLJPWP.js} +1 -1
- package/dist/m/c-A3E3NIMC.js +1 -0
- package/dist/m/{c-5R4PTRG2.js → c-AMSNPOCX.js} +1 -1
- package/dist/m/c-B3JM5ZXC.js +1 -0
- package/dist/m/c-BPF2NSPV.js +1 -0
- package/dist/m/c-BQ6LOBEE.js +1 -0
- package/dist/m/c-CJ3A7ZKE.js +1 -0
- package/dist/m/c-CQHPGKX2.js +1 -0
- package/dist/m/c-DCXOXBF3.js +1 -0
- package/dist/m/c-DFS4XZJV.js +2 -0
- package/dist/m/{c-F3CKJTJS.js → c-DNENWY4O.js} +1 -1
- package/dist/m/c-E4LIR5X5.js +1 -0
- package/dist/m/c-EVCQ3BKA.js +1 -0
- package/dist/m/{c-5Y6TNX5W.js → c-EZU56NQV.js} +2 -2
- package/dist/m/c-FDNHHZY5.js +1 -0
- package/dist/m/{c-SWRJFCNN.js → c-FRLHSY7E.js} +1 -1
- package/dist/m/c-FROJUZMV.js +1 -0
- package/dist/m/c-G2JPKCTP.js +1 -0
- package/dist/m/c-GB6TU7WV.js +1 -0
- package/dist/m/{c-GB3JTV6E.js → c-GFJJEBTU.js} +1 -1
- package/dist/m/{c-J7CA54IP.js → c-GSFIGKOP.js} +1 -1
- package/dist/m/{c-OYTFP5QN.js → c-GY422N3Q.js} +1 -1
- package/dist/m/c-HAWFNBYG.js +1 -0
- package/dist/m/{c-33HUYEDI.js → c-HDDEEL77.js} +1 -1
- package/dist/m/c-HFKUVACH.js +1 -0
- package/dist/m/c-HHDFCNHV.js +1 -0
- package/dist/m/c-I2ZATQCD.js +2 -0
- package/dist/m/c-IEM4NTCS.js +1 -0
- package/dist/m/c-IW6GGOU2.js +1 -0
- package/dist/m/{c-SGCCC4FW.js → c-JDCX26UP.js} +2 -2
- package/dist/m/c-JRKY44DK.js +1 -0
- package/dist/m/c-JU3NK25H.js +1 -0
- package/dist/m/{c-VFX45IZQ.js → c-KAU6E43D.js} +1 -1
- package/dist/m/c-KBS3UV6B.js +1 -0
- package/dist/m/c-KGVGROZT.js +1 -0
- package/dist/m/{c-YG2NJYS6.js → c-KQ6TYJAP.js} +2 -2
- package/dist/m/{c-2JL6E75N.js → c-KU3ZFADW.js} +1 -1
- package/dist/m/c-LWLBHLVC.js +3 -0
- package/dist/m/c-M2L47S2X.js +7 -0
- package/dist/m/c-MFP2ZWD4.js +1 -0
- package/dist/m/{c-4YSCRICO.js → c-MKSQ4Y57.js} +1 -1
- package/dist/m/{c-2M73252O.js → c-NUFFJVVJ.js} +1 -1
- package/dist/m/c-NUHSPT5P.js +1 -0
- package/dist/m/c-O72HVYSA.js +1 -0
- package/dist/m/c-OW5QIGG3.js +1 -0
- package/dist/m/c-PGRCK73F.js +1 -0
- package/dist/m/{c-4DRMS4QG.js → c-PS5ARVWR.js} +1 -1
- package/dist/m/c-Q622NS2Y.js +1 -0
- package/dist/m/c-Q7NQD65V.js +1 -0
- package/dist/m/c-QH63LQOS.js +1 -0
- package/dist/m/{c-TQZ2JYHD.js → c-QICP2YR5.js} +1 -1
- package/dist/m/{c-7VGZ7RU5.js → c-QJ4OM3G6.js} +1 -1
- package/dist/m/{c-MMUIUUZG.js → c-R3L7ZJGB.js} +1 -1
- package/dist/m/c-R7IWVINQ.js +1 -0
- package/dist/m/{c-HVYXIS72.js → c-RSMOODDU.js} +1 -1
- package/dist/m/{c-KKWLDOPM.js → c-RYUXUUBX.js} +1 -1
- package/dist/m/c-S4ZEH46B.js +9 -0
- package/dist/m/c-SDAXDM2D.js +1 -0
- package/dist/m/c-SMBZG5X4.js +1 -0
- package/dist/m/{c-UXBMRDZB.js → c-SMGU7RT3.js} +1 -1
- package/dist/m/c-SPIORRYS.js +1 -0
- package/dist/m/c-SWLRT66R.js +1 -0
- package/dist/m/c-SZGYW5HT.js +1 -0
- package/dist/m/c-TCJTVAFN.js +1 -0
- package/dist/m/{c-HRDCD3WN.js → c-TJ6WOJL3.js} +1 -1
- package/dist/m/c-TYLXX4NF.js +21 -0
- package/dist/m/c-U62OB4BT.js +1 -0
- package/dist/m/{c-NMJRXKPC.js → c-U6YWUAR3.js} +1 -1
- package/dist/m/c-UDCSU5G5.js +2 -0
- package/dist/m/{c-NZMNXVF6.js → c-UDUUJPFZ.js} +1 -1
- package/dist/m/c-VE6ZMYMC.js +1 -0
- package/dist/m/c-VG5XIN4T.js +1 -0
- package/dist/m/c-VHM5XQHK.js +1 -0
- package/dist/m/c-VI5RMSVY.js +1 -0
- package/dist/m/c-VRZN56XY.js +1 -0
- package/dist/m/c-W5MCHEGA.js +1 -0
- package/dist/m/c-WDC5GVKU.js +1 -0
- package/dist/m/c-WWXIDH4O.js +28 -0
- package/dist/m/{c-QIX5BJYN.js → c-XCNTK76U.js} +1 -1
- package/dist/m/c-XMS6DPBR.js +1 -0
- package/dist/m/c-Y5HBBIQ4.js +1 -0
- package/dist/m/{c-5M4YEKCX.js → c-ZVPVXTJF.js} +1 -1
- package/dist/m/c-ZVRKXKM7.js +1 -0
- package/dist/m/{c-HT33EQRN.js → c-ZWIOJGMT.js} +1 -1
- package/dist/m/core/admin-attrs-QHOKPJ7W.js +1 -0
- package/dist/m/core/autosave-HRYGUWV6.js +1 -0
- package/dist/m/core/conflict-notice-6MROJN2L.js +1 -0
- package/dist/m/core/host-permission-FNXVEFNE.js +1 -0
- package/dist/m/core/is-edit-mode-KTWA3O5N.js +1 -0
- package/dist/m/core/{persist-HKHA66IY.js → persist-JFYRHMRJ.js} +1 -1
- package/dist/m/core/recovery-FLTCC2OC.js +1 -0
- package/dist/m/core/save-HQZ4S667.js +1 -0
- package/dist/m/core/save-core-KGQWFUNH.js +1 -0
- package/dist/m/core/snapshot-EGQZNT45.js +1 -0
- package/dist/m/core/unsaved-warning-IDJLD66H.js +1 -0
- package/dist/m/events/index-RN2JNGBV.js +1 -0
- package/dist/m/internals/index-PVXGPUDA.js +1 -0
- package/dist/m/lib/cache-bust-MUMFXZK6.js +1 -0
- package/dist/m/loader-S4I7GKXR.js +1 -0
- package/dist/m/manifest-4C419D67.js +1 -0
- package/dist/m/manifest-edit-B077F288.js +1 -0
- package/dist/m/manifest.json +1555 -1058
- package/dist/m/options/options-EJMS3K24.js +1 -0
- package/dist/m/page/{editor-XUTB2HB2.js → editor-NNJA4OKD.js} +21 -8
- package/dist/m/plugins/{ai-edit-ZQ4CGWIP.js → ai-edit-QN4PMMNL.js} +1 -1
- package/dist/m/plugins/demo-RM5IR6AO.js +1 -0
- package/dist/m/plugins/indicator-URDBBOQO.js +1 -0
- package/dist/m/plugins/live-HFWD4OIH.js +1 -0
- package/dist/m/plugins/{menu-HCKO3DSP.js → menu-3HIEQSI6.js} +2 -2
- package/dist/m/plugins/page-JOKB6XPR.js +2 -0
- package/dist/m/plugins/page-view-NXOJHNW6.js +1 -0
- package/dist/m/plugins/people-SGPJKANK.js +1 -0
- package/dist/m/plugins/{richclay-RC3L2F6X.js → richclay-NRQDSWX6.js} +2 -2
- package/dist/m/plugins/sortable-GWT4PWDV.js +1 -0
- package/dist/m/plugins/source-4ZRSAIYM.js +1 -0
- package/dist/m/plugins/undo-VDA5P7FH.js +1 -0
- package/dist/m/plugins/{upgrade-ZNYODP5Y.js → upgrade-W7FG5VFG.js} +1 -1
- package/dist/m/plugins/upload-WLYKK6TL.js +1 -0
- package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +21 -0
- package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +1 -0
- package/dist/m/plugins/wire-4N6GYAF6.js +1 -0
- package/dist/m/{reader-core-ILWLXVMP.js → reader-core-DXYCURFF.js} +2 -2
- package/dist/m/sync/live-sync-J7VMYTKS.js +1 -0
- package/dist/m/ui/{index-EVHKMKBL.js → index-3JLVI27O.js} +1 -1
- package/dist/offline-manifest.json +857 -623
- package/dist/page/themes/light-1.css +25 -1
- package/dist/server/core.cjs +115 -24
- package/package.json +1 -1
- package/src/core/boot-phase.js +2 -1
- package/src/core/conflict-download.js +3 -29
- package/src/core/download.js +32 -0
- package/src/core/host-meta.js +18 -0
- package/src/core/save-core.js +8 -4
- package/src/core/save.js +4 -4
- package/src/core/snapshot.js +39 -1
- package/src/lib/autosave-scheduler.js +3 -4
- package/src/page/blocks.js +34 -1
- package/src/page/board-views.js +2 -16
- package/src/page/chart-tables.js +11 -0
- package/src/page/chart.js +6 -10
- package/src/page/checkpoint-proof.js +289 -0
- package/src/page/chrome.js +24 -6
- package/src/page/document-title-dom.js +12 -0
- package/src/page/document-title-runtime.js +15 -1
- package/src/page/document-title.js +4 -1
- package/src/page/editor.js +77 -12
- package/src/page/emoji-data.js +162 -0
- package/src/page/features/board.js +12 -3
- package/src/page/features/chart.js +11 -0
- package/src/page/features/dates.js +180 -0
- package/src/page/features/emoji.js +187 -0
- package/src/page/features/mentions.js +188 -0
- package/src/page/features/registry.js +6 -1
- package/src/page/features/table.js +2 -2
- package/src/page/format-command.js +9 -0
- package/src/page/format-host.js +54 -0
- package/src/page/format-native-commands.js +142 -0
- package/src/page/format-native.js +42 -0
- package/src/page/inline.js +32 -8
- package/src/page/items.js +15 -3
- package/src/page/markdown.js +26 -2
- package/src/page/mention-picker.js +99 -0
- package/src/page/merge.js +1 -1
- package/src/page/projection.js +58 -15
- package/src/page/save-projection.js +39 -0
- package/src/page/save-states.js +1 -1
- package/src/page/schema.js +33 -0
- package/src/page/themes/content.css +25 -1
- package/src/page/themes/light-1.css +25 -1
- package/src/page/view-hooks.js +32 -0
- package/src/page/ybind/binding.js +31 -4
- package/src/page/ybind/marks.js +2 -1
- package/src/page/ybind/provider.js +13 -2
- package/src/page/ybind/recovery-replay.js +362 -4
- package/src/plugins/live.js +37 -1
- package/src/plugins/page.js +117 -3
- package/src/plugins/visitor-drafts/intro.js +2 -1
- package/src/server/checkpoint/index.js +2 -2
- package/src/sync/saved-stream.js +17 -8
- package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +0 -1
- package/dist/m/attrs/save-freeze-FUP4Q4X3.js +0 -1
- package/dist/m/c-35NBGMZB.js +0 -1
- package/dist/m/c-3IQRECYS.js +0 -1
- package/dist/m/c-4B6H4G5V.js +0 -1
- package/dist/m/c-4FFHZM32.js +0 -1
- package/dist/m/c-66O32MV3.js +0 -28
- package/dist/m/c-7O6AFR65.js +0 -1
- package/dist/m/c-ANQTJ3B5.js +0 -2
- package/dist/m/c-AYVOREBE.js +0 -1
- package/dist/m/c-B2ZXPOS3.js +0 -3
- package/dist/m/c-BMIZKMPV.js +0 -1
- package/dist/m/c-CJZSLMVQ.js +0 -1
- package/dist/m/c-DBE6ORSI.js +0 -1
- package/dist/m/c-EDHUR4MX.js +0 -1
- package/dist/m/c-EY6UOM54.js +0 -1
- package/dist/m/c-F7Y6FLPY.js +0 -1
- package/dist/m/c-FA4DZVTC.js +0 -1
- package/dist/m/c-G2BUZITR.js +0 -1
- package/dist/m/c-GBGWXTX4.js +0 -1
- package/dist/m/c-GHBCCVG4.js +0 -1
- package/dist/m/c-IB52R237.js +0 -1
- package/dist/m/c-IKWNE4R4.js +0 -1
- package/dist/m/c-IU3IYEU6.js +0 -1
- package/dist/m/c-IZ2EBRAJ.js +0 -1
- package/dist/m/c-KR4UW4KW.js +0 -1
- package/dist/m/c-KY3VJGUA.js +0 -8
- package/dist/m/c-LXENEBV7.js +0 -1
- package/dist/m/c-PPOSCAQD.js +0 -1
- package/dist/m/c-QQHEX6HA.js +0 -1
- package/dist/m/c-R7RMANJI.js +0 -9
- package/dist/m/c-RI4NT75Y.js +0 -1
- package/dist/m/c-RUJKSUQW.js +0 -1
- package/dist/m/c-T4VO4OCB.js +0 -1
- package/dist/m/c-TR6RMDQ7.js +0 -3
- package/dist/m/c-UKHTLWUW.js +0 -1
- package/dist/m/c-UNN7LWCT.js +0 -1
- package/dist/m/c-V7PZZRG7.js +0 -1
- package/dist/m/c-VL52ADDR.js +0 -1
- package/dist/m/c-WWDBYXLK.js +0 -1
- package/dist/m/c-XNNW2CWK.js +0 -1
- package/dist/m/c-Z4VRC7GU.js +0 -7
- package/dist/m/c-Z6K52OLP.js +0 -19
- package/dist/m/c-ZDY2MDAA.js +0 -21
- package/dist/m/core/admin-attrs-VGNLAUDK.js +0 -1
- package/dist/m/core/autosave-E4PEFPZ6.js +0 -1
- package/dist/m/core/conflict-notice-VQ6Y64PH.js +0 -1
- package/dist/m/core/host-permission-XN3K2IW3.js +0 -1
- package/dist/m/core/is-edit-mode-JXHDVGUL.js +0 -1
- package/dist/m/core/recovery-TBBSICE2.js +0 -1
- package/dist/m/core/save-WUCHTQBE.js +0 -1
- package/dist/m/core/save-core-FJVHT5M3.js +0 -1
- package/dist/m/core/snapshot-F3SWDZMM.js +0 -1
- package/dist/m/core/unsaved-warning-STKBHE6G.js +0 -1
- package/dist/m/events/index-23DPTUT4.js +0 -1
- package/dist/m/internals/index-WOVAP3RU.js +0 -1
- package/dist/m/lib/cache-bust-CGWT6ZJI.js +0 -1
- package/dist/m/loader-HXX6YNH5.js +0 -1
- package/dist/m/manifest-F22DEDAC.js +0 -1
- package/dist/m/manifest-edit-C3C7376B.js +0 -1
- package/dist/m/options/options-2LH76JQ3.js +0 -1
- package/dist/m/plugins/demo-ZIBFAA3R.js +0 -1
- package/dist/m/plugins/indicator-PV4YW2ID.js +0 -1
- package/dist/m/plugins/live-NUTTXSBS.js +0 -1
- package/dist/m/plugins/page-KSUH7G6J.js +0 -2
- package/dist/m/plugins/page-view-W73OW7WI.js +0 -1
- package/dist/m/plugins/people-AJJ32YCG.js +0 -1
- package/dist/m/plugins/sortable-YRMQX3ZU.js +0 -1
- package/dist/m/plugins/source-KHK5UUSK.js +0 -1
- package/dist/m/plugins/undo-OF2L3CXU.js +0 -1
- package/dist/m/plugins/upload-XSGNILUY.js +0 -1
- package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +0 -21
- package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +0 -1
- package/dist/m/plugins/wire-ZWVWKF76.js +0 -1
- package/dist/m/sync/live-sync-4AUVXVJU.js +0 -1
- /package/dist/m/plugins/{richclay-RC3L2F6X.js.LEGAL.txt → richclay-NRQDSWX6.js.LEGAL.txt} +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Emoji: the Emoji action opens a picker of a small bundled glyph list and choosing one
|
|
2
|
+
// writes that glyph into the document as ordinary Unicode text. The glyph is the whole
|
|
3
|
+
// record — no mark, no node, no attribute and no format floor — so a reader shows it with
|
|
4
|
+
// nothing of this loaded, and a variation selector, a skin tone, a flag's regional pair, a
|
|
5
|
+
// keycap or a joined family goes in as the one grapheme a person sees, never split. The
|
|
6
|
+
// list is fetched the first time the picker opens and kept; a fetch that failed is not
|
|
7
|
+
// kept, so the next opening asks again.
|
|
8
|
+
import { state } from "../../vendor/prosemirror.vendor.js";
|
|
9
|
+
import { asStep } from "../blocksel.js";
|
|
10
|
+
import { composing } from "../markdown.js";
|
|
11
|
+
import { createMentionPicker } from "../mention-picker.js";
|
|
12
|
+
|
|
13
|
+
// One glyph, as a person sees it: counting code points or UTF-16 units would call a
|
|
14
|
+
// joined family seven things and a keycap several, and cut one in half.
|
|
15
|
+
export function graphemes(text) {
|
|
16
|
+
const value = String(text ?? "");
|
|
17
|
+
const segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null;
|
|
18
|
+
return segmenter ? [...segmenter.segment(value)].map((part) => part.segment) : [...value];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isEmojiText = (value) => graphemes(value).length === 1 && !/^\s*$/.test(String(value ?? ""));
|
|
22
|
+
|
|
23
|
+
// The data module's groups, flattened into the rows a picker lists: the glyph first, so the
|
|
24
|
+
// row reads as the emoji and its name, and the searchable words after it.
|
|
25
|
+
export function emojiEntries(groups) {
|
|
26
|
+
const list = [];
|
|
27
|
+
for (const [group, rows] of Object.entries(groups ?? {})) {
|
|
28
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
29
|
+
const [char, name, keywords = ""] = row ?? [];
|
|
30
|
+
if (typeof char !== "string" || typeof name !== "string") continue;
|
|
31
|
+
list.push({ id: `${group}:${name}`, char, name: `${char} ${name}`, keywords: `${name} ${keywords}` });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return list;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const filterEmoji = (list, query) => {
|
|
38
|
+
const q = String(query ?? "").trim().toLowerCase();
|
|
39
|
+
if (!q) return list;
|
|
40
|
+
return list.filter((entry) => entry.char === q || entry.keywords.toLowerCase().includes(q));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The list as one fetch for the life of a session: the first opening loads it, the next
|
|
44
|
+
// openings reuse it, and a fetch that failed leaves nothing cached so it is asked for again.
|
|
45
|
+
export function emojiLibrary(load = () => import("../emoji-data.js")) {
|
|
46
|
+
let pending = null;
|
|
47
|
+
return () => {
|
|
48
|
+
pending ??= Promise.resolve()
|
|
49
|
+
.then(() => load())
|
|
50
|
+
.then((mod) => emojiEntries(mod.EMOJI))
|
|
51
|
+
.catch((error) => { pending = null; throw error; });
|
|
52
|
+
return pending;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createFeature({ view = null, load = () => import("../emoji-data.js") } = {}) {
|
|
57
|
+
if (!view) return { run: () => ({ ok: false, code: "not-editing" }), dispose() {} };
|
|
58
|
+
const doc = view.dom.ownerDocument;
|
|
59
|
+
const library = emojiLibrary(load);
|
|
60
|
+
let session = null;
|
|
61
|
+
|
|
62
|
+
// The text the command belongs to, read from the document again at the commit: the picker
|
|
63
|
+
// holds no focus and writes nothing, so a caret that moved, a selection that is no longer
|
|
64
|
+
// the one the command was asked for or a place that takes no text all mean the bookmark no
|
|
65
|
+
// longer holds, and the command refuses rather than writing the glyph somewhere else.
|
|
66
|
+
function bookmark(s) {
|
|
67
|
+
const st = view.state;
|
|
68
|
+
const sel = st.selection;
|
|
69
|
+
if (sel.from !== s.from || sel.to !== s.to) return null;
|
|
70
|
+
if (s.from < 0 || s.to > st.doc.content.size) return null;
|
|
71
|
+
const $from = st.doc.resolve(s.from);
|
|
72
|
+
const $to = st.doc.resolve(s.to);
|
|
73
|
+
if (!$from.parent.isTextblock || $from.parent !== $to.parent) return null;
|
|
74
|
+
return { from: s.from, to: s.to };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Where the list hangs, in the page's own placement variables. A view that cannot measure
|
|
78
|
+
// the caret (one outside a laid-out document) still gets its list, just not positioned.
|
|
79
|
+
function place(s) {
|
|
80
|
+
try {
|
|
81
|
+
const box = view.dom.parentElement?.getBoundingClientRect?.() ?? { left: 0, top: 0 };
|
|
82
|
+
const at = view.coordsAtPos(s.from);
|
|
83
|
+
return { x: Math.max(8, at.left - box.left), y: at.bottom + 6 - box.top };
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function anchor(s) {
|
|
90
|
+
const id = s.picker.activeId();
|
|
91
|
+
if (id) view.dom.setAttribute("aria-activedescendant", id);
|
|
92
|
+
else if (view.dom.getAttribute("aria-activedescendant")?.startsWith(s.picker.el.id)) view.dom.removeAttribute("aria-activedescendant");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function close() {
|
|
96
|
+
const s = session;
|
|
97
|
+
if (!s) return;
|
|
98
|
+
session = null;
|
|
99
|
+
for (const [target, type, fn, options] of s.listen) target.removeEventListener(type, fn, options);
|
|
100
|
+
if (view.dom.getAttribute("aria-activedescendant")?.startsWith(`${s.picker.el.id}-`)) view.dom.removeAttribute("aria-activedescendant");
|
|
101
|
+
s.picker.destroy();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The one place a glyph reaches the document: the selection's text is replaced by it in a
|
|
105
|
+
// single transaction, with no mark and nothing else added.
|
|
106
|
+
function insertEmoji(at, char) {
|
|
107
|
+
const tr = view.state.tr.insertText(char, at.from, at.to);
|
|
108
|
+
tr.setSelection(state.TextSelection.create(tr.doc, at.from + char.length));
|
|
109
|
+
view.dispatch(asStep(tr));
|
|
110
|
+
view.focus();
|
|
111
|
+
return { ok: true, emoji: char };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function choose(index) {
|
|
115
|
+
const s = session;
|
|
116
|
+
if (!s) return { ok: false, code: "not-editing" };
|
|
117
|
+
const entry = s.picker.personAt(index);
|
|
118
|
+
const at = bookmark(s);
|
|
119
|
+
if (!entry || at === null || !isEmojiText(entry.char)) {
|
|
120
|
+
close();
|
|
121
|
+
if (!entry) return { ok: false, code: "no-emoji" };
|
|
122
|
+
if (at === null) return { ok: false, code: "stale-target" };
|
|
123
|
+
return { ok: false, code: "invalid-emoji" };
|
|
124
|
+
}
|
|
125
|
+
close();
|
|
126
|
+
return insertEmoji(at, entry.char);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function key(event) {
|
|
130
|
+
const s = session;
|
|
131
|
+
if (!s || composing(event, view)) return;
|
|
132
|
+
const stop = () => { event.preventDefault(); event.stopPropagation(); };
|
|
133
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
134
|
+
stop();
|
|
135
|
+
s.picker.move(event.key === "ArrowDown" ? 1 : -1);
|
|
136
|
+
anchor(s);
|
|
137
|
+
} else if (event.key === "Enter") {
|
|
138
|
+
stop();
|
|
139
|
+
choose(s.picker.index());
|
|
140
|
+
} else if (event.key === "Escape" || event.key === "Tab") {
|
|
141
|
+
stop();
|
|
142
|
+
close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function open(args = {}) {
|
|
147
|
+
close();
|
|
148
|
+
const s = { from: view.state.selection.from, to: view.state.selection.to, picker: null, listen: [] };
|
|
149
|
+
s.picker = createMentionPicker({ document: doc, label: "Insert an emoji", empty: "No emoji found", onPick: (index) => choose(index) });
|
|
150
|
+
session = s;
|
|
151
|
+
const listen = (target, type, fn, options) => {
|
|
152
|
+
target.addEventListener(type, fn, options);
|
|
153
|
+
s.listen.push([target, type, fn, options]);
|
|
154
|
+
};
|
|
155
|
+
listen(doc, "keydown", (event) => key(event), true);
|
|
156
|
+
// Typing closes the list: the caret has moved on, and the command that opened it has
|
|
157
|
+
// nothing to write where it no longer holds.
|
|
158
|
+
listen(view.dom, "input", () => close());
|
|
159
|
+
listen(doc, "mousedown", (event) => { if (!s.picker.el.contains(event.target)) close(); }, true);
|
|
160
|
+
(view.dom.parentElement ?? doc.body).appendChild(s.picker.el);
|
|
161
|
+
|
|
162
|
+
let list;
|
|
163
|
+
try {
|
|
164
|
+
list = await library();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
close();
|
|
167
|
+
return { ok: false, code: "unavailable", error };
|
|
168
|
+
}
|
|
169
|
+
if (session !== s) return { ok: false, code: "not-editing" };
|
|
170
|
+
if (bookmark(s) === null) {
|
|
171
|
+
close();
|
|
172
|
+
return { ok: false, code: "stale-target" };
|
|
173
|
+
}
|
|
174
|
+
s.picker.show(filterEmoji(list, args?.query), place(s));
|
|
175
|
+
anchor(s);
|
|
176
|
+
return { ok: true, picker: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
// The Emoji action: the picker at the caret, offering the bundled list. It writes
|
|
181
|
+
// nothing until one is chosen.
|
|
182
|
+
async run(args) {
|
|
183
|
+
return open(args);
|
|
184
|
+
},
|
|
185
|
+
dispose() { close(); },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Person mentions: @ opens a picker of people, and choosing one writes that person's name
|
|
2
|
+
// into the document as ordinary text with the `mention` mark over it. The label is the name
|
|
3
|
+
// the command saw, so a later rename never rewrites somebody's prose, and the id is the
|
|
4
|
+
// people registry's reference: nothing here notifies anyone, looks up an account or speaks
|
|
5
|
+
// to the network. Loaded the first time somebody types @, and disposed with the exit.
|
|
6
|
+
import { state } from "../../vendor/prosemirror.vendor.js";
|
|
7
|
+
import { schema } from "../schema.js";
|
|
8
|
+
import { asStep } from "../blocksel.js";
|
|
9
|
+
import { composing } from "../markdown.js";
|
|
10
|
+
import { createMentionPicker, filterPeople, peopleFrom } from "../mention-picker.js";
|
|
11
|
+
import { runFormatted } from "../format-command.js";
|
|
12
|
+
|
|
13
|
+
// clay.people's own id grammar (plugins/people.js): a mention keeps an id the registry made.
|
|
14
|
+
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
15
|
+
const TRIGGER = "@";
|
|
16
|
+
const QUERY_MAX = 100;
|
|
17
|
+
|
|
18
|
+
export function createFeature({ view = null } = {}) {
|
|
19
|
+
if (!view) return { run: () => ({ ok: false, code: "not-editing" }), dispose() {} };
|
|
20
|
+
const doc = view.dom.ownerDocument;
|
|
21
|
+
const registry = () => globalThis.clay?.people ?? null;
|
|
22
|
+
let session = null;
|
|
23
|
+
|
|
24
|
+
// The query the picker belongs to, read from the document again on every keystroke and
|
|
25
|
+
// again at the commit: a deleted @, a caret that left the block or a peer that replaced
|
|
26
|
+
// the text means the bookmark no longer holds, and the command refuses rather than
|
|
27
|
+
// handing the person whoever took that place.
|
|
28
|
+
function bookmark(s) {
|
|
29
|
+
const st = view.state;
|
|
30
|
+
if (!st.selection.empty) return null;
|
|
31
|
+
const head = st.selection.head;
|
|
32
|
+
if (head <= s.from || head - s.from > QUERY_MAX + 1 || head > st.doc.content.size) return null;
|
|
33
|
+
const $from = st.doc.resolve(s.from);
|
|
34
|
+
if (!$from.parent.isTextblock || $from.parent.type.spec.code) return null;
|
|
35
|
+
if (head > $from.end() || head < $from.start()) return null;
|
|
36
|
+
// The @ has to start a word: the beginning of a block, or after a space.
|
|
37
|
+
if ($from.parentOffset && !/\s/.test(st.doc.textBetween(s.from - 1, s.from, "", "\n"))) return null;
|
|
38
|
+
if (st.doc.textBetween(s.from, s.from + 1) !== TRIGGER) return null;
|
|
39
|
+
return { head, query: st.doc.textBetween(s.from + 1, head) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Where the list hangs, in the page's own placement variables. A view that cannot measure
|
|
43
|
+
// the caret (one outside a laid-out document) still gets its list, just not positioned.
|
|
44
|
+
function place(s) {
|
|
45
|
+
try {
|
|
46
|
+
const box = view.dom.parentElement?.getBoundingClientRect?.() ?? { left: 0, top: 0 };
|
|
47
|
+
const at = view.coordsAtPos(s.from);
|
|
48
|
+
return { x: Math.max(8, at.left - box.left), y: at.bottom + 6 - box.top };
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function anchor(s) {
|
|
55
|
+
const id = s.picker.activeId();
|
|
56
|
+
if (id) view.dom.setAttribute("aria-activedescendant", id);
|
|
57
|
+
else if (view.dom.getAttribute("aria-activedescendant")?.startsWith(s.picker.el.id)) view.dom.removeAttribute("aria-activedescendant");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function draw(s) {
|
|
61
|
+
s.picker.show(filterPeople(peopleFrom(s.saved, s.allowed), s.query), place(s));
|
|
62
|
+
anchor(s);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function close() {
|
|
66
|
+
const s = session;
|
|
67
|
+
if (!s) return;
|
|
68
|
+
session = null;
|
|
69
|
+
for (const [target, type, fn, options] of s.listen) target.removeEventListener(type, fn, options);
|
|
70
|
+
if (view.dom.getAttribute("aria-activedescendant")?.startsWith(`${s.picker.el.id}-`)) view.dom.removeAttribute("aria-activedescendant");
|
|
71
|
+
s.picker.destroy();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function refresh() {
|
|
75
|
+
const s = session;
|
|
76
|
+
if (!s || view.composing) return;
|
|
77
|
+
const at = bookmark(s);
|
|
78
|
+
if (!at) return close();
|
|
79
|
+
s.query = at.query;
|
|
80
|
+
draw(s);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function choose(index) {
|
|
84
|
+
const s = session;
|
|
85
|
+
if (!s) return { ok: false, code: "not-editing" };
|
|
86
|
+
const person = s.picker.personAt(index);
|
|
87
|
+
const at = bookmark(s);
|
|
88
|
+
const usable = person && at && at.query === s.query && ID.test(String(person.id ?? "")) && String(person.name ?? "").trim();
|
|
89
|
+
if (!usable) {
|
|
90
|
+
close();
|
|
91
|
+
return { ok: false, code: person ? "stale-target" : "no-person" };
|
|
92
|
+
}
|
|
93
|
+
const label = String(person.name).trim();
|
|
94
|
+
close();
|
|
95
|
+
// The gate awaits, so the bookmark is read again here: a caret that moved, a query that
|
|
96
|
+
// changed or a block that went away means the target no longer holds and nothing is
|
|
97
|
+
// written.
|
|
98
|
+
return runFormatted(view, () => {
|
|
99
|
+
const now = bookmark(s);
|
|
100
|
+
if (!now || now.query !== s.query) return { ok: false, code: "stale-target" };
|
|
101
|
+
// One transaction: the revalidated query becomes the name and its mark, then one
|
|
102
|
+
// unmarked space so typing at the edge of a mention stays plain text.
|
|
103
|
+
const tr = view.state.tr.insertText(label, s.from, now.head);
|
|
104
|
+
tr.addMark(s.from, s.from + label.length, schema.marks.mention.create({ id: person.id }));
|
|
105
|
+
tr.insertText(" ", s.from + label.length);
|
|
106
|
+
tr.setSelection(state.TextSelection.create(tr.doc, s.from + label.length + 1));
|
|
107
|
+
view.dispatch(asStep(tr));
|
|
108
|
+
view.focus();
|
|
109
|
+
return { ok: true, id: person.id, label };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function key(event) {
|
|
114
|
+
const s = session;
|
|
115
|
+
if (!s || composing(event, view)) return;
|
|
116
|
+
const stop = () => { event.preventDefault(); event.stopPropagation(); };
|
|
117
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
118
|
+
stop();
|
|
119
|
+
s.picker.move(event.key === "ArrowDown" ? 1 : -1);
|
|
120
|
+
anchor(s);
|
|
121
|
+
} else if (event.key === "Enter") {
|
|
122
|
+
stop();
|
|
123
|
+
choose(s.picker.index());
|
|
124
|
+
} else if (event.key === "Escape" || event.key === "Tab") {
|
|
125
|
+
stop();
|
|
126
|
+
close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The host's list arrives after the picker is already up: the people the document names
|
|
131
|
+
// show first, and a failed or missing list leaves exactly those. Neither presence nor an
|
|
132
|
+
// account lookup is a source of candidates.
|
|
133
|
+
function roster(s) {
|
|
134
|
+
const available = registry()?.available;
|
|
135
|
+
if (!available) return;
|
|
136
|
+
Promise.resolve().then(() => available()).then((allowed) => {
|
|
137
|
+
if (session !== s) return;
|
|
138
|
+
s.allowed = Array.isArray(allowed) ? allowed : null;
|
|
139
|
+
draw(s);
|
|
140
|
+
}).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function open(from) {
|
|
144
|
+
close();
|
|
145
|
+
const picker = createMentionPicker({ document: doc, onPick: (index) => choose(index) });
|
|
146
|
+
const s = { from, picker, query: "", allowed: null, saved: [], listen: [] };
|
|
147
|
+
try { s.saved = registry()?.list?.() ?? []; } catch { s.saved = []; }
|
|
148
|
+
session = s;
|
|
149
|
+
const listen = (target, type, fn, options) => {
|
|
150
|
+
target.addEventListener(type, fn, options);
|
|
151
|
+
s.listen.push([target, type, fn, options]);
|
|
152
|
+
};
|
|
153
|
+
listen(doc, "keydown", (event) => key(event), true);
|
|
154
|
+
listen(view.dom, "input", () => refresh());
|
|
155
|
+
listen(view.dom, "keyup", () => refresh());
|
|
156
|
+
listen(view.dom, "compositionend", () => refresh());
|
|
157
|
+
listen(doc, "mousedown", (event) => { if (!s.picker.el.contains(event.target)) close(); }, true);
|
|
158
|
+
(view.dom.parentElement ?? doc.body).appendChild(picker.el);
|
|
159
|
+
const at = bookmark(s);
|
|
160
|
+
if (!at) {
|
|
161
|
+
close();
|
|
162
|
+
return { ok: false, code: "stale-target" };
|
|
163
|
+
}
|
|
164
|
+
s.query = at.query;
|
|
165
|
+
draw(s);
|
|
166
|
+
roster(s);
|
|
167
|
+
return { ok: true, picker: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
// {from} is the @ the person typed; without one this is the Mention action, which types
|
|
172
|
+
// that @ at the caret and opens the same picker on it.
|
|
173
|
+
async run(args = {}) {
|
|
174
|
+
let from = Number.isInteger(args?.from) ? args.from : null;
|
|
175
|
+
if (from === null) {
|
|
176
|
+
const st = view.state;
|
|
177
|
+
const $from = st.selection.$from;
|
|
178
|
+
if (!st.selection.empty || !$from.parent.isTextblock || $from.parent.type.spec.code) return { ok: false, code: "stale-target" };
|
|
179
|
+
from = st.selection.from;
|
|
180
|
+
const tr = view.state.tr.insertText(TRIGGER);
|
|
181
|
+
tr.setSelection(state.TextSelection.create(tr.doc, from + 1));
|
|
182
|
+
view.dispatch(tr);
|
|
183
|
+
}
|
|
184
|
+
return open(from);
|
|
185
|
+
},
|
|
186
|
+
dispose() { close(); },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export const FEATURES = {
|
|
7
7
|
table: { types: ["table"], views: ["table", "table_cell"], module: "page/features/table.js", load: () => import("./table.js") },
|
|
8
8
|
board: { types: ["board"], module: "page/features/board.js", load: () => import("./board.js") },
|
|
9
|
+
chart: { types: ["chart"], views: ["chart"], module: "page/features/chart.js", load: () => import("./chart.js") },
|
|
9
10
|
code: { types: ["code_block"], module: "page/features/code.js", load: () => import("./code.js") },
|
|
10
11
|
media: { types: ["image", "file"], module: "page/features/media.js", load: () => import("./media.js") },
|
|
11
12
|
tool: { types: ["tool"], module: "page/features/tool.js", boot: ["page/tools.js", "page/tool-data.js"], load: () => import("./tool.js") },
|
|
@@ -20,4 +21,8 @@ export const featureForType = (type) => Object.keys(FEATURES).find((id) => FEATU
|
|
|
20
21
|
// and the offline manifest carries them, and `module` is the part on the wire (the load
|
|
21
22
|
// meter counts it). A row lands with the step that gives it a module: a literal import of
|
|
22
23
|
// a file this tree does not have yet would stop the chunk build.
|
|
23
|
-
export const COMMAND_FEATURES = {
|
|
24
|
+
export const COMMAND_FEATURES = {
|
|
25
|
+
mentions: { module: "page/features/mentions.js", load: () => import("./mentions.js") },
|
|
26
|
+
dates: { module: "page/features/dates.js", load: () => import("./dates.js") },
|
|
27
|
+
emoji: { module: "page/features/emoji.js", load: () => import("./emoji.js") },
|
|
28
|
+
};
|
|
@@ -4,7 +4,7 @@ import { DEFAULT_WIDTH, MAX_WIDTH, MIN_WIDTH, addColumn, addRow, deleteColumn, d
|
|
|
4
4
|
import { COLUMN_KINDS } from "../rich/csv.js";
|
|
5
5
|
import { KIND_ICONS, KIND_LABELS, columnKinds } from "../table-kinds.js";
|
|
6
6
|
import { openPageMenu } from "../chrome.js";
|
|
7
|
-
import {
|
|
7
|
+
import { loadChartPart } from "../items.js";
|
|
8
8
|
import { selectLine } from "../table-select.js";
|
|
9
9
|
import { TABLE_CSS } from "../table-style.js";
|
|
10
10
|
import { cellViews } from "../table-views.js";
|
|
@@ -252,7 +252,7 @@ export class TableView {
|
|
|
252
252
|
{ label: "Fit to content", icon: "↔", run: () => this.fit(colId) },
|
|
253
253
|
...(col.width ? [{ label: "Reset width", icon: "×", run: () => this.command(setColumnWidth(colId, null)) }] : []),
|
|
254
254
|
]) },
|
|
255
|
-
{ label: "Chart this table", icon: "📊", run: () => insertChartAfterTable(this.view,
|
|
255
|
+
{ label: "Chart this table", icon: "📊", run: () => { const at = this.getPos(); return loadChartPart(this.view).then(({ insertChartAfterTable }) => insertChartAfterTable(this.view, at)); } },
|
|
256
256
|
{ label: "Insert column left", icon: "+", run: run(addColumn(false)) },
|
|
257
257
|
{ label: "Insert column right", icon: "+", run: run(addColumn(true)) },
|
|
258
258
|
...(index > 0 ? [{ label: "Move left", icon: "←", run: run(moveColumn(-1)) }] : []),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// A command that writes a newer page format asks the format gate first. The answer is
|
|
2
|
+
// the command's own result when the gate let it apply, or the gate's refusal, so the
|
|
3
|
+
// caller writes nothing on a refusal.
|
|
4
|
+
export async function runFormatted(view, apply) {
|
|
5
|
+
if (typeof view.runFormatCommand !== "function") return { ok: false, code: "page-format-unsupported" };
|
|
6
|
+
const answer = await view.runFormatCommand(apply);
|
|
7
|
+
if (answer?.status === "applied") return answer.result;
|
|
8
|
+
return { ok: false, code: answer?.code ?? answer?.status ?? "page-format-refused", retry: answer?.retry === true };
|
|
9
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The production host adapter behind the format gate: the floor the document is
|
|
2
|
+
// already at, the host's own admission answer, and the one explicit save that
|
|
3
|
+
// raises it.
|
|
4
|
+
//
|
|
5
|
+
// The host is the authority, so `capabilities()` answers nothing here: the editors
|
|
6
|
+
// admitted to the document are checked under its admission/write lock when the save
|
|
7
|
+
// carrying the marker arrives, which is the check no registration can race. A save
|
|
8
|
+
// refused there reports `page-format-upgrade-required` like any other refusal, and
|
|
9
|
+
// this tab stays at the floor it already knew.
|
|
10
|
+
//
|
|
11
|
+
// Nothing is noted before the host answers: `notePageFormatFloor` runs when the gate
|
|
12
|
+
// accepts the activation, so a refusal leaves every later save at floor 0.
|
|
13
|
+
|
|
14
|
+
import { PAGE_FORMAT, PAGE_FORMAT_META, PAGE_FORMAT_UPGRADE, knownPageFormatFloor, pageFormatFloor } from "./format-level.js";
|
|
15
|
+
import { hostPageFormat } from "../core/host-meta.js";
|
|
16
|
+
|
|
17
|
+
const FAILED = "page-format-activation-failed";
|
|
18
|
+
|
|
19
|
+
// The document's own marker, as it was served. A tab that joined a document already
|
|
20
|
+
// at floor 1 must not ask to activate it again.
|
|
21
|
+
function servedFloor() {
|
|
22
|
+
const tag = document.querySelector(`meta[name="${PAGE_FORMAT_META}"]`);
|
|
23
|
+
return tag ? pageFormatFloor(tag.outerHTML) : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The editor's own explicit save, fetched when a floor is really raised: a tab that
|
|
27
|
+
// never uses a newer format never downloads the lane to raise one.
|
|
28
|
+
const savePage = async (options) => (await import("../core/save.js")).savePageForce(() => {}, options);
|
|
29
|
+
|
|
30
|
+
export function createFormatHost({
|
|
31
|
+
save = savePage,
|
|
32
|
+
supported = () => hostPageFormat().then((floor) => floor === PAGE_FORMAT),
|
|
33
|
+
} = {}) {
|
|
34
|
+
return {
|
|
35
|
+
read() {
|
|
36
|
+
return { floor: Math.max(knownPageFormatFloor(), servedFloor()), revision: null };
|
|
37
|
+
},
|
|
38
|
+
capabilities() {
|
|
39
|
+
return [];
|
|
40
|
+
},
|
|
41
|
+
supported,
|
|
42
|
+
async activate({ floor = PAGE_FORMAT } = {}) {
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = await save({ pageFormatFloor: floor });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { accepted: false, code: error?.code ?? FAILED };
|
|
48
|
+
}
|
|
49
|
+
if (result?.ok) return { accepted: true, revision: null, receipt: result.receipt ?? result.etag ?? null };
|
|
50
|
+
if (result?.code === PAGE_FORMAT_UPGRADE) return { accepted: false, code: PAGE_FORMAT_UPGRADE };
|
|
51
|
+
return { accepted: false, code: result?.code ?? FAILED };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The commands behind the four formats this release adds — underline, text color,
|
|
2
|
+
// highlight and the quote block — and the rows the More menu offers them by.
|
|
3
|
+
//
|
|
4
|
+
// Nothing here is on the ready path. Every caller loads this module the first
|
|
5
|
+
// time one of the commands runs, so reading and drawing an existing document
|
|
6
|
+
// never fetches a transaction, a palette or a refusal notice.
|
|
7
|
+
//
|
|
8
|
+
// Every command that writes one of them goes through runFormat, which is the
|
|
9
|
+
// editor's own format gate: nothing is applied and no descriptor is written
|
|
10
|
+
// unless the gate answered "applied".
|
|
11
|
+
import { history } from "../vendor/prosemirror.vendor.js";
|
|
12
|
+
import { schema } from "./schema.js";
|
|
13
|
+
import { PALETTE, markable } from "./format-native.js";
|
|
14
|
+
import { announce } from "./announce.js";
|
|
15
|
+
import { openPageMenu } from "./chrome.js";
|
|
16
|
+
|
|
17
|
+
// blocks.js's STEP, read here rather than imported from it: this module is part
|
|
18
|
+
// of the projection's graph, and blocks.js reads the projection's constants as it
|
|
19
|
+
// loads. A command of ours is one undo item by itself, like every other command.
|
|
20
|
+
const STEP = "page-step";
|
|
21
|
+
const asStep = (tr) => history.closeHistory(tr).setMeta(STEP, true);
|
|
22
|
+
|
|
23
|
+
const holds = (mark, attrs) => !!mark && Object.keys(mark.type.attrs).every((name) => mark.attrs[name] === attrs[name]);
|
|
24
|
+
|
|
25
|
+
function toggle(view, type, attrs) {
|
|
26
|
+
const st = view.state;
|
|
27
|
+
const { from, to, empty, $from } = st.selection;
|
|
28
|
+
const tr = st.tr;
|
|
29
|
+
if (empty) {
|
|
30
|
+
if (holds(type.isInSet(st.storedMarks || $from.marks()), attrs)) tr.removeStoredMark(type);
|
|
31
|
+
else tr.addStoredMark(type.create(attrs));
|
|
32
|
+
} else {
|
|
33
|
+
let all = true;
|
|
34
|
+
st.doc.nodesBetween(from, to, (node) => {
|
|
35
|
+
if (all && node.isText && !holds(type.isInSet(node.marks), attrs)) all = false;
|
|
36
|
+
});
|
|
37
|
+
if (all) tr.removeMark(from, to, type);
|
|
38
|
+
else tr.addMark(from, to, type.create(attrs));
|
|
39
|
+
}
|
|
40
|
+
view.dispatch(asStep(tr));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function remove(view, type) {
|
|
45
|
+
const st = view.state;
|
|
46
|
+
const tr = st.tr;
|
|
47
|
+
if (st.selection.empty) tr.removeStoredMark(type);
|
|
48
|
+
else tr.removeMark(st.selection.from, st.selection.to, type);
|
|
49
|
+
view.dispatch(asStep(tr));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The gate, in one place: an editor mounted without one cannot write a floor-1
|
|
54
|
+
// format, and a refusal leaves the content exactly as it was.
|
|
55
|
+
export function runFormat(view, apply) {
|
|
56
|
+
const run = view.runFormatCommand;
|
|
57
|
+
if (typeof run !== "function") return Promise.resolve({ status: "unsupported-host", code: "page-format-unsupported", retry: false });
|
|
58
|
+
return run(apply);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A mark command: toggle it on the selection, or with reset take it off. A
|
|
62
|
+
// selection the mark cannot reach is refused before the gate is asked, so a code
|
|
63
|
+
// block never raises the document's floor.
|
|
64
|
+
export function formatMark(view, name, attrs = {}, { reset = false } = {}) {
|
|
65
|
+
const type = schema.marks[name];
|
|
66
|
+
if (!type) return Promise.resolve({ status: "refused", code: "unsupported-format" });
|
|
67
|
+
if (!view.editable) return Promise.resolve({ status: "refused", code: "not-editable" });
|
|
68
|
+
if (!markable(view.state, type)) return Promise.resolve({ status: "refused", code: "mark-not-allowed" });
|
|
69
|
+
return runFormat(view, () => (reset ? remove(view, type) : toggle(view, type, attrs)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The four formats pasted in from elsewhere are floor-1 content too: the slice is
|
|
73
|
+
// inserted only once the gate answered applied. A refusal keeps the text and
|
|
74
|
+
// writes no descriptor; a selection that moved while the answer came back takes
|
|
75
|
+
// neither.
|
|
76
|
+
export function pasteFormats(view, slice, from, to) {
|
|
77
|
+
const place = () => {
|
|
78
|
+
if (view.isDestroyed) return false;
|
|
79
|
+
const st = view.state;
|
|
80
|
+
if (st.selection.from !== from || st.selection.to !== to) return false;
|
|
81
|
+
view.dispatch(st.tr.replaceSelection(slice).scrollIntoView());
|
|
82
|
+
return true;
|
|
83
|
+
};
|
|
84
|
+
const plain = () => {
|
|
85
|
+
const text = slice.content.textBetween(0, slice.content.size, "\n", " ");
|
|
86
|
+
if (!view.isDestroyed && text) view.dispatch(view.state.tr.insertText(text, from, to));
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
runFormat(view, place).then((result) => {
|
|
90
|
+
if (result?.status === "applied") return;
|
|
91
|
+
if (!view.isDestroyed) announce(view, result?.message ?? "That format isn\u2019t available here.");
|
|
92
|
+
plain();
|
|
93
|
+
}, plain);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The typed `" ` shortcut: the paragraph the reader is typing in became a quote,
|
|
97
|
+
// and the space that typed it goes with it. A refusal puts the space back as
|
|
98
|
+
// itself, so the typed character is never swallowed.
|
|
99
|
+
export function quoteFromSpace(plugin, view, { at, from, to }, plain) {
|
|
100
|
+
const convert = () => {
|
|
101
|
+
if (view.isDestroyed) return false;
|
|
102
|
+
const now = view.state;
|
|
103
|
+
const current = now.doc.nodeAt(at);
|
|
104
|
+
if (!current || current.type !== schema.nodes.paragraph || current.textContent !== '"') return plain();
|
|
105
|
+
const tr = now.tr.delete(at + 1, at + 2).setNodeMarkup(at, schema.nodes.quote, current.attrs);
|
|
106
|
+
view.dispatch(asStep(tr.setMeta(plugin, { transform: tr, from, to, text: " " })));
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
view.runFormatCommand(convert).then((result) => { if (result?.status !== "applied") plain(); }, plain);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The More menu's rows: the underline command, the palette of a color mark and
|
|
113
|
+
// the notice a refusal raises. The palette is fixed: nine keys and the way back
|
|
114
|
+
// to none, and no arbitrary color picker is offered.
|
|
115
|
+
const CANNOT_MARK = "This text can\u2019t take that format.";
|
|
116
|
+
const refusalText = (result) => (result.status === "upgrade-required" ? result.message : result.code === "mark-not-allowed" ? CANNOT_MARK : "That format isn\u2019t available here.");
|
|
117
|
+
|
|
118
|
+
const notice = (view, result) => {
|
|
119
|
+
if (result?.status !== "applied" && !view.isDestroyed) announce(view, refusalText(result ?? {}));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const runNative = (view, name, attrs = {}, options = undefined) => {
|
|
123
|
+
formatMark(view, name, attrs, options).then((result) => notice(view, result));
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// A block command from a later format, like the turn into a quote: the write asks
|
|
127
|
+
// the gate first, and a refusal is a notice rather than a change.
|
|
128
|
+
export const gateTurn = (view, turn) => {
|
|
129
|
+
runFormat(view, turn).then((result) => notice(view, result));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// The palette of one color mark, as its own short menu: the nine fixed keys and
|
|
133
|
+
// the way back to none.
|
|
134
|
+
export function nativeMenu(view, name, anchor) {
|
|
135
|
+
if (name === "underline") return runNative(view, name);
|
|
136
|
+
const colorLabel = name === "text_color" ? "Text" : "Highlight";
|
|
137
|
+
const noneLabel = name === "text_color" ? "Automatic" : "No highlight";
|
|
138
|
+
return openPageMenu(view, anchor, [
|
|
139
|
+
...PALETTE.map((color) => ({ key: `${name}_${color}`, label: `${colorLabel}: ${color[0].toUpperCase()}${color.slice(1)}`, icon: "\u25CF", run: (v) => runNative(v, name, { color }) })),
|
|
140
|
+
{ key: `${name}_none`, label: noneLabel, icon: "\u00d7", run: (v) => runNative(v, name, {}, { reset: true }) },
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The four formats this release adds: underline, text color, highlight and the
|
|
2
|
+
// quote block.
|
|
3
|
+
//
|
|
4
|
+
// Each is written as the named descriptor an older runtime already keeps as an
|
|
5
|
+
// opaque mark, `x["text_color",{"color":"red"}]`, so R reads back exactly what F
|
|
6
|
+
// wrote. The palette is fixed and its keys are the only colors a descriptor may
|
|
7
|
+
// name: one naming any other color stays opaque rather than being read as a
|
|
8
|
+
// style, and no command accepts arbitrary CSS.
|
|
9
|
+
//
|
|
10
|
+
// This half is the reading one: what the schema, inline.js and the projection need
|
|
11
|
+
// to read a descriptor back and draw it. The commands that write one are in
|
|
12
|
+
// format-native-commands.js, which a page loads the first time one runs.
|
|
13
|
+
export const NATIVE_NAMES = new Set(["underline", "text_color", "highlight"]);
|
|
14
|
+
|
|
15
|
+
export const PALETTE = ["gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"];
|
|
16
|
+
const COLORS = new Set(PALETTE);
|
|
17
|
+
|
|
18
|
+
// The descriptor attrs of a native mark, or null when this name and these attrs
|
|
19
|
+
// are not one of the formats this runtime writes.
|
|
20
|
+
export function nativeAttrs(name, attrs) {
|
|
21
|
+
const plain = attrs !== null && typeof attrs === "object" && !Array.isArray(attrs);
|
|
22
|
+
if (name === "underline") return plain && Object.keys(attrs).length === 0 ? {} : null;
|
|
23
|
+
if (name === "text_color" || name === "highlight") {
|
|
24
|
+
const keys = plain ? Object.keys(attrs) : [];
|
|
25
|
+
return keys.length === 1 && keys[0] === "color" && COLORS.has(attrs.color) ? { color: attrs.color } : null;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Whether this selection holds text the mark may be added to: a code block takes
|
|
31
|
+
// no marks, and a block selection is not text at all. It stays here, beside the
|
|
32
|
+
// reading half, because the keyboard decides whether Mod-u is its own before any
|
|
33
|
+
// command is loaded.
|
|
34
|
+
export function markable(st, type) {
|
|
35
|
+
const { $from, $to, empty } = st.selection;
|
|
36
|
+
if (empty) return $from.parent.type.allowsMarkType(type);
|
|
37
|
+
let ok = false;
|
|
38
|
+
st.doc.nodesBetween($from.pos, $to.pos, (node, _at, parent) => {
|
|
39
|
+
if (!ok && node.isText && parent?.type.allowsMarkType(type)) ok = true;
|
|
40
|
+
});
|
|
41
|
+
return ok;
|
|
42
|
+
}
|