@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.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { useSensors, useSensor, PointerSensor, TouchSensor, KeyboardSensor, pointerWithin, closestCenter, DndContext, DragOverlay, useDroppable, useDraggable } from '@dnd-kit/core';
2
2
  import { sortableKeyboardCoordinates, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
3
- import { X, Circle, Sparkles, FileText, AlignLeft, MousePointerClick, TextCursorInput, LayoutTemplate, Bell, Pilcrow, Image, Video, GalleryHorizontal, Table, GripVertical, Sun, Moon, MessageSquare, AppWindow, Home, Plus, ChevronDown, BookOpen, ExternalLink, AlertTriangle, Trash2, Code2, MoreHorizontal, Send, Search, CheckCircle2, Check, ChevronRight, LayoutGrid, ArrowUp, ArrowDown, Pencil, Copy, Type, Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote, Undo2, Redo2, Link as Link$1 } from 'lucide-react';
3
+ import { X, Circle, Sparkles, FileText, AlignLeft, MousePointerClick, TextCursorInput, LayoutTemplate, Bell, Pilcrow, Image, Video, GalleryHorizontal, Table, GripVertical, Sun, Moon, MessageSquare, AppWindow, Home, Plus, ChevronDown, BookOpen, ExternalLink, AlertTriangle, Trash2, Code2, MoreHorizontal, Send, Search, CheckCircle2, Check, ChevronRight, LayoutGrid, ArrowUp, ArrowDown, Pencil, Copy, Type, Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote, Undo2, Redo2, Link as Link$1, Smile } from 'lucide-react';
4
4
  import * as React from 'react';
5
- import { useMemo, useState, useCallback, useRef, useEffect, useId } from 'react';
5
+ import { createContext, useMemo, useState, useCallback, useRef, useEffect, useId, useContext } from 'react';
6
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
7
  import * as DialogPrimitive from '@radix-ui/react-dialog';
7
8
  import { clsx } from 'clsx';
8
9
  import { twMerge } from 'tailwind-merge';
9
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
10
10
  import * as TooltipPrimitive from '@radix-ui/react-tooltip';
11
11
  import { validateBlockKit, CARD_ACTIONS_MAX } from '@tightknitai/slack-block-kit-validator';
12
12
  import { nanoid } from 'nanoid';
@@ -17,12 +17,41 @@ import { CSS } from '@dnd-kit/utilities';
17
17
  import * as PopoverPrimitive from '@radix-ui/react-popover';
18
18
  import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
19
19
  import Link from '@tiptap/extension-link';
20
- import { useEditor, EditorContent } from '@tiptap/react';
20
+ import { Node, ReactNodeViewRenderer, mergeAttributes, NodeViewWrapper, useEditor, EditorContent } from '@tiptap/react';
21
21
  import StarterKit from '@tiptap/starter-kit';
22
22
  import 'slack-blocks-to-jsx/dist/style.css';
23
23
  import { Message } from 'slack-blocks-to-jsx';
24
24
 
25
25
  // src/components/block-kitchen.tsx
26
+ function makeEmojiHook(byName) {
27
+ return (data, parse) => {
28
+ const entry = byName.get(data.name);
29
+ if (entry) {
30
+ if (entry.url) {
31
+ return /* @__PURE__ */ jsx(
32
+ "img",
33
+ {
34
+ src: entry.url,
35
+ alt: `:${data.name}:`,
36
+ title: `:${data.name}:`,
37
+ className: "bk-custom-emoji",
38
+ style: {
39
+ display: "inline-block",
40
+ width: "1.25em",
41
+ height: "1.25em",
42
+ verticalAlign: "-0.25em",
43
+ objectFit: "contain"
44
+ }
45
+ }
46
+ );
47
+ }
48
+ if (entry.alias) {
49
+ return parse({ name: entry.alias, unicode: data.unicode, skin_tone: data.skin_tone });
50
+ }
51
+ }
52
+ return parse(data);
53
+ };
54
+ }
26
55
  var defaultPalette = [
27
56
  {
28
57
  name: "Agents",
@@ -1519,50 +1548,63 @@ function useIsMobile() {
1519
1548
  }, []);
1520
1549
  return isMobile;
1521
1550
  }
1551
+ var EMPTY = {
1552
+ customEmojis: [],
1553
+ byName: /* @__PURE__ */ new Map()
1554
+ };
1555
+ var CustomEmojiContext = createContext(EMPTY);
1556
+ function CustomEmojiProvider({ customEmojis, children }) {
1557
+ const value = useMemo(() => {
1558
+ if (!customEmojis || customEmojis.length === 0) {
1559
+ return EMPTY;
1560
+ }
1561
+ const byName = /* @__PURE__ */ new Map();
1562
+ for (const emoji of customEmojis) {
1563
+ byName.set(emoji.name, emoji);
1564
+ }
1565
+ return { customEmojis, byName };
1566
+ }, [customEmojis]);
1567
+ return /* @__PURE__ */ jsx(CustomEmojiContext.Provider, { value, children });
1568
+ }
1569
+ function useCustomEmojis() {
1570
+ return useContext(CustomEmojiContext);
1571
+ }
1522
1572
 
1523
1573
  // src/lib/error-grouping.ts
1524
1574
  function toValidatorSurface(surface) {
1525
1575
  return surface === "app_home" ? "home" : surface;
1526
1576
  }
1527
- function extractBlockIndex(path) {
1528
- if (!path || path === "(root)") {
1529
- return null;
1530
- }
1531
- const match = /^\/(\d+)(?:\/|$)/.exec(path);
1532
- if (!match) {
1533
- return null;
1577
+ var BLOCK_PREFIX = /^blocks\[(\d+)\]/;
1578
+ function extractBlockIndex(raw) {
1579
+ const match = BLOCK_PREFIX.exec(raw);
1580
+ return match ? Number.parseInt(match[1], 10) : null;
1581
+ }
1582
+ function toBlockMessage(raw) {
1583
+ const rest = raw.replace(BLOCK_PREFIX, "");
1584
+ if (rest.startsWith(":")) {
1585
+ return rest.replace(/^:\s*/, "");
1534
1586
  }
1535
- return Number.parseInt(match[1], 10);
1587
+ return rest.replace(/^\./, "");
1536
1588
  }
1537
- function stripBlockPrefix(path) {
1538
- return path.replace(/^\/\d+/, "") || "(root)";
1589
+ function toGeneralMessage(raw) {
1590
+ return raw.startsWith("(root)") ? raw.replace(/^\(root\):?\s*/, "") : raw;
1539
1591
  }
1540
1592
  function groupValidatorErrors(errors, blocks) {
1541
1593
  const byBlockId = /* @__PURE__ */ new Map();
1542
1594
  const general = [];
1543
1595
  for (const raw of errors) {
1544
- const space = raw.indexOf(" ");
1545
- const path = space === -1 ? "(root)" : raw.slice(0, space);
1546
- const message = space === -1 ? raw : raw.slice(space + 1);
1547
- const idx = extractBlockIndex(path);
1596
+ const idx = extractBlockIndex(raw);
1548
1597
  if (idx === null || idx >= blocks.length) {
1549
- general.push(raw);
1598
+ general.push(toGeneralMessage(raw));
1550
1599
  continue;
1551
1600
  }
1552
1601
  const id = blocks[idx].id;
1553
- const friendly = formatMessage(stripBlockPrefix(path), message);
1554
1602
  const bucket = byBlockId.get(id) ?? [];
1555
- bucket.push(friendly);
1603
+ bucket.push(toBlockMessage(raw));
1556
1604
  byBlockId.set(id, bucket);
1557
1605
  }
1558
1606
  return { byBlockId, general, total: errors.length };
1559
1607
  }
1560
- function formatMessage(relativePath, message) {
1561
- if (relativePath === "" || relativePath === "(root)") {
1562
- return message;
1563
- }
1564
- return `${relativePath}: ${message}`;
1565
- }
1566
1608
 
1567
1609
  // src/lib/url-safety.ts
1568
1610
  var SAFE_LINK_PROTOCOLS = /^(https?|ircs?|mailto|tel|sms|xmpp)$/i;
@@ -1598,6 +1640,12 @@ function isSafeHref(value) {
1598
1640
  }
1599
1641
  return SAFE_LINK_PROTOCOLS.test(scheme);
1600
1642
  }
1643
+ function hasExplicitSafeScheme(value) {
1644
+ if (typeof value !== "string" || value.length === 0) {
1645
+ return false;
1646
+ }
1647
+ return getScheme(value) !== null && isSafeHref(value);
1648
+ }
1601
1649
  function isSafeImageSrc(value) {
1602
1650
  if (value === null || value === void 0) {
1603
1651
  return false;
@@ -1682,10 +1730,47 @@ function toSlackBlocks(blocks) {
1682
1730
  });
1683
1731
  }
1684
1732
 
1733
+ // src/lib/validation-sanitize.ts
1734
+ var VALIDATION_EMOJI_PLACEHOLDER = "white_check_mark";
1735
+ function rewriteEmojiNames(value) {
1736
+ if (Array.isArray(value)) {
1737
+ let changed = false;
1738
+ const next = value.map((item) => {
1739
+ const rewritten = rewriteEmojiNames(item);
1740
+ if (rewritten !== item) {
1741
+ changed = true;
1742
+ }
1743
+ return rewritten;
1744
+ });
1745
+ return changed ? next : value;
1746
+ }
1747
+ if (value !== null && typeof value === "object") {
1748
+ const source = value;
1749
+ let changed = false;
1750
+ const next = {};
1751
+ for (const key of Object.keys(source)) {
1752
+ const rewritten = rewriteEmojiNames(source[key]);
1753
+ next[key] = rewritten;
1754
+ if (rewritten !== source[key]) {
1755
+ changed = true;
1756
+ }
1757
+ }
1758
+ if (next.type === "emoji" && typeof next.name === "string" && next.name !== VALIDATION_EMOJI_PLACEHOLDER) {
1759
+ next.name = VALIDATION_EMOJI_PLACEHOLDER;
1760
+ changed = true;
1761
+ }
1762
+ return changed ? next : value;
1763
+ }
1764
+ return value;
1765
+ }
1766
+ function stripCustomEmojiForValidation(blocks) {
1767
+ return blocks.map((block) => rewriteEmojiNames(block));
1768
+ }
1769
+
1685
1770
  // src/state/use-block-kit-validation.ts
1686
1771
  var DEBOUNCE_MS = 150;
1687
1772
  function computeValidation(blocks, surface) {
1688
- const payload = toSlackBlocks(blocks.map((b) => b.block));
1773
+ const payload = stripCustomEmojiForValidation(toSlackBlocks(blocks.map((b) => b.block)));
1689
1774
  const result = validateBlockKit(payload, {
1690
1775
  target: "blocks",
1691
1776
  surface: toValidatorSurface(surface)
@@ -3531,52 +3616,55 @@ function ContextEditor({ block, onChange }) {
3531
3616
  {
3532
3617
  label: "Items",
3533
3618
  help: "Shown in a horizontal row, usually as footnote-style content. Slack supports up to 10 items.",
3534
- children: /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: elements.map((el, idx) => {
3535
- if (el.type === "mrkdwn" || el.type === "plain_text") {
3536
- return /* @__PURE__ */ jsx(
3537
- TextItemCard,
3538
- {
3539
- index: idx,
3540
- element: el,
3541
- onChange: (next) => updateAt(idx, () => next),
3542
- onRemove: () => removeAt(idx)
3543
- },
3544
- idx
3545
- );
3546
- }
3547
- if (el.type === "image" && "image_url" in el) {
3548
- return /* @__PURE__ */ jsx(
3549
- ImageItemCard,
3619
+ children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
3620
+ elements.length === 0 ? /* @__PURE__ */ 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,
3621
+ elements.map((el, idx) => {
3622
+ if (el.type === "mrkdwn" || el.type === "plain_text") {
3623
+ return /* @__PURE__ */ jsx(
3624
+ TextItemCard,
3625
+ {
3626
+ index: idx,
3627
+ element: el,
3628
+ onChange: (next) => updateAt(idx, () => next),
3629
+ onRemove: () => removeAt(idx)
3630
+ },
3631
+ idx
3632
+ );
3633
+ }
3634
+ if (el.type === "image" && "image_url" in el) {
3635
+ return /* @__PURE__ */ jsx(
3636
+ ImageItemCard,
3637
+ {
3638
+ index: idx,
3639
+ element: el,
3640
+ onChange: (next) => updateAt(idx, () => next),
3641
+ onRemove: () => removeAt(idx)
3642
+ },
3643
+ idx
3644
+ );
3645
+ }
3646
+ return /* @__PURE__ */ jsxs(
3647
+ "div",
3550
3648
  {
3551
- index: idx,
3552
- element: el,
3553
- onChange: (next) => updateAt(idx, () => next),
3554
- onRemove: () => removeAt(idx)
3649
+ className: "flex items-center justify-between rounded border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground",
3650
+ children: [
3651
+ /* @__PURE__ */ jsx("span", { children: "Item not editable in the visual builder" }),
3652
+ /* @__PURE__ */ jsx(
3653
+ "button",
3654
+ {
3655
+ type: "button",
3656
+ "aria-label": "Remove item",
3657
+ onClick: () => removeAt(idx),
3658
+ className: "text-muted-foreground hover:text-destructive",
3659
+ children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
3660
+ }
3661
+ )
3662
+ ]
3555
3663
  },
3556
3664
  idx
3557
3665
  );
3558
- }
3559
- return /* @__PURE__ */ jsxs(
3560
- "div",
3561
- {
3562
- className: "flex items-center justify-between rounded border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground",
3563
- children: [
3564
- /* @__PURE__ */ jsx("span", { children: "Item not editable in the visual builder" }),
3565
- /* @__PURE__ */ jsx(
3566
- "button",
3567
- {
3568
- type: "button",
3569
- "aria-label": "Remove item",
3570
- onClick: () => removeAt(idx),
3571
- className: "text-muted-foreground hover:text-destructive",
3572
- children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
3573
- }
3574
- )
3575
- ]
3576
- },
3577
- idx
3578
- );
3579
- }) })
3666
+ })
3667
+ ] })
3580
3668
  }
3581
3669
  ),
3582
3670
  /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
@@ -3715,12 +3803,423 @@ function ImageItemCard({
3715
3803
  function DividerEditor() {
3716
3804
  return /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "A horizontal line that separates content. This block has no options to configure." });
3717
3805
  }
3806
+
3807
+ // src/lib/emoji-data.ts
3808
+ var TONE_CODEPOINT_TO_SKIN_TONE = {
3809
+ "1F3FB": 2,
3810
+ "1F3FC": 3,
3811
+ "1F3FD": 4,
3812
+ "1F3FE": 5,
3813
+ "1F3FF": 6
3814
+ };
3815
+ var CATEGORY_ORDER = [
3816
+ "Smileys & Emotion",
3817
+ "People & Body",
3818
+ "Animals & Nature",
3819
+ "Food & Drink",
3820
+ "Travel & Places",
3821
+ "Activities",
3822
+ "Objects",
3823
+ "Symbols",
3824
+ "Flags"
3825
+ ];
3826
+ function codepointsToGlyph(unified) {
3827
+ return unified.split("-").map((cp) => {
3828
+ const n = Number.parseInt(cp, 16);
3829
+ return Number.isFinite(n) ? String.fromCodePoint(n) : "";
3830
+ }).join("");
3831
+ }
3832
+ function unifiedToUnicode(unified) {
3833
+ return unified.toLowerCase();
3834
+ }
3835
+ function composeTextEmoji(name, skinTone) {
3836
+ if (skinTone && skinTone >= 2 && skinTone <= 6) {
3837
+ return `:${name}::skin-tone-${skinTone}:`;
3838
+ }
3839
+ return `:${name}:`;
3840
+ }
3841
+ function buildEmojiIndex(raw) {
3842
+ const all = [];
3843
+ const byName = /* @__PURE__ */ new Map();
3844
+ const byUnified = /* @__PURE__ */ new Map();
3845
+ for (const r of raw) {
3846
+ if (!CATEGORY_ORDER.includes(r.category)) {
3847
+ continue;
3848
+ }
3849
+ const shortNames = r.short_names?.length ? r.short_names : [r.short_name];
3850
+ const skinUnified = {};
3851
+ if (r.skin_variations) {
3852
+ for (const [toneKey, variation] of Object.entries(r.skin_variations)) {
3853
+ const skinTone = TONE_CODEPOINT_TO_SKIN_TONE[toneKey];
3854
+ if (skinTone && variation?.unified) {
3855
+ skinUnified[skinTone] = variation.unified;
3856
+ }
3857
+ }
3858
+ }
3859
+ const datum = {
3860
+ name: shortNames[0],
3861
+ shortNames,
3862
+ unified: r.unified,
3863
+ category: r.category,
3864
+ sortOrder: r.sort_order,
3865
+ skinUnified
3866
+ };
3867
+ all.push(datum);
3868
+ byUnified.set(r.unified.toUpperCase(), datum);
3869
+ for (const sn of shortNames) {
3870
+ if (!byName.has(sn)) {
3871
+ byName.set(sn, datum);
3872
+ }
3873
+ }
3874
+ }
3875
+ all.sort((a, b) => a.sortOrder - b.sortOrder);
3876
+ const byCategory = CATEGORY_ORDER.map((category) => ({
3877
+ category,
3878
+ emoji: all.filter((e) => e.category === category)
3879
+ })).filter((group) => group.emoji.length > 0);
3880
+ return { all, byName, byUnified, byCategory };
3881
+ }
3882
+ var indexPromise = null;
3883
+ var cachedIndex = null;
3884
+ async function loadEmojiIndex() {
3885
+ if (cachedIndex) {
3886
+ return cachedIndex;
3887
+ }
3888
+ if (!indexPromise) {
3889
+ indexPromise = import('emoji-datasource/emoji.json').then((mod) => {
3890
+ const raw = mod.default ?? mod;
3891
+ cachedIndex = buildEmojiIndex(raw);
3892
+ return cachedIndex;
3893
+ }).catch((err) => {
3894
+ indexPromise = null;
3895
+ throw err;
3896
+ });
3897
+ }
3898
+ return indexPromise;
3899
+ }
3900
+ function getEmojiIndex() {
3901
+ return cachedIndex;
3902
+ }
3903
+
3904
+ // src/lib/emoji-recents.ts
3905
+ var STORAGE_KEY = "bk:emoji-recents";
3906
+ var SKIN_TONE_KEY = "bk:emoji-skin-tone";
3907
+ var MAX_RECENTS = 24;
3908
+ function readEmojiSkinTone() {
3909
+ try {
3910
+ const raw = globalThis.localStorage?.getItem(SKIN_TONE_KEY);
3911
+ if (!raw) {
3912
+ return null;
3913
+ }
3914
+ const n = Number.parseInt(raw, 10);
3915
+ return n >= 2 && n <= 6 ? n : null;
3916
+ } catch {
3917
+ return null;
3918
+ }
3919
+ }
3920
+ function writeEmojiSkinTone(skinTone) {
3921
+ try {
3922
+ if (skinTone && skinTone >= 2 && skinTone <= 6) {
3923
+ globalThis.localStorage?.setItem(SKIN_TONE_KEY, String(skinTone));
3924
+ } else {
3925
+ globalThis.localStorage?.removeItem(SKIN_TONE_KEY);
3926
+ }
3927
+ } catch {
3928
+ }
3929
+ }
3930
+ function readEmojiRecents() {
3931
+ try {
3932
+ const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
3933
+ if (!raw) {
3934
+ return [];
3935
+ }
3936
+ const parsed = JSON.parse(raw);
3937
+ return Array.isArray(parsed) ? parsed.filter((n) => typeof n === "string") : [];
3938
+ } catch {
3939
+ return [];
3940
+ }
3941
+ }
3942
+ function pushEmojiRecent(name) {
3943
+ const next = [name, ...readEmojiRecents().filter((n) => n !== name)].slice(0, MAX_RECENTS);
3944
+ try {
3945
+ globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next));
3946
+ } catch {
3947
+ }
3948
+ return next;
3949
+ }
3950
+ var SKIN_TONES = [
3951
+ { tone: null, label: "Default", swatch: "\u270B" },
3952
+ { tone: 2, label: "Light", swatch: "\u270B\u{1F3FB}" },
3953
+ { tone: 3, label: "Medium-light", swatch: "\u270B\u{1F3FC}" },
3954
+ { tone: 4, label: "Medium", swatch: "\u270B\u{1F3FD}" },
3955
+ { tone: 5, label: "Medium-dark", swatch: "\u270B\u{1F3FE}" },
3956
+ { tone: 6, label: "Dark", swatch: "\u270B\u{1F3FF}" }
3957
+ ];
3958
+ function EmojiPicker({
3959
+ onSelect,
3960
+ autoFocus = true
3961
+ }) {
3962
+ const { customEmojis } = useCustomEmojis();
3963
+ const [index, setIndex] = useState(null);
3964
+ const [loadError, setLoadError] = useState(false);
3965
+ const [query, setQuery] = useState("");
3966
+ const [skinTone, setSkinTone] = useState(() => readEmojiSkinTone());
3967
+ const [recents, setRecents] = useState(() => readEmojiRecents());
3968
+ const searchRef = useRef(null);
3969
+ useEffect(() => {
3970
+ let active = true;
3971
+ loadEmojiIndex().then((idx) => {
3972
+ if (active) {
3973
+ setIndex(idx);
3974
+ }
3975
+ }).catch(() => {
3976
+ if (active) {
3977
+ setLoadError(true);
3978
+ }
3979
+ });
3980
+ return () => {
3981
+ active = false;
3982
+ };
3983
+ }, []);
3984
+ useEffect(() => {
3985
+ if (autoFocus) {
3986
+ searchRef.current?.focus();
3987
+ }
3988
+ }, [autoFocus]);
3989
+ const customByName = useMemo(() => {
3990
+ const map = /* @__PURE__ */ new Map();
3991
+ for (const e of customEmojis) {
3992
+ map.set(e.name, e);
3993
+ }
3994
+ return map;
3995
+ }, [customEmojis]);
3996
+ const customImageEmoji = useMemo(() => customEmojis.filter((e) => e.url), [customEmojis]);
3997
+ const handlePick = (selection) => {
3998
+ setRecents(pushEmojiRecent(selection.name));
3999
+ onSelect(selection);
4000
+ };
4001
+ const handleSkinTone = (tone) => {
4002
+ setSkinTone(tone);
4003
+ writeEmojiSkinTone(tone);
4004
+ };
4005
+ const sections = useMemo(() => {
4006
+ if (!index) {
4007
+ return [];
4008
+ }
4009
+ const trimmed = query.trim().toLowerCase();
4010
+ if (trimmed) {
4011
+ const standard = index.all.filter((d) => d.shortNames.some((sn) => sn.includes(trimmed))).slice(0, 120).map((datum) => ({ datum }));
4012
+ const custom = customImageEmoji.filter((e) => e.name.toLowerCase().includes(trimmed)).map((custom2) => ({ custom: custom2 }));
4013
+ return [{ key: "search", title: "Search results", emoji: [...custom, ...standard] }];
4014
+ }
4015
+ const out = [];
4016
+ if (recents.length > 0) {
4017
+ const recentEmoji = recents.map((name) => {
4018
+ const custom = customByName.get(name);
4019
+ if (custom?.url) {
4020
+ return { custom };
4021
+ }
4022
+ const datum = index.byName.get(name);
4023
+ return datum ? { datum } : null;
4024
+ }).filter((e) => e !== null);
4025
+ if (recentEmoji.length > 0) {
4026
+ out.push({ key: "recents", title: "Frequently used", emoji: recentEmoji });
4027
+ }
4028
+ }
4029
+ if (customImageEmoji.length > 0) {
4030
+ out.push({ key: "custom", title: "Custom", emoji: customImageEmoji.map((custom) => ({ custom })) });
4031
+ }
4032
+ for (const group of index.byCategory) {
4033
+ out.push({ key: group.category, title: group.category, emoji: group.emoji.map((datum) => ({ datum })) });
4034
+ }
4035
+ return out;
4036
+ }, [index, query, recents, customByName, customImageEmoji]);
4037
+ return /* @__PURE__ */ jsxs("div", { className: "bk-emoji-picker flex w-[320px] max-w-[90vw] flex-col gap-2", children: [
4038
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4039
+ /* @__PURE__ */ jsx(
4040
+ Input,
4041
+ {
4042
+ ref: searchRef,
4043
+ value: query,
4044
+ placeholder: "Search emoji\u2026",
4045
+ "aria-label": "Search emoji",
4046
+ onChange: (e) => setQuery(e.target.value),
4047
+ className: "h-8"
4048
+ }
4049
+ ),
4050
+ /* @__PURE__ */ jsx(SkinToneSelector, { value: skinTone, onChange: handleSkinTone })
4051
+ ] }),
4052
+ /* @__PURE__ */ jsx("div", { className: "h-64 overflow-y-auto pr-1", children: loadError ? /* @__PURE__ */ jsx("p", { className: "p-4 text-center text-xs text-muted-foreground", children: "Couldn't load emoji." }) : !index ? /* @__PURE__ */ jsx("p", { className: "p-4 text-center text-xs text-muted-foreground", children: "Loading emoji\u2026" }) : sections.every((s) => s.emoji.length === 0) ? /* @__PURE__ */ jsx("p", { className: "p-4 text-center text-xs text-muted-foreground", children: "No emoji found." }) : sections.map((section) => /* @__PURE__ */ jsxs("div", { className: "mb-2", children: [
4053
+ /* @__PURE__ */ jsx("div", { className: "sticky top-0 z-10 bg-background px-1 py-1 text-[11px] font-medium text-muted-foreground", children: section.title }),
4054
+ /* @__PURE__ */ jsx("div", { className: "grid grid-cols-8 gap-0.5", children: section.emoji.map(
4055
+ (entry, i) => entry.custom ? /* @__PURE__ */ jsx(
4056
+ CustomEmojiButton,
4057
+ {
4058
+ emoji: entry.custom,
4059
+ onPick: handlePick
4060
+ },
4061
+ `${section.key}-${entry.custom.name}-${i}`
4062
+ ) : entry.datum ? /* @__PURE__ */ jsx(
4063
+ StandardEmojiButton,
4064
+ {
4065
+ datum: entry.datum,
4066
+ skinTone,
4067
+ onPick: handlePick
4068
+ },
4069
+ `${section.key}-${entry.datum.name}-${i}`
4070
+ ) : null
4071
+ ) })
4072
+ ] }, section.key)) })
4073
+ ] });
4074
+ }
4075
+ function resolveStandard(datum, skinTone) {
4076
+ const tonedUnified = skinTone ? datum.skinUnified[skinTone] : void 0;
4077
+ return {
4078
+ name: datum.name,
4079
+ unicode: unifiedToUnicode(tonedUnified ?? datum.unified),
4080
+ src: null,
4081
+ skinTone: tonedUnified ? skinTone : null
4082
+ };
4083
+ }
4084
+ var emojiButtonClass = "flex h-8 w-8 cursor-pointer items-center justify-center rounded text-lg leading-none hover:bg-accent";
4085
+ function StandardEmojiButton({
4086
+ datum,
4087
+ skinTone,
4088
+ onPick
4089
+ }) {
4090
+ const tonedUnified = skinTone && datum.skinUnified[skinTone] || datum.unified;
4091
+ return /* @__PURE__ */ jsx(
4092
+ "button",
4093
+ {
4094
+ type: "button",
4095
+ className: emojiButtonClass,
4096
+ title: `:${datum.name}:`,
4097
+ "aria-label": datum.name,
4098
+ onClick: () => onPick(resolveStandard(datum, skinTone)),
4099
+ children: codepointsToGlyph(tonedUnified)
4100
+ }
4101
+ );
4102
+ }
4103
+ function CustomEmojiButton({ emoji, onPick }) {
4104
+ return /* @__PURE__ */ jsx(
4105
+ "button",
4106
+ {
4107
+ type: "button",
4108
+ className: emojiButtonClass,
4109
+ title: `:${emoji.name}:`,
4110
+ "aria-label": emoji.name,
4111
+ onClick: () => onPick({ name: emoji.name, unicode: null, src: emoji.url, skinTone: null }),
4112
+ children: emoji.url ? /* @__PURE__ */ jsx("img", { src: emoji.url, alt: `:${emoji.name}:`, className: "h-5 w-5 object-contain" }) : /* @__PURE__ */ jsxs("span", { className: "text-[10px]", children: [
4113
+ ":",
4114
+ emoji.name,
4115
+ ":"
4116
+ ] })
4117
+ }
4118
+ );
4119
+ }
4120
+ function SkinToneSelector({ value, onChange }) {
4121
+ const [open, setOpen] = useState(false);
4122
+ const active = SKIN_TONES.find((t) => t.tone === value) ?? SKIN_TONES[0];
4123
+ return /* @__PURE__ */ jsxs("div", { className: "relative shrink-0", children: [
4124
+ /* @__PURE__ */ jsx(
4125
+ "button",
4126
+ {
4127
+ type: "button",
4128
+ "aria-label": `Skin tone: ${active.label}`,
4129
+ title: `Skin tone: ${active.label}`,
4130
+ className: "flex h-8 w-8 items-center justify-center rounded border text-lg leading-none hover:bg-accent",
4131
+ onClick: () => setOpen((o) => !o),
4132
+ children: active.swatch
4133
+ }
4134
+ ),
4135
+ open && /* @__PURE__ */ 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__ */ jsx(
4136
+ "button",
4137
+ {
4138
+ type: "button",
4139
+ "aria-label": t.label,
4140
+ title: t.label,
4141
+ className: cn(emojiButtonClass, value === t.tone && "bg-accent"),
4142
+ onClick: () => {
4143
+ onChange(t.tone);
4144
+ setOpen(false);
4145
+ },
4146
+ children: t.swatch
4147
+ },
4148
+ t.label
4149
+ )) })
4150
+ ] });
4151
+ }
4152
+ function EmojiPickerButton({
4153
+ onSelect,
4154
+ className,
4155
+ label = "Insert emoji",
4156
+ align = "start"
4157
+ }) {
4158
+ const [open, setOpen] = useState(false);
4159
+ return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
4160
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
4161
+ "button",
4162
+ {
4163
+ type: "button",
4164
+ "aria-label": label,
4165
+ title: label,
4166
+ className: cn(
4167
+ "flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
4168
+ className
4169
+ ),
4170
+ children: /* @__PURE__ */ jsx(Smile, { className: "h-3.5 w-3.5" })
4171
+ }
4172
+ ) }),
4173
+ /* @__PURE__ */ jsx(PopoverContent, { align, className: "w-auto p-2", children: /* @__PURE__ */ jsx(
4174
+ EmojiPicker,
4175
+ {
4176
+ onSelect: (selection) => {
4177
+ onSelect(selection);
4178
+ setOpen(false);
4179
+ }
4180
+ }
4181
+ ) })
4182
+ ] });
4183
+ }
4184
+ function EmojiTextInsertButton({
4185
+ targetRef,
4186
+ value,
4187
+ onChange,
4188
+ className,
4189
+ align = "end"
4190
+ }) {
4191
+ return /* @__PURE__ */ jsx(
4192
+ EmojiPickerButton,
4193
+ {
4194
+ align,
4195
+ label: "Insert emoji",
4196
+ className,
4197
+ onSelect: (sel) => {
4198
+ const el = targetRef.current;
4199
+ const insert = composeTextEmoji(sel.name, sel.skinTone ?? void 0);
4200
+ const start = el?.selectionStart ?? value.length;
4201
+ const end = el?.selectionEnd ?? value.length;
4202
+ const next = value.slice(0, start) + insert + value.slice(end);
4203
+ onChange(next);
4204
+ requestAnimationFrame(() => {
4205
+ if (el) {
4206
+ const caret = start + insert.length;
4207
+ el.focus();
4208
+ el.setSelectionRange(caret, caret);
4209
+ }
4210
+ });
4211
+ }
4212
+ }
4213
+ );
4214
+ }
3718
4215
  var HEADER_MAX = 150;
3719
4216
  var HEADER_LEVELS = [1, 2, 3, 4];
3720
4217
  function HeaderEditor({ block, onChange }) {
3721
4218
  const text = block.text?.text ?? "";
3722
4219
  const remaining = HEADER_MAX - text.length;
3723
4220
  const level = block.level;
4221
+ const inputRef = useRef(null);
4222
+ const setText = (next) => onChange({ ...block, text: { type: "plain_text", text: next, emoji: true } });
3724
4223
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
3725
4224
  /* @__PURE__ */ jsx(
3726
4225
  EditorField,
@@ -3728,23 +4227,20 @@ function HeaderEditor({ block, onChange }) {
3728
4227
  label: "Heading text",
3729
4228
  help: `Plain text only. ${remaining} characters remaining.`,
3730
4229
  htmlFor: "header-text",
3731
- children: /* @__PURE__ */ jsx(
3732
- Input,
3733
- {
3734
- id: "header-text",
3735
- value: text,
3736
- maxLength: HEADER_MAX,
3737
- placeholder: "e.g. Weekly roundup",
3738
- onChange: (e) => onChange({
3739
- ...block,
3740
- text: {
3741
- type: "plain_text",
3742
- text: e.target.value,
3743
- emoji: true
3744
- }
3745
- })
3746
- }
3747
- )
4230
+ children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
4231
+ /* @__PURE__ */ jsx(
4232
+ Input,
4233
+ {
4234
+ ref: inputRef,
4235
+ id: "header-text",
4236
+ value: text,
4237
+ maxLength: HEADER_MAX,
4238
+ placeholder: "e.g. Weekly roundup",
4239
+ onChange: (e) => setText(e.target.value)
4240
+ }
4241
+ ),
4242
+ /* @__PURE__ */ jsx(EmojiTextInsertButton, { targetRef: inputRef, value: text, onChange: setText, className: "shrink-0 border" })
4243
+ ] })
3748
4244
  }
3749
4245
  ),
3750
4246
  /* @__PURE__ */ jsx(EditorField, { label: "Level", help: "Optional. Builder-only extension; Slack's API ignores this value.", children: /* @__PURE__ */ jsxs(
@@ -4607,22 +5103,29 @@ function CheckboxField({
4607
5103
  ] });
4608
5104
  }
4609
5105
  function MarkdownEditor({ block, onChange }) {
5106
+ const textRef = useRef(null);
5107
+ const text = block.text ?? "";
5108
+ const setText = (next) => onChange({ ...block, text: next });
4610
5109
  return /* @__PURE__ */ jsx(
4611
5110
  EditorField,
4612
5111
  {
4613
5112
  label: "Markdown",
4614
5113
  help: "Standard markdown with GFM: **bold**, _italic_, lists, tables, ```code```, [links](url).",
4615
5114
  htmlFor: "markdown-text",
4616
- children: /* @__PURE__ */ jsx(
4617
- Textarea,
4618
- {
4619
- id: "markdown-text",
4620
- value: block.text ?? "",
4621
- rows: 8,
4622
- placeholder: "e.g. **Roadmap**\\n\\n- Item one\\n- Item two",
4623
- onChange: (e) => onChange({ ...block, text: e.target.value })
4624
- }
4625
- )
5115
+ children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-1.5", children: [
5116
+ /* @__PURE__ */ jsx(
5117
+ Textarea,
5118
+ {
5119
+ ref: textRef,
5120
+ id: "markdown-text",
5121
+ value: text,
5122
+ rows: 8,
5123
+ placeholder: "e.g. **Roadmap**\\n\\n- Item one\\n- Item two",
5124
+ onChange: (e) => setText(e.target.value)
5125
+ }
5126
+ ),
5127
+ /* @__PURE__ */ jsx(EmojiTextInsertButton, { targetRef: textRef, value: text, onChange: setText, className: "mt-1 shrink-0 border" })
5128
+ ] })
4626
5129
  }
4627
5130
  );
4628
5131
  }
@@ -4717,6 +5220,26 @@ function PlanEditor({ block, onChange }) {
4717
5220
  ] });
4718
5221
  }
4719
5222
 
5223
+ // src/lib/emoji-resolve.ts
5224
+ function makeEmojiImportResolver(customByName, index = getEmojiIndex()) {
5225
+ return (el) => {
5226
+ const name = el.name ?? "";
5227
+ const skinTone = el.skin_tone ?? null;
5228
+ const custom = customByName.get(name);
5229
+ if (custom?.url) {
5230
+ return { name, src: custom.url, unicode: null, skinTone };
5231
+ }
5232
+ const lookupName = custom?.alias ?? name;
5233
+ const datum = index?.byName.get(lookupName);
5234
+ let unicode = el.unicode ?? null;
5235
+ if (!unicode && datum) {
5236
+ const toned = skinTone ? datum.skinUnified[skinTone] : void 0;
5237
+ unicode = unifiedToUnicode(toned ?? datum.unified);
5238
+ }
5239
+ return { name, src: null, unicode, skinTone };
5240
+ };
5241
+ }
5242
+
4720
5243
  // src/lib/rich-text-tiptap.ts
4721
5244
  var SUPPORTED_BLOCK_KINDS = /* @__PURE__ */ new Set([
4722
5245
  "rich_text_section",
@@ -4724,7 +5247,7 @@ var SUPPORTED_BLOCK_KINDS = /* @__PURE__ */ new Set([
4724
5247
  "rich_text_quote",
4725
5248
  "rich_text_preformatted"
4726
5249
  ]);
4727
- var SUPPORTED_INLINE_KINDS = /* @__PURE__ */ new Set(["text", "link"]);
5250
+ var SUPPORTED_INLINE_KINDS = /* @__PURE__ */ new Set(["text", "link", "emoji"]);
4728
5251
  var SUPPORTED_TEXT_STYLE_KEYS = /* @__PURE__ */ new Set(["bold", "italic", "strike", "code"]);
4729
5252
  var MAX_LIST_INDENT = 8;
4730
5253
  function detectLossy(block) {
@@ -4771,16 +5294,22 @@ function scanInlines(inlines, where, out) {
4771
5294
  }
4772
5295
  });
4773
5296
  }
4774
- function richTextToProseMirror(block) {
5297
+ var defaultEmojiResolver = (el) => ({
5298
+ name: el.name ?? "",
5299
+ src: null,
5300
+ unicode: el.unicode ?? null,
5301
+ skinTone: el.skin_tone ?? null
5302
+ });
5303
+ function richTextToProseMirror(block, resolveEmoji = defaultEmojiResolver) {
4775
5304
  const content = [];
4776
5305
  const listsByIndent = /* @__PURE__ */ new Map();
4777
5306
  for (const el of block.elements ?? []) {
4778
5307
  if (el.type === "rich_text_list") {
4779
- appendRichTextList(el, content, listsByIndent);
5308
+ appendRichTextList(el, content, listsByIndent, resolveEmoji);
4780
5309
  continue;
4781
5310
  }
4782
5311
  listsByIndent.clear();
4783
- const node = blockElementToPM(el);
5312
+ const node = blockElementToPM(el, resolveEmoji);
4784
5313
  if (node) {
4785
5314
  content.push(node);
4786
5315
  }
@@ -4790,12 +5319,12 @@ function richTextToProseMirror(block) {
4790
5319
  content: content.length > 0 ? content : [{ type: "paragraph" }]
4791
5320
  };
4792
5321
  }
4793
- function appendRichTextList(el, topLevel, listsByIndent) {
5322
+ function appendRichTextList(el, topLevel, listsByIndent, resolveEmoji) {
4794
5323
  const indent = el.indent ?? 0;
4795
5324
  const listType = el.style === "ordered" ? "orderedList" : "bulletList";
4796
5325
  const items = (el.elements ?? []).map((section) => ({
4797
5326
  type: "listItem",
4798
- content: [{ type: "paragraph", content: inlinesToPM(section.elements ?? []) }]
5327
+ content: [{ type: "paragraph", content: inlinesToPM(section.elements ?? [], resolveEmoji) }]
4799
5328
  }));
4800
5329
  for (const k of Array.from(listsByIndent.keys())) {
4801
5330
  if (k > indent) {
@@ -4821,11 +5350,11 @@ function appendRichTextList(el, topLevel, listsByIndent) {
4821
5350
  const parentLastItem = parent.content[parent.content.length - 1];
4822
5351
  parentLastItem.content = [...parentLastItem.content ?? [], list];
4823
5352
  }
4824
- function blockElementToPM(el) {
5353
+ function blockElementToPM(el, resolveEmoji) {
4825
5354
  if (el.type === "rich_text_section") {
4826
5355
  return {
4827
5356
  type: "paragraph",
4828
- content: inlinesToPM(el.elements ?? [])
5357
+ content: inlinesToPM(el.elements ?? [], resolveEmoji)
4829
5358
  };
4830
5359
  }
4831
5360
  if (el.type === "rich_text_quote") {
@@ -4834,7 +5363,7 @@ function blockElementToPM(el) {
4834
5363
  content: [
4835
5364
  {
4836
5365
  type: "paragraph",
4837
- content: inlinesToPM(el.elements ?? [])
5366
+ content: inlinesToPM(el.elements ?? [], resolveEmoji)
4838
5367
  }
4839
5368
  ]
4840
5369
  };
@@ -4849,15 +5378,31 @@ function blockElementToPM(el) {
4849
5378
  }
4850
5379
  return null;
4851
5380
  }
4852
- function inlinesToPM(inlines) {
5381
+ function pushTextWithHardBreaks(out, text, marks) {
5382
+ const segments = text.split("\n");
5383
+ segments.forEach((segment, i) => {
5384
+ if (i > 0) {
5385
+ out.push({ type: "hardBreak" });
5386
+ }
5387
+ if (segment) {
5388
+ out.push({ type: "text", text: segment, marks });
5389
+ }
5390
+ });
5391
+ }
5392
+ function inlinesToPM(inlines, resolveEmoji) {
4853
5393
  const out = [];
4854
5394
  for (const inline of inlines) {
4855
5395
  if (inline.type === "text") {
4856
5396
  if (!inline.text) {
4857
5397
  continue;
4858
5398
  }
4859
- const marks = styleToMarks(inline.style);
4860
- out.push({ type: "text", text: inline.text, marks });
5399
+ pushTextWithHardBreaks(out, inline.text, styleToMarks(inline.style));
5400
+ } else if (inline.type === "emoji") {
5401
+ const attrs = resolveEmoji(inline);
5402
+ if (!attrs.name) {
5403
+ continue;
5404
+ }
5405
+ out.push({ type: "emoji", attrs });
4861
5406
  } else if (inline.type === "link") {
4862
5407
  const link = inline;
4863
5408
  const text = link.text || link.url || "";
@@ -4975,6 +5520,23 @@ function flattenList(listNode, indent, out) {
4975
5520
  function proseMirrorInlinesToRichTextElements(nodes) {
4976
5521
  const out = [];
4977
5522
  for (const node of nodes) {
5523
+ if (node.type === "emoji") {
5524
+ const name = String(node.attrs?.name ?? "");
5525
+ if (!name) {
5526
+ continue;
5527
+ }
5528
+ const emoji = { type: "emoji", name };
5529
+ const skinTone = node.attrs?.skinTone;
5530
+ if (typeof skinTone === "number" && skinTone >= 2 && skinTone <= 6) {
5531
+ emoji.skin_tone = skinTone;
5532
+ }
5533
+ out.push(emoji);
5534
+ continue;
5535
+ }
5536
+ if (node.type === "hardBreak") {
5537
+ out.push({ type: "text", text: "\n" });
5538
+ continue;
5539
+ }
4978
5540
  if (node.type !== "text" || !node.text) {
4979
5541
  continue;
4980
5542
  }
@@ -5045,6 +5607,49 @@ function sameStyle(a, b) {
5045
5607
  }
5046
5608
  return ak.every((k, i) => k === bk[i]);
5047
5609
  }
5610
+ function EmojiNodeView({ node }) {
5611
+ const { name, src, unicode } = node.attrs;
5612
+ const label = `:${name}:`;
5613
+ return /* @__PURE__ */ jsx(NodeViewWrapper, { as: "span", className: "bk-emoji-node", "data-emoji": name, title: label, children: src ? /* @__PURE__ */ jsx(
5614
+ "img",
5615
+ {
5616
+ src,
5617
+ alt: label,
5618
+ style: {
5619
+ display: "inline-block",
5620
+ width: "1.25em",
5621
+ height: "1.25em",
5622
+ verticalAlign: "-0.25em",
5623
+ objectFit: "contain"
5624
+ }
5625
+ }
5626
+ ) : /* @__PURE__ */ jsx("span", { "aria-label": label, role: "img", children: unicode ? codepointsToGlyph(unicode) : label }) });
5627
+ }
5628
+ var EmojiNode = Node.create({
5629
+ name: "emoji",
5630
+ group: "inline",
5631
+ inline: true,
5632
+ atom: true,
5633
+ selectable: true,
5634
+ addAttributes() {
5635
+ return {
5636
+ name: { default: "" },
5637
+ src: { default: null },
5638
+ unicode: { default: null },
5639
+ skinTone: { default: null }
5640
+ };
5641
+ },
5642
+ parseHTML() {
5643
+ return [{ tag: "span[data-emoji]" }];
5644
+ },
5645
+ renderHTML({ HTMLAttributes }) {
5646
+ const name = HTMLAttributes.name ?? "";
5647
+ return ["span", mergeAttributes({ "data-emoji": name }, HTMLAttributes), `:${name}:`];
5648
+ },
5649
+ addNodeView() {
5650
+ return ReactNodeViewRenderer(EmojiNodeView);
5651
+ }
5652
+ });
5048
5653
  var BLOCK_ELEMENT_LABEL = {
5049
5654
  rich_text_section: "Section",
5050
5655
  rich_text_list: "List",
@@ -5383,15 +5988,34 @@ function InlineFields({
5383
5988
  }
5384
5989
  if (kind === "emoji") {
5385
5990
  const emoji = element;
5386
- return /* @__PURE__ */ jsx(EditorField, { label: "Name", help: "Emoji shortcode without colons, e.g. wave", htmlFor: `${idPrefix}-emoji`, children: /* @__PURE__ */ jsx(
5387
- Input,
5991
+ return /* @__PURE__ */ jsx(
5992
+ EditorField,
5388
5993
  {
5389
- id: `${idPrefix}-emoji`,
5390
- value: emoji.name ?? "",
5391
- placeholder: "e.g. wave",
5392
- onChange: (e) => onChange({ ...emoji, name: e.target.value })
5994
+ label: "Name",
5995
+ help: "Pick from the emoji list, or type a shortcode (without colons).",
5996
+ htmlFor: `${idPrefix}-emoji`,
5997
+ children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
5998
+ /* @__PURE__ */ jsx(
5999
+ Input,
6000
+ {
6001
+ id: `${idPrefix}-emoji`,
6002
+ value: emoji.name ?? "",
6003
+ placeholder: "e.g. wave",
6004
+ onChange: (e) => onChange({ ...emoji, name: e.target.value })
6005
+ }
6006
+ ),
6007
+ /* @__PURE__ */ jsx(
6008
+ EmojiPickerButton,
6009
+ {
6010
+ align: "end",
6011
+ label: "Pick emoji",
6012
+ className: "shrink-0 border",
6013
+ onSelect: (sel) => onChange({ ...emoji, name: sel.name, skin_tone: sel.skinTone ?? void 0, unicode: void 0 })
6014
+ }
6015
+ )
6016
+ ] })
5393
6017
  }
5394
- ) });
6018
+ );
5395
6019
  }
5396
6020
  if (kind === "user") {
5397
6021
  const user = element;
@@ -5600,8 +6224,59 @@ function LossyBanner({ reasons }) {
5600
6224
  ] })
5601
6225
  ] });
5602
6226
  }
6227
+ var RICH_TEXT_INPUT_RULE_ALLOWLIST = ["bold", "italic", "strike", "code"];
6228
+ function buildRichTextExtensions() {
6229
+ return [
6230
+ StarterKit.configure({
6231
+ heading: false,
6232
+ horizontalRule: false,
6233
+ // StarterKit bundles its own Link extension (enabled by default),
6234
+ // which would register a second, unconfigured `link` mark and a
6235
+ // duplicate autolinker alongside the hardened one we add below.
6236
+ // Disable it so our single, security-configured Link is the only
6237
+ // link extension in the schema.
6238
+ link: false
6239
+ }),
6240
+ EmojiNode,
6241
+ Link.configure({
6242
+ openOnClick: false,
6243
+ autolink: true,
6244
+ defaultProtocol: "https",
6245
+ // Pin TipTap's link allowlist to the same set as our shared
6246
+ // `isSafeHref` helper. The upstream default also includes
6247
+ // ftp/cid/callto which we don't expect inside Slack content.
6248
+ protocols: ["http", "https", "mailto", "tel", "sms", "xmpp"],
6249
+ // Belt-and-suspenders: even if a downstream contributor widens
6250
+ // `protocols` later, our isSafeHref guard rejects everything
6251
+ // outside the safe-link set. setLink and toggleLink both gate
6252
+ // on this hook before applying the mark.
6253
+ isAllowedUri: (url) => isSafeHref(url),
6254
+ // Only auto-link tokens that carry an explicit, safe scheme. TipTap's
6255
+ // default would link any `host.tld` (e.g. `2.xyz`, `report.zip`,
6256
+ // `logo.png`) because the suffix is a real gTLD, which is wrong for
6257
+ // free-form Slack message text. Deliberate links can still be added
6258
+ // via the link button.
6259
+ shouldAutoLink: hasExplicitSafeScheme,
6260
+ HTMLAttributes: { rel: "noreferrer noopener", target: "_blank" }
6261
+ })
6262
+ ];
6263
+ }
5603
6264
  function RichTextWysiwygEditor({ block, onChange }) {
5604
6265
  const lastEmittedRef = useRef(null);
6266
+ const { byName: customByName } = useCustomEmojis();
6267
+ const [emojiIndex, setEmojiIndex] = useState(null);
6268
+ useEffect(() => {
6269
+ let active = true;
6270
+ loadEmojiIndex().then((idx) => active && setEmojiIndex(idx)).catch(() => {
6271
+ });
6272
+ return () => {
6273
+ active = false;
6274
+ };
6275
+ }, []);
6276
+ const resolveEmoji = useMemo(
6277
+ () => makeEmojiImportResolver(customByName, emojiIndex),
6278
+ [customByName, emojiIndex]
6279
+ );
5605
6280
  const handleUpdate = useCallback(
5606
6281
  (editor2) => {
5607
6282
  const next = proseMirrorToRichText(editor2.getJSON());
@@ -5611,28 +6286,9 @@ function RichTextWysiwygEditor({ block, onChange }) {
5611
6286
  [onChange]
5612
6287
  );
5613
6288
  const editor = useEditor({
5614
- extensions: [
5615
- StarterKit.configure({
5616
- heading: false,
5617
- horizontalRule: false
5618
- }),
5619
- Link.configure({
5620
- openOnClick: false,
5621
- autolink: true,
5622
- defaultProtocol: "https",
5623
- // Pin TipTap's link allowlist to the same set as our shared
5624
- // `isSafeHref` helper. The upstream default also includes
5625
- // ftp/cid/callto which we don't expect inside Slack content.
5626
- protocols: ["http", "https", "mailto", "tel", "sms", "xmpp"],
5627
- // Belt-and-suspenders: even if a downstream contributor widens
5628
- // `protocols` later, our isSafeHref guard rejects everything
5629
- // outside the safe-link set. setLink and toggleLink both gate
5630
- // on this hook before applying the mark.
5631
- isAllowedUri: (url) => isSafeHref(url),
5632
- HTMLAttributes: { rel: "noreferrer noopener", target: "_blank" }
5633
- })
5634
- ],
5635
- content: richTextToProseMirror(block),
6289
+ extensions: buildRichTextExtensions(),
6290
+ enableInputRules: RICH_TEXT_INPUT_RULE_ALLOWLIST,
6291
+ content: richTextToProseMirror(block, resolveEmoji),
5636
6292
  editorProps: {
5637
6293
  attributes: {
5638
6294
  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"
@@ -5647,10 +6303,10 @@ function RichTextWysiwygEditor({ block, onChange }) {
5647
6303
  if (lastEmittedRef.current && JSON.stringify(lastEmittedRef.current) === JSON.stringify(block)) {
5648
6304
  return;
5649
6305
  }
5650
- editor.commands.setContent(richTextToProseMirror(block), {
6306
+ editor.commands.setContent(richTextToProseMirror(block, resolveEmoji), {
5651
6307
  emitUpdate: false
5652
6308
  });
5653
- }, [block, editor]);
6309
+ }, [block, editor, resolveEmoji]);
5654
6310
  if (!editor) {
5655
6311
  return /* @__PURE__ */ jsx("div", { className: "min-h-[120px] rounded-md border border-input bg-background" });
5656
6312
  }
@@ -5699,6 +6355,16 @@ function Toolbar({ editor }) {
5699
6355
  ),
5700
6356
  /* @__PURE__ */ jsx(Divider, {}),
5701
6357
  /* @__PURE__ */ jsx(LinkPopover, { editor }),
6358
+ /* @__PURE__ */ jsx(
6359
+ EmojiPickerButton,
6360
+ {
6361
+ align: "start",
6362
+ onSelect: (sel) => editor.chain().focus().insertContent({
6363
+ type: "emoji",
6364
+ attrs: { name: sel.name, src: sel.src, unicode: sel.unicode, skinTone: sel.skinTone }
6365
+ }).run()
6366
+ }
6367
+ ),
5702
6368
  /* @__PURE__ */ jsx(Divider, {}),
5703
6369
  /* @__PURE__ */ jsx(
5704
6370
  ToolbarButton,
@@ -5897,6 +6563,8 @@ function defaultImage() {
5897
6563
  function SectionEditor({ block, onChange }) {
5898
6564
  const text = block.text?.text ?? "";
5899
6565
  const accessoryKind = detectAccessory(block.accessory);
6566
+ const textRef = useRef(null);
6567
+ const setText = (next) => onChange({ ...block, text: { type: "mrkdwn", text: next } });
5900
6568
  const setAccessoryKind = (next) => {
5901
6569
  if (next === accessoryKind) {
5902
6570
  return;
@@ -5920,19 +6588,20 @@ function SectionEditor({ block, onChange }) {
5920
6588
  label: "Text",
5921
6589
  help: "Supports *bold*, _italic_, ~strike~, `code`, and <url|link> formatting.",
5922
6590
  htmlFor: "section-text",
5923
- children: /* @__PURE__ */ jsx(
5924
- Textarea,
5925
- {
5926
- id: "section-text",
5927
- value: text,
5928
- rows: 4,
5929
- placeholder: "e.g. Welcome to the channel! Ping <@U123> with any questions.",
5930
- onChange: (e) => onChange({
5931
- ...block,
5932
- text: { type: "mrkdwn", text: e.target.value }
5933
- })
5934
- }
5935
- )
6591
+ children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-1.5", children: [
6592
+ /* @__PURE__ */ jsx(
6593
+ Textarea,
6594
+ {
6595
+ ref: textRef,
6596
+ id: "section-text",
6597
+ value: text,
6598
+ rows: 4,
6599
+ placeholder: "e.g. Welcome to the channel! Ping <@U123> with any questions.",
6600
+ onChange: (e) => setText(e.target.value)
6601
+ }
6602
+ ),
6603
+ /* @__PURE__ */ jsx(EmojiTextInsertButton, { targetRef: textRef, value: text, onChange: setText, className: "mt-1 shrink-0 border" })
6604
+ ] })
5936
6605
  }
5937
6606
  ),
5938
6607
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 rounded-md border bg-muted/20 p-3", children: [
@@ -5973,19 +6642,22 @@ function ButtonAccessoryFields({ button, onChange }) {
5973
6642
  const url = button.url ?? "";
5974
6643
  const value = button.value ?? "";
5975
6644
  const style = button.style === "primary" || button.style === "danger" ? button.style : "default";
6645
+ const labelRef = useRef(null);
6646
+ const setLabel = (next) => onChange({ ...button, text: { type: "plain_text", text: next, emoji: true } });
5976
6647
  return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
5977
- /* @__PURE__ */ jsx(EditorField, { label: "Label", htmlFor: "section-acc-btn-label", children: /* @__PURE__ */ jsx(
5978
- Input,
5979
- {
5980
- id: "section-acc-btn-label",
5981
- value: label,
5982
- placeholder: "e.g. Learn more",
5983
- onChange: (e) => onChange({
5984
- ...button,
5985
- text: { type: "plain_text", text: e.target.value, emoji: true }
5986
- })
5987
- }
5988
- ) }),
6648
+ /* @__PURE__ */ jsx(EditorField, { label: "Label", htmlFor: "section-acc-btn-label", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
6649
+ /* @__PURE__ */ jsx(
6650
+ Input,
6651
+ {
6652
+ ref: labelRef,
6653
+ id: "section-acc-btn-label",
6654
+ value: label,
6655
+ placeholder: "e.g. Learn more",
6656
+ onChange: (e) => setLabel(e.target.value)
6657
+ }
6658
+ ),
6659
+ /* @__PURE__ */ jsx(EmojiTextInsertButton, { targetRef: labelRef, value: label, onChange: setLabel, className: "shrink-0 border" })
6660
+ ] }) }),
5989
6661
  /* @__PURE__ */ jsx(
5990
6662
  EditorField,
5991
6663
  {
@@ -7540,6 +8212,7 @@ function BlockKitchen(props) {
7540
8212
  initialBlocks,
7541
8213
  onChange,
7542
8214
  previewHooks,
8215
+ customEmojis,
7543
8216
  loadChannels,
7544
8217
  loadSendAsUserStatus,
7545
8218
  onSend,
@@ -7583,6 +8256,13 @@ function BlockKitchen(props) {
7583
8256
  const [previewSurface, setPreviewSurface] = useState(allowedSurfaces[0]);
7584
8257
  const [activePaletteVariantId, setActivePaletteVariantId] = useState(null);
7585
8258
  const isMobile = useIsMobile();
8259
+ const mergedPreviewHooks = useMemo(() => {
8260
+ if (!customEmojis || customEmojis.length === 0) {
8261
+ return previewHooks;
8262
+ }
8263
+ const byName = new Map(customEmojis.map((emoji) => [emoji.name, emoji]));
8264
+ return { emoji: makeEmojiHook(byName), ...previewHooks };
8265
+ }, [customEmojis, previewHooks]);
7586
8266
  const validation = useBlockKitValidation(blocks, "message");
7587
8267
  const sensors = useSensors(
7588
8268
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -7632,7 +8312,7 @@ function BlockKitchen(props) {
7632
8312
  }, []);
7633
8313
  const activePaletteVariant = activePaletteVariantId ? variantById.get(activePaletteVariantId) : null;
7634
8314
  const blockPayloads = blocks.map((b) => b.block);
7635
- return /* @__PURE__ */ jsx(BrandThemeScope, { theme, children: /* @__PURE__ */ jsx(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxs(
8315
+ return /* @__PURE__ */ jsx(BrandThemeScope, { theme, children: /* @__PURE__ */ jsx(CustomEmojiProvider, { customEmojis, children: /* @__PURE__ */ jsx(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxs(
7636
8316
  DndContext,
7637
8317
  {
7638
8318
  sensors,
@@ -7678,7 +8358,7 @@ function BlockKitchen(props) {
7678
8358
  {
7679
8359
  blocks,
7680
8360
  workspaceName,
7681
- previewHooks,
8361
+ previewHooks: mergedPreviewHooks,
7682
8362
  previewTheme,
7683
8363
  previewSurface,
7684
8364
  errorsByBlockId: validation.byBlockId,
@@ -7754,7 +8434,7 @@ function BlockKitchen(props) {
7754
8434
  )
7755
8435
  ]
7756
8436
  }
7757
- ) }) });
8437
+ ) }) }) });
7758
8438
  }
7759
8439
  var UNCATEGORIZED = "Other";
7760
8440
  function TemplateThumbnail({ blocks, theme }) {