@kubex/zinc 1.1.165 → 1.1.168

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.
@@ -1,9 +1,10 @@
1
1
  import {ACTION_GROUPS, EDITOR_ACTIONS, matchesShortcut, shortcutLabel, slashItems} from './actions';
2
2
  import {classMap} from "lit/directives/class-map.js";
3
- import {continueList, isEmptyListItem} from './list-continuation';
3
+ import {continueList, indentList, isEmptyListItem} from './list-continuation';
4
4
  import {type CSSResultGroup, html, type PropertyValues, type TemplateResult, unsafeCSS} from 'lit';
5
5
  import {defaultValue} from "../../internal/default-value";
6
6
  import {FormControlController} from "../../internal/form";
7
+ import {pastedRemarkd} from './paste';
7
8
  import {property, query, state} from 'lit/decorators.js';
8
9
  import {parse as remarkdParse} from "remarkd-js";
9
10
  import {ToolbarOverflowController} from '../../internal/toolbar-overflow';
@@ -15,6 +16,7 @@ import ZnMenu from "../menu";
15
16
  import ZnMenuItem from "../menu-item";
16
17
  import ZnSlashMenu, {SlashMenuController, type SlashMenuItem} from "../slash-menu";
17
18
  import type {EditorAction, InlineMark} from './actions';
19
+ import type {RangeEdit} from './list-continuation';
18
20
  import type {ZincFormControl} from '../../internal/zinc-element';
19
21
  import type ZnFile from "../file";
20
22
 
@@ -978,6 +980,11 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
978
980
  if (!edit) return;
979
981
  e.preventDefault();
980
982
  this.applyDraftEdit(input, edit.value, edit.caret, edit.caret);
983
+ } else if (this.isListTab(e)) {
984
+ const edit = indentList(input.value, input.selectionStart, input.selectionEnd, e.shiftKey);
985
+ if (!edit) return;
986
+ e.preventDefault();
987
+ this.applyDraftEdit(input, edit.value, edit.start, edit.end);
981
988
  } else if (e.key === 'Enter' && e.shiftKey) {
982
989
  e.preventDefault();
983
990
  this.suppressBlurCommit = true;
@@ -1010,14 +1017,23 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1010
1017
  return e.key === 'Enter' && !e.isComposing && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey;
1011
1018
  }
1012
1019
 
1020
+ private isListTab(e: KeyboardEvent): boolean {
1021
+ return e.key === 'Tab' && !e.isComposing && !e.metaKey && !e.ctrlKey && !e.altKey;
1022
+ }
1023
+
1013
1024
  private handleRawKeydown = (e: KeyboardEvent) => {
1014
- if (!this.isPlainEnter(e)) return;
1015
1025
  const input = e.target as HTMLTextAreaElement;
1016
- const edit = continueList(input.value, input.selectionStart, input.selectionEnd);
1026
+ let edit: RangeEdit | null = null;
1027
+ if (this.isPlainEnter(e)) {
1028
+ const next = continueList(input.value, input.selectionStart, input.selectionEnd);
1029
+ edit = next && {value: next.value, start: next.caret, end: next.caret};
1030
+ } else if (this.isListTab(e)) {
1031
+ edit = indentList(input.value, input.selectionStart, input.selectionEnd, e.shiftKey);
1032
+ }
1017
1033
  if (!edit) return;
1018
1034
  e.preventDefault();
1019
1035
  input.value = edit.value;
1020
- input.setSelectionRange(edit.caret, edit.caret);
1036
+ input.setSelectionRange(edit.start, edit.end);
1021
1037
  this.handleRawInput(e);
1022
1038
  };
1023
1039
 
@@ -1061,12 +1077,28 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
1061
1077
 
1062
1078
  private handleEditPaste = (e: ClipboardEvent) => {
1063
1079
  const file = Array.from(e.clipboardData?.files ?? []).find(f => f.type.startsWith('image/'));
1064
- if (!file) return;
1080
+ if (!file) {
1081
+ this.pasteFormatted(e);
1082
+ return;
1083
+ }
1065
1084
  e.preventDefault();
1066
1085
  const index = this.editingIndex ?? this.blocks.length;
1067
1086
  void this.insertImage(file, index + 1);
1068
1087
  };
1069
1088
 
1089
+ /** Pastes rich clipboard content as remarkd source, keeping its lists and formatting. */
1090
+ private pasteFormatted = (e: ClipboardEvent) => {
1091
+ const source = pastedRemarkd(e.clipboardData);
1092
+ if (source === null) return;
1093
+ e.preventDefault();
1094
+ const input = e.target as HTMLTextAreaElement;
1095
+ // insertText keeps the paste on the native undo stack; it fires `input` like a typed paste.
1096
+ if (!document.execCommand('insertText', false, source)) {
1097
+ input.setRangeText(source, input.selectionStart, input.selectionEnd, 'end');
1098
+ input.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
1099
+ }
1100
+ };
1101
+
1070
1102
  private handleDragOver = (e: DragEvent) => {
1071
1103
  if (this.rawMode) return;
1072
1104
  if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
@@ -2006,6 +2038,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
2006
2038
  .value=${this.value}
2007
2039
  @input=${this.handleRawInput}
2008
2040
  @keydown=${this.handleRawKeydown}
2041
+ @paste=${this.pasteFormatted}
2009
2042
  @blur=${this.commitRaw}></textarea>`;
2010
2043
  }
2011
2044
 
@@ -649,6 +649,90 @@ Second"></zn-remarkd-editor>`);
649
649
  await el.updateComplete;
650
650
  expect(el.value).to.equal('- [ ] first\n- [ ] ');
651
651
  });
652
+
653
+ const tab = (input: HTMLTextAreaElement, shiftKey = false) => {
654
+ const event = new KeyboardEvent('keydown', {key: 'Tab', shiftKey, bubbles: true, composed: true, cancelable: true});
655
+ input.dispatchEvent(event);
656
+ return event;
657
+ };
658
+
659
+ it('should nest and un-nest a list item on Tab and Shift+Tab in the open block', async () => {
660
+ const {el, input} = await editDraft('- a\n- b');
661
+ expect(tab(input).defaultPrevented).to.be.true;
662
+ await el.updateComplete;
663
+ expect(input.value).to.equal('- a\n-- b');
664
+ expect(input.selectionStart).to.equal(8);
665
+
666
+ expect(tab(input, true).defaultPrevented).to.be.true;
667
+ await el.updateComplete;
668
+ expect(input.value).to.equal('- a\n- b');
669
+ });
670
+
671
+ it('should leave Tab alone outside a list', async () => {
672
+ const {input} = await editDraft('hello');
673
+ expect(tab(input).defaultPrevented).to.be.false;
674
+ });
675
+
676
+ it('should nest a list item on Tab in the raw source view', async () => {
677
+ const el = await fixture<ZnRemarkdEditor>(html`
678
+ <zn-remarkd-editor allow-raw value="- first"></zn-remarkd-editor>`);
679
+ el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!
680
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
681
+ await el.updateComplete;
682
+ const raw = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')!;
683
+ raw.setSelectionRange(7, 7);
684
+
685
+ expect(tab(raw).defaultPrevented).to.be.true;
686
+ await el.updateComplete;
687
+ expect(el.value).to.equal('-- first');
688
+ });
689
+ });
690
+
691
+ describe('paste', () => {
692
+ const paste = (input: HTMLTextAreaElement, data: Record<string, string>) => {
693
+ const clipboardData = new DataTransfer();
694
+ for (const [type, value] of Object.entries(data)) clipboardData.setData(type, value);
695
+ const event = new ClipboardEvent('paste', {clipboardData, bubbles: true, composed: true, cancelable: true});
696
+ input.dispatchEvent(event);
697
+ return event;
698
+ };
699
+
700
+ it('should paste rich content as remarkd source at the caret', async () => {
701
+ const el = await fixture<ZnRemarkdEditor>(html`
702
+ <zn-remarkd-editor value="Intro"></zn-remarkd-editor>`);
703
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
704
+ await el.updateComplete;
705
+ const input = typeInBlock(el, 'Intro\n');
706
+ input.focus();
707
+
708
+ const event = paste(input, {'text/html': '<ol><li>a<ol><li>b</li></ol></li></ol>', 'text/plain': 'a\nb'});
709
+ expect(event.defaultPrevented).to.be.true;
710
+ await el.updateComplete;
711
+ expect(input.value).to.equal('Intro\n1. a\n1.1. b');
712
+ });
713
+
714
+ it('should leave a plain-text paste to the browser', async () => {
715
+ const el = await fixture<ZnRemarkdEditor>(html`
716
+ <zn-remarkd-editor value="Intro"></zn-remarkd-editor>`);
717
+ el.shadowRoot!.querySelector<HTMLElement>('.remarkd-editor__rendered')!.click();
718
+ await el.updateComplete;
719
+ const input = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__input')!;
720
+ expect(paste(input, {'text/plain': 'just text'}).defaultPrevented).to.be.false;
721
+ });
722
+
723
+ it('should paste rich content in the raw source view', async () => {
724
+ const el = await fixture<ZnRemarkdEditor>(html`
725
+ <zn-remarkd-editor allow-raw value=""></zn-remarkd-editor>`);
726
+ el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!
727
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
728
+ await el.updateComplete;
729
+ const raw = el.shadowRoot!.querySelector<HTMLTextAreaElement>('.remarkd-editor__raw')!;
730
+ raw.focus();
731
+
732
+ expect(paste(raw, {'text/html': '<ul><li>a</li></ul>'}).defaultPrevented).to.be.true;
733
+ await el.updateComplete;
734
+ expect(el.value).to.equal('- a');
735
+ });
652
736
  });
653
737
 
654
738
  it('should insert a placeholder when nothing is selected', async () => {
@@ -915,7 +999,7 @@ Second"></zn-remarkd-editor>`);
915
999
 
916
1000
  const menu = el.shadowRoot!.querySelector<ZnSlashMenu>('zn-slash-menu')!;
917
1001
  expect(menu.items.length).to.equal(EDITOR_ACTIONS.length);
918
- expect(menu.items.some(item => item.group === 'Admonitions')).to.be.true;
1002
+ expect(menu.items.some(item => item.group === 'Alerts')).to.be.true;
919
1003
  });
920
1004
 
921
1005
  it('should omit the Include action from the slash menu without an include-url', async () => {