@jackuait/blok 0.7.1 → 0.7.3-beta.1

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.
Files changed (28) hide show
  1. package/dist/blok.mjs +2 -2
  2. package/dist/chunks/{blok-D-7DpjTs.mjs → blok-Ua3rzLVN.mjs} +1210 -1174
  3. package/dist/chunks/{constants-DXYRzX7f.mjs → constants-CNjvg-95.mjs} +314 -176
  4. package/dist/chunks/{tools-Chd7Auwx.mjs → tools-DB7A1iro.mjs} +31 -12
  5. package/dist/full.mjs +3 -3
  6. package/dist/react.mjs +2 -2
  7. package/dist/tools.mjs +2 -2
  8. package/package.json +1 -1
  9. package/src/components/constants/data-attributes.ts +2 -0
  10. package/src/components/inline-tools/inline-tool-marker.ts +11 -0
  11. package/src/components/modules/blockEvents/composers/keyboardNavigation.ts +1 -9
  12. package/src/components/modules/caret.ts +13 -1
  13. package/src/components/modules/paste/google-docs-preprocessor.ts +96 -38
  14. package/src/components/modules/toolbar/blockSettings.ts +1 -1
  15. package/src/components/modules/toolbar/index.ts +37 -2
  16. package/src/components/modules/toolbar/inline/index.ts +24 -2
  17. package/src/components/selection/cursor.ts +7 -0
  18. package/src/components/ui/toolbox.ts +14 -0
  19. package/src/components/utils/popover/components/popover-item/popover-item-default/popover-item-default.ts +1 -1
  20. package/src/components/utils/popover/components/search-input/search-input.const.ts +1 -0
  21. package/src/components/utils/popover/components/search-input/search-input.ts +32 -1
  22. package/src/components/utils/popover/popover-desktop.ts +298 -36
  23. package/src/components/utils/popover/popover-inline.ts +8 -0
  24. package/src/styles/main.css +39 -11
  25. package/src/tools/paragraph/index.ts +3 -5
  26. package/src/tools/table/index.ts +70 -0
  27. package/src/tools/table/table-cell-blocks.ts +15 -3
  28. package/src/tools/table/table-cell-clipboard.ts +32 -5
@@ -3,7 +3,8 @@ import { Flipper } from '../../flipper';
3
3
  import { keyCodes } from '../../utils';
4
4
 
5
5
  import type { PopoverItem, PopoverItemRenderParamsMap } from './components/popover-item';
6
- import { PopoverItemSeparator, css as popoverItemCls , PopoverItemDefault } from './components/popover-item';
6
+ import { PopoverItemSeparator, css as popoverItemCls, PopoverItemDefault, PopoverItemType } from './components/popover-item';
7
+ import type { PopoverItemParams } from '@/types/utils/popover/popover-item';
7
8
  import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html';
8
9
  import type { SearchableItem } from './components/search-input';
9
10
  import { SearchInput, SearchInputEvent, scoreSearchMatch } from './components/search-input';
@@ -67,6 +68,14 @@ export class PopoverDesktop extends PopoverAbstract {
67
68
  */
68
69
  private leftAlignElement: HTMLElement | undefined;
69
70
 
71
+ /**
72
+ * Updates the element whose left edge is used for horizontal positioning.
73
+ * @param element - new element to align against, or undefined to fall back to trigger
74
+ */
75
+ public setLeftAlignElement(element: HTMLElement | undefined): void {
76
+ this.leftAlignElement = element;
77
+ }
78
+
70
79
  /**
71
80
  * Popover size cache
72
81
  */
@@ -78,6 +87,20 @@ export class PopoverDesktop extends PopoverAbstract {
78
87
  */
79
88
  private originalItemOrder: Element[] | undefined;
80
89
 
90
+ /**
91
+ * Cache of promoted items built from nested children.
92
+ * Built once on first non-empty search, destroyed on clear/hide/destroy.
93
+ */
94
+ private promotedItemCache: {
95
+ items: PopoverItemDefault[];
96
+ parentChains: Map<PopoverItemDefault, string[]>;
97
+ } | null = null;
98
+
99
+ /**
100
+ * Temporary group separator elements injected during search.
101
+ */
102
+ private promotedSeparators: HTMLElement[] = [];
103
+
81
104
  /**
82
105
  * Construct the instance
83
106
  * @param params - popover params
@@ -313,6 +336,7 @@ export class PopoverDesktop extends PopoverAbstract {
313
336
  * Closes popover
314
337
  */
315
338
  public hide = (): void => {
339
+ this.cleanupPromotedItems();
316
340
  super.hide();
317
341
 
318
342
  this.destroyNestedPopoverIfExists();
@@ -405,7 +429,7 @@ export class PopoverDesktop extends PopoverAbstract {
405
429
  * Destroys nested popover unless the mouse moved into it.
406
430
  * @param event - mouseleave event
407
431
  */
408
- private handleMouseLeave(event: Event): void {
432
+ protected handleMouseLeave(event: Event): void {
409
433
  const mouseEvent = event as MouseEvent;
410
434
  const relatedTarget = mouseEvent.relatedTarget;
411
435
 
@@ -422,6 +446,31 @@ export class PopoverDesktop extends PopoverAbstract {
422
446
  this.previouslyHoveredItem = null;
423
447
  }
424
448
 
449
+ /**
450
+ * Retrieves popover item that is the target of the specified event.
451
+ * Overridden to include promoted items from recursive search.
452
+ * @param event - event to retrieve popover item from
453
+ */
454
+ protected override getTargetItem(event: Event): PopoverItemDefault | PopoverItemHtml | undefined {
455
+ const allItems = this.promotedItemCache !== null
456
+ ? [...this.items, ...this.promotedItemCache.items]
457
+ : this.items;
458
+
459
+ return allItems
460
+ .filter((item): item is PopoverItemDefault | PopoverItemHtml =>
461
+ item instanceof PopoverItemDefault || item instanceof PopoverItemHtml
462
+ )
463
+ .find(item => {
464
+ const itemEl = item.getElement();
465
+
466
+ if (itemEl === null) {
467
+ return false;
468
+ }
469
+
470
+ return event.composedPath().includes(itemEl);
471
+ });
472
+ }
473
+
425
474
  /**
426
475
  * Sets CSS variable with position of item near which nested popover should be displayed.
427
476
  * Is used for correct positioning of the nested popover
@@ -724,6 +773,125 @@ export class PopoverDesktop extends PopoverAbstract {
724
773
  focusedItem?.onFocus();
725
774
  };
726
775
 
776
+ /**
777
+ * Builds cache of PopoverItemDefault instances from nested children.
778
+ * Recursively walks the item tree to arbitrary depth.
779
+ * Each cached item is mapped to its parent chain for group labeling.
780
+ */
781
+ private buildPromotedItemCache(): { items: PopoverItemDefault[]; parentChains: Map<PopoverItemDefault, string[]> } {
782
+ const cache = {
783
+ items: [] as PopoverItemDefault[],
784
+ parentChains: new Map<PopoverItemDefault, string[]>(),
785
+ };
786
+
787
+ this.collectPromotedChildren(this.items, [], cache);
788
+
789
+ return cache;
790
+ }
791
+
792
+ /**
793
+ * Recursively collects default child items from items that have children.
794
+ * @param items - items to inspect for children
795
+ * @param parentChain - ancestor label chain accumulated so far
796
+ * @param cache - mutable cache to populate
797
+ */
798
+ private collectPromotedChildren(
799
+ items: PopoverItem[],
800
+ parentChain: string[],
801
+ cache: { items: PopoverItemDefault[]; parentChains: Map<PopoverItemDefault, string[]> }
802
+ ): void {
803
+ for (const item of items) {
804
+ if (!(item instanceof PopoverItemDefault) || !item.hasChildren) {
805
+ continue;
806
+ }
807
+
808
+ const label = item.title ?? item.name ?? '';
809
+ const newChain = [...parentChain, label];
810
+
811
+ this.collectDefaultChildren(item.children, newChain, cache);
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Constructs PopoverItemDefault instances from raw params and adds them to the cache.
817
+ * @param childParams - raw child item params from a parent item
818
+ * @param parentChain - ancestor label chain for this group
819
+ * @param cache - mutable cache to populate
820
+ */
821
+ private collectDefaultChildren(
822
+ childParams: PopoverItemParams[],
823
+ parentChain: string[],
824
+ cache: { items: PopoverItemDefault[]; parentChains: Map<PopoverItemDefault, string[]> }
825
+ ): void {
826
+ for (const childParam of childParams) {
827
+ if (childParam.type !== undefined && childParam.type !== PopoverItemType.Default) {
828
+ continue;
829
+ }
830
+
831
+ const childInstance = new PopoverItemDefault(childParam);
832
+
833
+ if (childInstance.name !== undefined && this.isNamePermanentlyHidden(childInstance.name)) {
834
+ childInstance.destroy();
835
+ continue;
836
+ }
837
+
838
+ cache.items.push(childInstance);
839
+ cache.parentChains.set(childInstance, parentChain);
840
+
841
+ if (childInstance.hasChildren) {
842
+ this.collectPromotedChildren([childInstance], parentChain, cache);
843
+ }
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Removes promoted items and group separators from DOM and destroys cached instances.
849
+ * Idempotent — safe to call when cache is already null.
850
+ */
851
+ private cleanupPromotedItems(): void {
852
+ for (const separator of this.promotedSeparators) {
853
+ separator.remove();
854
+ }
855
+ this.promotedSeparators = [];
856
+
857
+ if (this.promotedItemCache !== null) {
858
+ for (const item of this.promotedItemCache.items) {
859
+ item.getElement()?.remove();
860
+ item.destroy();
861
+ }
862
+ this.promotedItemCache = null;
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Creates a group separator element for promoted search results.
868
+ * @param label - the parent chain label (e.g., "Convert to" or "Parent › Child")
869
+ */
870
+ private createGroupSeparator(label: string): HTMLElement {
871
+ const el = document.createElement('div');
872
+
873
+ el.setAttribute(DATA_ATTR.promotedGroupLabel, '');
874
+ el.setAttribute('role', 'separator');
875
+ el.className = 'px-3 pt-2.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-gray-text/50 cursor-default';
876
+ el.textContent = label;
877
+
878
+ return el;
879
+ }
880
+
881
+ /**
882
+ * Appends DOM elements for a group of promoted items to the items container.
883
+ * @param groupItems - promoted items with their scores
884
+ */
885
+ private appendPromotedGroupElements(groupItems: Array<{ item: PopoverItemDefault; score: number }>): void {
886
+ for (const { item } of groupItems) {
887
+ const el = item.getElement();
888
+
889
+ if (el !== null) {
890
+ this.nodes.items?.appendChild(el);
891
+ }
892
+ }
893
+ }
894
+
727
895
  /**
728
896
  * Adds search to the popover
729
897
  */
@@ -733,7 +901,42 @@ export class PopoverDesktop extends PopoverAbstract {
733
901
  placeholder: this.messages.search,
734
902
  });
735
903
 
736
- this.search.on(SearchInputEvent.Search, this.onSearch);
904
+ this.search.on(SearchInputEvent.Search, (searchData: { query: string; items: SearchableItem[] }) => {
905
+ const isEmptyQuery = searchData.query === '';
906
+
907
+ if (isEmptyQuery) {
908
+ this.cleanupPromotedItems();
909
+ this.onSearch({
910
+ query: searchData.query,
911
+ topLevelItems: searchData.items as unknown as PopoverItemDefault[],
912
+ promotedItems: [],
913
+ });
914
+
915
+ return;
916
+ }
917
+
918
+ // Build cache on first non-empty search
919
+ if (this.promotedItemCache === null) {
920
+ this.promotedItemCache = this.buildPromotedItemCache();
921
+ }
922
+
923
+ // Score promoted items against the query
924
+ const { parentChains } = this.promotedItemCache;
925
+ const promotedScored = this.promotedItemCache.items
926
+ .map(item => ({
927
+ item,
928
+ score: scoreSearchMatch(item, searchData.query),
929
+ chain: parentChains.get(item) ?? [],
930
+ }))
931
+ .filter(({ score }) => score > 0)
932
+ .sort((a, b) => b.score - a.score);
933
+
934
+ this.onSearch({
935
+ query: searchData.query,
936
+ topLevelItems: searchData.items as unknown as PopoverItemDefault[],
937
+ promotedItems: promotedScored,
938
+ });
939
+ });
737
940
 
738
941
  const searchElement = this.search.getElement();
739
942
 
@@ -749,45 +952,60 @@ export class PopoverDesktop extends PopoverAbstract {
749
952
  */
750
953
  public override filterItems(query: string): void {
751
954
  if (query === '') {
955
+ this.cleanupPromotedItems();
752
956
  this.onSearch({
753
957
  query,
754
- items: this.itemsDefault as unknown as SearchableItem[],
958
+ topLevelItems: this.itemsDefault,
959
+ promotedItems: [],
755
960
  });
756
961
 
757
962
  return;
758
963
  }
759
964
 
760
- const scoredItems = this.itemsDefault
965
+ // Build cache on first non-empty search
966
+ if (this.promotedItemCache === null) {
967
+ this.promotedItemCache = this.buildPromotedItemCache();
968
+ }
969
+
970
+ // Score top-level items
971
+ const topLevelScored = this.itemsDefault
761
972
  .map(item => ({ item, score: scoreSearchMatch(item, query) }))
762
973
  .filter(({ score }) => score > 0)
763
974
  .sort((a, b) => b.score - a.score);
764
975
 
765
- const matchingItems = scoredItems.map(({ item }) => item);
976
+ // Score promoted items from cache
977
+ const { parentChains: chains } = this.promotedItemCache;
978
+ const promotedScored = this.promotedItemCache.items
979
+ .map(item => ({
980
+ item,
981
+ score: scoreSearchMatch(item, query),
982
+ chain: chains.get(item) ?? [],
983
+ }))
984
+ .filter(({ score }) => score > 0)
985
+ .sort((a, b) => b.score - a.score);
766
986
 
767
987
  this.onSearch({
768
988
  query,
769
- items: matchingItems as unknown as SearchableItem[],
989
+ topLevelItems: topLevelScored.map(({ item }) => item),
990
+ promotedItems: promotedScored,
770
991
  });
771
992
  }
772
993
 
773
994
  /**
774
- * Handles input inside search field
775
- * @param data - search input event data
776
- * @param data.query - search query text
777
- * @param data.items - search results
995
+ * Handles search results from both filterItems and SearchInput.
996
+ * Renders top-level matches and promoted children with group separators.
778
997
  */
779
- private onSearch = (data: { query: string, items: SearchableItem[] }): void => {
998
+ private onSearch = (data: {
999
+ query: string;
1000
+ topLevelItems: PopoverItemDefault[] | SearchableItem[];
1001
+ promotedItems: Array<{ item: PopoverItemDefault; score: number; chain: string[] }>;
1002
+ }): void => {
780
1003
  const isEmptyQuery = data.query === '';
781
- const isNothingFound = data.items.length === 0;
782
-
783
- // Cast data.items to PopoverItemDefault[] since we know that's what filterItems passes
784
- const matchingItems = data.items as unknown as PopoverItemDefault[];
1004
+ const matchingTopLevel = data.topLevelItems as unknown as PopoverItemDefault[];
1005
+ const isNothingFound = matchingTopLevel.length === 0 && data.promotedItems.length === 0;
785
1006
 
786
1007
  /**
787
1008
  * When nothing is found, disable transitions so items hide instantly.
788
- * The "Nothing found" message fade-in provides the visual transition;
789
- * animating the last items' collapse simultaneously causes a jarring
790
- * height bounce in the popover container.
791
1009
  */
792
1010
  if (isNothingFound) {
793
1011
  this.items.forEach(item => {
@@ -800,48 +1018,92 @@ export class PopoverDesktop extends PopoverAbstract {
800
1018
  const isDefaultItem = item instanceof PopoverItemDefault;
801
1019
  const isSeparatorOrHtml = item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml;
802
1020
  const isHidden = isDefaultItem
803
- ? !matchingItems.includes(item) || (item.name !== undefined && this.isNamePermanentlyHidden(item.name))
1021
+ ? !matchingTopLevel.includes(item) || (item.name !== undefined && this.isNamePermanentlyHidden(item.name))
804
1022
  : isSeparatorOrHtml && (isNothingFound || !isEmptyQuery);
805
1023
 
806
1024
  item.toggleHidden(isHidden);
807
1025
  });
808
1026
 
809
1027
  if (isNothingFound) {
810
- // Force reflow so the instant hide takes effect, then restore transitions
811
1028
  this.nodes.popoverContainer.offsetHeight;
812
1029
  this.items.forEach(item => {
813
1030
  item.getElement()?.style.removeProperty('transition-duration');
814
1031
  });
815
1032
  }
816
1033
 
817
- // Reorder DOM elements to reflect ranking
818
- if (!isEmptyQuery && matchingItems.length > 0) {
819
- this.reorderItemsByRank(matchingItems);
1034
+ // Reorder top-level DOM elements to reflect ranking
1035
+ if (!isEmptyQuery && matchingTopLevel.length > 0) {
1036
+ this.reorderItemsByRank(matchingTopLevel);
820
1037
  } else if (isEmptyQuery && this.originalItemOrder !== undefined) {
821
1038
  this.restoreOriginalItemOrder();
822
1039
  }
823
1040
 
1041
+ // Detach previous promoted elements from DOM (don't destroy cache)
1042
+ for (const separator of this.promotedSeparators) {
1043
+ separator.remove();
1044
+ }
1045
+ this.promotedSeparators = [];
1046
+
1047
+ if (this.promotedItemCache !== null) {
1048
+ for (const item of this.promotedItemCache.items) {
1049
+ item.getElement()?.remove();
1050
+ }
1051
+ }
1052
+
1053
+ // Render promoted items grouped by parent chain
1054
+ if (data.promotedItems.length > 0) {
1055
+ const groups = new Map<string, Array<{ item: PopoverItemDefault; score: number }>>();
1056
+
1057
+ for (const entry of data.promotedItems) {
1058
+ const label = entry.chain.join(' \u203A ');
1059
+ const existing = groups.get(label);
1060
+
1061
+ if (existing !== undefined) {
1062
+ existing.push({ item: entry.item, score: entry.score });
1063
+ } else {
1064
+ groups.set(label, [{ item: entry.item, score: entry.score }]);
1065
+ }
1066
+ }
1067
+
1068
+ // Sort groups by best score in each group
1069
+ const sortedGroups = [...groups.entries()].sort((a, b) => {
1070
+ const bestA = Math.max(...a[1].map(e => e.score));
1071
+ const bestB = Math.max(...b[1].map(e => e.score));
1072
+
1073
+ return bestB - bestA;
1074
+ });
1075
+
1076
+ for (const [label, groupItems] of sortedGroups) {
1077
+ const separator = this.createGroupSeparator(label);
1078
+
1079
+ this.promotedSeparators.push(separator);
1080
+ this.nodes.items?.appendChild(separator);
1081
+
1082
+ this.appendPromotedGroupElements(groupItems);
1083
+ }
1084
+ }
1085
+
824
1086
  this.toggleNothingFoundMessage(isNothingFound);
825
1087
 
826
- /** List of elements available for keyboard navigation considering search query applied */
827
- const flippableElements = isEmptyQuery ? this.flippableElements : data.items.map(item => (item as PopoverItem).getElement());
1088
+ // Build flippable elements list: top-level matches + promoted items
1089
+ const topLevelFlippable = isEmptyQuery
1090
+ ? this.flippableElements
1091
+ : matchingTopLevel.map(item => item.getElement());
1092
+
1093
+ const promotedFlippable = data.promotedItems.map(({ item }) => item.getElement());
1094
+
1095
+ const flippableElements = [
1096
+ ...topLevelFlippable,
1097
+ ...promotedFlippable,
1098
+ ].filter((el): el is HTMLElement => el !== null);
828
1099
 
829
1100
  if (!this.flipper?.isActivated) {
830
1101
  return;
831
1102
  }
832
1103
 
833
- /** Update flipper items with only visible */
834
1104
  this.flipper.deactivate();
835
- this.flipper.activate(flippableElements as HTMLElement[]);
1105
+ this.flipper.activate(flippableElements);
836
1106
 
837
- /**
838
- * Focus first item after filtering.
839
- * Always skip the first Tab press so it just "enters" the menu rather than
840
- * advancing to second item. This applies regardless of whether the query is
841
- * empty (initial "/" open) or non-empty (user is typing to filter), because
842
- * the user's keyboard focus is still in the search input - pressing Tab
843
- * should enter the list at item 0, not advance from 0 to 1.
844
- */
845
1107
  if (flippableElements.length > 0) {
846
1108
  this.flipper.focusItem(0, { skipNextTab: true });
847
1109
  }
@@ -240,6 +240,14 @@ export class PopoverInline extends PopoverDesktop {
240
240
  return;
241
241
  }
242
242
 
243
+ /**
244
+ * Disable mouse-leave event handling.
245
+ * Inline toolbar uses click-to-toggle for nested popovers, not hover.
246
+ */
247
+ protected override handleMouseLeave(): void {
248
+ return;
249
+ }
250
+
243
251
  /**
244
252
  * Sets CSS variable with position of item near which nested popover should be displayed.
245
253
  * Is used to position nested popover right below clicked item
@@ -37,9 +37,11 @@
37
37
  `all: initial !important` — main editor wrapper (outermost boundary).
38
38
  Resets every property to its CSS initial value with !important priority,
39
39
  blocking even `!important` host-page styles from cascading in.
40
- Blok's own Tailwind utility classes (applied to the same elements or
41
- descendants) override this because they have equal or higher specificity
42
- and appear later in the cascade.
40
+ However, `!important` declarations always beat normal declarations
41
+ regardless of specificity or source order, so Tailwind utility classes
42
+ applied to the wrapper element itself (e.g. `relative`, `z-1`) are
43
+ overridden. Properties that the wrapper needs must be explicitly
44
+ re-applied with `!important` below (same pattern as font-family/color).
43
45
 
44
46
  [data-blok-popover]:not([data-blok-popover-inline])
45
47
  Inherited-properties-only reset — toolbox/settings popovers, appended
@@ -72,6 +74,27 @@
72
74
  */
73
75
  [data-blok-interface=blok] {
74
76
  all: initial !important;
77
+
78
+ /*
79
+ Re-apply layout properties that `all: initial` resets.
80
+ The wrapper must be a positioned containing block so that
81
+ absolutely-positioned children (inline toolbar, main toolbar)
82
+ resolve against it rather than the document root. Without this,
83
+ the inline toolbar renders off-screen when the page is scrolled.
84
+ */
85
+ position: relative !important;
86
+ box-sizing: border-box !important;
87
+ display: block !important;
88
+ z-index: 1 !important;
89
+ }
90
+
91
+ /*
92
+ RTL direction is set conditionally via JS (ui.ts) using the
93
+ data-blok-rtl attribute. `all: initial` resets direction to `ltr`,
94
+ so we must re-apply it with !important when the attribute is present.
95
+ */
96
+ [data-blok-interface=blok][data-blok-rtl=true] {
97
+ direction: rtl !important;
75
98
  }
76
99
 
77
100
  /* Font family — user-configurable via config.style.fontFamily */
@@ -327,8 +350,9 @@
327
350
  resize: vertical;
328
351
  }
329
352
 
330
- /* Remove inner padding in Chrome and Safari on macOS. */
331
- :where([data-blok-interface], [data-blok-popover]) ::-webkit-search-decoration {
353
+ /* Remove inner padding and native cancel button in Chrome and Safari on macOS. */
354
+ :where([data-blok-interface], [data-blok-popover]) ::-webkit-search-decoration,
355
+ :where([data-blok-interface], [data-blok-popover]) ::-webkit-search-cancel-button {
332
356
  -webkit-appearance: none;
333
357
  }
334
358
 
@@ -394,10 +418,13 @@
394
418
  outline: none;
395
419
  }
396
420
 
397
- /* Never show a focus outline on contenteditable blocks — the text cursor
398
- is sufficient affordance regardless of how focus was triggered. */
399
- [data-blok-interface] [contenteditable]:focus-visible,
400
- [data-blok-popover] [contenteditable]:focus-visible {
421
+ /* Never show a focus outline on text-entry elements — the text cursor
422
+ is sufficient affordance regardless of how focus was triggered.
423
+ Browsers always match :focus-visible on these elements even on mouse
424
+ click (per CSS Selectors L4), so the :focus-visible restore rule below
425
+ would otherwise override element-level outline-hidden utilities. */
426
+ [data-blok-interface] :is([contenteditable], input, textarea):focus-visible,
427
+ [data-blok-popover] :is([contenteditable], input, textarea):focus-visible {
401
428
  outline: none;
402
429
  }
403
430
 
@@ -952,8 +979,9 @@
952
979
  * When the user types "/" to open the toolbox, the contenteditable
953
980
  * transforms to look like a search input with a placeholder.
954
981
  */
955
- [data-blok-slash-search] {
956
- @apply bg-search-input-bg rounded-lg transition-colors duration-150 max-w-[240px];
982
+ [data-blok-slash-search],
983
+ [data-blok-slash-search]:focus-visible {
984
+ @apply bg-search-input-bg rounded-[4px] transition-colors duration-150 max-w-[240px];
957
985
  }
958
986
 
959
987
  [data-blok-slash-search]::after {
@@ -346,11 +346,9 @@ export class Paragraph implements BlockTool {
346
346
 
347
347
  this._data = data;
348
348
 
349
- queueMicrotask(() => {
350
- if (this._element) {
351
- this._element.innerHTML = this._data.text || '';
352
- }
353
- });
349
+ if (this._element) {
350
+ this._element.innerHTML = this._data.text || '';
351
+ }
354
352
  }
355
353
 
356
354
  /**
@@ -1395,6 +1395,20 @@ export class Table implements BlockTool {
1395
1395
  return;
1396
1396
  }
1397
1397
 
1398
+ /**
1399
+ * Single-cell (1×1) payloads should insert content inline at the caret
1400
+ * position rather than replacing the entire target cell. This matches user
1401
+ * expectations: copying one cell and pasting into another cell (or the same
1402
+ * cell) appends/inserts the text instead of overwriting.
1403
+ */
1404
+ if (payload.rows === 1 && payload.cols === 1) {
1405
+ e.preventDefault();
1406
+ e.stopPropagation();
1407
+ this.insertSingleCellPayloadInline(payload.cells[0][0]);
1408
+
1409
+ return;
1410
+ }
1411
+
1398
1412
  e.preventDefault();
1399
1413
  e.stopPropagation();
1400
1414
 
@@ -1406,6 +1420,62 @@ export class Table implements BlockTool {
1406
1420
  this.pastePayloadIntoCells(gridEl, payload, targetRowIndex, targetColIndex);
1407
1421
  }
1408
1422
 
1423
+ /**
1424
+ * Insert the content of a single clipboard cell at the current caret position.
1425
+ * Extracts text from each block and joins with line breaks.
1426
+ */
1427
+ private insertSingleCellPayloadInline(cell: { blocks: ClipboardBlockData[] }): void {
1428
+ const html = cell.blocks
1429
+ .map((block) => {
1430
+ if (typeof block.data.text === 'string') {
1431
+ return block.data.text;
1432
+ }
1433
+
1434
+ return '';
1435
+ })
1436
+ .filter(Boolean)
1437
+ .join('<br>');
1438
+
1439
+ if (!html) {
1440
+ return;
1441
+ }
1442
+
1443
+ const selection = window.getSelection();
1444
+
1445
+ if (!selection || selection.rangeCount === 0) {
1446
+ return;
1447
+ }
1448
+
1449
+ const range = selection.getRangeAt(0);
1450
+
1451
+ range.deleteContents();
1452
+
1453
+ const fragment = document.createDocumentFragment();
1454
+ const wrapper = document.createElement('div');
1455
+
1456
+ wrapper.innerHTML = html;
1457
+
1458
+ Array.from(wrapper.childNodes).forEach((child) => fragment.appendChild(child));
1459
+
1460
+ if (fragment.childNodes.length === 0) {
1461
+ fragment.appendChild(new Text());
1462
+ }
1463
+
1464
+ const lastChild = fragment.lastChild as ChildNode;
1465
+
1466
+ range.insertNode(fragment);
1467
+
1468
+ const newRange = document.createRange();
1469
+ const nodeToSetCaret = lastChild.nodeType === Node.TEXT_NODE ? lastChild : lastChild.firstChild;
1470
+
1471
+ if (nodeToSetCaret !== null && nodeToSetCaret.textContent !== null) {
1472
+ newRange.setStart(nodeToSetCaret, nodeToSetCaret.textContent.length);
1473
+ }
1474
+
1475
+ selection.removeAllRanges();
1476
+ selection.addRange(newRange);
1477
+ }
1478
+
1409
1479
  private pastePayloadIntoCells(
1410
1480
  gridEl: HTMLElement,
1411
1481
  payload: TableCellsClipboard,
@@ -144,8 +144,8 @@ export class TableCellBlocks {
144
144
  return;
145
145
  }
146
146
 
147
- // ArrowDown at last row -> exit table
148
- if (event.key === 'ArrowDown' && position.row === this.getRowCount() - 1) {
147
+ // ArrowDown at last row -> exit table (skip if already handled by block-level navigation)
148
+ if (event.key === 'ArrowDown' && !event.defaultPrevented && position.row === this.getRowCount() - 1) {
149
149
  event.preventDefault();
150
150
  this.exitTableForward();
151
151
  }
@@ -492,7 +492,19 @@ export class TableCellBlocks {
492
492
  return;
493
493
  }
494
494
 
495
- container.appendChild(block.holder);
495
+ // Insert at the correct DOM position based on the flat array order,
496
+ // so that pressing Enter on a non-last paragraph inserts the new block
497
+ // right after the current one instead of always at the end of the cell.
498
+ const blocksCount = this.api.blocks.getBlocksCount();
499
+ const nextSiblingHolder = Array.from(
500
+ { length: blocksCount - index - 1 },
501
+ (_, offset) => this.api.blocks.getBlockByIndex(index + 1 + offset)
502
+ ).find(
503
+ candidate => candidate?.holder.parentElement === container
504
+ )?.holder ?? null;
505
+
506
+ // insertBefore(el, null) is equivalent to appendChild
507
+ container.insertBefore(block.holder, nextSiblingHolder);
496
508
  this.api.blocks.setBlockParent(blockId, this.tableBlockId);
497
509
  this.stripPlaceholders(container);
498
510
  }