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