text-input-guard 0.1.4 → 0.1.5

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.
@@ -330,10 +330,25 @@ class SwapState {
330
330
  * @property {boolean} warn - warnログを出すかどうか
331
331
  * @property {string} invalidClass - エラー時に付与するclass名
332
332
  * @property {boolean} composing - IME変換中かどうか
333
+ * @property {string|null} inputType - 直前の入力操作種別(insertText / insertFromPaste / insertCompositionText 等)
334
+ * @property {string} beforeText - 挿入前の全文字列(置換範囲は除去済み)
335
+ * @property {number} replaceStart - 挿入位置/置換開始位置(selectionStart)
336
+ * @property {number} replaceEnd - 置換終了位置(selectionEnd)
337
+ * @property {string} insertedText - 挿入された文字列
338
+ * @property {string} afterText - 挿入後の全文字列(後で代入する)
333
339
  * @property {(e: TigError) => void} pushError - エラーを登録する関数
334
340
  * @property {(req: RevertRequest) => void} requestRevert - 入力を直前の受理値へ巻き戻す要求
335
341
  */
336
342
 
343
+ /**
344
+ * beforeinput で採取する「入力直前スナップショット」
345
+ * - 入力反映前の状態を保持し、差分判定や挿入位置特定に利用する
346
+ * @typedef {Object} BeforeInputSnapshot
347
+ * @property {SelectionState} selection - 入力反映前の選択範囲(挿入/置換位置の判定に使用)
348
+ * @property {string|null} inputType - 入力種別(insertText / insertFromPaste / deleteContentBackward 等)
349
+ * @property {string|null} insertedText - 挿入された文字列(通常入力時に取得できる場合あり)
350
+ */
351
+
337
352
  /**
338
353
  * 1つの入力制御ルール定義
339
354
  * - 各フェーズの処理を必要に応じて実装する
@@ -590,6 +605,11 @@ class InputGuard {
590
605
  */
591
606
  this.onInput = this.onInput.bind(this);
592
607
 
608
+ /**
609
+ * beforeinputイベントハンドラ(this固定)
610
+ */
611
+ this.onBeforeInput = this.onBeforeInput.bind(this);
612
+
593
613
  /**
594
614
  * blurイベントハンドラ(this固定)
595
615
  */
@@ -619,17 +639,24 @@ class InputGuard {
619
639
  this.pendingCompositionCommit = false;
620
640
 
621
641
  /**
622
- * 直前に受理した表示値(block時の戻し先)
642
+ * 直前に受理した表示値、正しい情報のスナップショットのような情報(block時の戻し先)
623
643
  * @type {string}
624
644
  */
625
645
  this.lastAcceptedValue = "";
626
646
 
627
647
  /**
628
- * 直前に受理したselectionblock時の戻し先)
648
+ * 直前に受理したselection、正しい情報のスナップショットのような情報(block時の戻し先)
629
649
  * @type {SelectionState}
630
650
  */
631
651
  this.lastAcceptedSelection = { start: null, end: null, direction: null };
632
652
 
653
+ /**
654
+ * 入力直前スナップショット(beforeinputで更新)
655
+ * length等の「挿入位置優先」ロジックで使用する
656
+ * @type {BeforeInputSnapshot|null}
657
+ */
658
+ this.beforeInputSnapshot = null;
659
+
633
660
  /**
634
661
  * ルールからのrevert要求
635
662
  * @type {RevertRequest|null}
@@ -830,6 +857,7 @@ class InputGuard {
830
857
  this.displayElement.addEventListener("compositionstart", this.onCompositionStart);
831
858
  this.displayElement.addEventListener("compositionend", this.onCompositionEnd);
832
859
  this.displayElement.addEventListener("input", this.onInput);
860
+ this.displayElement.addEventListener("beforeinput", this.onBeforeInput);
833
861
  this.displayElement.addEventListener("blur", this.onBlur);
834
862
 
835
863
  // フォーカスで編集用に戻す
@@ -850,6 +878,7 @@ class InputGuard {
850
878
  this.displayElement.removeEventListener("compositionstart", this.onCompositionStart);
851
879
  this.displayElement.removeEventListener("compositionend", this.onCompositionEnd);
852
880
  this.displayElement.removeEventListener("input", this.onInput);
881
+ this.displayElement.removeEventListener("beforeinput", this.onBeforeInput);
853
882
  this.displayElement.removeEventListener("blur", this.onBlur);
854
883
  this.displayElement.removeEventListener("focus", this.onFocus);
855
884
  this.displayElement.removeEventListener("keyup", this.onSelectionChange);
@@ -891,6 +920,70 @@ class InputGuard {
891
920
  * @returns {GuardContext}
892
921
  */
893
922
  createCtx() {
923
+ const snap = this.beforeInputSnapshot;
924
+ const inputType = snap?.inputType ?? "";
925
+ const insertedText = snap?.insertedText ?? "";
926
+
927
+ // 受理済み(正規化済み)の全文を「今回の編集の基準」として使う
928
+ // display.value はブラウザ側の編集結果が混ざるので、差分再構成の基準にはしない
929
+ const beforeText = this.lastAcceptedValue ?? "";
930
+
931
+ // selection は2系統ある:
932
+ // - snapSel: beforeinput 時点で取得した selection(今回の編集の基準点になり得る)
933
+ // - lastSel: ユーザー操作(keyup/mouseup/select 等)で追跡している selection(常にUIの見た目に近い)
934
+ //
935
+ // 基本は snapSel を優先するが、IME が絡むと snapSel が「変換中の範囲(composition range)」を指して
936
+ // “本当のキャレット位置” と一致しないことがあるため、その場合は lastSel を採用する。
937
+ const snapSel = snap?.selection ?? null;
938
+ const lastSel = this.lastAcceptedSelection;
939
+
940
+ // 通常は beforeinput の selection(snapSel)を使うのが一番正確
941
+ let baseSel = snapSel ?? lastSel;
942
+
943
+ // IME由来の入力(変換中の確定/更新など)は、beforeinput の selection が
944
+ // 「IMEが管理する範囲」になってしまうことがある。
945
+ // この場合 snapSel を使うと “勝手に上書き” が起きやすいので lastSel に寄せる。
946
+ const isCompositionInput =
947
+ this.composing ||
948
+ inputType === "insertCompositionText" ||
949
+ inputType === "deleteCompositionText" ||
950
+ inputType === "insertFromComposition";
951
+
952
+ // もう一つの検知:snapSel が「範囲選択」なのに lastSel が「キャレットのみ」なら、
953
+ // その範囲はユーザーが選択したのではなく、IMEが作っている範囲である可能性が高い。
954
+ // (例:12|34 に 5 を入れたいのに、IME範囲を置換して 1254 になる、など)
955
+ const looksLikeImeRange =
956
+ snapSel &&
957
+ (snapSel.start !== snapSel.end) &&
958
+ (lastSel.start === lastSel.end) &&
959
+ (inputType === "insertText" || inputType === "insertCompositionText");
960
+
961
+ if (isCompositionInput || looksLikeImeRange) {
962
+ baseSel = lastSel;
963
+ }
964
+
965
+ let replaceStart = baseSel.start ?? 0;
966
+ let replaceEnd = baseSel.end ?? 0;
967
+
968
+ // Backspace / Delete は「挿入文字がない(dataがnull)」ことが多い。
969
+ // そのままだと差分再構成で “何も変わらない” 扱いになって削除が効かなくなるため、
970
+ // 選択範囲が無い場合は「削除される1文字ぶん」の置換範囲をここで作る。
971
+ //
972
+ // ※ 選択範囲がある削除は replaceStart!=replaceEnd なので補正不要(その範囲を消すだけでよい)
973
+ if (replaceStart === replaceEnd) {
974
+ if (inputType === "deleteContentBackward") {
975
+ // Backspace: キャレットの左側1文字を削除
976
+ replaceStart = Math.max(0, replaceStart - 1);
977
+ replaceEnd = snapSel.start ?? replaceEnd;
978
+ } else if (inputType === "deleteContentForward") {
979
+ // Delete: キャレットの右側1文字を削除
980
+ replaceStart = snapSel.start ?? replaceStart;
981
+ replaceEnd = Math.min(beforeText.length, replaceEnd + 1);
982
+ }
983
+ // 追加で拾うならここ:
984
+ // deleteWordBackward / deleteWordForward / deleteByCut / deleteSoftLineBackward ... etc
985
+ }
986
+
894
987
  return {
895
988
  hostElement: this.hostElement,
896
989
  displayElement: this.displayElement,
@@ -899,6 +992,12 @@ class InputGuard {
899
992
  warn: this.warn,
900
993
  invalidClass: this.invalidClass,
901
994
  composing: this.composing,
995
+ inputType,
996
+ beforeText,
997
+ replaceStart,
998
+ replaceEnd,
999
+ insertedText,
1000
+ afterText: null, // 後で代入する
902
1001
  pushError: (e) => this.errors.push(e),
903
1002
  requestRevert: (req) => {
904
1003
  // 1回でもrevert要求が出たら採用(最初の理由を保持)
@@ -1063,6 +1162,23 @@ class InputGuard {
1063
1162
  this.evaluateInput();
1064
1163
  }
1065
1164
 
1165
+ /**
1166
+ * beforeinput:入力が反映される直前に呼ばれる
1167
+ * - ここでの value/selection が「今回の編集の基準点」になる
1168
+ * @param {InputEvent} e
1169
+ * @returns {void}
1170
+ */
1171
+ onBeforeInput(e) {
1172
+ const el = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1173
+ // 現時点(反映前)の選択範囲
1174
+ const selection = this.readSelection(el);
1175
+ /** @type {string|null} */
1176
+ const inputType = typeof e.inputType === "string" ? e.inputType : null;
1177
+ /** @type {string|null} */
1178
+ const insertedText = typeof e.data === "string" ? e.data : null;
1179
+ this.beforeInputSnapshot = { selection, inputType, insertedText };
1180
+ }
1181
+
1066
1182
  /**
1067
1183
  * blurイベント:確定時評価(normalize → validate → fix → format、同期、class更新)
1068
1184
  * @returns {void}
@@ -1073,7 +1189,7 @@ class InputGuard {
1073
1189
  }
1074
1190
 
1075
1191
  /**
1076
- * focusイベント:表示整形(カンマ等)を剥がして編集しやすい状態にする
1192
+ * focusイベント:表示整形を剥がして編集しやすい状態にする
1077
1193
  * - validate は走らせない(触っただけで赤くしたくないため)
1078
1194
  * @returns {void}
1079
1195
  */
@@ -1084,9 +1200,10 @@ class InputGuard {
1084
1200
  const current = display.value;
1085
1201
 
1086
1202
  const ctx = this.createCtx();
1203
+ ctx.afterText = current;
1087
1204
 
1088
1205
  let v = current;
1089
- v = this.runNormalizeChar(v, ctx); // カンマ除去が効く
1206
+ v = this.runNormalizeChar(v, ctx);
1090
1207
  v = this.runNormalizeStructure(v, ctx);
1091
1208
 
1092
1209
  if (v !== current) {
@@ -1153,6 +1270,83 @@ class InputGuard {
1153
1270
  }
1154
1271
  }
1155
1272
 
1273
+ /**
1274
+ * evaluateInput専用createCtx(ルール実行に渡すコンテキストを作り、正規箇所も実行する)
1275
+ *
1276
+ * - CTX を作成する中で、文字の正規化と構造の正規化を行い、CTXのその情報に合わせる
1277
+ * - runNormalizeChar に対して入力した文字のみを入れることで、処理の高速化とキャレットズレが起きないように制御する
1278
+ * - runNormalizeStructure は全体の文字を入れるため、ここでの処理はキャレットズレが起きる可能性がある
1279
+ * @returns {GuardContext}
1280
+ */
1281
+ createCtxAndNormalize() {
1282
+ const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1283
+ const current = display.value;
1284
+ const ctx = this.createCtx();
1285
+ ctx.afterText = current;
1286
+
1287
+ // 元のテキスト
1288
+ const beforeText = ctx.beforeText;
1289
+
1290
+ // 追加入力したテキスト
1291
+ let insertedText = ctx.insertedText;
1292
+
1293
+ // 左端の挿入箇所
1294
+ const replaceStart = ctx.replaceStart;
1295
+
1296
+ // 現状のテキスト
1297
+ const tempText = current;
1298
+
1299
+ // 作成する全体のテキスト
1300
+ let newText = beforeText;
1301
+
1302
+ if (ctx.replaceStart !== ctx.replaceEnd) {
1303
+ // 選択範囲の前までと、選択範囲の後ろを結合して、間(選択部分)を削除する
1304
+ newText = beforeText.slice(0, ctx.replaceStart) + beforeText.slice(ctx.replaceEnd);
1305
+ }
1306
+
1307
+ // CTX の情報を最新の情報へ更新する
1308
+ ctx.beforeText = newText;
1309
+
1310
+ // 挿入するテキストのみ文字チェックを行う
1311
+ const normalizeCharText = this.runNormalizeChar(insertedText, ctx);
1312
+ insertedText = normalizeCharText;
1313
+
1314
+ // 作成したテキストを挿入する
1315
+ newText = newText.slice(0, replaceStart) + insertedText + newText.slice(replaceStart);
1316
+
1317
+ // 挿入したテキストの右側にカーソル位置をずらす
1318
+ // insertedText は UTF-16 code unit 長なので、
1319
+ // Selection は JS の index(UTF-16)前提で計算
1320
+ /**
1321
+ * @type {SelectionState}
1322
+ */
1323
+ let newSelection = { start: replaceStart + insertedText.length, end: replaceStart + insertedText.length, direction: "forward" };
1324
+
1325
+ // 挿入後文章全体に構造チェックを行う
1326
+ const normalizeStructureText = this.runNormalizeStructure(newText, ctx);
1327
+
1328
+ // 構成した文章がずれていた場合、カーソル位置の見直しを行う
1329
+ if (newText !== normalizeStructureText) {
1330
+ newText = normalizeStructureText;
1331
+ // 入力した実際のテキスト(tempText)から、現在位置から左側のみ切り出して、左側のみ再チェックする
1332
+ // 文章の長さに依存した変更があった場合は厳しいが、それ以外は以下の方法で切り抜けられる可能性が高い
1333
+ let leftText = tempText.slice(0, replaceStart);
1334
+ leftText = this.runNormalizeChar(leftText, ctx);
1335
+ leftText = this.runNormalizeStructure(leftText, ctx);
1336
+ const newPos = Math.min(leftText.length, newText.length);
1337
+ newSelection = { start: newPos, end: newPos, direction: "forward" };
1338
+ }
1339
+
1340
+ // 画面を更新
1341
+ this.syncDisplay(newText);
1342
+ this.writeSelection(display, newSelection);
1343
+
1344
+ // CTX の情報を最新の情報へ更新する
1345
+ ctx.afterText = newText;
1346
+
1347
+ return ctx;
1348
+ }
1349
+
1156
1350
  /**
1157
1351
  * 入力中の評価(IME中は何もしない)
1158
1352
  * - 固定順:normalize.char → normalize.structure → validate
@@ -1168,26 +1362,15 @@ class InputGuard {
1168
1362
  this.revertRequest = null;
1169
1363
 
1170
1364
  const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1171
- const current = display.value;
1172
-
1173
- const ctx = this.createCtx();
1174
1365
 
1175
- // raw候補(入力中は表示値=rawとして扱う)
1176
- let raw = current;
1177
-
1178
- raw = this.runNormalizeChar(raw, ctx);
1179
- raw = this.runNormalizeStructure(raw, ctx);
1180
-
1181
- // normalizeで変わったら反映(selection補正)
1182
- if (raw !== current) {
1183
- this.setDisplayValuePreserveCaret(display, raw, ctx);
1184
- }
1366
+ const ctx = this.createCtxAndNormalize();
1367
+ const raw = ctx.afterText;
1185
1368
 
1186
1369
  // validate(入力中:エラー出すだけ)
1187
1370
  this.runValidate(raw, ctx);
1188
1371
 
1189
- // revert要求が出たら巻き戻して終了
1190
1372
  if (this.revertRequest) {
1373
+ // revert要求が出たら巻き戻して終了
1191
1374
  this.revertDisplay(this.revertRequest);
1192
1375
  return;
1193
1376
  }
@@ -1221,6 +1404,7 @@ class InputGuard {
1221
1404
 
1222
1405
  // 1) raw候補(displayから取得)
1223
1406
  let raw = display.value;
1407
+ ctx.afterText = raw;
1224
1408
 
1225
1409
  // 2) 正規化(rawとして扱う形に揃える)
1226
1410
  raw = this.runNormalizeChar(raw, ctx);
@@ -1354,83 +1538,111 @@ class InputGuard {
1354
1538
  }
1355
1539
 
1356
1540
  /**
1357
- * The script is part of TextInputGuard.
1541
+ * dataset/option boolean 値を解釈する
1542
+ * - 未指定(null/undefined)の場合は defaultValue を返す
1543
+ * - 空文字 "" は常に true(HTML属性文化)
1544
+ * - 指定があるが解釈できない場合は undefined
1358
1545
  *
1359
- * AUTHOR:
1360
- * natade-jp (https://github.com/natade-jp)
1546
+ * true : true / 1 / "true" / "1" / "yes" / "on" / ""
1547
+ * false : false / 0 / "false" / "0" / "no" / "off"
1361
1548
  *
1362
- * LICENSE:
1363
- * The MIT license https://opensource.org/licenses/MIT
1364
- */
1365
-
1366
- /**
1367
- * datasetのboolean値を解釈する
1368
- * - 未指定なら undefined
1369
- * - "" / "true" / "1" / "yes" / "on" は true
1370
- * - "false" / "0" / "no" / "off" は false
1371
- * @param {string|undefined} v
1549
+ * @param {string|number|boolean|undefined|null} v
1550
+ * @param {boolean} [defaultValue]
1372
1551
  * @returns {boolean|undefined}
1373
1552
  */
1374
- function parseDatasetBool(v) {
1375
- if (v == null) { return; }
1553
+ function parseDatasetBool(v, defaultValue) {
1554
+ if (v === null || v === undefined) { return defaultValue; }
1555
+
1556
+ if (typeof v === "boolean") { return v; }
1557
+
1558
+ if (typeof v === "number") {
1559
+ if (v === 1) { return true; }
1560
+ if (v === 0) { return false; }
1561
+ return;
1562
+ }
1563
+
1376
1564
  const s = String(v).trim().toLowerCase();
1377
- if (s === "" || s === "true" || s === "1" || s === "yes" || s === "on") { return true; }
1565
+
1566
+ // dataset の属性存在を true とみなす(例: data-xxx="")
1567
+ if (s === "") { return true; }
1568
+
1569
+ if (s === "true" || s === "1" || s === "yes" || s === "on") { return true; }
1378
1570
  if (s === "false" || s === "0" || s === "no" || s === "off") { return false; }
1571
+
1379
1572
  return;
1380
1573
  }
1381
1574
 
1382
1575
  /**
1383
- * datasetのnumber値を解釈する(整数想定)
1384
- * - 未指定/空なら undefined
1576
+ * dataset/option number 値を解釈する
1577
+ * - 未指定(null/undefined/空文字)の場合は defaultValue を返す
1385
1578
  * - 数値でなければ undefined
1386
- * @param {string|undefined} v
1579
+ * @param {string|number|undefined|null} v
1580
+ * @param {number} [defaultValue]
1387
1581
  * @returns {number|undefined}
1388
1582
  */
1389
- function parseDatasetNumber(v) {
1390
- if (v == null) { return; }
1583
+ function parseDatasetNumber(v, defaultValue) {
1584
+ if (v === null || v === undefined) { return defaultValue; }
1585
+
1586
+ if (typeof v === "number") {
1587
+ return Number.isFinite(v) ? v : undefined;
1588
+ }
1589
+
1391
1590
  const s = String(v).trim();
1392
- if (s === "") { return; }
1591
+ if (s === "") { return defaultValue; }
1592
+
1393
1593
  const n = Number(s);
1394
1594
  return Number.isFinite(n) ? n : undefined;
1395
1595
  }
1396
1596
 
1397
1597
  /**
1398
- * enumを解釈する(未指定なら undefined)
1598
+ * enumを解釈する
1599
+ * - 未指定(null/undefined/空文字)の場合は defaultValue を返す
1600
+ * - 値が指定されているが allowed に含まれない場合は undefined を返す
1601
+ *
1399
1602
  * @template {string} T
1400
- * @param {string|undefined} v
1603
+ * @param {string|undefined|null} v
1401
1604
  * @param {readonly T[]} allowed
1605
+ * @param {T} [defaultValue]
1402
1606
  * @returns {T|undefined}
1403
1607
  */
1404
- function parseDatasetEnum(v, allowed) {
1405
- if (v == null) { return; }
1608
+ function parseDatasetEnum(v, allowed, defaultValue) {
1609
+ if (v === null || v === undefined) { return defaultValue; }
1610
+
1406
1611
  const s = String(v).trim();
1407
- if (s === "") { return; }
1408
- // 大文字小文字を区別したいならここを変える(今は厳密一致)
1409
- return /** @type {T|undefined} */ (allowed.includes(/** @type {any} */ (s)) ? s : undefined);
1612
+ if (s === "") { return defaultValue; }
1613
+
1614
+ return /** @type {T|undefined} */ (
1615
+ allowed.includes(/** @type {any} */ (s)) ? s : undefined
1616
+ );
1410
1617
  }
1411
1618
 
1412
1619
  /**
1413
- * enum のカンマ区切り複数指定を解釈する(未指定なら undefined)
1414
- * - 未指定なら undefined
1620
+ * enum のカンマ区切り複数指定を解釈する
1621
+ * - 未指定(null/undefined/空文字)の場合は defaultValue を返す
1415
1622
  * - 空要素は無視
1416
1623
  * - allowed に含まれないものは除外
1417
1624
  *
1418
- * 例:
1419
- * - "a,b,c" -> ["a","b","c"](allowed に含まれるもののみ)
1420
- * - "" / " " -> undefined
1421
- * - "x,y"(どちらも allowed 外)-> []
1422
- *
1423
1625
  * @template {string} T
1424
- * @param {string|undefined} v
1626
+ * @param {string|T[]|undefined|null} v
1425
1627
  * @param {readonly T[]} allowed
1628
+ * @param {T[]} [defaultValue]
1426
1629
  * @returns {T[]|undefined}
1427
1630
  */
1428
- function parseDatasetEnumList(v, allowed) {
1429
- if (v == null) { return; }
1631
+ function parseDatasetEnumList(v, allowed, defaultValue) {
1632
+ if (v === null || v === undefined) { return defaultValue; }
1633
+
1634
+ // JSオプションで配列直渡しも許可
1635
+ if (Array.isArray(v)) {
1636
+ const result = v.filter(
1637
+ /** @returns {x is T} */
1638
+ (x) => allowed.includes(/** @type {any} */ (x))
1639
+ );
1640
+ return result;
1641
+ }
1642
+
1430
1643
  const s = String(v).trim();
1431
- if (s === "") { return; }
1644
+ if (s === "") { return defaultValue; }
1432
1645
 
1433
- /** @type {string[]} */
1434
1646
  const list = s
1435
1647
  .split(",")
1436
1648
  .map((x) => x.trim())
@@ -5888,7 +6100,7 @@ const createCategoryTester = function (categories) {
5888
6100
  /**
5889
6101
  * 1文字が許可されるか判定する関数を作る
5890
6102
  * @param {FilterCategory[]} categoryList
5891
- * @param {(graphem: Grapheme, s: string) => boolean} categoryTest
6103
+ * @param {(g: Grapheme, s: string) => boolean} categoryTest
5892
6104
  * @param {RegExp|undefined} allowRe
5893
6105
  * @param {RegExp|undefined} denyRe
5894
6106
  * @returns {(g: Grapheme, s: string) => boolean}
@@ -5941,10 +6153,10 @@ const scanByAllowed = function (value, isAllowed, maxInvalidChars = 20) {
5941
6153
  * グラフェムの配列
5942
6154
  * @type {Grapheme[]}
5943
6155
  */
5944
- const graphemArray = Mojix.toMojiArrayFromString(v);
6156
+ const graphemeArray = Mojix.toMojiArrayFromString(v);
5945
6157
 
5946
6158
  // JS の文字列イテレータはコードポイント単位で回るので Array.from は不要
5947
- for (const g of graphemArray) {
6159
+ for (const g of graphemeArray) {
5948
6160
  const s = Mojix.toStringFromMojiArray([g]);
5949
6161
  if (isAllowed(g, s)) {
5950
6162
  filtered += s;
@@ -6119,6 +6331,245 @@ filter.fromDataset = function fromDataset(dataset, _el) {
6119
6331
  return filter(options);
6120
6332
  };
6121
6333
 
6334
+ /**
6335
+ * The script is part of TextInputGuard.
6336
+ *
6337
+ * AUTHOR:
6338
+ * natade-jp (https://github.com/natade-jp)
6339
+ *
6340
+ * LICENSE:
6341
+ * The MIT license https://opensource.org/licenses/MIT
6342
+ */
6343
+
6344
+
6345
+ /**
6346
+ * length ルールのオプション
6347
+ * @typedef {Object} LengthRuleOptions
6348
+ * @property {number} [max] - 最大長(グラフェム数)。未指定なら制限なし
6349
+ * @property {"block"|"error"} [overflowInput="block"] - 入力中に最大長を超えたときの挙動
6350
+ * @property {"grapheme"|"utf-16"|"utf-32"} [unit="grapheme"] - 長さの単位
6351
+ *
6352
+ * block : 最大長を超える部分を切る
6353
+ * error : エラーを積むだけ(値は変更しない)
6354
+ */
6355
+
6356
+ /**
6357
+ * グラフェム(1グラフェムは、UTF-32の配列)
6358
+ * @typedef {number[]} Grapheme
6359
+ */
6360
+
6361
+ /**
6362
+ * グラフェム/UTF-16コード単位/UTF-32コード単位の長さを調べる
6363
+ * @param {string} text
6364
+ * @param {"grapheme"|"utf-16"|"utf-32"} unit
6365
+ * @returns {number}
6366
+ */
6367
+ const getTextLengthByUnit = function(text, unit) {
6368
+ if (unit === "grapheme") {
6369
+ return Mojix.toMojiArrayFromString(text).length;
6370
+ } else if (unit === "utf-16") {
6371
+ return Mojix.toUTF16Array(text).length;
6372
+ } else if (unit === "utf-32") {
6373
+ return Mojix.toUTF32Array(text).length;
6374
+ } else {
6375
+ // ここには来ない
6376
+ throw new Error(`Invalid unit: ${unit}`);
6377
+ }
6378
+ };
6379
+
6380
+ /**
6381
+ * グラフェム/UTF-16コード単位/UTF-32コード単位でテキストを切る
6382
+ * @param {string} text
6383
+ * @param {"grapheme"|"utf-16"|"utf-32"} unit
6384
+ * @param {number} max
6385
+ * @returns {string}
6386
+ */
6387
+ const cutTextByUnit = function(text, unit, max) {
6388
+ /**
6389
+ * グラフェムの配列
6390
+ * @type {Grapheme[]}
6391
+ */
6392
+ const graphemeArray = Mojix.toMojiArrayFromString(text);
6393
+
6394
+ /**
6395
+ * 現在の位置
6396
+ */
6397
+ let count = 0;
6398
+
6399
+ /**
6400
+ * グラフェムの配列(出力用)
6401
+ * @type {Grapheme[]}
6402
+ */
6403
+ const outputGraphemeArray = [];
6404
+
6405
+ for (let i = 0; i < graphemeArray.length; i++) {
6406
+ const g = graphemeArray[i];
6407
+
6408
+ // 1グラフェムあたりの長さ
6409
+ let graphemeCount = 0;
6410
+ if (unit === "grapheme") {
6411
+ graphemeCount = 1;
6412
+ } else if (unit === "utf-16") {
6413
+ graphemeCount = 0;
6414
+ for (let i = 0; i < g.length; i++) {
6415
+ graphemeCount += (g[i] > 0xFFFF) ? 2 : 1;
6416
+ }
6417
+ } else if (unit === "utf-32") {
6418
+ graphemeCount = g.length;
6419
+ }
6420
+
6421
+ if (count + graphemeCount > max) {
6422
+ // 空配列を渡すとNUL文字を返すため、空配列のときは空文字を返す
6423
+ if (outputGraphemeArray.length === 0) {
6424
+ return "";
6425
+ }
6426
+ // 超える前の位置で文字列化して返す
6427
+ return Mojix.toStringFromMojiArray(outputGraphemeArray);
6428
+ }
6429
+
6430
+ count += graphemeCount;
6431
+ outputGraphemeArray.push(g);
6432
+ }
6433
+
6434
+ // 全部入るなら元の text を返す
6435
+ return text;
6436
+ };
6437
+
6438
+ /**
6439
+ * 元のテキストと追加のテキストの合計が max を超える場合、追加のテキストを切って合計が max に収まるようにする
6440
+ * @param {string} beforeText 元のテキスト
6441
+ * @param {string} insertedText 追加するテキスト
6442
+ * @param {"grapheme"|"utf-16"|"utf-32"} unit
6443
+ * @param {number} max
6444
+ * @returns {string} 追加するテキストを切ったもの(切る必要がない場合は insertedText をそのまま返す)
6445
+ */
6446
+ const cutLength = function(beforeText, insertedText, unit, max) {
6447
+ const orgLen = getTextLengthByUnit(beforeText, unit);
6448
+
6449
+ // すでに最大長を超えている場合は追加のテキストを全て切る
6450
+ if (orgLen >= max) { return ""; }
6451
+
6452
+ const addLen = getTextLengthByUnit(insertedText, unit);
6453
+ const totalLen = orgLen + addLen;
6454
+
6455
+ if (totalLen <= max) {
6456
+ // 今回の追加で範囲内に収まるなら何もしない
6457
+ return insertedText;
6458
+ }
6459
+
6460
+ // 超える場合は追加のテキストを切る
6461
+ const allowedAddLen = max - orgLen;
6462
+ return cutTextByUnit(insertedText, unit, allowedAddLen);
6463
+ };
6464
+
6465
+ /**
6466
+ * length ルールを生成する
6467
+ * @param {LengthRuleOptions} [options]
6468
+ * @returns {Rule}
6469
+ */
6470
+ function length(options = {}) {
6471
+ /** @type {LengthRuleOptions} */
6472
+ const opt = {
6473
+ max: typeof options.max === "number" ? options.max : undefined,
6474
+ overflowInput: options.overflowInput ?? "block",
6475
+ unit: options.unit ?? "grapheme"
6476
+ };
6477
+
6478
+ return {
6479
+ name: "length",
6480
+ targets: ["input", "textarea"],
6481
+
6482
+ normalizeChar(value, ctx) {
6483
+ // block 以外は何もしない
6484
+ if (opt.overflowInput !== "block") {
6485
+ return value;
6486
+ }
6487
+ // max 未指定なら制限なし
6488
+ if (typeof opt.max !== "number") {
6489
+ return value;
6490
+ }
6491
+
6492
+ const beforeText = ctx.beforeText;
6493
+ const insertedText = ctx.insertedText;
6494
+ if (insertedText === "") {
6495
+ return value;
6496
+ }
6497
+
6498
+ const cutText = cutLength(beforeText, insertedText, opt.unit, opt.max);
6499
+ return cutText;
6500
+ },
6501
+
6502
+ validate(value, ctx) {
6503
+ // error 以外は何もしない
6504
+ if (opt.overflowInput !== "error") {
6505
+ return value;
6506
+ }
6507
+ // max 未指定なら制限なし
6508
+ if (typeof opt.max !== "number") {
6509
+ return;
6510
+ }
6511
+
6512
+ const len = getTextLengthByUnit(value, opt.unit);
6513
+ if (len > opt.max) {
6514
+ ctx.pushError({
6515
+ code: "length.max_overflow",
6516
+ rule: "length",
6517
+ phase: "validate",
6518
+ detail: { max: opt.max, actual: len }
6519
+ });
6520
+ }
6521
+ }
6522
+ };
6523
+ }
6524
+
6525
+ /**
6526
+ * datasetから length ルールを生成する
6527
+ * - data-tig-rules-length が無ければ null
6528
+ * - オプションは data-tig-rules-length-xxx から読む
6529
+ *
6530
+ * 対応する data 属性(dataset 名)
6531
+ * - data-tig-rules-length -> dataset.tigRulesLength
6532
+ * - data-tig-rules-length-max -> dataset.tigRulesLengthMax
6533
+ * - data-tig-rules-length-overflow-input -> dataset.tigRulesLengthOverflowInput
6534
+ * - data-tig-rules-length-unit -> dataset.tigRulesLengthUnit
6535
+ *
6536
+ * @param {DOMStringMap} dataset
6537
+ * @param {HTMLInputElement|HTMLTextAreaElement} _el
6538
+ * @returns {Rule|null}
6539
+ */
6540
+ length.fromDataset = function fromDataset(dataset, _el) {
6541
+ // ON判定
6542
+ if (dataset.tigRulesLength == null) {
6543
+ return null;
6544
+ }
6545
+
6546
+ /** @type {LengthRuleOptions} */
6547
+ const options = {};
6548
+
6549
+ const max = parseDatasetNumber(dataset.tigRulesLengthMax);
6550
+ if (max != null) {
6551
+ options.max = max;
6552
+ }
6553
+
6554
+ const overflowInput = parseDatasetEnum(
6555
+ dataset.tigRulesLengthOverflowInput,
6556
+ ["block", "error"]
6557
+ );
6558
+ if (overflowInput != null) {
6559
+ options.overflowInput = overflowInput;
6560
+ }
6561
+
6562
+ const unit = parseDatasetEnum(
6563
+ dataset.tigRulesLengthUnit,
6564
+ ["grapheme", "utf-16", "utf-32"]
6565
+ );
6566
+ if (unit != null) {
6567
+ options.unit = unit;
6568
+ }
6569
+
6570
+ return length(options);
6571
+ };
6572
+
6122
6573
  /**
6123
6574
  * The script is part of TextInputGuard.
6124
6575
  *
@@ -6377,6 +6828,7 @@ const auto = new InputGuardAutoAttach(attach, [
6377
6828
  { name: "kana", fromDataset: kana.fromDataset },
6378
6829
  { name: "ascii", fromDataset: ascii.fromDataset },
6379
6830
  { name: "filter", fromDataset: filter.fromDataset },
6831
+ { name: "length", fromDataset: length.fromDataset },
6380
6832
  { name: "prefix", fromDataset: prefix.fromDataset },
6381
6833
  { name: "suffix", fromDataset: suffix.fromDataset },
6382
6834
  { name: "trim", fromDataset: trim.fromDataset }
@@ -6398,6 +6850,7 @@ const rules = {
6398
6850
  kana,
6399
6851
  ascii,
6400
6852
  filter,
6853
+ length,
6401
6854
  prefix,
6402
6855
  suffix,
6403
6856
  trim
@@ -6405,10 +6858,10 @@ const rules = {
6405
6858
 
6406
6859
  /**
6407
6860
  * バージョン(ビルド時に置換したいならここを差し替える)
6408
- * 例: rollup replace で ""0.1.4"" を package.json の version に置換
6861
+ * 例: rollup replace で ""0.1.5"" を package.json の version に置換
6409
6862
  */
6410
6863
  // @ts-ignore
6411
6864
  // eslint-disable-next-line no-undef
6412
- const version = "0.1.4" ;
6865
+ const version = "0.1.5" ;
6413
6866
 
6414
- export { ascii, attach, attachAll, autoAttach, comma, digits, filter, kana, numeric, prefix, rules, suffix, trim, version };
6867
+ export { ascii, attach, attachAll, autoAttach, comma, digits, filter, kana, length, numeric, prefix, rules, suffix, trim, version };