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.
@@ -415,6 +415,7 @@ class SwapState {
415
415
  * @property {string} [invalidClass="is-invalid"] - エラー時に付けるclass名
416
416
  * @property {SeparateValueOptions} [separateValue] - 表示値と内部値の分離設定
417
417
  * @property {(result: ValidateResult) => void} [onValidate] - 評価完了時の通知(input/commitごと)
418
+ * @property {(result: Guard) => void} [onChange] - フォーカスが外れた値が変更されていた場合の通知
418
419
  */
419
420
 
420
421
  /**
@@ -580,6 +581,18 @@ class InputGuard {
580
581
  */
581
582
  this.onValidate = options.onValidate;
582
583
 
584
+ /**
585
+ * blur時に値が変更されていた場合の通知
586
+ * @type {((result: Guard) => void) | undefined}
587
+ */
588
+ this.onChange = options.onChange;
589
+
590
+ /**
591
+ * onChange 判定のための直前の値(blur時にこれと比較して変化を検知する)
592
+ * @type {string}
593
+ */
594
+ this.previousValue = "";
595
+
583
596
  /**
584
597
  * 実際に送信を担う要素(swap時は hidden(raw) 側)
585
598
  * swapしない場合は originalElement と同一
@@ -760,6 +773,8 @@ class InputGuard {
760
773
  this.bindEvents();
761
774
  // 初期値を評価
762
775
  this.evaluateCommit();
776
+ // 初期値を記録
777
+ this.previousValue = this.getDisplayValue();
763
778
  }
764
779
 
765
780
  /**
@@ -992,11 +1007,49 @@ class InputGuard {
992
1007
  if (this.warn) ;
993
1008
  }
994
1009
 
1010
+ /**
1011
+ * 変更前後の文字列から置換範囲と挿入文字列を推測
1012
+ * @param {string} beforeText
1013
+ * @param {string} afterText
1014
+ * @returns {{ replaceStart: number, replaceEnd: number, insertedText: string }}
1015
+ */
1016
+ detectTextDiff(beforeText, afterText) {
1017
+ let start = 0;
1018
+
1019
+ while (
1020
+ start < beforeText.length &&
1021
+ start < afterText.length &&
1022
+ beforeText[start] === afterText[start]
1023
+ ) {
1024
+ start++;
1025
+ }
1026
+
1027
+ let beforeEnd = beforeText.length;
1028
+ let afterEnd = afterText.length;
1029
+
1030
+ while (
1031
+ beforeEnd > start &&
1032
+ afterEnd > start &&
1033
+ beforeText[beforeEnd - 1] === afterText[afterEnd - 1]
1034
+ ) {
1035
+ beforeEnd--;
1036
+ afterEnd--;
1037
+ }
1038
+
1039
+ return {
1040
+ replaceStart: start,
1041
+ replaceEnd: beforeEnd,
1042
+ insertedText: afterText.slice(start, afterEnd)
1043
+ };
1044
+ }
1045
+
995
1046
  /**
996
1047
  * ルール実行に渡すコンテキストを作る(pushErrorで errors に積める)
997
1048
  * @returns {GuardContext}
998
1049
  */
999
1050
  createCtx({ useSnapshot = true } = {}) {
1051
+ // 入力後のテキストを取得
1052
+ const afterText = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement).value;
1000
1053
  const snap = useSnapshot ? this.beforeInputSnapshot : null;
1001
1054
  let inputType = snap?.inputType ?? "";
1002
1055
  let insertedText = snap?.insertedText ?? "";
@@ -1039,23 +1092,23 @@ class InputGuard {
1039
1092
  baseSel = lastSel;
1040
1093
  }
1041
1094
 
1042
- // beforeinput がない環境では、差分再構成の基準が「前回の受理値」しかないため、そこから今回の編集内容を推測する必要がある。
1095
+ // オートコンプリートの処理
1096
+ // inputType が取得できないため existBeforeInputEvent 情報で判断
1097
+ // 差分再構成の基準が「前回の受理値」しかないため、そこから今回の編集内容を推測する必要がある。
1043
1098
  if (beforeText.length === 0 || !this.existBeforeInputEvent) {
1044
- const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1045
- const current = display.value;
1046
1099
  // 前回の値がとれないものの、何かしら入力情報がある状態
1047
- if (current.length > 0) {
1100
+ if (afterText.length > 0) {
1048
1101
  // 文字列の先頭が前回の受理値と同じなら、末尾に何かしら入力されたと考えられる(オートコンプリート等)
1049
- if (current.toLocaleLowerCase().startsWith(beforeText.toLocaleLowerCase())) {
1050
- if (!current.startsWith(beforeText)) {
1102
+ if (afterText.toLocaleLowerCase().startsWith(beforeText.toLocaleLowerCase())) {
1103
+ if (!afterText.startsWith(beforeText)) {
1051
1104
  // 文字は同じだが、大文字と小文字の情報が替わっているなどのパターン
1052
1105
  // 差し代わりが起きているため、前回値は基準にならないと判断して、差分全体を insertedText とする
1053
1106
  beforeText = "";
1054
- insertedText = current;
1107
+ insertedText = afterText;
1055
1108
  } else {
1056
1109
  // 末尾に追加されたと考えられる部分を insertedText とする
1057
- // 例: beforeText="abc" → current="abcde" なら、"de" が insertedText
1058
- insertedText = current.slice(beforeText.length);
1110
+ // 例: beforeText="abc" → afterText="abcde" なら、"de" が insertedText
1111
+ insertedText = afterText.slice(beforeText.length);
1059
1112
  }
1060
1113
  // キャレットは前回値の末尾にあると推測する
1061
1114
  baseSel = /** @type {SelectionState} */ {
@@ -1074,10 +1127,10 @@ class InputGuard {
1074
1127
  let replaceStart = baseSel.start ?? 0;
1075
1128
  let replaceEnd = baseSel.end ?? 0;
1076
1129
 
1130
+ // 削除操作の特殊処理
1077
1131
  // Backspace / Delete は「挿入文字がない(dataがnull)」ことが多い。
1078
1132
  // そのままだと差分再構成で “何も変わらない” 扱いになって削除が効かなくなるため、
1079
1133
  // 選択範囲が無い場合は「削除される1文字ぶん」の置換範囲をここで作る。
1080
- //
1081
1134
  // ※ 選択範囲がある削除は replaceStart!=replaceEnd なので補正不要(その範囲を消すだけでよい)
1082
1135
  if (replaceStart === replaceEnd) {
1083
1136
  if (inputType === "deleteContentBackward") {
@@ -1093,6 +1146,14 @@ class InputGuard {
1093
1146
  // deleteWordBackward / deleteWordForward / deleteByCut / deleteSoftLineBackward ... etc
1094
1147
  }
1095
1148
 
1149
+ // アンドゥリドゥの特殊処理
1150
+ if (inputType === "historyUndo" || inputType === "historyRedo") {
1151
+ const diff = this.detectTextDiff(beforeText, afterText);
1152
+ replaceStart = diff.replaceStart;
1153
+ replaceEnd = diff.replaceEnd;
1154
+ insertedText = diff.insertedText;
1155
+ }
1156
+
1096
1157
  return {
1097
1158
  hostElement: this.hostElement,
1098
1159
  displayElement: this.displayElement,
@@ -1106,7 +1167,7 @@ class InputGuard {
1106
1167
  replaceStart,
1107
1168
  replaceEnd,
1108
1169
  insertedText,
1109
- afterText: null, // 後で代入する
1170
+ afterText,
1110
1171
  pushError: (e) => this.errors.push(e),
1111
1172
  requestRevert: (req) => {
1112
1173
  // 1回でもrevert要求が出たら採用(最初の理由を保持)
@@ -1316,7 +1377,10 @@ class InputGuard {
1316
1377
  /** @type {string|null} */
1317
1378
  const inputType = typeof e.inputType === "string" ? e.inputType : null;
1318
1379
  /** @type {string|null} */
1319
- const insertedText = typeof e.data === "string" ? e.data : null;
1380
+ let insertedText = typeof e.data === "string" ? e.data : null;
1381
+ if (insertedText === null && (inputType === "insertLineBreak" || inputType === "insertParagraph")) {
1382
+ insertedText = "\n";
1383
+ }
1320
1384
  this.existBeforeInputEvent = true;
1321
1385
  this.beforeInputSnapshot = { selection, inputType, insertedText };
1322
1386
  }
@@ -1328,6 +1392,12 @@ class InputGuard {
1328
1392
  onBlur() {
1329
1393
  // console.log("[text-input-guard] blur");
1330
1394
  this.evaluateCommit();
1395
+ if (this.previousValue !== this.getDisplayValue()) {
1396
+ this.previousValue = this.getDisplayValue();
1397
+ if (this.onChange) {
1398
+ this.onChange(this.getGuard());
1399
+ }
1400
+ }
1331
1401
  }
1332
1402
 
1333
1403
  /**
@@ -1452,10 +1522,7 @@ class InputGuard {
1452
1522
  * @returns {GuardContext}
1453
1523
  */
1454
1524
  createCtxAndNormalize() {
1455
- const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1456
- const current = display.value;
1457
1525
  const ctx = this.createCtx();
1458
- ctx.afterText = current;
1459
1526
 
1460
1527
  // 元のテキスト
1461
1528
  const beforeText = ctx.beforeText;
@@ -1467,7 +1534,7 @@ class InputGuard {
1467
1534
  const replaceStart = ctx.replaceStart;
1468
1535
 
1469
1536
  // 現状のテキスト
1470
- const tempText = current;
1537
+ const tempText = ctx.afterText;
1471
1538
 
1472
1539
  // 作成する全体のテキスト
1473
1540
  let newText = beforeText;
@@ -1512,7 +1579,7 @@ class InputGuard {
1512
1579
 
1513
1580
  // 画面を更新
1514
1581
  this.syncDisplay(newText);
1515
- this.writeSelection(display, newSelection);
1582
+ this.writeSelection(this.displayElement, newSelection);
1516
1583
 
1517
1584
  // CTX の情報を最新の情報へ更新する
1518
1585
  ctx.afterText = newText;
@@ -6977,9 +7044,10 @@ width.fromDataset = function fromDataset(dataset, _el) {
6977
7044
  /**
6978
7045
  * bytes ルールのオプション
6979
7046
  * @typedef {Object} BytesRuleOptions
6980
- * @property {number} [max] - 最大長(グラフェム数)。未指定なら制限なし
7047
+ * @property {number} [max] - バイト数。未指定なら制限なし
6981
7048
  * @property {"block"|"error"} [mode="block"] - 入力中に最大長を超えたときの挙動
6982
7049
  * @property {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} [unit="utf-8"] - サイズの単位(sjis系を使用する場合はfilterも必須)
7050
+ * @property {"\n"|"\r"|"\r\n"} [newline="\n"] - 改行の扱い(バイト数計算に影響あり)
6983
7051
  */
6984
7052
 
6985
7053
  /**
@@ -6988,25 +7056,28 @@ width.fromDataset = function fromDataset(dataset, _el) {
6988
7056
  */
6989
7057
 
6990
7058
  /**
6991
- * グラフェム/UTF-16コード単位/UTF-32コード単位の長さを調べる
7059
+ * テキストのバイト数を調べる
6992
7060
  * @param {string} text
6993
7061
  * @param {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} unit
7062
+ * @param {"\n"|"\r"|"\r\n"} newline
6994
7063
  * @returns {number}
6995
7064
  */
6996
- const getTextBytesByUnit = function(text, unit) {
7065
+ const getTextBytesByUnit = function(text, unit, newline) {
6997
7066
  if (text.length === 0) {
6998
7067
  return 0;
6999
7068
  }
7069
+
7070
+ const normalizedText = text.replace(/\r?\n/g, newline);
7071
+
7000
7072
  if (unit === "utf-8") {
7001
- return Mojix.toUTF8Array(text).length;
7073
+ return Mojix.toUTF8Array(normalizedText).length;
7002
7074
  } else if (unit === "utf-16") {
7003
- return Mojix.toUTF16Array(text).length * 2;
7075
+ return Mojix.toUTF16Array(normalizedText).length * 2;
7004
7076
  } else if (unit === "utf-32") {
7005
- return Mojix.toUTF32Array(text).length * 4;
7077
+ return Mojix.toUTF32Array(normalizedText).length * 4;
7006
7078
  } else if (unit === "sjis" || unit === "cp932") {
7007
- return Mojix.encode(text, "Shift_JIS").length;
7079
+ return Mojix.encode(normalizedText, "Shift_JIS").length;
7008
7080
  } else {
7009
- // ここには来ない
7010
7081
  throw new Error(`Invalid unit: ${unit}`);
7011
7082
  }
7012
7083
  };
@@ -7016,9 +7087,10 @@ const getTextBytesByUnit = function(text, unit) {
7016
7087
  * @param {string} text
7017
7088
  * @param {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} unit
7018
7089
  * @param {number} max
7090
+ * @param {"\n"|"\r"|"\r\n"} newline
7019
7091
  * @returns {string}
7020
7092
  */
7021
- const cutTextByUnit = function(text, unit, max) {
7093
+ const cutTextByUnit = function(text, unit, max, newline) {
7022
7094
  /**
7023
7095
  * グラフェムの配列
7024
7096
  * @type {Grapheme[]}
@@ -7037,19 +7109,10 @@ const cutTextByUnit = function(text, unit, max) {
7037
7109
  const outputGraphemeArray = [];
7038
7110
 
7039
7111
  for (let i = 0; i < graphemeArray.length; i++) {
7040
- const g = graphemeArray[i];
7041
-
7042
7112
  // 1グラフェムあたりの長さ
7043
- let byteCount = 0;
7044
- if (unit === "utf-8") {
7045
- byteCount = Mojix.toUTF8Array(Mojix.toStringFromMojiArray([g])).length;
7046
- } else if (unit === "utf-16") {
7047
- byteCount = Mojix.toUTF16Array(Mojix.toStringFromMojiArray([g])).length * 2;
7048
- } else if (unit === "utf-32") {
7049
- byteCount = Mojix.toUTF32Array(Mojix.toStringFromMojiArray([g])).length * 4;
7050
- } else if (unit === "sjis" || unit === "cp932") {
7051
- byteCount = Mojix.encode(Mojix.toStringFromMojiArray([g]), "Shift_JIS").length;
7052
- }
7113
+ const g = graphemeArray[i];
7114
+ const gText = Mojix.toStringFromMojiArray([g]);
7115
+ const byteCount = getTextBytesByUnit(gText, unit, newline);
7053
7116
 
7054
7117
  if (count + byteCount > max) {
7055
7118
  // 空配列を渡すとNUL文字を返すため、空配列のときは空文字を返す
@@ -7074,15 +7137,16 @@ const cutTextByUnit = function(text, unit, max) {
7074
7137
  * @param {string} insertedText 追加するテキスト
7075
7138
  * @param {"utf-8"|"utf-16"|"utf-32"|"sjis"|"cp932"} unit
7076
7139
  * @param {number} max
7140
+ * @param {"\n"|"\r"|"\r\n"} newline
7077
7141
  * @returns {string} 追加するテキストを切ったもの(切る必要がない場合は insertedText をそのまま返す)
7078
7142
  */
7079
- const cutBytes = function(beforeText, insertedText, unit, max) {
7080
- const beforeTextLen = getTextBytesByUnit(beforeText, unit);
7143
+ const cutBytes = function(beforeText, insertedText, unit, max, newline) {
7144
+ const beforeTextLen = getTextBytesByUnit(beforeText, unit, newline);
7081
7145
 
7082
7146
  // すでに最大長を超えている場合は追加のテキストを全て切る
7083
7147
  if (beforeTextLen >= max) { return ""; }
7084
7148
 
7085
- const insertedTextLen = getTextBytesByUnit(insertedText, unit);
7149
+ const insertedTextLen = getTextBytesByUnit(insertedText, unit, newline);
7086
7150
  const totalLen = beforeTextLen + insertedTextLen;
7087
7151
 
7088
7152
  if (totalLen <= max) {
@@ -7092,7 +7156,7 @@ const cutBytes = function(beforeText, insertedText, unit, max) {
7092
7156
 
7093
7157
  // 超える場合は追加のテキストを切る
7094
7158
  const allowedAddLen = max - beforeTextLen;
7095
- return cutTextByUnit(insertedText, unit, allowedAddLen);
7159
+ return cutTextByUnit(insertedText, unit, allowedAddLen, newline);
7096
7160
  };
7097
7161
 
7098
7162
  /**
@@ -7101,12 +7165,10 @@ const cutBytes = function(beforeText, insertedText, unit, max) {
7101
7165
  * @returns {Rule}
7102
7166
  */
7103
7167
  function bytes(options = {}) {
7104
- /** @type {BytesRuleOptions} */
7105
- const opt = {
7106
- max: typeof options.max === "number" ? options.max : undefined,
7107
- mode: options.mode ?? "block",
7108
- unit: options.unit ?? "utf-8"
7109
- };
7168
+ const max = typeof options.max === "number" ? options.max : undefined;
7169
+ const mode = options.mode ?? "block";
7170
+ const unit = options.unit ?? "utf-8";
7171
+ const newline = options.newline ?? "\n";
7110
7172
 
7111
7173
  return {
7112
7174
  name: "bytes",
@@ -7114,35 +7176,35 @@ function bytes(options = {}) {
7114
7176
 
7115
7177
  normalizeChar(value, ctx) {
7116
7178
  // block 以外は何もしない
7117
- if (opt.mode !== "block") {
7179
+ if (mode !== "block") {
7118
7180
  return value;
7119
7181
  }
7120
7182
  // max 未指定なら制限なし
7121
- if (typeof opt.max !== "number") {
7183
+ if (typeof max !== "number") {
7122
7184
  return value;
7123
7185
  }
7124
7186
 
7125
- const cutText = cutBytes(ctx.beforeText, value, opt.unit, opt.max);
7187
+ const cutText = cutBytes(ctx.beforeText, value, unit, max, newline);
7126
7188
  return cutText;
7127
7189
  },
7128
7190
 
7129
7191
  validate(value, ctx) {
7130
7192
  // error 以外は何もしない
7131
- if (opt.mode !== "error") {
7193
+ if (mode !== "error") {
7132
7194
  return;
7133
7195
  }
7134
7196
  // max 未指定なら制限なし
7135
- if (typeof opt.max !== "number") {
7197
+ if (typeof max !== "number") {
7136
7198
  return;
7137
7199
  }
7138
7200
 
7139
- const len = getTextBytesByUnit(value, opt.unit);
7140
- if (len > opt.max) {
7201
+ const len = getTextBytesByUnit(value, unit, newline);
7202
+ if (len > max) {
7141
7203
  ctx.pushError({
7142
7204
  code: "bytes.max_overflow",
7143
7205
  rule: "bytes",
7144
7206
  phase: "validate",
7145
- detail: { limit: opt.max, actual: len }
7207
+ detail: { limit: max, actual: len }
7146
7208
  });
7147
7209
  }
7148
7210
  }
@@ -7159,6 +7221,7 @@ function bytes(options = {}) {
7159
7221
  * - data-tig-rules-bytes-max -> dataset.tigRulesBytesMax
7160
7222
  * - data-tig-rules-bytes-mode -> dataset.tigRulesBytesMode
7161
7223
  * - data-tig-rules-bytes-unit -> dataset.tigRulesBytesUnit
7224
+ * - data-tig-rules-bytes-newline -> dataset.tigRulesBytesNewline
7162
7225
  *
7163
7226
  * @param {DOMStringMap} dataset
7164
7227
  * @param {HTMLInputElement|HTMLTextAreaElement} _el
@@ -7191,6 +7254,14 @@ bytes.fromDataset = function fromDataset(dataset, _el) {
7191
7254
  options.unit = unit;
7192
7255
  }
7193
7256
 
7257
+ const newline = parseDatasetEnum(
7258
+ dataset.tigRulesBytesNewline,
7259
+ ["\n", "\r", "\r\n"]
7260
+ );
7261
+ if (newline != null) {
7262
+ options.newline = newline;
7263
+ }
7264
+
7194
7265
  return bytes(options);
7195
7266
  };
7196
7267
 
@@ -7488,12 +7559,37 @@ const rules = {
7488
7559
 
7489
7560
  /**
7490
7561
  * バージョン(ビルド時に置換したいならここを差し替える)
7491
- * 例: rollup replace で ""1.0.2"" を package.json の version に置換
7562
+ * 例: rollup replace で ""1.1.0"" を package.json の version に置換
7492
7563
  */
7493
7564
  // @ts-ignore
7494
7565
  // eslint-disable-next-line no-undef
7495
- const version = "1.0.2" ;
7566
+ const version = "1.1.0" ;
7567
+
7568
+ /**
7569
+ * UMD公開時のグローバルオブジェクト
7570
+ */
7571
+ const TextInputGuard = {
7572
+ attach,
7573
+ attachAll,
7574
+ autoAttach,
7575
+ rules,
7576
+ numeric,
7577
+ digits,
7578
+ comma,
7579
+ imeOff,
7580
+ kana,
7581
+ ascii,
7582
+ filter,
7583
+ length,
7584
+ width,
7585
+ bytes,
7586
+ prefix,
7587
+ suffix,
7588
+ trim,
7589
+ version
7590
+ };
7496
7591
 
7592
+ exports.TextInputGuard = TextInputGuard;
7497
7593
  exports.ascii = ascii;
7498
7594
  exports.attach = attach;
7499
7595
  exports.attachAll = attachAll;