text-input-guard 1.0.2 → 1.1.0

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.
@@ -419,6 +419,7 @@
419
419
  * @property {string} [invalidClass="is-invalid"] - エラー時に付けるclass名
420
420
  * @property {SeparateValueOptions} [separateValue] - 表示値と内部値の分離設定
421
421
  * @property {(result: ValidateResult) => void} [onValidate] - 評価完了時の通知(input/commitごと)
422
+ * @property {(result: Guard) => void} [onChange] - フォーカスが外れた値が変更されていた場合の通知
422
423
  */
423
424
 
424
425
  /**
@@ -584,6 +585,18 @@
584
585
  */
585
586
  this.onValidate = options.onValidate;
586
587
 
588
+ /**
589
+ * blur時に値が変更されていた場合の通知
590
+ * @type {((result: Guard) => void) | undefined}
591
+ */
592
+ this.onChange = options.onChange;
593
+
594
+ /**
595
+ * onChange 判定のための直前の値(blur時にこれと比較して変化を検知する)
596
+ * @type {string}
597
+ */
598
+ this.previousValue = "";
599
+
587
600
  /**
588
601
  * 実際に送信を担う要素(swap時は hidden(raw) 側)
589
602
  * swapしない場合は originalElement と同一
@@ -764,6 +777,8 @@
764
777
  this.bindEvents();
765
778
  // 初期値を評価
766
779
  this.evaluateCommit();
780
+ // 初期値を記録
781
+ this.previousValue = this.getDisplayValue();
767
782
  }
768
783
 
769
784
  /**
@@ -996,11 +1011,49 @@
996
1011
  if (this.warn) ;
997
1012
  }
998
1013
 
1014
+ /**
1015
+ * 変更前後の文字列から置換範囲と挿入文字列を推測
1016
+ * @param {string} beforeText
1017
+ * @param {string} afterText
1018
+ * @returns {{ replaceStart: number, replaceEnd: number, insertedText: string }}
1019
+ */
1020
+ detectTextDiff(beforeText, afterText) {
1021
+ let start = 0;
1022
+
1023
+ while (
1024
+ start < beforeText.length &&
1025
+ start < afterText.length &&
1026
+ beforeText[start] === afterText[start]
1027
+ ) {
1028
+ start++;
1029
+ }
1030
+
1031
+ let beforeEnd = beforeText.length;
1032
+ let afterEnd = afterText.length;
1033
+
1034
+ while (
1035
+ beforeEnd > start &&
1036
+ afterEnd > start &&
1037
+ beforeText[beforeEnd - 1] === afterText[afterEnd - 1]
1038
+ ) {
1039
+ beforeEnd--;
1040
+ afterEnd--;
1041
+ }
1042
+
1043
+ return {
1044
+ replaceStart: start,
1045
+ replaceEnd: beforeEnd,
1046
+ insertedText: afterText.slice(start, afterEnd)
1047
+ };
1048
+ }
1049
+
999
1050
  /**
1000
1051
  * ルール実行に渡すコンテキストを作る(pushErrorで errors に積める)
1001
1052
  * @returns {GuardContext}
1002
1053
  */
1003
1054
  createCtx({ useSnapshot = true } = {}) {
1055
+ // 入力後のテキストを取得
1056
+ const afterText = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement).value;
1004
1057
  const snap = useSnapshot ? this.beforeInputSnapshot : null;
1005
1058
  let inputType = snap?.inputType ?? "";
1006
1059
  let insertedText = snap?.insertedText ?? "";
@@ -1043,23 +1096,23 @@
1043
1096
  baseSel = lastSel;
1044
1097
  }
1045
1098
 
1046
- // beforeinput がない環境では、差分再構成の基準が「前回の受理値」しかないため、そこから今回の編集内容を推測する必要がある。
1099
+ // オートコンプリートの処理
1100
+ // inputType が取得できないため existBeforeInputEvent 情報で判断
1101
+ // 差分再構成の基準が「前回の受理値」しかないため、そこから今回の編集内容を推測する必要がある。
1047
1102
  if (beforeText.length === 0 || !this.existBeforeInputEvent) {
1048
- const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1049
- const current = display.value;
1050
1103
  // 前回の値がとれないものの、何かしら入力情報がある状態
1051
- if (current.length > 0) {
1104
+ if (afterText.length > 0) {
1052
1105
  // 文字列の先頭が前回の受理値と同じなら、末尾に何かしら入力されたと考えられる(オートコンプリート等)
1053
- if (current.toLocaleLowerCase().startsWith(beforeText.toLocaleLowerCase())) {
1054
- if (!current.startsWith(beforeText)) {
1106
+ if (afterText.toLocaleLowerCase().startsWith(beforeText.toLocaleLowerCase())) {
1107
+ if (!afterText.startsWith(beforeText)) {
1055
1108
  // 文字は同じだが、大文字と小文字の情報が替わっているなどのパターン
1056
1109
  // 差し代わりが起きているため、前回値は基準にならないと判断して、差分全体を insertedText とする
1057
1110
  beforeText = "";
1058
- insertedText = current;
1111
+ insertedText = afterText;
1059
1112
  } else {
1060
1113
  // 末尾に追加されたと考えられる部分を insertedText とする
1061
- // 例: beforeText="abc" → current="abcde" なら、"de" が insertedText
1062
- insertedText = current.slice(beforeText.length);
1114
+ // 例: beforeText="abc" → afterText="abcde" なら、"de" が insertedText
1115
+ insertedText = afterText.slice(beforeText.length);
1063
1116
  }
1064
1117
  // キャレットは前回値の末尾にあると推測する
1065
1118
  baseSel = /** @type {SelectionState} */ {
@@ -1078,10 +1131,10 @@
1078
1131
  let replaceStart = baseSel.start ?? 0;
1079
1132
  let replaceEnd = baseSel.end ?? 0;
1080
1133
 
1134
+ // 削除操作の特殊処理
1081
1135
  // Backspace / Delete は「挿入文字がない(dataがnull)」ことが多い。
1082
1136
  // そのままだと差分再構成で “何も変わらない” 扱いになって削除が効かなくなるため、
1083
1137
  // 選択範囲が無い場合は「削除される1文字ぶん」の置換範囲をここで作る。
1084
- //
1085
1138
  // ※ 選択範囲がある削除は replaceStart!=replaceEnd なので補正不要(その範囲を消すだけでよい)
1086
1139
  if (replaceStart === replaceEnd) {
1087
1140
  if (inputType === "deleteContentBackward") {
@@ -1097,6 +1150,14 @@
1097
1150
  // deleteWordBackward / deleteWordForward / deleteByCut / deleteSoftLineBackward ... etc
1098
1151
  }
1099
1152
 
1153
+ // アンドゥリドゥの特殊処理
1154
+ if (inputType === "historyUndo" || inputType === "historyRedo") {
1155
+ const diff = this.detectTextDiff(beforeText, afterText);
1156
+ replaceStart = diff.replaceStart;
1157
+ replaceEnd = diff.replaceEnd;
1158
+ insertedText = diff.insertedText;
1159
+ }
1160
+
1100
1161
  return {
1101
1162
  hostElement: this.hostElement,
1102
1163
  displayElement: this.displayElement,
@@ -1110,7 +1171,7 @@
1110
1171
  replaceStart,
1111
1172
  replaceEnd,
1112
1173
  insertedText,
1113
- afterText: null, // 後で代入する
1174
+ afterText,
1114
1175
  pushError: (e) => this.errors.push(e),
1115
1176
  requestRevert: (req) => {
1116
1177
  // 1回でもrevert要求が出たら採用(最初の理由を保持)
@@ -1320,7 +1381,10 @@
1320
1381
  /** @type {string|null} */
1321
1382
  const inputType = typeof e.inputType === "string" ? e.inputType : null;
1322
1383
  /** @type {string|null} */
1323
- const insertedText = typeof e.data === "string" ? e.data : null;
1384
+ let insertedText = typeof e.data === "string" ? e.data : null;
1385
+ if (insertedText === null && (inputType === "insertLineBreak" || inputType === "insertParagraph")) {
1386
+ insertedText = "\n";
1387
+ }
1324
1388
  this.existBeforeInputEvent = true;
1325
1389
  this.beforeInputSnapshot = { selection, inputType, insertedText };
1326
1390
  }
@@ -1332,6 +1396,12 @@
1332
1396
  onBlur() {
1333
1397
  // console.log("[text-input-guard] blur");
1334
1398
  this.evaluateCommit();
1399
+ if (this.previousValue !== this.getDisplayValue()) {
1400
+ this.previousValue = this.getDisplayValue();
1401
+ if (this.onChange) {
1402
+ this.onChange(this.getGuard());
1403
+ }
1404
+ }
1335
1405
  }
1336
1406
 
1337
1407
  /**
@@ -1456,10 +1526,7 @@
1456
1526
  * @returns {GuardContext}
1457
1527
  */
1458
1528
  createCtxAndNormalize() {
1459
- const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1460
- const current = display.value;
1461
1529
  const ctx = this.createCtx();
1462
- ctx.afterText = current;
1463
1530
 
1464
1531
  // 元のテキスト
1465
1532
  const beforeText = ctx.beforeText;
@@ -1471,7 +1538,7 @@
1471
1538
  const replaceStart = ctx.replaceStart;
1472
1539
 
1473
1540
  // 現状のテキスト
1474
- const tempText = current;
1541
+ const tempText = ctx.afterText;
1475
1542
 
1476
1543
  // 作成する全体のテキスト
1477
1544
  let newText = beforeText;
@@ -1516,7 +1583,7 @@
1516
1583
 
1517
1584
  // 画面を更新
1518
1585
  this.syncDisplay(newText);
1519
- this.writeSelection(display, newSelection);
1586
+ this.writeSelection(this.displayElement, newSelection);
1520
1587
 
1521
1588
  // CTX の情報を最新の情報へ更新する
1522
1589
  ctx.afterText = newText;
@@ -6981,9 +7048,10 @@
6981
7048
  /**
6982
7049
  * bytes ルールのオプション
6983
7050
  * @typedef {Object} BytesRuleOptions
6984
- * @property {number} [max] - 最大長(グラフェム数)。未指定なら制限なし
7051
+ * @property {number} [max] - バイト数。未指定なら制限なし
6985
7052
  * @property {"block"|"error"} [mode="block"] - 入力中に最大長を超えたときの挙動
6986
7053
  * @property {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} [unit="utf-8"] - サイズの単位(sjis系を使用する場合はfilterも必須)
7054
+ * @property {"\n"|"\r"|"\r\n"} [newline="\n"] - 改行の扱い(バイト数計算に影響あり)
6987
7055
  */
6988
7056
 
6989
7057
  /**
@@ -6992,25 +7060,28 @@
6992
7060
  */
6993
7061
 
6994
7062
  /**
6995
- * グラフェム/UTF-16コード単位/UTF-32コード単位の長さを調べる
7063
+ * テキストのバイト数を調べる
6996
7064
  * @param {string} text
6997
7065
  * @param {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} unit
7066
+ * @param {"\n"|"\r"|"\r\n"} newline
6998
7067
  * @returns {number}
6999
7068
  */
7000
- const getTextBytesByUnit = function(text, unit) {
7069
+ const getTextBytesByUnit = function(text, unit, newline) {
7001
7070
  if (text.length === 0) {
7002
7071
  return 0;
7003
7072
  }
7073
+
7074
+ const normalizedText = text.replace(/\r?\n/g, newline);
7075
+
7004
7076
  if (unit === "utf-8") {
7005
- return Mojix.toUTF8Array(text).length;
7077
+ return Mojix.toUTF8Array(normalizedText).length;
7006
7078
  } else if (unit === "utf-16") {
7007
- return Mojix.toUTF16Array(text).length * 2;
7079
+ return Mojix.toUTF16Array(normalizedText).length * 2;
7008
7080
  } else if (unit === "utf-32") {
7009
- return Mojix.toUTF32Array(text).length * 4;
7081
+ return Mojix.toUTF32Array(normalizedText).length * 4;
7010
7082
  } else if (unit === "sjis" || unit === "cp932") {
7011
- return Mojix.encode(text, "Shift_JIS").length;
7083
+ return Mojix.encode(normalizedText, "Shift_JIS").length;
7012
7084
  } else {
7013
- // ここには来ない
7014
7085
  throw new Error(`Invalid unit: ${unit}`);
7015
7086
  }
7016
7087
  };
@@ -7020,9 +7091,10 @@
7020
7091
  * @param {string} text
7021
7092
  * @param {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} unit
7022
7093
  * @param {number} max
7094
+ * @param {"\n"|"\r"|"\r\n"} newline
7023
7095
  * @returns {string}
7024
7096
  */
7025
- const cutTextByUnit = function(text, unit, max) {
7097
+ const cutTextByUnit = function(text, unit, max, newline) {
7026
7098
  /**
7027
7099
  * グラフェムの配列
7028
7100
  * @type {Grapheme[]}
@@ -7041,19 +7113,10 @@
7041
7113
  const outputGraphemeArray = [];
7042
7114
 
7043
7115
  for (let i = 0; i < graphemeArray.length; i++) {
7044
- const g = graphemeArray[i];
7045
-
7046
7116
  // 1グラフェムあたりの長さ
7047
- let byteCount = 0;
7048
- if (unit === "utf-8") {
7049
- byteCount = Mojix.toUTF8Array(Mojix.toStringFromMojiArray([g])).length;
7050
- } else if (unit === "utf-16") {
7051
- byteCount = Mojix.toUTF16Array(Mojix.toStringFromMojiArray([g])).length * 2;
7052
- } else if (unit === "utf-32") {
7053
- byteCount = Mojix.toUTF32Array(Mojix.toStringFromMojiArray([g])).length * 4;
7054
- } else if (unit === "sjis" || unit === "cp932") {
7055
- byteCount = Mojix.encode(Mojix.toStringFromMojiArray([g]), "Shift_JIS").length;
7056
- }
7117
+ const g = graphemeArray[i];
7118
+ const gText = Mojix.toStringFromMojiArray([g]);
7119
+ const byteCount = getTextBytesByUnit(gText, unit, newline);
7057
7120
 
7058
7121
  if (count + byteCount > max) {
7059
7122
  // 空配列を渡すとNUL文字を返すため、空配列のときは空文字を返す
@@ -7078,15 +7141,16 @@
7078
7141
  * @param {string} insertedText 追加するテキスト
7079
7142
  * @param {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} unit
7080
7143
  * @param {number} max
7144
+ * @param {"\n"|"\r"|"\r\n"} newline
7081
7145
  * @returns {string} 追加するテキストを切ったもの(切る必要がない場合は insertedText をそのまま返す)
7082
7146
  */
7083
- const cutBytes = function(beforeText, insertedText, unit, max) {
7084
- const beforeTextLen = getTextBytesByUnit(beforeText, unit);
7147
+ const cutBytes = function(beforeText, insertedText, unit, max, newline) {
7148
+ const beforeTextLen = getTextBytesByUnit(beforeText, unit, newline);
7085
7149
 
7086
7150
  // すでに最大長を超えている場合は追加のテキストを全て切る
7087
7151
  if (beforeTextLen >= max) { return ""; }
7088
7152
 
7089
- const insertedTextLen = getTextBytesByUnit(insertedText, unit);
7153
+ const insertedTextLen = getTextBytesByUnit(insertedText, unit, newline);
7090
7154
  const totalLen = beforeTextLen + insertedTextLen;
7091
7155
 
7092
7156
  if (totalLen <= max) {
@@ -7096,7 +7160,7 @@
7096
7160
 
7097
7161
  // 超える場合は追加のテキストを切る
7098
7162
  const allowedAddLen = max - beforeTextLen;
7099
- return cutTextByUnit(insertedText, unit, allowedAddLen);
7163
+ return cutTextByUnit(insertedText, unit, allowedAddLen, newline);
7100
7164
  };
7101
7165
 
7102
7166
  /**
@@ -7105,12 +7169,10 @@
7105
7169
  * @returns {Rule}
7106
7170
  */
7107
7171
  function bytes(options = {}) {
7108
- /** @type {BytesRuleOptions} */
7109
- const opt = {
7110
- max: typeof options.max === "number" ? options.max : undefined,
7111
- mode: options.mode ?? "block",
7112
- unit: options.unit ?? "utf-8"
7113
- };
7172
+ const max = typeof options.max === "number" ? options.max : undefined;
7173
+ const mode = options.mode ?? "block";
7174
+ const unit = options.unit ?? "utf-8";
7175
+ const newline = options.newline ?? "\n";
7114
7176
 
7115
7177
  return {
7116
7178
  name: "bytes",
@@ -7118,35 +7180,35 @@
7118
7180
 
7119
7181
  normalizeChar(value, ctx) {
7120
7182
  // block 以外は何もしない
7121
- if (opt.mode !== "block") {
7183
+ if (mode !== "block") {
7122
7184
  return value;
7123
7185
  }
7124
7186
  // max 未指定なら制限なし
7125
- if (typeof opt.max !== "number") {
7187
+ if (typeof max !== "number") {
7126
7188
  return value;
7127
7189
  }
7128
7190
 
7129
- const cutText = cutBytes(ctx.beforeText, value, opt.unit, opt.max);
7191
+ const cutText = cutBytes(ctx.beforeText, value, unit, max, newline);
7130
7192
  return cutText;
7131
7193
  },
7132
7194
 
7133
7195
  validate(value, ctx) {
7134
7196
  // error 以外は何もしない
7135
- if (opt.mode !== "error") {
7197
+ if (mode !== "error") {
7136
7198
  return;
7137
7199
  }
7138
7200
  // max 未指定なら制限なし
7139
- if (typeof opt.max !== "number") {
7201
+ if (typeof max !== "number") {
7140
7202
  return;
7141
7203
  }
7142
7204
 
7143
- const len = getTextBytesByUnit(value, opt.unit);
7144
- if (len > opt.max) {
7205
+ const len = getTextBytesByUnit(value, unit, newline);
7206
+ if (len > max) {
7145
7207
  ctx.pushError({
7146
7208
  code: "bytes.max_overflow",
7147
7209
  rule: "bytes",
7148
7210
  phase: "validate",
7149
- detail: { limit: opt.max, actual: len }
7211
+ detail: { limit: max, actual: len }
7150
7212
  });
7151
7213
  }
7152
7214
  }
@@ -7163,6 +7225,7 @@
7163
7225
  * - data-tig-rules-bytes-max -> dataset.tigRulesBytesMax
7164
7226
  * - data-tig-rules-bytes-mode -> dataset.tigRulesBytesMode
7165
7227
  * - data-tig-rules-bytes-unit -> dataset.tigRulesBytesUnit
7228
+ * - data-tig-rules-bytes-newline -> dataset.tigRulesBytesNewline
7166
7229
  *
7167
7230
  * @param {DOMStringMap} dataset
7168
7231
  * @param {HTMLInputElement|HTMLTextAreaElement} _el
@@ -7195,6 +7258,14 @@
7195
7258
  options.unit = unit;
7196
7259
  }
7197
7260
 
7261
+ const newline = parseDatasetEnum(
7262
+ dataset.tigRulesBytesNewline,
7263
+ ["\n", "\r", "\r\n"]
7264
+ );
7265
+ if (newline != null) {
7266
+ options.newline = newline;
7267
+ }
7268
+
7198
7269
  return bytes(options);
7199
7270
  };
7200
7271
 
@@ -7492,12 +7563,37 @@
7492
7563
 
7493
7564
  /**
7494
7565
  * バージョン(ビルド時に置換したいならここを差し替える)
7495
- * 例: rollup replace で ""1.0.2"" を package.json の version に置換
7566
+ * 例: rollup replace で ""1.1.0"" を package.json の version に置換
7496
7567
  */
7497
7568
  // @ts-ignore
7498
7569
  // eslint-disable-next-line no-undef
7499
- const version = "1.0.2" ;
7570
+ const version = "1.1.0" ;
7571
+
7572
+ /**
7573
+ * UMD公開時のグローバルオブジェクト
7574
+ */
7575
+ const TextInputGuard = {
7576
+ attach,
7577
+ attachAll,
7578
+ autoAttach,
7579
+ rules,
7580
+ numeric,
7581
+ digits,
7582
+ comma,
7583
+ imeOff,
7584
+ kana,
7585
+ ascii,
7586
+ filter,
7587
+ length,
7588
+ width,
7589
+ bytes,
7590
+ prefix,
7591
+ suffix,
7592
+ trim,
7593
+ version
7594
+ };
7500
7595
 
7596
+ exports.TextInputGuard = TextInputGuard;
7501
7597
  exports.ascii = ascii;
7502
7598
  exports.attach = attach;
7503
7599
  exports.attachAll = attachAll;