ep_vim 0.6.1 → 0.7.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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test)",
5
+ "Bash(npm run format)"
6
+ ]
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_vim",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Vim-mode plugin for Etherpad with modal editing, motions, and operators",
5
5
  "author": {
6
6
  "name": "Seth Rothschild",
@@ -18,6 +18,9 @@ const {
18
18
  paragraphForward,
19
19
  paragraphBackward,
20
20
  getTextInRange,
21
+ matchingBracketPos,
22
+ paragraphTextRange,
23
+ sentenceTextRange,
21
24
  } = require("./vim-core");
22
25
 
23
26
  // --- State variables ---
@@ -48,6 +51,73 @@ const textObjectRange = (key, lineText, char, type) => {
48
51
  return textBracketRange(lineText, char, key, type);
49
52
  };
50
53
 
54
+ const resolveTextObject = (key, type, line, lineText, char, rep) => {
55
+ if (key === "p") {
56
+ return paragraphTextRange(rep, line, type);
57
+ }
58
+ if (key === "s") {
59
+ const r = sentenceTextRange(lineText, char, type);
60
+ if (!r) return null;
61
+ return {
62
+ startLine: line,
63
+ startChar: r.start,
64
+ endLine: line,
65
+ endChar: r.end,
66
+ };
67
+ }
68
+ const r = textObjectRange(key, lineText, char, type);
69
+ if (!r) return null;
70
+ return { startLine: line, startChar: r.start, endLine: line, endChar: r.end };
71
+ };
72
+
73
+ const getVisibleLineRange = (rep) => {
74
+ const totalLines = rep.lines.length();
75
+ if (!editorDoc) return { top: 0, bottom: totalLines - 1 };
76
+ const lineDivs = editorDoc.body.querySelectorAll("div");
77
+ const lineCount = Math.min(lineDivs.length, totalLines);
78
+
79
+ // The iframe doesn't scroll — the outer page does. getBoundingClientRect()
80
+ // inside the iframe is relative to the iframe document top (not the outer
81
+ // viewport). We need the iframe's own position in the outer viewport to
82
+ // know which lines are actually visible.
83
+ const frameEl = editorDoc.defaultView.frameElement;
84
+ const iframeTop = frameEl ? frameEl.getBoundingClientRect().top : 0;
85
+ const outerViewportHeight = window.parent ? window.parent.innerHeight : 600;
86
+
87
+ let top = 0;
88
+ let bottom = lineCount - 1;
89
+ for (let i = 0; i < lineCount; i++) {
90
+ const rect = lineDivs[i].getBoundingClientRect();
91
+ if (iframeTop + rect.bottom > 0) {
92
+ top = i;
93
+ break;
94
+ }
95
+ }
96
+ for (let i = lineCount - 1; i >= 0; i--) {
97
+ const rect = lineDivs[i].getBoundingClientRect();
98
+ if (iframeTop + rect.top < outerViewportHeight) {
99
+ bottom = i;
100
+ break;
101
+ }
102
+ }
103
+
104
+ // Lines can wrap, so find the middle by pixel position rather than index.
105
+ const visibleTop = iframeTop + lineDivs[top].getBoundingClientRect().top;
106
+ const visibleBottom =
107
+ iframeTop + lineDivs[bottom].getBoundingClientRect().bottom;
108
+ const pixelMidpoint = (visibleTop + visibleBottom) / 2;
109
+ let mid = top;
110
+ for (let i = top; i <= bottom; i++) {
111
+ const rect = lineDivs[i].getBoundingClientRect();
112
+ if (iframeTop + (rect.top + rect.bottom) / 2 >= pixelMidpoint) {
113
+ mid = i;
114
+ break;
115
+ }
116
+ }
117
+
118
+ return { top, mid, bottom };
119
+ };
120
+
51
121
  // --- Count helpers ---
52
122
 
53
123
  const consumeCount = () => {
@@ -66,7 +136,9 @@ const getCount = () => pendingCount || 1;
66
136
  const setRegister = (value) => {
67
137
  register = value;
68
138
  const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
69
- navigator.clipboard.writeText(text).catch(() => {});
139
+ if (navigator.clipboard) {
140
+ navigator.clipboard.writeText(text).catch(() => {});
141
+ }
70
142
  };
71
143
 
72
144
  const moveCursor = (editorInfo, line, char) => {
@@ -315,6 +387,38 @@ const resolveMotion = (key, line, char, lineText, rep, count) => {
315
387
  return "pending";
316
388
  }
317
389
 
390
+ if (key === "%") {
391
+ const pos = matchingBracketPos(rep, line, char);
392
+ if (pos) {
393
+ desiredColumn = null;
394
+ return { line: pos.line, char: pos.char };
395
+ }
396
+ return { line, char };
397
+ }
398
+
399
+ if (key === "H") {
400
+ desiredColumn = null;
401
+ const { top } = getVisibleLineRange(rep);
402
+ const targetLine = clampLine(top + count - 1, rep);
403
+ const targetText = getLineText(rep, targetLine);
404
+ return { line: targetLine, char: firstNonBlank(targetText) };
405
+ }
406
+
407
+ if (key === "M") {
408
+ desiredColumn = null;
409
+ const { mid } = getVisibleLineRange(rep);
410
+ const targetText = getLineText(rep, mid);
411
+ return { line: mid, char: firstNonBlank(targetText) };
412
+ }
413
+
414
+ if (key === "L") {
415
+ desiredColumn = null;
416
+ const { bottom } = getVisibleLineRange(rep);
417
+ const targetLine = clampLine(bottom - count + 1, rep);
418
+ const targetText = getLineText(rep, targetLine);
419
+ return { line: targetLine, char: firstNonBlank(targetText) };
420
+ }
421
+
318
422
  return null;
319
423
  };
320
424
 
@@ -472,12 +576,12 @@ const handleKey = (rep, editorInfo, key) => {
472
576
  const type = pendingKey;
473
577
  pendingKey = null;
474
578
  pendingOperator = null;
475
- const range = textObjectRange(key, lineText, char, type);
579
+ const range = resolveTextObject(key, type, line, lineText, char, rep);
476
580
  if (range) {
477
581
  applyCharOperator(
478
582
  op,
479
- [line, range.start],
480
- [line, range.end],
583
+ [range.startLine, range.startChar],
584
+ [range.endLine, range.endChar],
481
585
  editorInfo,
482
586
  rep,
483
587
  );
@@ -521,6 +625,26 @@ const handleKey = (rep, editorInfo, key) => {
521
625
  return true;
522
626
  }
523
627
 
628
+ if (key === "%") {
629
+ pendingOperator = null;
630
+ const matchPos = matchingBracketPos(rep, line, char);
631
+ if (matchPos) {
632
+ let start, end;
633
+ if (
634
+ matchPos.line > line ||
635
+ (matchPos.line === line && matchPos.char > char)
636
+ ) {
637
+ start = [line, char];
638
+ end = [matchPos.line, matchPos.char + 1];
639
+ } else {
640
+ start = [matchPos.line, matchPos.char];
641
+ end = [line, char + 1];
642
+ }
643
+ applyCharOperator(op, start, end, editorInfo, rep);
644
+ }
645
+ return true;
646
+ }
647
+
524
648
  pendingOperator = null;
525
649
  const range = motionRange(key, char, lineText, count);
526
650
  if (range && range.end > range.start) {
@@ -540,10 +664,10 @@ const handleKey = (rep, editorInfo, key) => {
540
664
  if (inVisual && (pendingKey === "i" || pendingKey === "a")) {
541
665
  const type = pendingKey;
542
666
  pendingKey = null;
543
- const range = textObjectRange(key, lineText, char, type);
667
+ const range = resolveTextObject(key, type, line, lineText, char, rep);
544
668
  if (range) {
545
- visualAnchor = [line, range.start];
546
- visualCursor = [line, range.end];
669
+ visualAnchor = [range.startLine, range.startChar];
670
+ visualCursor = [range.endLine, range.endChar];
547
671
  setVisualMode("char");
548
672
  updateVisualSelection(editorInfo, rep);
549
673
  }
@@ -301,6 +301,132 @@ const textBracketRange = (lineText, char, bracket, type) => {
301
301
  return null;
302
302
  };
303
303
 
304
+ const offsetToPos = (rep, offset) => {
305
+ const totalLines = rep.lines.length();
306
+ for (let i = 0; i < totalLines; i++) {
307
+ const lineStart = rep.lines.offsetOfIndex(i);
308
+ const lineLen = getLineText(rep, i).length;
309
+ if (offset >= lineStart && offset < lineStart + lineLen) {
310
+ return { line: i, char: offset - lineStart };
311
+ }
312
+ }
313
+ return null;
314
+ };
315
+
316
+ const matchingBracketPos = (rep, line, char) => {
317
+ const lineText = getLineText(rep, line);
318
+ let bracketChar = -1;
319
+ let bracket = null;
320
+ for (let i = char; i < lineText.length; i++) {
321
+ if (lineText[i] in BRACKET_PAIRS) {
322
+ bracketChar = i;
323
+ bracket = lineText[i];
324
+ break;
325
+ }
326
+ }
327
+ if (bracketChar === -1) return null;
328
+ const isOpen = OPEN_BRACKETS.has(bracket);
329
+ const match = BRACKET_PAIRS[bracket];
330
+ const startOffset = rep.lines.offsetOfIndex(line) + bracketChar;
331
+ const alltext = rep.alltext;
332
+ let depth = 0;
333
+ if (isOpen) {
334
+ for (let i = startOffset; i < alltext.length; i++) {
335
+ if (alltext[i] === bracket) depth++;
336
+ else if (alltext[i] === match) {
337
+ depth--;
338
+ if (depth === 0) return offsetToPos(rep, i);
339
+ }
340
+ }
341
+ } else {
342
+ for (let i = startOffset; i >= 0; i--) {
343
+ if (alltext[i] === bracket) depth++;
344
+ else if (alltext[i] === match) {
345
+ depth--;
346
+ if (depth === 0) return offsetToPos(rep, i);
347
+ }
348
+ }
349
+ }
350
+ return null;
351
+ };
352
+
353
+ const paragraphTextRange = (rep, line, type) => {
354
+ const totalLines = rep.lines.length();
355
+ const lineIsBlank = (l) => getLineText(rep, l).length === 0;
356
+ const onBlank = lineIsBlank(line);
357
+ let start = line;
358
+ while (start > 0 && lineIsBlank(start - 1) === onBlank) start--;
359
+ let end = line;
360
+ while (end < totalLines - 1 && lineIsBlank(end + 1) === onBlank) end++;
361
+ if (type === "i") {
362
+ return {
363
+ startLine: start,
364
+ startChar: 0,
365
+ endLine: end,
366
+ endChar: getLineText(rep, end).length,
367
+ };
368
+ }
369
+ if (!onBlank) {
370
+ let trailingEnd = end;
371
+ while (trailingEnd < totalLines - 1 && lineIsBlank(trailingEnd + 1))
372
+ trailingEnd++;
373
+ if (trailingEnd > end) {
374
+ return {
375
+ startLine: start,
376
+ startChar: 0,
377
+ endLine: trailingEnd,
378
+ endChar: getLineText(rep, trailingEnd).length,
379
+ };
380
+ }
381
+ let leadingStart = start;
382
+ while (leadingStart > 0 && lineIsBlank(leadingStart - 1)) leadingStart--;
383
+ return {
384
+ startLine: leadingStart,
385
+ startChar: 0,
386
+ endLine: end,
387
+ endChar: getLineText(rep, end).length,
388
+ };
389
+ }
390
+ let paraEnd = end;
391
+ while (paraEnd < totalLines - 1 && !lineIsBlank(paraEnd + 1)) paraEnd++;
392
+ return {
393
+ startLine: start,
394
+ startChar: 0,
395
+ endLine: paraEnd,
396
+ endChar: getLineText(rep, paraEnd).length,
397
+ };
398
+ };
399
+
400
+ const sentenceTextRange = (lineText, char, type) => {
401
+ const isTerminator = (ch) => ch === "." || ch === "!" || ch === "?";
402
+ let start = 0;
403
+ for (let i = char - 1; i >= 0; i--) {
404
+ if (isTerminator(lineText[i])) {
405
+ let j = i + 1;
406
+ while (j < lineText.length && lineText[j] === " ") j++;
407
+ start = j;
408
+ break;
409
+ }
410
+ }
411
+ let end = lineText.length;
412
+ for (let i = char; i < lineText.length; i++) {
413
+ if (isTerminator(lineText[i])) {
414
+ end = i + 1;
415
+ break;
416
+ }
417
+ }
418
+ if (type === "i") {
419
+ let s = start;
420
+ let e = end;
421
+ while (s < e && lineText[s] === " ") s++;
422
+ while (e > s && lineText[e - 1] === " ") e--;
423
+ return { start: s, end: e };
424
+ }
425
+ let e = end;
426
+ while (e < lineText.length && lineText[e] === " ") e++;
427
+ return { start, end: e };
428
+ };
429
+
304
430
  const getTextInRange = (rep, start, end, type) => {
305
431
  if (start[0] === end[0]) {
306
432
  return getLineText(rep, start[0]).slice(start[1], end[1]);
@@ -336,4 +462,7 @@ module.exports = {
336
462
  paragraphForward,
337
463
  paragraphBackward,
338
464
  getTextInRange,
465
+ matchingBracketPos,
466
+ paragraphTextRange,
467
+ sentenceTextRange,
339
468
  };