@quillmark/svelte 0.25.0 → 0.26.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.
@@ -36,6 +36,7 @@ import { linkPastePlugin } from './link-paste.js';
36
36
  import { linePastePlugin } from './line-paste.js';
37
37
  import { listPastePlugin } from './list-paste.js';
38
38
  import { markdownPastePlugin } from './markdown-paste.js';
39
+ import { wordPastePlugin } from './word-paste.js';
39
40
  import { nestingPlugin } from './nesting.js';
40
41
  import { undoStepPlugin } from './undo.js';
41
42
  import { tablePastePlugin } from './table-paste.js';
@@ -572,7 +573,7 @@ export function proseLeafPlugins(schema, opts) {
572
573
  // Each answers to something only the block schema holds: any other leaf is
573
574
  // textblocks alone, with no island and no gap to put a cursor in.
574
575
  if (isBlockSchema(schema))
575
- list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), listPastePlugin(), taskPlugin(), nestingPlugin());
576
+ list.push(gapCursor(), pastAtomPlugin(), islandPastePlugin(), tablePastePlugin(), tableCopyPlugin(), markdownPastePlugin(), wordPastePlugin(), listPastePlugin(), taskPlugin(), nestingPlugin());
576
577
  if (schema === plainSchema)
577
578
  list.push(plainClipboardPlugin());
578
579
  if (isInlineSchema(schema))
@@ -10,10 +10,11 @@
10
10
  // Otherwise each line becomes the kind: a run of plain lines is wrapped, one item a
11
11
  // line, and a run of continuation lines the same way where it stands, the first items
12
12
  // of a sub-list under their item, as `- ` or `1. ` at a continuation's head opens one
13
- // (`blocks.ts` §`openingAnItem`); a list of the other kind is retyped whole, a list
14
- // being one kind (`blocks.ts` §`listAtItem`); a line already in the kind stays. What it
15
- // wraps or retypes joins the lists of its kind it leaves touching (`touching.ts`), so
16
- // the lines of one selection count as one list.
13
+ // (`blocks.ts` §`openingAnItem`); the selected items of a list of the other kind split
14
+ // out of it as a list of the kind, a list being one kind (`blocks.ts` §`listAtItem`),
15
+ // its tail counting from 1 as `unlist` leaves one; a line already in the kind stays.
16
+ // What it wraps or retypes joins the lists of its kind it leaves touching
17
+ // (`touching.ts`), so the lines of one selection count as one list.
17
18
  import { Fragment, NodeRange, Slice } from 'prosemirror-model';
18
19
  import { wrapRangeInList } from 'prosemirror-schema-list';
19
20
  import { ReplaceAroundStep } from 'prosemirror-transform';
@@ -108,20 +109,41 @@ function unlist(tr, type, itemType) {
108
109
  tr.setNodeMarkup(tail, null, { ...rest.attrs, start: 1 });
109
110
  }
110
111
  }
112
+ /** Items `first` to `last` of the list at `pos` split out as a list of their own,
113
+ * the tail after them counting from 1. Returns where that list stands. */
114
+ function splitOut(tr, pos, first, last) {
115
+ const list = tr.doc.nodeAt(pos);
116
+ const $inside = tr.doc.resolve(pos + 1);
117
+ if (last + 1 < list.childCount) {
118
+ tr.split($inside.posAtIndex(last + 1));
119
+ const tail = tr.doc.resolve(pos + 1).posAtIndex(last + 1) + 1;
120
+ if (list.attrs.start != null && list.attrs.start !== 1)
121
+ tr.setNodeMarkup(tail, null, { ...list.attrs, start: 1 });
122
+ }
123
+ if (first === 0)
124
+ return pos;
125
+ tr.split($inside.posAtIndex(first));
126
+ return $inside.posAtIndex(first) + 1;
127
+ }
111
128
  /** Make every line the kind, and join what that leaves touching. */
112
129
  function relist(tr, lines, type, paragraph) {
113
130
  const changed = [];
114
131
  const runs = [];
132
+ const others = new Map();
115
133
  for (const line of lines) {
116
134
  const { $at, item } = line;
117
135
  if (line.covered)
118
136
  continue;
119
137
  if (line.opens) {
138
+ if ($at.node(item - 1).type === type)
139
+ continue;
120
140
  const pos = $at.before(item - 1);
121
- if ($at.node(item - 1).type !== type && !changed.some((c) => c.pos === pos)) {
122
- tr.setNodeMarkup(pos, type, type.name === 'ordered_list' ? { start: 1 } : null);
123
- changed.push({ pos, step: tr.steps.length });
124
- }
141
+ const index = $at.index(item - 1);
142
+ const span = others.get(pos);
143
+ if (span)
144
+ span.last = index;
145
+ else
146
+ others.set(pos, { first: index, last: index });
125
147
  continue;
126
148
  }
127
149
  const end = line.pos + $at.parent.nodeSize;
@@ -138,6 +160,11 @@ function relist(tr, lines, type, paragraph) {
138
160
  return false;
139
161
  changed.push({ pos: run.from, step: tr.steps.length });
140
162
  }
163
+ for (const [pos, span] of [...others].reverse()) {
164
+ const at = splitOut(tr, tr.mapping.map(pos), span.first, span.last);
165
+ tr.setNodeMarkup(at, type, type.name === 'ordered_list' ? { start: 1 } : null);
166
+ changed.push({ pos: at, step: tr.steps.length });
167
+ }
141
168
  const seams = [];
142
169
  for (const { pos, step } of changed) {
143
170
  const at = tr.mapping.slice(step).map(pos);
@@ -0,0 +1,5 @@
1
+ import { Plugin } from 'prosemirror-state';
2
+ /** `html` with every run of Word list paragraphs made a list; `html` itself where there
3
+ * are none. */
4
+ export declare function wordLists(html: string): string;
5
+ export declare function wordPastePlugin(): Plugin;
@@ -0,0 +1,87 @@
1
+ // A list copied out of Word or Outlook lands as a list. Desktop Word writes no `<ol>` or
2
+ // `<li>`: each item is a paragraph styled `mso-list:l0 level2 lfo1`, its marker drawn by a
3
+ // `mso-list:Ignore` span ahead of the text. Each run of those paragraphs is rebuilt here as
4
+ // the nested lists it draws, the marker span dropped, so the paste rules after this one
5
+ // (`list-paste.ts`) see a list as any other source writes one.
6
+ import { Plugin } from 'prosemirror-state';
7
+ /** A Word list paragraph's style: the list it is in, and its level from 1. */
8
+ const MSO_LIST = /mso-list:\s*(l\d+)\s+level(\d+)\s+(lfo\d+)/i;
9
+ /** A numbered marker: digits, or letters, closed by `.` or `)`, or in parentheses, and a
10
+ * legal outline's `1.2.` whose last part is the item's own. Every other marker is a
11
+ * bullet, Word's `·`, `o` and `§` among them. */
12
+ const ORDINAL = /^\(?(?:\d+\.)*(\d+|[a-z]+)[.)]$/i;
13
+ /** The marker text of a list paragraph, its span taken out. */
14
+ function takeMarker(p) {
15
+ const span = [...p.querySelectorAll('span')].find((s) => /mso-list:\s*Ignore/i.test(s.getAttribute('style') ?? ''));
16
+ if (!span)
17
+ return '';
18
+ const marker = (span.textContent ?? '').replace(/\s+/g, '');
19
+ span.remove();
20
+ return marker;
21
+ }
22
+ /** `run`'s paragraphs as nested lists, one level of the stack a level of Word's. */
23
+ function rebuild(doc, run) {
24
+ const stack = [];
25
+ let root = null;
26
+ for (const { p, level } of run) {
27
+ const marker = takeMarker(p);
28
+ while (stack.length && stack[stack.length - 1].level > level)
29
+ stack.pop();
30
+ const top = stack[stack.length - 1];
31
+ if (!top || top.level < level) {
32
+ const ordinal = ORDINAL.exec(marker);
33
+ const list = doc.createElement(ordinal ? 'ol' : 'ul');
34
+ if (ordinal && /^\d+$/.test(ordinal[1]) && ordinal[1] !== '1')
35
+ list.setAttribute('start', ordinal[1]);
36
+ if (top)
37
+ top.list.lastElementChild.append(list);
38
+ else
39
+ root = list;
40
+ stack.push({ level, list });
41
+ }
42
+ const item = doc.createElement('li');
43
+ p.removeAttribute('style');
44
+ p.removeAttribute('class');
45
+ item.append(p);
46
+ stack[stack.length - 1].list.append(item);
47
+ }
48
+ return root;
49
+ }
50
+ /** `html` with every run of Word list paragraphs made a list; `html` itself where there
51
+ * are none. */
52
+ export function wordLists(html) {
53
+ if (!/mso-list/i.test(html))
54
+ return html;
55
+ const doc = new DOMParser().parseFromString(html, 'text/html');
56
+ const runs = [];
57
+ let id = null;
58
+ for (const p of doc.body.querySelectorAll('p')) {
59
+ const match = MSO_LIST.exec(p.getAttribute('style') ?? '');
60
+ if (!match)
61
+ continue;
62
+ const prev = runs[runs.length - 1]?.at(-1)?.p;
63
+ let sibling = p.previousSibling;
64
+ while (sibling &&
65
+ sibling.nodeType !== 1 &&
66
+ !(sibling.nodeType === 3 && sibling.textContent.trim()))
67
+ sibling = sibling.previousSibling;
68
+ const list = match[1] + match[3];
69
+ const entry = { p, level: Number(match[2]) };
70
+ if (prev && sibling === prev && list === id)
71
+ runs[runs.length - 1].push(entry);
72
+ else
73
+ runs.push([entry]);
74
+ id = list;
75
+ }
76
+ if (!runs.length)
77
+ return html;
78
+ for (const run of runs) {
79
+ const anchor = doc.createComment('');
80
+ run[0].p.before(anchor);
81
+ anchor.replaceWith(rebuild(doc, run));
82
+ }
83
+ return doc.body.innerHTML;
84
+ }
85
+ export function wordPastePlugin() {
86
+ return new Plugin({ props: { transformPastedHTML: (html) => wordLists(html) } });
87
+ }
@@ -13,6 +13,9 @@ const WHEEL_RATE = [0.0025, 0.08, 0.25];
13
13
  * rate that inverts the pinch's encoding. */
14
14
  const PINCH_DELTA = 50;
15
15
  const PINCH_RATE = 0.01;
16
+ /** How far, as a log of the spread's ratio, two fingers part or close before a touch
17
+ * pinch zooms rather than only pans. */
18
+ const PINCH_SLOP = 0.04;
16
19
  const px = (value) => parseFloat(value) || 0;
17
20
  export function createViewport(container, slots, setScale, resetLabel) {
18
21
  let zoom;
@@ -154,14 +157,13 @@ export function createViewport(container, slots, setScale, resetLabel) {
154
157
  const factor = Math.exp(-e.deltaY * (pinched ? PINCH_RATE : WHEEL_RATE[e.deltaMode]));
155
158
  glideTo((g) => g.raw * factor, e.clientX, e.clientY);
156
159
  }
157
- // The keys reach the pane under the pointer as well as the focused one: a click on a
158
- // page lands its caret in the editor, taking the focus with it.
159
- // The first pane to take a key claims it, so two previews never both step, and a key
160
- // a host's own handler took is left to it.
160
+ // The keys are the focused pane's alone: elsewhere they are the browser's own zoom,
161
+ // the pointer over the pane notwithstanding. A key a host's own handler took is left
162
+ // to it.
161
163
  function onKeyDown(e) {
162
164
  if (!(e.ctrlKey || e.metaKey) || e.altKey || e.defaultPrevented || e.isComposing)
163
165
  return;
164
- if (!pointer && !container.contains(document.activeElement))
166
+ if (!container.contains(document.activeElement))
165
167
  return;
166
168
  const dir = e.key === '=' || e.key === '+' ? 1 : e.key === '-' || e.key === '_' ? -1 : 0;
167
169
  if ((!dir && e.key !== '0') || !bounds())
@@ -169,30 +171,54 @@ export function createViewport(container, slots, setScale, resetLabel) {
169
171
  e.preventDefault();
170
172
  step((b) => (dir ? stepZoom(b.current, dir, b.floor) : undefined));
171
173
  }
172
- // A two-finger pinch on a touch screen. The browser's own page zoom is held off by the
173
- // desk's `touch-action` and by cancelling the move, which also stops the pan a second
174
- // finger would otherwise start.
175
174
  let pinch;
176
- const spread = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
177
- function onTouchStart(e) {
178
- if (e.touches.length !== 2)
179
- return;
175
+ // The fingers on this pane, not one resting on the editor beside it.
176
+ const fingers = (e) => [...e.touches].filter((t) => t.target instanceof Node && container.contains(t.target));
177
+ const spread = ([a, b]) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
178
+ const midpoint = ([a, b]) => ({
179
+ x: (a.clientX + b.clientX) / 2,
180
+ y: (a.clientY + b.clientY) / 2
181
+ });
182
+ function grip(at) {
183
+ const page = pageAt(at.y);
184
+ const r = page?.getBoundingClientRect();
185
+ if (!page || !r?.width || !r.height)
186
+ return undefined;
187
+ return { page, fx: (at.x - r.left) / r.width, fy: (at.y - r.top) / r.height };
188
+ }
189
+ // A finger landing or lifting starts the pinch over with the two that remain.
190
+ function onTouches(e) {
191
+ pinch = undefined;
192
+ const t = fingers(e);
180
193
  const b = bounds();
181
- if (b)
182
- pinch = { spread: spread(e.touches), raw: from(b).raw };
194
+ const held = t.length === 2 && b ? grip(midpoint(t)) : undefined;
195
+ if (held)
196
+ pinch = { spread: spread(t), raw: from(b).raw, zooming: false, ...held };
183
197
  }
184
198
  function onTouchMove(e) {
185
- if (!pinch || e.touches.length !== 2)
199
+ const t = fingers(e);
200
+ if (!pinch || t.length !== 2)
186
201
  return;
187
202
  e.preventDefault();
188
- const [a, b] = [e.touches[0], e.touches[1]];
189
- const ratio = spread(e.touches) / pinch.spread;
190
- const start = pinch.raw;
191
- glideTo(() => start * ratio, (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
192
- }
193
- function onTouchEnd(e) {
194
- if (e.touches.length < 2)
195
- pinch = undefined;
203
+ const mid = midpoint(t);
204
+ const ratio = spread(t) / pinch.spread;
205
+ if (pinch.zooming) {
206
+ const start = pinch.raw;
207
+ glideTo(() => start * ratio, mid.x, mid.y);
208
+ }
209
+ else if (Math.abs(Math.log(ratio)) >= PINCH_SLOP) {
210
+ pinch.zooming = true;
211
+ pinch.spread = spread(t);
212
+ }
213
+ // A move the browser is already scrolling cannot be cancelled: its pan stands, and
214
+ // the pinch holds whatever is under the fingers now.
215
+ if (!e.cancelable) {
216
+ Object.assign(pinch, grip(mid));
217
+ return;
218
+ }
219
+ const r = pinch.page.getBoundingClientRect();
220
+ container.scrollLeft += r.left + pinch.fx * r.width - mid.x;
221
+ container.scrollTop += r.top + pinch.fy * r.height - mid.y;
196
222
  }
197
223
  // Safari's trackpad pinch. iOS sends these beside the touches, which are the ones read
198
224
  // there; either way the page zoom is cancelled.
@@ -231,10 +257,10 @@ export function createViewport(container, slots, setScale, resetLabel) {
231
257
  const listeners = [
232
258
  [container, 'wheel', onWheel, { passive: false }],
233
259
  [window, 'keydown', onKeyDown],
234
- [container, 'touchstart', onTouchStart, { passive: true }],
260
+ [container, 'touchstart', onTouches, { passive: true }],
235
261
  [container, 'touchmove', onTouchMove, { passive: false }],
236
- [container, 'touchend', onTouchEnd],
237
- [container, 'touchcancel', onTouchEnd],
262
+ [container, 'touchend', onTouches],
263
+ [container, 'touchcancel', onTouches],
238
264
  [container, 'gesturestart', onGestureStart],
239
265
  [container, 'gesturechange', onGestureChange],
240
266
  [container, 'gestureend', onGestureEnd],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",