@tightknitai/block-kitchen 0.8.3 → 0.8.5

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/index.cjs CHANGED
@@ -4,10 +4,10 @@ var core = require('@dnd-kit/core');
4
4
  var sortable = require('@dnd-kit/sortable');
5
5
  var lucideReact = require('lucide-react');
6
6
  var React = require('react');
7
+ var jsxRuntime = require('react/jsx-runtime');
7
8
  var DialogPrimitive = require('@radix-ui/react-dialog');
8
9
  var clsx = require('clsx');
9
10
  var tailwindMerge = require('tailwind-merge');
10
- var jsxRuntime = require('react/jsx-runtime');
11
11
  var TooltipPrimitive = require('@radix-ui/react-tooltip');
12
12
  var slackBlockKitValidator = require('@tightknitai/slack-block-kit-validator');
13
13
  var nanoid = require('nanoid');
@@ -53,6 +53,35 @@ var Link__default = /*#__PURE__*/_interopDefault(Link);
53
53
  var StarterKit__default = /*#__PURE__*/_interopDefault(StarterKit);
54
54
 
55
55
  // src/components/block-kitchen.tsx
56
+ function makeEmojiHook(byName) {
57
+ return (data, parse) => {
58
+ const entry = byName.get(data.name);
59
+ if (entry) {
60
+ if (entry.url) {
61
+ return /* @__PURE__ */ jsxRuntime.jsx(
62
+ "img",
63
+ {
64
+ src: entry.url,
65
+ alt: `:${data.name}:`,
66
+ title: `:${data.name}:`,
67
+ className: "bk-custom-emoji",
68
+ style: {
69
+ display: "inline-block",
70
+ width: "1.25em",
71
+ height: "1.25em",
72
+ verticalAlign: "-0.25em",
73
+ objectFit: "contain"
74
+ }
75
+ }
76
+ );
77
+ }
78
+ if (entry.alias) {
79
+ return parse({ name: entry.alias, unicode: data.unicode, skin_tone: data.skin_tone });
80
+ }
81
+ }
82
+ return parse(data);
83
+ };
84
+ }
56
85
  var defaultPalette = [
57
86
  {
58
87
  name: "Agents",
@@ -1549,50 +1578,63 @@ function useIsMobile() {
1549
1578
  }, []);
1550
1579
  return isMobile;
1551
1580
  }
1581
+ var EMPTY = {
1582
+ customEmojis: [],
1583
+ byName: /* @__PURE__ */ new Map()
1584
+ };
1585
+ var CustomEmojiContext = React.createContext(EMPTY);
1586
+ function CustomEmojiProvider({ customEmojis, children }) {
1587
+ const value = React.useMemo(() => {
1588
+ if (!customEmojis || customEmojis.length === 0) {
1589
+ return EMPTY;
1590
+ }
1591
+ const byName = /* @__PURE__ */ new Map();
1592
+ for (const emoji of customEmojis) {
1593
+ byName.set(emoji.name, emoji);
1594
+ }
1595
+ return { customEmojis, byName };
1596
+ }, [customEmojis]);
1597
+ return /* @__PURE__ */ jsxRuntime.jsx(CustomEmojiContext.Provider, { value, children });
1598
+ }
1599
+ function useCustomEmojis() {
1600
+ return React.useContext(CustomEmojiContext);
1601
+ }
1552
1602
 
1553
1603
  // src/lib/error-grouping.ts
1554
1604
  function toValidatorSurface(surface) {
1555
1605
  return surface === "app_home" ? "home" : surface;
1556
1606
  }
1557
- function extractBlockIndex(path) {
1558
- if (!path || path === "(root)") {
1559
- return null;
1560
- }
1561
- const match = /^\/(\d+)(?:\/|$)/.exec(path);
1562
- if (!match) {
1563
- return null;
1607
+ var BLOCK_PREFIX = /^blocks\[(\d+)\]/;
1608
+ function extractBlockIndex(raw) {
1609
+ const match = BLOCK_PREFIX.exec(raw);
1610
+ return match ? Number.parseInt(match[1], 10) : null;
1611
+ }
1612
+ function toBlockMessage(raw) {
1613
+ const rest = raw.replace(BLOCK_PREFIX, "");
1614
+ if (rest.startsWith(":")) {
1615
+ return rest.replace(/^:\s*/, "");
1564
1616
  }
1565
- return Number.parseInt(match[1], 10);
1617
+ return rest.replace(/^\./, "");
1566
1618
  }
1567
- function stripBlockPrefix(path) {
1568
- return path.replace(/^\/\d+/, "") || "(root)";
1619
+ function toGeneralMessage(raw) {
1620
+ return raw.startsWith("(root)") ? raw.replace(/^\(root\):?\s*/, "") : raw;
1569
1621
  }
1570
1622
  function groupValidatorErrors(errors, blocks) {
1571
1623
  const byBlockId = /* @__PURE__ */ new Map();
1572
1624
  const general = [];
1573
1625
  for (const raw of errors) {
1574
- const space = raw.indexOf(" ");
1575
- const path = space === -1 ? "(root)" : raw.slice(0, space);
1576
- const message = space === -1 ? raw : raw.slice(space + 1);
1577
- const idx = extractBlockIndex(path);
1626
+ const idx = extractBlockIndex(raw);
1578
1627
  if (idx === null || idx >= blocks.length) {
1579
- general.push(raw);
1628
+ general.push(toGeneralMessage(raw));
1580
1629
  continue;
1581
1630
  }
1582
1631
  const id = blocks[idx].id;
1583
- const friendly = formatMessage(stripBlockPrefix(path), message);
1584
1632
  const bucket = byBlockId.get(id) ?? [];
1585
- bucket.push(friendly);
1633
+ bucket.push(toBlockMessage(raw));
1586
1634
  byBlockId.set(id, bucket);
1587
1635
  }
1588
1636
  return { byBlockId, general, total: errors.length };
1589
1637
  }
1590
- function formatMessage(relativePath, message) {
1591
- if (relativePath === "" || relativePath === "(root)") {
1592
- return message;
1593
- }
1594
- return `${relativePath}: ${message}`;
1595
- }
1596
1638
 
1597
1639
  // src/lib/url-safety.ts
1598
1640
  var SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;
@@ -1628,6 +1670,12 @@ function isSafeHref(value) {
1628
1670
  }
1629
1671
  return SAFE_LINK_PROTOCOLS.test(scheme);
1630
1672
  }
1673
+ function hasExplicitSafeScheme(value) {
1674
+ if (typeof value !== "string" || value.length === 0) {
1675
+ return false;
1676
+ }
1677
+ return getScheme(value) !== null && isSafeHref(value);
1678
+ }
1631
1679
  function isSafeImageSrc(value) {
1632
1680
  if (value === null || value === void 0) {
1633
1681
  return false;
@@ -1712,10 +1760,47 @@ function toSlackBlocks(blocks) {
1712
1760
  });
1713
1761
  }
1714
1762
 
1763
+ // src/lib/validation-sanitize.ts
1764
+ var VALIDATION_EMOJI_PLACEHOLDER = "white_check_mark";
1765
+ function rewriteEmojiNames(value) {
1766
+ if (Array.isArray(value)) {
1767
+ let changed = false;
1768
+ const next = value.map((item) => {
1769
+ const rewritten = rewriteEmojiNames(item);
1770
+ if (rewritten !== item) {
1771
+ changed = true;
1772
+ }
1773
+ return rewritten;
1774
+ });
1775
+ return changed ? next : value;
1776
+ }
1777
+ if (value !== null && typeof value === "object") {
1778
+ const source = value;
1779
+ let changed = false;
1780
+ const next = {};
1781
+ for (const key of Object.keys(source)) {
1782
+ const rewritten = rewriteEmojiNames(source[key]);
1783
+ next[key] = rewritten;
1784
+ if (rewritten !== source[key]) {
1785
+ changed = true;
1786
+ }
1787
+ }
1788
+ if (next.type === "emoji" && typeof next.name === "string" && next.name !== VALIDATION_EMOJI_PLACEHOLDER) {
1789
+ next.name = VALIDATION_EMOJI_PLACEHOLDER;
1790
+ changed = true;
1791
+ }
1792
+ return changed ? next : value;
1793
+ }
1794
+ return value;
1795
+ }
1796
+ function stripCustomEmojiForValidation(blocks) {
1797
+ return blocks.map((block) => rewriteEmojiNames(block));
1798
+ }
1799
+
1715
1800
  // src/state/use-block-kit-validation.ts
1716
1801
  var DEBOUNCE_MS = 150;
1717
1802
  function computeValidation(blocks, surface) {
1718
- const payload = toSlackBlocks(blocks.map((b) => b.block));
1803
+ const payload = stripCustomEmojiForValidation(toSlackBlocks(blocks.map((b) => b.block)));
1719
1804
  const result = slackBlockKitValidator.validateBlockKit(payload, {
1720
1805
  target: "blocks",
1721
1806
  surface: toValidatorSurface(surface)
@@ -3561,52 +3646,55 @@ function ContextEditor({ block, onChange }) {
3561
3646
  {
3562
3647
  label: "Items",
3563
3648
  help: "Shown in a horizontal row, usually as footnote-style content. Slack supports up to 10 items.",
3564
- children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-col gap-2", children: elements.map((el, idx) => {
3565
- if (el.type === "mrkdwn" || el.type === "plain_text") {
3566
- return /* @__PURE__ */ jsxRuntime.jsx(
3567
- TextItemCard,
3568
- {
3569
- index: idx,
3570
- element: el,
3571
- onChange: (next) => updateAt(idx, () => next),
3572
- onRemove: () => removeAt(idx)
3573
- },
3574
- idx
3575
- );
3576
- }
3577
- if (el.type === "image" && "image_url" in el) {
3578
- return /* @__PURE__ */ jsxRuntime.jsx(
3579
- ImageItemCard,
3649
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2", children: [
3650
+ elements.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "rounded-md border border-dashed bg-muted/20 px-3 py-2 text-xs text-muted-foreground", children: "No items yet \u2014 a context block needs at least one. Add text or an image below, or delete the block if you don't need it." }) : null,
3651
+ elements.map((el, idx) => {
3652
+ if (el.type === "mrkdwn" || el.type === "plain_text") {
3653
+ return /* @__PURE__ */ jsxRuntime.jsx(
3654
+ TextItemCard,
3655
+ {
3656
+ index: idx,
3657
+ element: el,
3658
+ onChange: (next) => updateAt(idx, () => next),
3659
+ onRemove: () => removeAt(idx)
3660
+ },
3661
+ idx
3662
+ );
3663
+ }
3664
+ if (el.type === "image" && "image_url" in el) {
3665
+ return /* @__PURE__ */ jsxRuntime.jsx(
3666
+ ImageItemCard,
3667
+ {
3668
+ index: idx,
3669
+ element: el,
3670
+ onChange: (next) => updateAt(idx, () => next),
3671
+ onRemove: () => removeAt(idx)
3672
+ },
3673
+ idx
3674
+ );
3675
+ }
3676
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3677
+ "div",
3580
3678
  {
3581
- index: idx,
3582
- element: el,
3583
- onChange: (next) => updateAt(idx, () => next),
3584
- onRemove: () => removeAt(idx)
3679
+ className: "flex items-center justify-between rounded border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground",
3680
+ children: [
3681
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Item not editable in the visual builder" }),
3682
+ /* @__PURE__ */ jsxRuntime.jsx(
3683
+ "button",
3684
+ {
3685
+ type: "button",
3686
+ "aria-label": "Remove item",
3687
+ onClick: () => removeAt(idx),
3688
+ className: "text-muted-foreground hover:text-destructive",
3689
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Trash2, { className: "h-3.5 w-3.5" })
3690
+ }
3691
+ )
3692
+ ]
3585
3693
  },
3586
3694
  idx
3587
3695
  );
3588
- }
3589
- return /* @__PURE__ */ jsxRuntime.jsxs(
3590
- "div",
3591
- {
3592
- className: "flex items-center justify-between rounded border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground",
3593
- children: [
3594
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Item not editable in the visual builder" }),
3595
- /* @__PURE__ */ jsxRuntime.jsx(
3596
- "button",
3597
- {
3598
- type: "button",
3599
- "aria-label": "Remove item",
3600
- onClick: () => removeAt(idx),
3601
- className: "text-muted-foreground hover:text-destructive",
3602
- children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Trash2, { className: "h-3.5 w-3.5" })
3603
- }
3604
- )
3605
- ]
3606
- },
3607
- idx
3608
- );
3609
- }) })
3696
+ })
3697
+ ] })
3610
3698
  }
3611
3699
  ),
3612
3700
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex gap-2", children: [
@@ -3745,12 +3833,423 @@ function ImageItemCard({
3745
3833
  function DividerEditor() {
3746
3834
  return /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: "A horizontal line that separates content. This block has no options to configure." });
3747
3835
  }
3836
+
3837
+ // src/lib/emoji-data.ts
3838
+ var TONE_CODEPOINT_TO_SKIN_TONE = {
3839
+ "1F3FB": 2,
3840
+ "1F3FC": 3,
3841
+ "1F3FD": 4,
3842
+ "1F3FE": 5,
3843
+ "1F3FF": 6
3844
+ };
3845
+ var CATEGORY_ORDER = [
3846
+ "Smileys & Emotion",
3847
+ "People & Body",
3848
+ "Animals & Nature",
3849
+ "Food & Drink",
3850
+ "Travel & Places",
3851
+ "Activities",
3852
+ "Objects",
3853
+ "Symbols",
3854
+ "Flags"
3855
+ ];
3856
+ function codepointsToGlyph(unified) {
3857
+ return unified.split("-").map((cp) => {
3858
+ const n = Number.parseInt(cp, 16);
3859
+ return Number.isFinite(n) ? String.fromCodePoint(n) : "";
3860
+ }).join("");
3861
+ }
3862
+ function unifiedToUnicode(unified) {
3863
+ return unified.toLowerCase();
3864
+ }
3865
+ function composeTextEmoji(name, skinTone) {
3866
+ if (skinTone && skinTone >= 2 && skinTone <= 6) {
3867
+ return `:${name}::skin-tone-${skinTone}:`;
3868
+ }
3869
+ return `:${name}:`;
3870
+ }
3871
+ function buildEmojiIndex(raw) {
3872
+ const all = [];
3873
+ const byName = /* @__PURE__ */ new Map();
3874
+ const byUnified = /* @__PURE__ */ new Map();
3875
+ for (const r of raw) {
3876
+ if (!CATEGORY_ORDER.includes(r.category)) {
3877
+ continue;
3878
+ }
3879
+ const shortNames = r.short_names?.length ? r.short_names : [r.short_name];
3880
+ const skinUnified = {};
3881
+ if (r.skin_variations) {
3882
+ for (const [toneKey, variation] of Object.entries(r.skin_variations)) {
3883
+ const skinTone = TONE_CODEPOINT_TO_SKIN_TONE[toneKey];
3884
+ if (skinTone && variation?.unified) {
3885
+ skinUnified[skinTone] = variation.unified;
3886
+ }
3887
+ }
3888
+ }
3889
+ const datum = {
3890
+ name: shortNames[0],
3891
+ shortNames,
3892
+ unified: r.unified,
3893
+ category: r.category,
3894
+ sortOrder: r.sort_order,
3895
+ skinUnified
3896
+ };
3897
+ all.push(datum);
3898
+ byUnified.set(r.unified.toUpperCase(), datum);
3899
+ for (const sn of shortNames) {
3900
+ if (!byName.has(sn)) {
3901
+ byName.set(sn, datum);
3902
+ }
3903
+ }
3904
+ }
3905
+ all.sort((a, b) => a.sortOrder - b.sortOrder);
3906
+ const byCategory = CATEGORY_ORDER.map((category) => ({
3907
+ category,
3908
+ emoji: all.filter((e) => e.category === category)
3909
+ })).filter((group) => group.emoji.length > 0);
3910
+ return { all, byName, byUnified, byCategory };
3911
+ }
3912
+ var indexPromise = null;
3913
+ var cachedIndex = null;
3914
+ async function loadEmojiIndex() {
3915
+ if (cachedIndex) {
3916
+ return cachedIndex;
3917
+ }
3918
+ if (!indexPromise) {
3919
+ indexPromise = import('emoji-datasource/emoji.json').then((mod) => {
3920
+ const raw = mod.default ?? mod;
3921
+ cachedIndex = buildEmojiIndex(raw);
3922
+ return cachedIndex;
3923
+ }).catch((err) => {
3924
+ indexPromise = null;
3925
+ throw err;
3926
+ });
3927
+ }
3928
+ return indexPromise;
3929
+ }
3930
+ function getEmojiIndex() {
3931
+ return cachedIndex;
3932
+ }
3933
+
3934
+ // src/lib/emoji-recents.ts
3935
+ var STORAGE_KEY = "bk:emoji-recents";
3936
+ var SKIN_TONE_KEY = "bk:emoji-skin-tone";
3937
+ var MAX_RECENTS = 24;
3938
+ function readEmojiSkinTone() {
3939
+ try {
3940
+ const raw = globalThis.localStorage?.getItem(SKIN_TONE_KEY);
3941
+ if (!raw) {
3942
+ return null;
3943
+ }
3944
+ const n = Number.parseInt(raw, 10);
3945
+ return n >= 2 && n <= 6 ? n : null;
3946
+ } catch {
3947
+ return null;
3948
+ }
3949
+ }
3950
+ function writeEmojiSkinTone(skinTone) {
3951
+ try {
3952
+ if (skinTone && skinTone >= 2 && skinTone <= 6) {
3953
+ globalThis.localStorage?.setItem(SKIN_TONE_KEY, String(skinTone));
3954
+ } else {
3955
+ globalThis.localStorage?.removeItem(SKIN_TONE_KEY);
3956
+ }
3957
+ } catch {
3958
+ }
3959
+ }
3960
+ function readEmojiRecents() {
3961
+ try {
3962
+ const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
3963
+ if (!raw) {
3964
+ return [];
3965
+ }
3966
+ const parsed = JSON.parse(raw);
3967
+ return Array.isArray(parsed) ? parsed.filter((n) => typeof n === "string") : [];
3968
+ } catch {
3969
+ return [];
3970
+ }
3971
+ }
3972
+ function pushEmojiRecent(name) {
3973
+ const next = [name, ...readEmojiRecents().filter((n) => n !== name)].slice(0, MAX_RECENTS);
3974
+ try {
3975
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next));
3976
+ } catch {
3977
+ }
3978
+ return next;
3979
+ }
3980
+ var SKIN_TONES = [
3981
+ { tone: null, label: "Default", swatch: "\u270B" },
3982
+ { tone: 2, label: "Light", swatch: "\u270B\u{1F3FB}" },
3983
+ { tone: 3, label: "Medium-light", swatch: "\u270B\u{1F3FC}" },
3984
+ { tone: 4, label: "Medium", swatch: "\u270B\u{1F3FD}" },
3985
+ { tone: 5, label: "Medium-dark", swatch: "\u270B\u{1F3FE}" },
3986
+ { tone: 6, label: "Dark", swatch: "\u270B\u{1F3FF}" }
3987
+ ];
3988
+ function EmojiPicker({
3989
+ onSelect,
3990
+ autoFocus = true
3991
+ }) {
3992
+ const { customEmojis } = useCustomEmojis();
3993
+ const [index, setIndex] = React.useState(null);
3994
+ const [loadError, setLoadError] = React.useState(false);
3995
+ const [query, setQuery] = React.useState("");
3996
+ const [skinTone, setSkinTone] = React.useState(() => readEmojiSkinTone());
3997
+ const [recents, setRecents] = React.useState(() => readEmojiRecents());
3998
+ const searchRef = React.useRef(null);
3999
+ React.useEffect(() => {
4000
+ let active = true;
4001
+ loadEmojiIndex().then((idx) => {
4002
+ if (active) {
4003
+ setIndex(idx);
4004
+ }
4005
+ }).catch(() => {
4006
+ if (active) {
4007
+ setLoadError(true);
4008
+ }
4009
+ });
4010
+ return () => {
4011
+ active = false;
4012
+ };
4013
+ }, []);
4014
+ React.useEffect(() => {
4015
+ if (autoFocus) {
4016
+ searchRef.current?.focus();
4017
+ }
4018
+ }, [autoFocus]);
4019
+ const customByName = React.useMemo(() => {
4020
+ const map = /* @__PURE__ */ new Map();
4021
+ for (const e of customEmojis) {
4022
+ map.set(e.name, e);
4023
+ }
4024
+ return map;
4025
+ }, [customEmojis]);
4026
+ const customImageEmoji = React.useMemo(() => customEmojis.filter((e) => e.url), [customEmojis]);
4027
+ const handlePick = (selection) => {
4028
+ setRecents(pushEmojiRecent(selection.name));
4029
+ onSelect(selection);
4030
+ };
4031
+ const handleSkinTone = (tone) => {
4032
+ setSkinTone(tone);
4033
+ writeEmojiSkinTone(tone);
4034
+ };
4035
+ const sections = React.useMemo(() => {
4036
+ if (!index) {
4037
+ return [];
4038
+ }
4039
+ const trimmed = query.trim().toLowerCase();
4040
+ if (trimmed) {
4041
+ const standard = index.all.filter((d) => d.shortNames.some((sn) => sn.includes(trimmed))).slice(0, 120).map((datum) => ({ datum }));
4042
+ const custom = customImageEmoji.filter((e) => e.name.toLowerCase().includes(trimmed)).map((custom2) => ({ custom: custom2 }));
4043
+ return [{ key: "search", title: "Search results", emoji: [...custom, ...standard] }];
4044
+ }
4045
+ const out = [];
4046
+ if (recents.length > 0) {
4047
+ const recentEmoji = recents.map((name) => {
4048
+ const custom = customByName.get(name);
4049
+ if (custom?.url) {
4050
+ return { custom };
4051
+ }
4052
+ const datum = index.byName.get(name);
4053
+ return datum ? { datum } : null;
4054
+ }).filter((e) => e !== null);
4055
+ if (recentEmoji.length > 0) {
4056
+ out.push({ key: "recents", title: "Frequently used", emoji: recentEmoji });
4057
+ }
4058
+ }
4059
+ if (customImageEmoji.length > 0) {
4060
+ out.push({ key: "custom", title: "Custom", emoji: customImageEmoji.map((custom) => ({ custom })) });
4061
+ }
4062
+ for (const group of index.byCategory) {
4063
+ out.push({ key: group.category, title: group.category, emoji: group.emoji.map((datum) => ({ datum })) });
4064
+ }
4065
+ return out;
4066
+ }, [index, query, recents, customByName, customImageEmoji]);
4067
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "bk-emoji-picker flex w-[320px] max-w-[90vw] flex-col gap-2", children: [
4068
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [
4069
+ /* @__PURE__ */ jsxRuntime.jsx(
4070
+ Input,
4071
+ {
4072
+ ref: searchRef,
4073
+ value: query,
4074
+ placeholder: "Search emoji\u2026",
4075
+ "aria-label": "Search emoji",
4076
+ onChange: (e) => setQuery(e.target.value),
4077
+ className: "h-8"
4078
+ }
4079
+ ),
4080
+ /* @__PURE__ */ jsxRuntime.jsx(SkinToneSelector, { value: skinTone, onChange: handleSkinTone })
4081
+ ] }),
4082
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "h-64 overflow-y-auto pr-1", children: loadError ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "p-4 text-center text-xs text-muted-foreground", children: "Couldn't load emoji." }) : !index ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "p-4 text-center text-xs text-muted-foreground", children: "Loading emoji\u2026" }) : sections.every((s) => s.emoji.length === 0) ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "p-4 text-center text-xs text-muted-foreground", children: "No emoji found." }) : sections.map((section) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-2", children: [
4083
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "sticky top-0 z-10 bg-background px-1 py-1 text-[11px] font-medium text-muted-foreground", children: section.title }),
4084
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "grid grid-cols-8 gap-0.5", children: section.emoji.map(
4085
+ (entry, i) => entry.custom ? /* @__PURE__ */ jsxRuntime.jsx(
4086
+ CustomEmojiButton,
4087
+ {
4088
+ emoji: entry.custom,
4089
+ onPick: handlePick
4090
+ },
4091
+ `${section.key}-${entry.custom.name}-${i}`
4092
+ ) : entry.datum ? /* @__PURE__ */ jsxRuntime.jsx(
4093
+ StandardEmojiButton,
4094
+ {
4095
+ datum: entry.datum,
4096
+ skinTone,
4097
+ onPick: handlePick
4098
+ },
4099
+ `${section.key}-${entry.datum.name}-${i}`
4100
+ ) : null
4101
+ ) })
4102
+ ] }, section.key)) })
4103
+ ] });
4104
+ }
4105
+ function resolveStandard(datum, skinTone) {
4106
+ const tonedUnified = skinTone ? datum.skinUnified[skinTone] : void 0;
4107
+ return {
4108
+ name: datum.name,
4109
+ unicode: unifiedToUnicode(tonedUnified ?? datum.unified),
4110
+ src: null,
4111
+ skinTone: tonedUnified ? skinTone : null
4112
+ };
4113
+ }
4114
+ var emojiButtonClass = "flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg leading-none hover:bg-accent";
4115
+ function StandardEmojiButton({
4116
+ datum,
4117
+ skinTone,
4118
+ onPick
4119
+ }) {
4120
+ const tonedUnified = skinTone && datum.skinUnified[skinTone] || datum.unified;
4121
+ return /* @__PURE__ */ jsxRuntime.jsx(
4122
+ "button",
4123
+ {
4124
+ type: "button",
4125
+ className: emojiButtonClass,
4126
+ title: `:${datum.name}:`,
4127
+ "aria-label": datum.name,
4128
+ onClick: () => onPick(resolveStandard(datum, skinTone)),
4129
+ children: codepointsToGlyph(tonedUnified)
4130
+ }
4131
+ );
4132
+ }
4133
+ function CustomEmojiButton({ emoji, onPick }) {
4134
+ return /* @__PURE__ */ jsxRuntime.jsx(
4135
+ "button",
4136
+ {
4137
+ type: "button",
4138
+ className: emojiButtonClass,
4139
+ title: `:${emoji.name}:`,
4140
+ "aria-label": emoji.name,
4141
+ onClick: () => onPick({ name: emoji.name, unicode: null, src: emoji.url, skinTone: null }),
4142
+ children: emoji.url ? /* @__PURE__ */ jsxRuntime.jsx("img", { src: emoji.url, alt: `:${emoji.name}:`, className: "h-5 w-5 object-contain" }) : /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-[10px]", children: [
4143
+ ":",
4144
+ emoji.name,
4145
+ ":"
4146
+ ] })
4147
+ }
4148
+ );
4149
+ }
4150
+ function SkinToneSelector({ value, onChange }) {
4151
+ const [open, setOpen] = React.useState(false);
4152
+ const active = SKIN_TONES.find((t) => t.tone === value) ?? SKIN_TONES[0];
4153
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative shrink-0", children: [
4154
+ /* @__PURE__ */ jsxRuntime.jsx(
4155
+ "button",
4156
+ {
4157
+ type: "button",
4158
+ "aria-label": `Skin tone: ${active.label}`,
4159
+ title: `Skin tone: ${active.label}`,
4160
+ className: "flex h-8 w-8 items-center justify-center rounded border text-lg leading-none hover:bg-accent",
4161
+ onClick: () => setOpen((o) => !o),
4162
+ children: active.swatch
4163
+ }
4164
+ ),
4165
+ open && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute right-0 z-20 mt-1 flex gap-0.5 rounded-md border bg-background p-1 shadow-md", children: SKIN_TONES.map((t) => /* @__PURE__ */ jsxRuntime.jsx(
4166
+ "button",
4167
+ {
4168
+ type: "button",
4169
+ "aria-label": t.label,
4170
+ title: t.label,
4171
+ className: cn(emojiButtonClass, value === t.tone && "bg-accent"),
4172
+ onClick: () => {
4173
+ onChange(t.tone);
4174
+ setOpen(false);
4175
+ },
4176
+ children: t.swatch
4177
+ },
4178
+ t.label
4179
+ )) })
4180
+ ] });
4181
+ }
4182
+ function EmojiPickerButton({
4183
+ onSelect,
4184
+ className,
4185
+ label = "Insert emoji",
4186
+ align = "start"
4187
+ }) {
4188
+ const [open, setOpen] = React.useState(false);
4189
+ return /* @__PURE__ */ jsxRuntime.jsxs(Popover, { open, onOpenChange: setOpen, children: [
4190
+ /* @__PURE__ */ jsxRuntime.jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxRuntime.jsx(
4191
+ "button",
4192
+ {
4193
+ type: "button",
4194
+ "aria-label": label,
4195
+ title: label,
4196
+ className: cn(
4197
+ "flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
4198
+ className
4199
+ ),
4200
+ children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Smile, { className: "h-3.5 w-3.5" })
4201
+ }
4202
+ ) }),
4203
+ /* @__PURE__ */ jsxRuntime.jsx(PopoverContent, { align, className: "w-auto p-2", children: /* @__PURE__ */ jsxRuntime.jsx(
4204
+ EmojiPicker,
4205
+ {
4206
+ onSelect: (selection) => {
4207
+ onSelect(selection);
4208
+ setOpen(false);
4209
+ }
4210
+ }
4211
+ ) })
4212
+ ] });
4213
+ }
4214
+ function EmojiTextInsertButton({
4215
+ targetRef,
4216
+ value,
4217
+ onChange,
4218
+ className,
4219
+ align = "end"
4220
+ }) {
4221
+ return /* @__PURE__ */ jsxRuntime.jsx(
4222
+ EmojiPickerButton,
4223
+ {
4224
+ align,
4225
+ label: "Insert emoji",
4226
+ className,
4227
+ onSelect: (sel) => {
4228
+ const el = targetRef.current;
4229
+ const insert = composeTextEmoji(sel.name, sel.skinTone ?? void 0);
4230
+ const start = el?.selectionStart ?? value.length;
4231
+ const end = el?.selectionEnd ?? value.length;
4232
+ const next = value.slice(0, start) + insert + value.slice(end);
4233
+ onChange(next);
4234
+ requestAnimationFrame(() => {
4235
+ if (el) {
4236
+ const caret = start + insert.length;
4237
+ el.focus();
4238
+ el.setSelectionRange(caret, caret);
4239
+ }
4240
+ });
4241
+ }
4242
+ }
4243
+ );
4244
+ }
3748
4245
  var HEADER_MAX = 150;
3749
4246
  var HEADER_LEVELS = [1, 2, 3, 4];
3750
4247
  function HeaderEditor({ block, onChange }) {
3751
4248
  const text = block.text?.text ?? "";
3752
4249
  const remaining = HEADER_MAX - text.length;
3753
4250
  const level = block.level;
4251
+ const inputRef = React.useRef(null);
4252
+ const setText = (next) => onChange({ ...block, text: { type: "plain_text", text: next, emoji: true } });
3754
4253
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
3755
4254
  /* @__PURE__ */ jsxRuntime.jsx(
3756
4255
  EditorField,
@@ -3758,23 +4257,20 @@ function HeaderEditor({ block, onChange }) {
3758
4257
  label: "Heading text",
3759
4258
  help: `Plain text only. ${remaining} characters remaining.`,
3760
4259
  htmlFor: "header-text",
3761
- children: /* @__PURE__ */ jsxRuntime.jsx(
3762
- Input,
3763
- {
3764
- id: "header-text",
3765
- value: text,
3766
- maxLength: HEADER_MAX,
3767
- placeholder: "e.g. Weekly roundup",
3768
- onChange: (e) => onChange({
3769
- ...block,
3770
- text: {
3771
- type: "plain_text",
3772
- text: e.target.value,
3773
- emoji: true
3774
- }
3775
- })
3776
- }
3777
- )
4260
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
4261
+ /* @__PURE__ */ jsxRuntime.jsx(
4262
+ Input,
4263
+ {
4264
+ ref: inputRef,
4265
+ id: "header-text",
4266
+ value: text,
4267
+ maxLength: HEADER_MAX,
4268
+ placeholder: "e.g. Weekly roundup",
4269
+ onChange: (e) => setText(e.target.value)
4270
+ }
4271
+ ),
4272
+ /* @__PURE__ */ jsxRuntime.jsx(EmojiTextInsertButton, { targetRef: inputRef, value: text, onChange: setText, className: "shrink-0 border" })
4273
+ ] })
3778
4274
  }
3779
4275
  ),
3780
4276
  /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Level", help: "Optional. Builder-only extension; Slack's API ignores this value.", children: /* @__PURE__ */ jsxRuntime.jsxs(
@@ -4637,22 +5133,29 @@ function CheckboxField({
4637
5133
  ] });
4638
5134
  }
4639
5135
  function MarkdownEditor({ block, onChange }) {
5136
+ const textRef = React.useRef(null);
5137
+ const text = block.text ?? "";
5138
+ const setText = (next) => onChange({ ...block, text: next });
4640
5139
  return /* @__PURE__ */ jsxRuntime.jsx(
4641
5140
  EditorField,
4642
5141
  {
4643
5142
  label: "Markdown",
4644
5143
  help: "Standard markdown with GFM: **bold**, _italic_, lists, tables, ```code```, [links](url).",
4645
5144
  htmlFor: "markdown-text",
4646
- children: /* @__PURE__ */ jsxRuntime.jsx(
4647
- Textarea,
4648
- {
4649
- id: "markdown-text",
4650
- value: block.text ?? "",
4651
- rows: 8,
4652
- placeholder: "e.g. **Roadmap**\\n\\n- Item one\\n- Item two",
4653
- onChange: (e) => onChange({ ...block, text: e.target.value })
4654
- }
4655
- )
5145
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-1.5", children: [
5146
+ /* @__PURE__ */ jsxRuntime.jsx(
5147
+ Textarea,
5148
+ {
5149
+ ref: textRef,
5150
+ id: "markdown-text",
5151
+ value: text,
5152
+ rows: 8,
5153
+ placeholder: "e.g. **Roadmap**\\n\\n- Item one\\n- Item two",
5154
+ onChange: (e) => setText(e.target.value)
5155
+ }
5156
+ ),
5157
+ /* @__PURE__ */ jsxRuntime.jsx(EmojiTextInsertButton, { targetRef: textRef, value: text, onChange: setText, className: "mt-1 shrink-0 border" })
5158
+ ] })
4656
5159
  }
4657
5160
  );
4658
5161
  }
@@ -4747,6 +5250,26 @@ function PlanEditor({ block, onChange }) {
4747
5250
  ] });
4748
5251
  }
4749
5252
 
5253
+ // src/lib/emoji-resolve.ts
5254
+ function makeEmojiImportResolver(customByName, index = getEmojiIndex()) {
5255
+ return (el) => {
5256
+ const name = el.name ?? "";
5257
+ const skinTone = el.skin_tone ?? null;
5258
+ const custom = customByName.get(name);
5259
+ if (custom?.url) {
5260
+ return { name, src: custom.url, unicode: null, skinTone };
5261
+ }
5262
+ const lookupName = custom?.alias ?? name;
5263
+ const datum = index?.byName.get(lookupName);
5264
+ let unicode = el.unicode ?? null;
5265
+ if (!unicode && datum) {
5266
+ const toned = skinTone ? datum.skinUnified[skinTone] : void 0;
5267
+ unicode = unifiedToUnicode(toned ?? datum.unified);
5268
+ }
5269
+ return { name, src: null, unicode, skinTone };
5270
+ };
5271
+ }
5272
+
4750
5273
  // src/lib/rich-text-tiptap.ts
4751
5274
  var SUPPORTED_BLOCK_KINDS = /* @__PURE__ */ new Set([
4752
5275
  "rich_text_section",
@@ -4754,7 +5277,7 @@ var SUPPORTED_BLOCK_KINDS = /* @__PURE__ */ new Set([
4754
5277
  "rich_text_quote",
4755
5278
  "rich_text_preformatted"
4756
5279
  ]);
4757
- var SUPPORTED_INLINE_KINDS = /* @__PURE__ */ new Set(["text", "link"]);
5280
+ var SUPPORTED_INLINE_KINDS = /* @__PURE__ */ new Set(["text", "link", "emoji"]);
4758
5281
  var SUPPORTED_TEXT_STYLE_KEYS = /* @__PURE__ */ new Set(["bold", "italic", "strike", "code"]);
4759
5282
  var MAX_LIST_INDENT = 8;
4760
5283
  function detectLossy(block) {
@@ -4801,16 +5324,22 @@ function scanInlines(inlines, where, out) {
4801
5324
  }
4802
5325
  });
4803
5326
  }
4804
- function richTextToProseMirror(block) {
5327
+ var defaultEmojiResolver = (el) => ({
5328
+ name: el.name ?? "",
5329
+ src: null,
5330
+ unicode: el.unicode ?? null,
5331
+ skinTone: el.skin_tone ?? null
5332
+ });
5333
+ function richTextToProseMirror(block, resolveEmoji = defaultEmojiResolver) {
4805
5334
  const content = [];
4806
5335
  const listsByIndent = /* @__PURE__ */ new Map();
4807
5336
  for (const el of block.elements ?? []) {
4808
5337
  if (el.type === "rich_text_list") {
4809
- appendRichTextList(el, content, listsByIndent);
5338
+ appendRichTextList(el, content, listsByIndent, resolveEmoji);
4810
5339
  continue;
4811
5340
  }
4812
5341
  listsByIndent.clear();
4813
- const node = blockElementToPM(el);
5342
+ const node = blockElementToPM(el, resolveEmoji);
4814
5343
  if (node) {
4815
5344
  content.push(node);
4816
5345
  }
@@ -4820,12 +5349,12 @@ function richTextToProseMirror(block) {
4820
5349
  content: content.length > 0 ? content : [{ type: "paragraph" }]
4821
5350
  };
4822
5351
  }
4823
- function appendRichTextList(el, topLevel, listsByIndent) {
5352
+ function appendRichTextList(el, topLevel, listsByIndent, resolveEmoji) {
4824
5353
  const indent = el.indent ?? 0;
4825
5354
  const listType = el.style === "ordered" ? "orderedList" : "bulletList";
4826
5355
  const items = (el.elements ?? []).map((section) => ({
4827
5356
  type: "listItem",
4828
- content: [{ type: "paragraph", content: inlinesToPM(section.elements ?? []) }]
5357
+ content: [{ type: "paragraph", content: inlinesToPM(section.elements ?? [], resolveEmoji) }]
4829
5358
  }));
4830
5359
  for (const k of Array.from(listsByIndent.keys())) {
4831
5360
  if (k > indent) {
@@ -4851,11 +5380,11 @@ function appendRichTextList(el, topLevel, listsByIndent) {
4851
5380
  const parentLastItem = parent.content[parent.content.length - 1];
4852
5381
  parentLastItem.content = [...parentLastItem.content ?? [], list];
4853
5382
  }
4854
- function blockElementToPM(el) {
5383
+ function blockElementToPM(el, resolveEmoji) {
4855
5384
  if (el.type === "rich_text_section") {
4856
5385
  return {
4857
5386
  type: "paragraph",
4858
- content: inlinesToPM(el.elements ?? [])
5387
+ content: inlinesToPM(el.elements ?? [], resolveEmoji)
4859
5388
  };
4860
5389
  }
4861
5390
  if (el.type === "rich_text_quote") {
@@ -4864,7 +5393,7 @@ function blockElementToPM(el) {
4864
5393
  content: [
4865
5394
  {
4866
5395
  type: "paragraph",
4867
- content: inlinesToPM(el.elements ?? [])
5396
+ content: inlinesToPM(el.elements ?? [], resolveEmoji)
4868
5397
  }
4869
5398
  ]
4870
5399
  };
@@ -4879,15 +5408,31 @@ function blockElementToPM(el) {
4879
5408
  }
4880
5409
  return null;
4881
5410
  }
4882
- function inlinesToPM(inlines) {
5411
+ function pushTextWithHardBreaks(out, text, marks) {
5412
+ const segments = text.split("\n");
5413
+ segments.forEach((segment, i) => {
5414
+ if (i > 0) {
5415
+ out.push({ type: "hardBreak" });
5416
+ }
5417
+ if (segment) {
5418
+ out.push({ type: "text", text: segment, marks });
5419
+ }
5420
+ });
5421
+ }
5422
+ function inlinesToPM(inlines, resolveEmoji) {
4883
5423
  const out = [];
4884
5424
  for (const inline of inlines) {
4885
5425
  if (inline.type === "text") {
4886
5426
  if (!inline.text) {
4887
5427
  continue;
4888
5428
  }
4889
- const marks = styleToMarks(inline.style);
4890
- out.push({ type: "text", text: inline.text, marks });
5429
+ pushTextWithHardBreaks(out, inline.text, styleToMarks(inline.style));
5430
+ } else if (inline.type === "emoji") {
5431
+ const attrs = resolveEmoji(inline);
5432
+ if (!attrs.name) {
5433
+ continue;
5434
+ }
5435
+ out.push({ type: "emoji", attrs });
4891
5436
  } else if (inline.type === "link") {
4892
5437
  const link = inline;
4893
5438
  const text = link.text || link.url || "";
@@ -5005,6 +5550,23 @@ function flattenList(listNode, indent, out) {
5005
5550
  function proseMirrorInlinesToRichTextElements(nodes) {
5006
5551
  const out = [];
5007
5552
  for (const node of nodes) {
5553
+ if (node.type === "emoji") {
5554
+ const name = String(node.attrs?.name ?? "");
5555
+ if (!name) {
5556
+ continue;
5557
+ }
5558
+ const emoji = { type: "emoji", name };
5559
+ const skinTone = node.attrs?.skinTone;
5560
+ if (typeof skinTone === "number" && skinTone >= 2 && skinTone <= 6) {
5561
+ emoji.skin_tone = skinTone;
5562
+ }
5563
+ out.push(emoji);
5564
+ continue;
5565
+ }
5566
+ if (node.type === "hardBreak") {
5567
+ out.push({ type: "text", text: "\n" });
5568
+ continue;
5569
+ }
5008
5570
  if (node.type !== "text" || !node.text) {
5009
5571
  continue;
5010
5572
  }
@@ -5075,6 +5637,49 @@ function sameStyle(a, b) {
5075
5637
  }
5076
5638
  return ak.every((k, i) => k === bk[i]);
5077
5639
  }
5640
+ function EmojiNodeView({ node }) {
5641
+ const { name, src, unicode } = node.attrs;
5642
+ const label = `:${name}:`;
5643
+ return /* @__PURE__ */ jsxRuntime.jsx(react.NodeViewWrapper, { as: "span", className: "bk-emoji-node", "data-emoji": name, title: label, children: src ? /* @__PURE__ */ jsxRuntime.jsx(
5644
+ "img",
5645
+ {
5646
+ src,
5647
+ alt: label,
5648
+ style: {
5649
+ display: "inline-block",
5650
+ width: "1.25em",
5651
+ height: "1.25em",
5652
+ verticalAlign: "-0.25em",
5653
+ objectFit: "contain"
5654
+ }
5655
+ }
5656
+ ) : /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-label": label, role: "img", children: unicode ? codepointsToGlyph(unicode) : label }) });
5657
+ }
5658
+ var EmojiNode = react.Node.create({
5659
+ name: "emoji",
5660
+ group: "inline",
5661
+ inline: true,
5662
+ atom: true,
5663
+ selectable: true,
5664
+ addAttributes() {
5665
+ return {
5666
+ name: { default: "" },
5667
+ src: { default: null },
5668
+ unicode: { default: null },
5669
+ skinTone: { default: null }
5670
+ };
5671
+ },
5672
+ parseHTML() {
5673
+ return [{ tag: "span[data-emoji]" }];
5674
+ },
5675
+ renderHTML({ HTMLAttributes }) {
5676
+ const name = HTMLAttributes.name ?? "";
5677
+ return ["span", react.mergeAttributes({ "data-emoji": name }, HTMLAttributes), `:${name}:`];
5678
+ },
5679
+ addNodeView() {
5680
+ return react.ReactNodeViewRenderer(EmojiNodeView);
5681
+ }
5682
+ });
5078
5683
  var BLOCK_ELEMENT_LABEL = {
5079
5684
  rich_text_section: "Section",
5080
5685
  rich_text_list: "List",
@@ -5413,15 +6018,34 @@ function InlineFields({
5413
6018
  }
5414
6019
  if (kind === "emoji") {
5415
6020
  const emoji = element;
5416
- return /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Name", help: "Emoji shortcode without colons, e.g. wave", htmlFor: `${idPrefix}-emoji`, children: /* @__PURE__ */ jsxRuntime.jsx(
5417
- Input,
6021
+ return /* @__PURE__ */ jsxRuntime.jsx(
6022
+ EditorField,
5418
6023
  {
5419
- id: `${idPrefix}-emoji`,
5420
- value: emoji.name ?? "",
5421
- placeholder: "e.g. wave",
5422
- onChange: (e) => onChange({ ...emoji, name: e.target.value })
6024
+ label: "Name",
6025
+ help: "Pick from the emoji list, or type a shortcode (without colons).",
6026
+ htmlFor: `${idPrefix}-emoji`,
6027
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
6028
+ /* @__PURE__ */ jsxRuntime.jsx(
6029
+ Input,
6030
+ {
6031
+ id: `${idPrefix}-emoji`,
6032
+ value: emoji.name ?? "",
6033
+ placeholder: "e.g. wave",
6034
+ onChange: (e) => onChange({ ...emoji, name: e.target.value })
6035
+ }
6036
+ ),
6037
+ /* @__PURE__ */ jsxRuntime.jsx(
6038
+ EmojiPickerButton,
6039
+ {
6040
+ align: "end",
6041
+ label: "Pick emoji",
6042
+ className: "shrink-0 border",
6043
+ onSelect: (sel) => onChange({ ...emoji, name: sel.name, skin_tone: sel.skinTone ?? void 0, unicode: void 0 })
6044
+ }
6045
+ )
6046
+ ] })
5423
6047
  }
5424
- ) });
6048
+ );
5425
6049
  }
5426
6050
  if (kind === "user") {
5427
6051
  const user = element;
@@ -5630,8 +6254,59 @@ function LossyBanner({ reasons }) {
5630
6254
  ] })
5631
6255
  ] });
5632
6256
  }
6257
+ var RICH_TEXT_INPUT_RULE_ALLOWLIST = ["bold", "italic", "strike", "code"];
6258
+ function buildRichTextExtensions() {
6259
+ return [
6260
+ StarterKit__default.default.configure({
6261
+ heading: false,
6262
+ horizontalRule: false,
6263
+ // StarterKit bundles its own Link extension (enabled by default),
6264
+ // which would register a second, unconfigured `link` mark and a
6265
+ // duplicate autolinker alongside the hardened one we add below.
6266
+ // Disable it so our single, security-configured Link is the only
6267
+ // link extension in the schema.
6268
+ link: false
6269
+ }),
6270
+ EmojiNode,
6271
+ Link__default.default.configure({
6272
+ openOnClick: false,
6273
+ autolink: true,
6274
+ defaultProtocol: "https",
6275
+ // Pin TipTap's link allowlist to the same set as our shared
6276
+ // `isSafeHref` helper. The upstream default also includes
6277
+ // ftp/cid/callto which we don't expect inside Slack content.
6278
+ protocols: ["http", "https", "mailto", "tel", "sms", "xmpp"],
6279
+ // Belt-and-suspenders: even if a downstream contributor widens
6280
+ // `protocols` later, our isSafeHref guard rejects everything
6281
+ // outside the safe-link set. setLink and toggleLink both gate
6282
+ // on this hook before applying the mark.
6283
+ isAllowedUri: (url) => isSafeHref(url),
6284
+ // Only auto-link tokens that carry an explicit, safe scheme. TipTap's
6285
+ // default would link any `host.tld` (e.g. `2.xyz`, `report.zip`,
6286
+ // `logo.png`) because the suffix is a real gTLD, which is wrong for
6287
+ // free-form Slack message text. Deliberate links can still be added
6288
+ // via the link button.
6289
+ shouldAutoLink: hasExplicitSafeScheme,
6290
+ HTMLAttributes: { rel: "noreferrer noopener", target: "_blank" }
6291
+ })
6292
+ ];
6293
+ }
5633
6294
  function RichTextWysiwygEditor({ block, onChange }) {
5634
6295
  const lastEmittedRef = React.useRef(null);
6296
+ const { byName: customByName } = useCustomEmojis();
6297
+ const [emojiIndex, setEmojiIndex] = React.useState(null);
6298
+ React.useEffect(() => {
6299
+ let active = true;
6300
+ loadEmojiIndex().then((idx) => active && setEmojiIndex(idx)).catch(() => {
6301
+ });
6302
+ return () => {
6303
+ active = false;
6304
+ };
6305
+ }, []);
6306
+ const resolveEmoji = React.useMemo(
6307
+ () => makeEmojiImportResolver(customByName, emojiIndex),
6308
+ [customByName, emojiIndex]
6309
+ );
5635
6310
  const handleUpdate = React.useCallback(
5636
6311
  (editor2) => {
5637
6312
  const next = proseMirrorToRichText(editor2.getJSON());
@@ -5641,28 +6316,9 @@ function RichTextWysiwygEditor({ block, onChange }) {
5641
6316
  [onChange]
5642
6317
  );
5643
6318
  const editor = react.useEditor({
5644
- extensions: [
5645
- StarterKit__default.default.configure({
5646
- heading: false,
5647
- horizontalRule: false
5648
- }),
5649
- Link__default.default.configure({
5650
- openOnClick: false,
5651
- autolink: true,
5652
- defaultProtocol: "https",
5653
- // Pin TipTap's link allowlist to the same set as our shared
5654
- // `isSafeHref` helper. The upstream default also includes
5655
- // ftp/cid/callto which we don't expect inside Slack content.
5656
- protocols: ["http", "https", "mailto", "tel", "sms", "xmpp"],
5657
- // Belt-and-suspenders: even if a downstream contributor widens
5658
- // `protocols` later, our isSafeHref guard rejects everything
5659
- // outside the safe-link set. setLink and toggleLink both gate
5660
- // on this hook before applying the mark.
5661
- isAllowedUri: (url) => isSafeHref(url),
5662
- HTMLAttributes: { rel: "noreferrer noopener", target: "_blank" }
5663
- })
5664
- ],
5665
- content: richTextToProseMirror(block),
6319
+ extensions: buildRichTextExtensions(),
6320
+ enableInputRules: RICH_TEXT_INPUT_RULE_ALLOWLIST,
6321
+ content: richTextToProseMirror(block, resolveEmoji),
5666
6322
  editorProps: {
5667
6323
  attributes: {
5668
6324
  class: "bk-rich-editor min-h-[120px] rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring [&_p]:my-1 [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:pl-5 [&_li]:my-0 [&_pre]:bg-muted [&_pre]:rounded [&_pre]:p-2 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground"
@@ -5677,10 +6333,10 @@ function RichTextWysiwygEditor({ block, onChange }) {
5677
6333
  if (lastEmittedRef.current && JSON.stringify(lastEmittedRef.current) === JSON.stringify(block)) {
5678
6334
  return;
5679
6335
  }
5680
- editor.commands.setContent(richTextToProseMirror(block), {
6336
+ editor.commands.setContent(richTextToProseMirror(block, resolveEmoji), {
5681
6337
  emitUpdate: false
5682
6338
  });
5683
- }, [block, editor]);
6339
+ }, [block, editor, resolveEmoji]);
5684
6340
  if (!editor) {
5685
6341
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "min-h-[120px] rounded-md border border-input bg-background" });
5686
6342
  }
@@ -5729,6 +6385,16 @@ function Toolbar({ editor }) {
5729
6385
  ),
5730
6386
  /* @__PURE__ */ jsxRuntime.jsx(Divider, {}),
5731
6387
  /* @__PURE__ */ jsxRuntime.jsx(LinkPopover, { editor }),
6388
+ /* @__PURE__ */ jsxRuntime.jsx(
6389
+ EmojiPickerButton,
6390
+ {
6391
+ align: "start",
6392
+ onSelect: (sel) => editor.chain().focus().insertContent({
6393
+ type: "emoji",
6394
+ attrs: { name: sel.name, src: sel.src, unicode: sel.unicode, skinTone: sel.skinTone }
6395
+ }).run()
6396
+ }
6397
+ ),
5732
6398
  /* @__PURE__ */ jsxRuntime.jsx(Divider, {}),
5733
6399
  /* @__PURE__ */ jsxRuntime.jsx(
5734
6400
  ToolbarButton,
@@ -5927,6 +6593,8 @@ function defaultImage() {
5927
6593
  function SectionEditor({ block, onChange }) {
5928
6594
  const text = block.text?.text ?? "";
5929
6595
  const accessoryKind = detectAccessory(block.accessory);
6596
+ const textRef = React.useRef(null);
6597
+ const setText = (next) => onChange({ ...block, text: { type: "mrkdwn", text: next } });
5930
6598
  const setAccessoryKind = (next) => {
5931
6599
  if (next === accessoryKind) {
5932
6600
  return;
@@ -5950,19 +6618,20 @@ function SectionEditor({ block, onChange }) {
5950
6618
  label: "Text",
5951
6619
  help: "Supports *bold*, _italic_, ~strike~, `code`, and <url|link> formatting.",
5952
6620
  htmlFor: "section-text",
5953
- children: /* @__PURE__ */ jsxRuntime.jsx(
5954
- Textarea,
5955
- {
5956
- id: "section-text",
5957
- value: text,
5958
- rows: 4,
5959
- placeholder: "e.g. Welcome to the channel! Ping <@U123> with any questions.",
5960
- onChange: (e) => onChange({
5961
- ...block,
5962
- text: { type: "mrkdwn", text: e.target.value }
5963
- })
5964
- }
5965
- )
6621
+ children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-start gap-1.5", children: [
6622
+ /* @__PURE__ */ jsxRuntime.jsx(
6623
+ Textarea,
6624
+ {
6625
+ ref: textRef,
6626
+ id: "section-text",
6627
+ value: text,
6628
+ rows: 4,
6629
+ placeholder: "e.g. Welcome to the channel! Ping <@U123> with any questions.",
6630
+ onChange: (e) => setText(e.target.value)
6631
+ }
6632
+ ),
6633
+ /* @__PURE__ */ jsxRuntime.jsx(EmojiTextInsertButton, { targetRef: textRef, value: text, onChange: setText, className: "mt-1 shrink-0 border" })
6634
+ ] })
5966
6635
  }
5967
6636
  ),
5968
6637
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3 rounded-md border bg-muted/20 p-3", children: [
@@ -6003,19 +6672,22 @@ function ButtonAccessoryFields({ button, onChange }) {
6003
6672
  const url = button.url ?? "";
6004
6673
  const value = button.value ?? "";
6005
6674
  const style = button.style === "primary" || button.style === "danger" ? button.style : "default";
6675
+ const labelRef = React.useRef(null);
6676
+ const setLabel = (next) => onChange({ ...button, text: { type: "plain_text", text: next, emoji: true } });
6006
6677
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-3", children: [
6007
- /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Label", htmlFor: "section-acc-btn-label", children: /* @__PURE__ */ jsxRuntime.jsx(
6008
- Input,
6009
- {
6010
- id: "section-acc-btn-label",
6011
- value: label,
6012
- placeholder: "e.g. Learn more",
6013
- onChange: (e) => onChange({
6014
- ...button,
6015
- text: { type: "plain_text", text: e.target.value, emoji: true }
6016
- })
6017
- }
6018
- ) }),
6678
+ /* @__PURE__ */ jsxRuntime.jsx(EditorField, { label: "Label", htmlFor: "section-acc-btn-label", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
6679
+ /* @__PURE__ */ jsxRuntime.jsx(
6680
+ Input,
6681
+ {
6682
+ ref: labelRef,
6683
+ id: "section-acc-btn-label",
6684
+ value: label,
6685
+ placeholder: "e.g. Learn more",
6686
+ onChange: (e) => setLabel(e.target.value)
6687
+ }
6688
+ ),
6689
+ /* @__PURE__ */ jsxRuntime.jsx(EmojiTextInsertButton, { targetRef: labelRef, value: label, onChange: setLabel, className: "shrink-0 border" })
6690
+ ] }) }),
6019
6691
  /* @__PURE__ */ jsxRuntime.jsx(
6020
6692
  EditorField,
6021
6693
  {
@@ -7570,6 +8242,7 @@ function BlockKitchen(props) {
7570
8242
  initialBlocks,
7571
8243
  onChange,
7572
8244
  previewHooks,
8245
+ customEmojis,
7573
8246
  loadChannels,
7574
8247
  loadSendAsUserStatus,
7575
8248
  onSend,
@@ -7613,6 +8286,13 @@ function BlockKitchen(props) {
7613
8286
  const [previewSurface, setPreviewSurface] = React.useState(allowedSurfaces[0]);
7614
8287
  const [activePaletteVariantId, setActivePaletteVariantId] = React.useState(null);
7615
8288
  const isMobile = useIsMobile();
8289
+ const mergedPreviewHooks = React.useMemo(() => {
8290
+ if (!customEmojis || customEmojis.length === 0) {
8291
+ return previewHooks;
8292
+ }
8293
+ const byName = new Map(customEmojis.map((emoji) => [emoji.name, emoji]));
8294
+ return { emoji: makeEmojiHook(byName), ...previewHooks };
8295
+ }, [customEmojis, previewHooks]);
7616
8296
  const validation = useBlockKitValidation(blocks, "message");
7617
8297
  const sensors = core.useSensors(
7618
8298
  core.useSensor(core.PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -7662,7 +8342,7 @@ function BlockKitchen(props) {
7662
8342
  }, []);
7663
8343
  const activePaletteVariant = activePaletteVariantId ? variantById.get(activePaletteVariantId) : null;
7664
8344
  const blockPayloads = blocks.map((b) => b.block);
7665
- return /* @__PURE__ */ jsxRuntime.jsx(BrandThemeScope, { theme, children: /* @__PURE__ */ jsxRuntime.jsx(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxRuntime.jsxs(
8345
+ return /* @__PURE__ */ jsxRuntime.jsx(BrandThemeScope, { theme, children: /* @__PURE__ */ jsxRuntime.jsx(CustomEmojiProvider, { customEmojis, children: /* @__PURE__ */ jsxRuntime.jsx(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxRuntime.jsxs(
7666
8346
  core.DndContext,
7667
8347
  {
7668
8348
  sensors,
@@ -7708,7 +8388,7 @@ function BlockKitchen(props) {
7708
8388
  {
7709
8389
  blocks,
7710
8390
  workspaceName,
7711
- previewHooks,
8391
+ previewHooks: mergedPreviewHooks,
7712
8392
  previewTheme,
7713
8393
  previewSurface,
7714
8394
  errorsByBlockId: validation.byBlockId,
@@ -7784,7 +8464,7 @@ function BlockKitchen(props) {
7784
8464
  )
7785
8465
  ]
7786
8466
  }
7787
- ) }) });
8467
+ ) }) }) });
7788
8468
  }
7789
8469
  var UNCATEGORIZED = "Other";
7790
8470
  function TemplateThumbnail({ blocks, theme }) {