edytor 0.1.0-next.19 → 0.1.0-next.21

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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  Edytor aims to be for Svelte what Slate.js is for React: a heavily customizable editor with an API to build any kind of collaborative rich text editor.
17
17
 
18
- > **Work in progress.** Edytor is a pre-release (`0.1.0-next.19`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
18
+ > **Work in progress.** Edytor is a pre-release (`0.1.0-next.21`, `edytor@next` on npm) and not ready for production; the API changes between releases without a compatibility layer. The untagged `edytor@0.0.11` on npm predates the current API. Issues and PRs are welcome: when you report a bug, include the document's JSON value.
19
19
 
20
20
  ## Features
21
21
 
@@ -341,6 +341,7 @@
341
341
  use:editableRootBrowserAttributes={browserMutationGuardAttributes}
342
342
  use:nonNativeEditableBlockChromeSelection
343
343
  data-edytor
344
+ data-edytor-selection={edytor.selection.value.kind === 'blocks' ? 'blocks' : undefined}
344
345
  contenteditable={!readonly}
345
346
  role="textbox"
346
347
  aria-multiline="true"
@@ -356,3 +357,15 @@
356
357
  ></span>
357
358
  </div>
358
359
  {/if}
360
+
361
+ <style>
362
+ /*
363
+ * A block selection shows as its selected blocks, never as a native range:
364
+ * the range a pointer drag across columns still extends under it
365
+ * (`sel.drag.across-columns`) is not highlighted, over any theme.
366
+ */
367
+ :global([data-edytor][data-edytor-selection='blocks'] ::selection),
368
+ :global([data-edytor][data-edytor-selection='blocks']::selection) {
369
+ background: transparent !important;
370
+ }
371
+ </style>
@@ -95,7 +95,12 @@ export class ColumnResize {
95
95
  const strips = this.shown ? this.gaps(origin) : [];
96
96
  const drag = this.drag;
97
97
  const guide = drag && {
98
- x: drag.start + this.widthAt(drag) + drag.gap / 2 - GUIDE / 2 - origin.left,
98
+ // Where the hover guide showed: the band's middle, never the gap's (it would jump onto the `+`).
99
+ x: drag.start +
100
+ this.widthAt(drag) +
101
+ bandOf({ left: 0, right: drag.gap }).width / 2 -
102
+ GUIDE / 2 -
103
+ origin.left,
99
104
  top: drag.top - origin.top,
100
105
  height: drag.height
101
106
  };
@@ -5,8 +5,8 @@ export type ToolbarOptions = {
5
5
  /**
6
6
  * Replace the toolbar; it renders while `controller.isVisible`, placed
7
7
  * above the selection (mark your bar `data-edytor-toolbar-bar` if panels
8
- * hang below it). Buttons should `preventDefault` on mousedown to keep
9
- * the selection.
8
+ * hang below it). A press on it never takes the editor's focus (its
9
+ * fields, an input or a select, take their own).
10
10
  */
11
11
  toolbar?: Snippet<[ToolbarController]>;
12
12
  };
@@ -1,5 +1,9 @@
1
1
  import Toolbar from './Toolbar.svelte';
2
2
  import { ToolbarController } from './ToolbarController.svelte.js';
3
+ /** A press in a field of the chrome (an input, a select): it takes focus. */
4
+ const isNativeFieldEvent = (event) => event
5
+ .composedPath()
6
+ .some((target) => target instanceof Element && target.matches('input, textarea, select'));
3
7
  /** The selection toolbar, with your own markup through a `toolbar` snippet. */
4
8
  export const createToolbarPlugin = (options = {}) => (edytor) => {
5
9
  const controller = new ToolbarController(edytor);
@@ -33,7 +37,24 @@ export const createToolbarPlugin = (options = {}) => (edytor) => {
33
37
  controller.updateFromSelection(selection);
34
38
  edytor.overlay.invalidate();
35
39
  },
36
- onEdytorAttached: () => edytor.overlay.mount(Toolbar, { controller, toolbar: options.toolbar }, 'edytor-toolbar-host', 60, positionToolbar)
40
+ onEdytorAttached: () => {
41
+ const unmount = edytor.overlay.mount(Toolbar, { controller, toolbar: options.toolbar }, 'edytor-toolbar-host', 60, positionToolbar);
42
+ // No press on the chrome takes focus, its background and a custom
43
+ // snippet's markup included (not only its buttons): the editor keeps
44
+ // its focus and its selection. A field (the link panel's input) takes
45
+ // its own. Every press has a `mousedown`, WebKit's lone one too
46
+ // (`onFocus.ts`), so no `pointerdown` is cancelled.
47
+ const host = edytor.overlay.layer?.querySelector('[data-edytor-toolbar-host]');
48
+ const keep = (event) => {
49
+ if (!isNativeFieldEvent(event))
50
+ event.preventDefault();
51
+ };
52
+ host?.addEventListener('mousedown', keep);
53
+ return () => {
54
+ host?.removeEventListener('mousedown', keep);
55
+ unmount();
56
+ };
57
+ }
37
58
  };
38
59
  };
39
60
  /** The Notion-style selection toolbar. */
@@ -48,6 +48,21 @@ export declare const flipToggles: (blocks: readonly Block[]) => void;
48
48
  * (`selectedMembers`).
49
49
  */
50
50
  export declare const liftLayouts: (blocks: Iterable<Block>) => Block[];
51
+ /**
52
+ * A pointer drag-selection from `anchor` (the block its press landed in) to
53
+ * `focus` (the block the drag reached), Notion's rule
54
+ * (`sel.drag.across-columns`): once the drag leaves the anchor's column for
55
+ * another column of the same layout, it selects blocks — every shown block
56
+ * from one to the other in reading order, but the layouts and columns
57
+ * themselves (each block a member as `selectedMembers` reads it), so a
58
+ * sweep over every block of every column lifts to the layout
59
+ * (`liftLayouts`, D3). The innermost layout holding both in different
60
+ * columns decides. `null` (a text range) while both lie in one column, or
61
+ * when no layout holding the anchor holds the focus (a drag leaving the
62
+ * layout, or one that starts outside it). Decided from the roles
63
+ * (`isLayout`/`isLayoutItem`).
64
+ */
65
+ export declare const acrossColumns: (anchor: Block, focus: Block) => Block[] | null;
51
66
  /** Whether a block selection holds `blocks`, a layout it covers whole counting as held (D3). */
52
67
  export declare const holdsBlocks: (selected: Iterable<Block>, blocks: readonly Block[]) => boolean;
53
68
  /**
@@ -91,6 +91,40 @@ export const liftLayouts = (blocks) => {
91
91
  }
92
92
  return [...all];
93
93
  };
94
+ /**
95
+ * A pointer drag-selection from `anchor` (the block its press landed in) to
96
+ * `focus` (the block the drag reached), Notion's rule
97
+ * (`sel.drag.across-columns`): once the drag leaves the anchor's column for
98
+ * another column of the same layout, it selects blocks — every shown block
99
+ * from one to the other in reading order, but the layouts and columns
100
+ * themselves (each block a member as `selectedMembers` reads it), so a
101
+ * sweep over every block of every column lifts to the layout
102
+ * (`liftLayouts`, D3). The innermost layout holding both in different
103
+ * columns decides. `null` (a text range) while both lie in one column, or
104
+ * when no layout holding the anchor holds the focus (a drag leaving the
105
+ * layout, or one that starts outside it). Decided from the roles
106
+ * (`isLayout`/`isLayoutItem`).
107
+ */
108
+ export const acrossColumns = (anchor, focus) => {
109
+ const { edytor } = anchor;
110
+ const { facade } = edytor;
111
+ let across = false;
112
+ for (let item = anchor.parent; item && !item.isRoot && !across; item = item.parent) {
113
+ if (!facade.isLayoutItem(item.id))
114
+ continue;
115
+ if (focus.isChildOf(item))
116
+ return null;
117
+ across = Boolean(item.parent && focus.isChildOf(item.parent));
118
+ }
119
+ if (!across)
120
+ return null;
121
+ const [first, last] = edytor.compareBlocks(anchor, focus) <= 0 ? [anchor, focus] : [focus, anchor];
122
+ const blocks = [];
123
+ for (let at = first; at && edytor.compareBlocks(at, last) <= 0; at = shown(at, 'blockAfter'))
124
+ if (!facade.isLayout(at.id) && !facade.isLayoutItem(at.id))
125
+ blocks.push(at);
126
+ return blocks;
127
+ };
94
128
  /** Whether a block selection holds `blocks`, a layout it covers whole counting as held (D3). */
95
129
  export const holdsBlocks = (selected, blocks) => {
96
130
  const as = [...selected];
@@ -11,7 +11,7 @@ import { seam } from '../crdt/anchors.js';
11
11
  import { getTextPath } from '../events/events.utils.js';
12
12
  import { landed } from '../session/navigation.js';
13
13
  import * as visibility from './visibility.js';
14
- import { caretBeside, shownText } from './replaceSelection.js';
14
+ import { acrossColumns, caretBeside, shownText } from './replaceSelection.js';
15
15
  /** The compatibility state of no (or an unresolvable) selection. */
16
16
  const EMPTY_STATE = Object.freeze({
17
17
  yStart: 0,
@@ -661,6 +661,7 @@ export class EdytorSelection {
661
661
  return;
662
662
  }
663
663
  this.pointerDragStart = this.getTextPointFromClientPoint(event.clientX, event.clientY);
664
+ this.#across = false;
664
665
  };
665
666
  clearPointerDragStart = () => {
666
667
  this.pointerDragStart = null;
@@ -668,13 +669,53 @@ export class EdytorSelection {
668
669
  };
669
670
  /** A normalization a pointer drag held back (the gesture is the user's, O57). */
670
671
  #held = false;
671
- /** The drag ended: the DOM selection it left is derived again, now normalized. */
672
+ /** The pointer drag selected blocks across columns (`acrossColumns`), until its release. */
673
+ #across = false;
674
+ /**
675
+ * The drag ended: the DOM selection it left is derived again, now
676
+ * normalized. A drag that ends as a block selection across columns keeps
677
+ * it: the native range it ignored goes (the projector shows a block
678
+ * selection as no range).
679
+ */
672
680
  #dropped = () => {
681
+ const across = this.#across;
682
+ this.#across = false;
683
+ if (across && this.value.kind === 'blocks') {
684
+ this.#held = false;
685
+ return this.display();
686
+ }
673
687
  if (!this.#held)
674
688
  return;
675
689
  this.#held = false;
676
690
  this.applySelectionSnapshot(getDomSelectionSnapshot(this.edytor.node));
677
691
  };
692
+ /**
693
+ * Under a pointer drag, a native range from one column into another
694
+ * column of the same layout is a block selection (`acrossColumns`,
695
+ * Notion): the blocks it covers are selected and the native range is
696
+ * ignored — the browser keeps extending it from the press, so the drag
697
+ * coming back into its own column is a text range again. Its highlight
698
+ * is hidden while the value is a block selection
699
+ * (`data-edytor-selection`). Only a range anchored where the press
700
+ * landed is the drag's: a Shift+click extends the range it found, which
701
+ * stays a text range in document order, as the keyboard's does (D7).
702
+ * Answers whether it selected blocks.
703
+ */
704
+ #dragAcross = (dom) => {
705
+ if (!dom?.anchorNode || !dom.focusNode || dom.isCollapsed)
706
+ return false;
707
+ const anchor = this.getTextOfNode(dom.anchorNode, dom.anchorOffset)?.parent;
708
+ const focus = this.getTextOfNode(dom.focusNode, dom.focusOffset)?.parent;
709
+ if (!anchor || anchor.id !== this.pointerDragStart?.text.parent.id)
710
+ return false;
711
+ const blocks = focus && !anchor.isRoot && acrossColumns(anchor, focus);
712
+ if (!blocks || !blocks.length)
713
+ return false;
714
+ this.#across = true;
715
+ this.select(blockSelection(blocks.map((block) => block.id)), 'dom');
716
+ this.edytor.projector.observe();
717
+ return true;
718
+ };
678
719
  /**
679
720
  * The block selection value over which the DOM caret was put — a primary
680
721
  * pointer press in the editor, or a foreign `selectionchange` (a script's,
@@ -798,7 +839,10 @@ export class EdytorSelection {
798
839
  this.applySelectionSnapshot(selection);
799
840
  };
800
841
  applySelectionSnapshot = (selection, options = {}) => {
801
- if (this.selectedBlocks.size > 0)
842
+ if (this.dragging && this.#dragAcross(selection))
843
+ return;
844
+ // A block selection ignores the DOM, but the one a drag across columns made while it lasts.
845
+ if (this.selectedBlocks.size > 0 && !(this.dragging && this.#across))
802
846
  return;
803
847
  const container = this.edytor.node;
804
848
  if (!selection?.anchorNode ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edytor",
3
- "version": "0.1.0-next.19",
3
+ "version": "0.1.0-next.21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/beynar/edytor.git"