-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
1155 lines (1132 loc) · 73.1 KB
/
Copy pathindex.html
File metadata and controls
1155 lines (1132 loc) · 73.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#11121A">
<meta name="color-scheme" content="dark">
<title>Feelime — 离线优先的 Android 输入法</title>
<meta name="description" content="Feelime 是离线优先的开源 Android 输入法:全拼、双拼、T9、笔画与离线语音,词库可自定义,数据不出设备。在线体验真实键盘,下载最新版。">
<meta property="og:type" content="website">
<meta property="og:locale" content="zh_CN">
<meta property="og:url" content="https://feelime.github.io/">
<meta property="og:title" content="Feelime — 离线优先的 Android 输入法">
<meta property="og:description" content="自在输入,数据留在本机。拼音、笔画、语音全离线,键盘词库可定制,开源可审计。">
<meta property="og:image" content="https://feelime.github.io/assets/social-preview.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="Feelime 绿色 F 标志与 feelime 光标字样">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="assets/feelime-logo.svg">
<style>
:root {
color-scheme: dark;
--bg: #11121A; --surface: #171821; --raised: #1D1F29;
--green: #09D967; --text: #F5F5F7; --muted: #9A9AA8; --line: #2B2D38;
--font: system-ui, -apple-system, "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 96px; }
body { margin: 0; background: var(--bg); color: var(--text); font: 16px/1.7 var(--font); -webkit-font-smoothing: antialiased; }
a { color: inherit; text-decoration: none; }
button, select { font: inherit; }
button, a, select { -webkit-tap-highlight-color: transparent; }
button { color: inherit; cursor: pointer; }
button:disabled { cursor: wait; opacity: .45; }
button, select { touch-action: manipulation; }
img, svg { display: block; }
h1, h2, h3, p { margin: 0; }
h1, h2, h3 { line-height: 1.3; }
[hidden] { display: none !important; }
::selection { background: #09D94B40; color: var(--text); }
:focus-visible { outline: 2px solid var(--green); outline-offset: 5px; }
.container { width: min(1160px, calc(100% - 64px)); margin-inline: auto; }
.icon { width: 20px; height: 20px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.icon-solid { fill: currentColor; stroke: none; }
.muted { color: var(--muted); }
.skip-link { position: fixed; top: -80px; left: 16px; z-index: 30; padding: 10px 20px; background: var(--green); color: var(--bg); border-radius: 8px; }
.skip-link:focus { top: 12px; }
.site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid #F5F5F70B; background: #11121AF2; backdrop-filter: blur(16px); }
.nav { height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.brand { display: flex; align-items: center; gap: 9px; font-size: 23px; font-weight: 750; letter-spacing: -1px; }
.brand img { width: 34px; height: 34px; }
.brand-cursor { color: var(--green); margin-left: 2px; font-weight: 500; }
.nav-links { display: flex; align-items: center; gap: 30px; font-size: 14px; }
.nav-links a { display: flex; align-items: center; justify-content: center; min-height: 44px; color: #B7B7C2; transition: color .15s; }
.nav-links a:hover { color: var(--text); }
.nav-links .nav-github { gap: 8px; color: var(--text); padding-left: 26px; border-left: 1px solid var(--line); min-height: 24px; }
.hero { text-align: center; padding: 86px 0 84px; }
.release-link { display: inline-flex; align-items: center; gap: 12px; border: 1px solid var(--line); background: var(--surface); padding: 5px 12px 5px 7px; border-radius: 7px; font-size: 12px; color: #C5C5CF; min-height: 36px; }
.release-link b { color: var(--green); background: #09D96712; border-radius: 4px; padding: 1px 7px; font-weight: 600; }
.release-link .icon { width: 14px; height: 14px; }
.hero-brand { display: flex; justify-content: center; align-items: center; gap: 26px; margin: 31px 0 27px; }
.hero-logo { width: 104px; height: 104px; }
.hero h1 { display: flex; align-items: center; gap: 11px; font-size: clamp(76px, 10vw, 132px); font-weight: 750; line-height: 1; letter-spacing: -.065em; }
.cursor { display: block; width: 8px; height: .87em; background: var(--green); margin-top: .08em; animation: cursor-blink 1.25s step-end 4; }
@keyframes cursor-blink { 0%, 58%, 100% { opacity: 1; } 59%, 99% { opacity: 0; } }
.hero-tagline { font-size: clamp(24px, 3vw, 34px); font-weight: 650; letter-spacing: -.025em; }
.hero-description { margin-top: 15px; color: var(--muted); font-size: 17px; line-height: 1.9; }
.hero-actions { display: flex; justify-content: center; gap: 14px; margin-top: 30px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 52px; padding: 11px 26px; border: 1px solid var(--line); border-radius: 9px; font-size: 15px; font-weight: 600; transition: background .18s, border-color .18s, transform .18s; }
.button:hover { border-color: #626371; transform: translateY(-2px); }
.button-primary { background: var(--green); border-color: var(--green); color: #092015; }
.button-primary:hover { background: #2AE77F; border-color: #2AE77F; }
.button-secondary { background: #1B1D27; }
.hero-details { display: flex; justify-content: center; align-items: center; gap: 25px; margin-top: 30px; color: var(--muted); font-size: 12px; }
.hero-details span { display: flex; align-items: center; gap: 7px; }
.hero-details .icon { width: 15px; height: 15px; color: #9697A5; }
.section-label { display: flex; align-items: center; gap: 9px; color: var(--green); font-size: 12px; font-weight: 650; letter-spacing: .1em; margin-bottom: 17px; }
.section-label::before { content: ""; width: 6px; height: 6px; background: var(--green); border-radius: 1px; }
.section-heading { font-size: clamp(28px, 3.5vw, 42px); font-weight: 650; letter-spacing: -.03em; }
.preview-section { background: #14151E; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 74px 0 32px; }
.preview-heading { text-align: center; }
.preview-heading .section-label { justify-content: center; }
.preview-description { color: var(--muted); margin-top: 15px; font-size: 14px; }
.preview-tools { display: flex; align-items: center; justify-content: center; gap: 16px; margin: 30px 0 38px; }
.theme-control { display: flex; align-items: center; gap: 9px; font-size: 12px; color: var(--muted); }
.theme-control select { min-height: 40px; padding: 6px 27px 6px 12px; border: 1px solid #393B46; border-radius: 7px; background: var(--raised); color: var(--text); font-size: 13px; }
.tool-button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; border: 1px solid #393B46; border-radius: 7px; padding: 6px 12px; background: transparent; font-size: 13px; transition: border-color .15s, background .15s; }
.tool-button:hover:enabled { border-color: var(--muted); background: var(--raised); }
.tool-button .icon { width: 16px; height: 16px; }
.preview-layout { display: grid; grid-template-columns: minmax(0, 1fr) 406px minmax(0, 1fr); gap: 52px; align-items: center; }
.preview-aside { display: grid; gap: 37px; }
.preview-tip { max-width: 220px; }
.preview-aside:first-child .preview-tip { margin-left: auto; }
.preview-tip .tip-icon { color: var(--green); margin-bottom: 16px; }
.preview-tip h3 { font-size: 16px; font-weight: 600; margin-bottom: 10px; }
.preview-tip p { color: var(--muted); font-size: 13px; line-height: 1.85; }
.preview-tip .tip-action { display: inline-flex; align-items: center; gap: 7px; margin-top: 15px; padding: 6px 0; min-height: 36px; border: 0; background: transparent; color: var(--green); font-size: 12px; }
.tip-action .icon { width: 14px; height: 14px; }
.tip-keys { display: flex; gap: 5px; margin-bottom: 17px; }
.tip-keys kbd { min-width: 29px; padding: 2px 6px; background: #242630; border: 1px solid #3E404D; border-bottom-width: 3px; border-radius: 5px; color: #D5D6DF; font: 13px/1.7 var(--font); text-align: center; }
.device { width: 390px; height: 780px; box-sizing: content-box; position: relative; border: 8px solid #292B35; border-radius: 46px; background: #181A23; box-shadow: 0 0 0 1px #51525D, 0 30px 60px #0005; }
.device::after { content: ""; position: absolute; width: 3px; height: 61px; top: 158px; right: -12px; border-radius: 0 3px 3px 0; background: #454750; }
.device-screen { position: absolute; inset: 0; overflow: hidden; border-radius: 37px; isolation: isolate; }
#kb-frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #181A23; }
.device-status { position: absolute; top: 0; left: 0; right: 0; height: 43px; padding: 12px 24px 0; display: flex; align-items: flex-start; justify-content: space-between; font-size: 11px; font-weight: 600; color: #DBDCE5; pointer-events: none; z-index: 2; }
.device-status::before { content: ""; position: absolute; top: 13px; left: calc(50% - 5px); width: 10px; height: 10px; border-radius: 50%; background: #07090E; box-shadow: 0 0 0 3px #252732; }
.device-status .status-icons { display: flex; gap: 5px; }
.device-status .icon { width: 14px; height: 14px; }
.device[data-theme="light"] .device-status { color: #303240; }
.preview-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; text-align: center; padding: 30px; background: #181A23; color: var(--muted); font-size: 14px; z-index: 3; }
.preview-loading img { width: 56px; height: 56px; }
.preview-loading p { max-width: 270px; }
.preview-status { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 9px; margin: 23px 24px 0; color: var(--muted); font-size: 11px; text-align: center; min-height: 25px; }
.status-dot { width: 5px; height: 5px; background: var(--muted); border-radius: 50%; }
.preview-status[data-ready="true"] .status-dot { background: var(--green); }
.preview-note { text-align: center; margin: 8px 24px 0; font-size: 12px; color: var(--muted); }
.preview-mobile-hint { display: none; }
.log-details { margin-top: 28px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.log-details summary { cursor: pointer; width: fit-content; margin-inline: auto; padding: 15px 6px 0; min-height: 44px; }
#logBody { margin-top: 18px; padding: 14px 16px; max-height: 230px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); font: 11px/1.8 ui-monospace, monospace; }
#logBody:empty::before { content: "体验键盘后,可在这里查看本地调用记录。"; }
#logBody .entry { border-bottom: 1px solid #F5F5F709; overflow-wrap: anywhere; }
#logBody b { color: var(--green); font-weight: 500; }
#logBody .dim { color: var(--muted); }
.features-section { padding-block: 96px; }
.features-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 40px; margin-bottom: 37px; }
.features-heading > p { color: var(--muted); font-size: 14px; max-width: 310px; line-height: 1.9; }
.feature-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.feature-card { display: flex; flex-direction: column; min-height: 248px; padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; transition: border-color .2s, transform .2s; }
.feature-card:hover { border-color: #4B4E5B; transform: translateY(-3px); }
.feature-card > .icon { color: var(--green); width: 24px; height: 24px; margin-bottom: 25px; }
.feature-card h3 { font-size: 19px; font-weight: 600; margin-bottom: 10px; }
.feature-card p { font-size: 13px; color: var(--muted); line-height: 1.9; }
.feature-caption { display: flex; align-items: center; gap: 7px; color: #797C8D; font: 10px/1.7 ui-monospace, monospace; letter-spacing: .04em; margin-top: auto; padding-top: 21px; }
.feature-caption::before { content: ""; width: 14px; height: 1px; background: #4B4E5B; }
.open-source { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 24px; padding: 23px 0 0; color: var(--muted); font-size: 13px; }
.open-source p { display: flex; align-items: center; gap: 10px; }
.text-link { display: inline-flex; align-items: center; gap: 8px; color: var(--text); min-height: 44px; white-space: nowrap; }
.text-link:hover { color: var(--green); }
.text-link .icon { width: 16px; height: 16px; }
.download-section { padding: 68px 0 76px; border-block: 1px solid var(--line); background: #171A20; }
.download-layout { display: grid; grid-template-columns: 1fr 1.05fr; align-items: center; gap: 65px; }
.download-copy > p:last-child { color: var(--muted); font-size: 14px; margin-top: 18px; max-width: 390px; line-height: 1.9; }
.download-options { display: grid; gap: 12px; }
.download-card { display: flex; align-items: center; gap: 18px; padding: 23px 24px; border: 1px solid #09D96755; border-radius: 11px; background: #1C2423; transition: border-color .15s, background .15s; }
a.download-card:hover { border-color: var(--green); background: #1D2C27; }
.download-card > .icon { width: 30px; height: 30px; color: var(--green); }
.download-card > .icon:last-child { margin-left: auto; width: 20px; height: 20px; }
.download-card h3 { font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 12px; }
/* APK 卡双动作:GitHub 直链 + ghproxy 镜像(整卡不再是链接,拆成两个按钮)。 */
.dl-actions { margin-left: auto; display: flex; gap: 8px; flex: none; }
.dl-btn { display: inline-flex; align-items: center; min-height: 40px; padding: 6px 13px; border: 1px solid #09D96755; border-radius: 7px; background: #09D96712; color: var(--green); font-size: 13px; font-weight: 600; white-space: nowrap; transition: border-color .15s, background .15s, color .15s; }
.dl-btn:hover { border-color: var(--green); background: #1D2C27; }
.dl-btn.ghost { border-color: #39423E; background: transparent; color: #9ED9B4; }
.dl-btn.ghost:hover { border-color: var(--green); background: #1D2C27; color: var(--green); }
/* 链路测速后的推荐位:胜者换到第一位(样式由 ghost 切换完成)。 */
.dl-btn.recommended { order: -1; }
.version-tag { padding: 2px 6px; color: var(--green); background: #09D96713; border: 1px solid #09D96725; border-radius: 4px; font: 11px/1.5 ui-monospace, monospace; }
.download-card p { color: var(--muted); font-size: 12px; margin-top: 7px; }
.download-card.unavailable { border-color: var(--line); background: transparent; color: #9A9AA8; }
.download-card.unavailable > .icon { color: #747887; }
.coming-soon { margin-left: auto; font-size: 11px; white-space: nowrap; border: 1px solid #41434E; border-radius: 4px; padding: 3px 7px; }
.site-footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: 30px; }
.footer-brand { display: flex; align-items: center; gap: 14px; color: var(--muted); font-size: 12px; }
.footer-brand img { width: 28px; height: 28px; }
.footer-links { display: flex; align-items: center; gap: 25px; font-size: 12px; color: var(--muted); }
.footer-links a { display: flex; align-items: center; min-height: 44px; }
.footer-links a:hover { color: var(--text); }
@media (max-width: 1040px) {
.preview-layout { gap: 25px; }
.preview-tip { max-width: 175px; }
.preview-tip p { font-size: 12px; }
.download-layout { gap: 40px; }
}
@media (max-width: 860px) {
.preview-layout { grid-template-columns: 1fr; justify-items: center; }
.preview-aside { display: none; }
.preview-mobile-hint { display: block; text-align: center; font-size: 13px; color: var(--muted); margin: -15px 24px 24px; }
.feature-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.features-heading { align-items: flex-start; flex-direction: column; gap: 18px; }
.features-heading > p { max-width: none; }
.download-layout { grid-template-columns: 1fr; gap: 32px; }
.download-copy > p:last-child { max-width: 540px; }
}
@media (max-width: 580px) {
html { scroll-padding-top: 82px; }
.container { width: calc(100% - 40px); }
.nav { height: 66px; gap: 12px; }
.brand { font-size: 21px; gap: 4px; }
.brand img { width: 29px; height: 29px; }
.nav-links { gap: 17px; font-size: 12px; }
.nav-links .nav-features, .nav-github span { display: none; }
.nav-links .nav-github { padding: 0; border: 0; min-height: 44px; }
.hero { padding: 59px 0 63px; }
.hero-brand { gap: 14px; margin-block: 31px 23px; }
.hero-logo { width: 61px; height: 61px; }
.hero h1 { font-size: clamp(59px, 15vw, 80px); gap: 8px; }
.cursor { width: 5px; }
.hero-tagline { font-size: clamp(20px, 5.5vw, 28px); letter-spacing: -.04em; }
.hero-description { font-size: 14px; margin-top: 13px; }
.hero-actions { gap: 10px; margin-top: 25px; }
.button { padding-inline: 20px; min-height: 49px; font-size: 14px; }
.hero-details { gap: 15px; font-size: 10px; margin-top: 25px; }
.hero-details span { gap: 5px; }
.hero-details .icon { width: 13px; height: 13px; }
.preview-section { padding-top: 49px; padding-bottom: 15px; }
.section-label { font-size: 11px; margin-bottom: 13px; }
.preview-description { font-size: 12px; line-height: 1.9; max-width: 300px; margin-inline: auto; }
.preview-tools { gap: 10px; margin-top: 23px; margin-bottom: 31px; }
.theme-control { gap: 7px; }
.tool-button, .theme-control select { min-height: 44px; }
.tool-button { padding-inline: 10px; font-size: 12px; }
.preview-layout.container { width: 100%; }
.device { width: 100%; height: 780px; border: 0; border-radius: 0; box-shadow: none; }
.device-screen { border-radius: 0; }
.device::after, .device-status { display: none; }
.preview-status { font-size: 10px; margin-top: 17px; }
.preview-note { font-size: 11px; }
.log-details { margin-top: 19px; }
.features-section { padding-block: 58px; }
.features-heading { margin-bottom: 25px; }
.feature-grid { gap: 12px; }
.feature-card { min-height: 248px; padding: 20px 18px; }
.feature-card h3 { font-size: 17px; }
.feature-card p { font-size: 12px; }
.feature-card > .icon { margin-bottom: 20px; }
.feature-caption { font-size: 9px; letter-spacing: 0; }
.open-source { flex-direction: column; align-items: flex-start; gap: 5px; padding-top: 15px; }
.open-source p { font-size: 12px; }
.download-section { padding-block: 49px; }
.download-card { gap: 13px; padding: 20px 17px; flex-wrap: wrap; }
/* 窄屏:文字区整行,按钮换第二行(两键等宽),h3 不被挤压断行。 */
.dl-actions { width: calc(100% - 43px); margin-left: 43px; gap: 10px; }
.dl-btn { flex: 1; min-height: 44px; padding-inline: 10px; font-size: 13px; }
.download-card h3 { font-size: 16px; gap: 7px; }
.download-card p { font-size: 10px; }
.site-footer { flex-direction: column; gap: 4px; padding-block: 25px 19px; }
.footer-links { gap: 25px; }
}
@media (max-width: 360px) {
.hero-logo { width: 49px; height: 49px; }
.hero-details { gap: 9px; }
.theme-control > span { display: none; }
.feature-grid { grid-template-columns: 1fr; }
.feature-card { min-height: 0; }
.download-card { padding-inline: 13px; gap: 10px; }
.coming-soon { font-size: 10px; padding: 3px 5px; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation: none !important; transition: none !important; }
}
</style>
</head>
<body id="top">
<a class="skip-link" href="#main">跳到正文</a>
<svg width="0" height="0" aria-hidden="true" style="position:absolute;overflow:hidden">
<defs>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-5-5 5 5-5 5"></path></symbol>
<symbol id="i-external" viewBox="0 0 24 24"><path d="M7 17 17 7M7 7h10v10"></path></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"></path></symbol>
<symbol id="i-github" viewBox="0 0 24 24"><path d="M12 .9a11.1 11.1 0 0 0-3.51 21.63c.56.1.76-.24.76-.54v-2.1c-3.1.68-3.76-1.31-3.76-1.31-.5-1.28-1.23-1.62-1.23-1.62-1.01-.7.08-.68.08-.68 1.12.08 1.7 1.15 1.7 1.15 1 1.7 2.62 1.21 3.26.92.1-.72.39-1.21.71-1.49-2.47-.28-5.07-1.24-5.07-5.51 0-1.22.44-2.21 1.15-2.99-.12-.28-.5-1.42.11-2.95 0 0 .94-.3 3.05 1.14a10.59 10.59 0 0 1 5.55 0c2.11-1.43 3.04-1.14 3.04-1.14.61 1.53.23 2.67.12 2.95.71.78 1.14 1.77 1.14 2.99 0 4.28-2.61 5.22-5.09 5.5.4.35.75 1.02.75 2.06v3.06c0 .3.2.65.77.54A11.1 11.1 0 0 0 12 .9Z"></path></symbol>
<symbol id="i-keyboard" viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="3"></rect><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10"></path></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="13" rx="3"></rect><path d="M5 10v2a7 7 0 0 0 14 0v-2m-7 9v3m-4 0h8"></path></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6l8-3Z"></path><path d="m8 12 3 3 5-6"></path></symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24"><rect x="8" y="2" width="8" height="4" rx="1"></rect><path d="M8 4H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3M8 11h8m-8 4h6"></path></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></symbol>
<symbol id="i-theme" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"></circle><path d="M12 4v16" fill="none"></path><path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor" stroke="none"></path></symbol>
<symbol id="i-update" viewBox="0 0 24 24"><path d="M20 7a9 9 0 0 0-15-2L2 8m0-5v5h5m-3 9a9 9 0 0 0 15 2l3-3m0 5v-5h-5"></path></symbol>
<symbol id="i-code" viewBox="0 0 24 24"><path d="m7 6-6 6 6 6m10-12 6 6-6 6m-3-15-4 18"></path></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M4 7h16M4 17h16"></path><rect x="7" y="4" width="4" height="6" rx="1" fill="#14151E"></rect><rect x="14" y="14" width="4" height="6" rx="1" fill="#14151E"></rect></symbol>
<symbol id="i-reset" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2 7M3 4v7h7"></path></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m7 4 13 8-13 8V4Z"></path></symbol>
<symbol id="i-signal" viewBox="0 0 24 24"><path d="M4 19v-3m5 3v-7m5 7V8m5 11V4"></path></symbol>
<symbol id="i-battery" viewBox="0 0 24 24"><rect x="2" y="6" width="18" height="12" rx="2"></rect><path d="M23 10v4M6 10h10v4H6z" fill="currentColor"></path></symbol>
</defs>
</svg>
<header class="site-header">
<nav class="nav container" aria-label="主导航">
<a class="brand" href="#top" aria-label="Feelime 首页"><img src="assets/feelime-logo.svg" width="34" height="34" alt=""><span>feelime<span class="brand-cursor" aria-hidden="true">|</span></span></a>
<div class="nav-links">
<a href="#preview">在线体验</a><a class="nav-features" href="#features">功能</a><a href="#download">下载</a>
<a class="nav-github" href="https://github.com/feelime/feelime" aria-label="GitHub 源码"><svg class="icon icon-solid" aria-hidden="true"><use href="#i-github"></use></svg><span>GitHub</span></a>
</div>
</nav>
</header>
<main id="main">
<section class="hero container" aria-labelledby="hero-title">
<a class="release-link" data-release-href href="https://github.com/feelime/feelime/releases/latest"><b data-release-version>v1.0.21</b><span>从这里,开始自在输入</span><svg class="icon" aria-hidden="true"><use href="#i-arrow"></use></svg></a>
<div class="hero-brand"><img class="hero-logo" src="assets/feelime-logo.svg" width="104" height="104" alt="Feelime 标志"><h1 id="hero-title">feelime<span class="cursor" aria-hidden="true"></span></h1></div>
<p class="hero-tagline">自在输入,数据留在本机。</p>
<p class="hero-description">拼音、双拼、T9、笔画与语音,离线也能用。<br>一款开源、可定制的 Android 输入法。</p>
<div class="hero-actions">
<a class="button button-primary" data-release-href href="https://github.com/feelime/feelime/releases/latest"><svg class="icon" aria-hidden="true"><use href="#i-download"></use></svg>下载 APK</a>
<a class="button button-secondary" href="#preview">在线体验<svg class="icon" aria-hidden="true"><use href="#i-arrow"></use></svg></a>
</div>
<div class="hero-details"><span><svg class="icon" aria-hidden="true"><use href="#i-keyboard"></use></svg>离线拼音</span><span><svg class="icon" aria-hidden="true"><use href="#i-mic"></use></svg>离线语音</span><span><svg class="icon" aria-hidden="true"><use href="#i-shield"></use></svg>开源可审计</span></div>
</section>
<section class="preview-section" id="preview" aria-labelledby="preview-title">
<div class="preview-heading container"><p class="section-label">在线体验</p><h2 class="section-heading" id="preview-title">先试手感,再做决定。</h2><p class="preview-description">真实键盘源码驱动 · 本地运行的模拟桥,无任何数据上传</p></div>
<div class="preview-tools container" aria-label="预览控制">
<input id="previewLocale" type="hidden" value="zh">
<label class="theme-control" for="previewTheme"><span>键盘主题</span><select id="previewTheme" disabled><option value="dark" selected>深色</option><option value="light">浅色</option></select></label>
<button class="tool-button" id="actSettings" type="button" data-preview-action disabled><svg class="icon" aria-hidden="true"><use href="#i-settings"></use></svg>设置</button>
<button class="tool-button" id="actClear" type="button" data-preview-action disabled><svg class="icon" aria-hidden="true"><use href="#i-reset"></use></svg>清空</button>
</div>
<p class="preview-mobile-hint">轻点下方键盘,试着输入 nihao,再选择「你好」。</p>
<div class="preview-layout container">
<aside class="preview-aside" aria-label="输入体验提示">
<div class="preview-tip"><div class="tip-keys" aria-hidden="true"><kbd>n</kbd><kbd>i</kbd><kbd>h</kbd><kbd>a</kbd><kbd>o</kbd></div><h3>从一句「你好」开始</h3><p>用鼠标或手指轻点键盘,<br>看拼音如何变成一句问候。</p><button class="tip-action" id="actCompose" type="button" data-preview-action disabled>帮我输入 nihao<svg class="icon" aria-hidden="true"><use href="#i-arrow"></use></svg></button></div>
<div class="preview-tip"><svg class="icon tip-icon" aria-hidden="true"><use href="#i-clipboard"></use></svg><h3>常说的话,随手就有</h3><p>打开键盘里的剪贴板与常用语面板,试试一键填入常用内容。</p></div>
</aside>
<div class="device" id="stage" data-theme="dark" aria-busy="false">
<div class="device-screen">
<div class="device-status" aria-hidden="true"><span>9:41</span><span class="status-icons"><svg class="icon"><use href="#i-signal"></use></svg><svg class="icon"><use href="#i-battery"></use></svg></span></div>
<iframe id="kb-frame" title="Feelime 真实键盘交互预览" referrerpolicy="no-referrer"></iframe>
<div class="preview-loading" id="previewLoading"><img src="assets/feelime-logo.svg" width="56" height="56" alt=""><p id="loadingMessage" role="status">键盘将在进入视野后加载</p><button class="tool-button" id="retryPreview" type="button" hidden>重新加载</button><noscript>开启浏览器 JavaScript 后即可体验键盘,也可以下载 APK 在 Android 上使用。</noscript></div>
</div>
</div>
<aside class="preview-aside" aria-label="键盘功能提示">
<div class="preview-tip"><svg class="icon tip-icon" aria-hidden="true"><use href="#i-theme"></use></svg><h3>深浅之间,都很顺眼</h3><p>切换上方的主题选项,<br>找到你更喜欢的键盘样子。</p></div>
<div class="preview-tip"><div class="tip-keys" aria-hidden="true"><kbd>Ctrl</kbd><kbd>Fn</kbd><kbd>Win</kbd></div><h3>不止于打字</h3><p>控制键也能随手组合。<br>写代码、用终端,多一点从容。</p><button class="tip-action" id="actCtrl" type="button" data-preview-action disabled>试试控制键<svg class="icon" aria-hidden="true"><use href="#i-arrow"></use></svg></button></div>
</aside>
</div>
<div class="preview-status" id="previewStatus"><span class="status-dot" aria-hidden="true"></span><span id="prefetch" role="status">等待加载真实键盘</span></div>
<p class="preview-note">候选词与语音为模拟演示,不调用麦克风。完整离线输入请安装 APK。</p>
<details class="log-details container" id="logDrawer"><summary>开发者 · 查看本地桥调用日志</summary><div id="logBody" aria-label="FeelimeNative 调用记录"></div></details>
</section>
<section class="features-section container" id="features" aria-labelledby="features-title">
<div class="features-heading"><div><p class="section-label">日常所需,认真做好</p><h2 class="section-heading" id="features-title">小小键盘,装下你的习惯。</h2></div><p>从顺手的拼音到随口的语音,<br>让输入贴近你,让数据留在身边。</p></div>
<div class="feature-grid">
<article class="feature-card"><svg class="icon" aria-hidden="true"><use href="#i-keyboard"></use></svg><h3>拼音,离线照常打</h3><p>基于 librime 中文引擎,全拼、双拼、九宫格 T9、笔画都能打;候选带中文联想,打到一半帮你接下一句。</p><span class="feature-caption">librime · 全拼 / 双拼 / T9 / 笔画</span></article>
<article class="feature-card"><svg class="icon" aria-hidden="true"><use href="#i-mic"></use></svg><h3>想到什么,就说出来</h3><p>语音在设备上识别,边说边出字,再做整句纠错。下载模型后即可离线使用,录音不上传。</p><span class="feature-caption">sherpa-onnx · 本地识别</span></article>
<article class="feature-card"><svg class="icon" aria-hidden="true"><use href="#i-clipboard"></use></svg><h3>少打一点,快一点</h3><p>复制过的内容、经常说的话,收在剪贴板与常用语面板;打 date、riqi,当前日期时间直接进候选。</p><span class="feature-caption">剪贴板 / 常用语 / 日期候选</span></article>
<article class="feature-card"><svg class="icon" aria-hidden="true"><use href="#i-book"></use></svg><h3>词库,由你做主</h3><p>支持导入 rime 词库(.dict.yaml),也能在本机整套换装社区词库;常用语简码、候选符号随心加。</p><span class="feature-caption">rime 词库 · 导入 / 换装</span></article>
<article class="feature-card"><svg class="icon" aria-hidden="true"><use href="#i-theme"></use></svg><h3>按你的手感来</h3><p>深浅主题、键盘高度与字号可调,单手模式、按键声音振动;手势输入与 Ctrl / Fn / Win 粘滞组合都在。</p><span class="feature-caption">主题 / 单手 / 控制键</span></article>
<article class="feature-card"><svg class="icon" aria-hidden="true"><use href="#i-code"></use></svg><h3>每一行,都看得见</h3><p>应用与键盘源码开放(GPL-3.0),键盘还能独立热更新,不重装应用也能焕新。欢迎带着想法或代码一起来。</p><span class="feature-caption">GPL-3.0 · 开源</span></article>
</div>
<div class="open-source"><p><svg class="icon" aria-hidden="true"><use href="#i-code"></use></svg>好用的输入法,也应该让人放心。</p><a class="text-link" href="https://github.com/feelime/feelime">到 GitHub 看看<svg class="icon" aria-hidden="true"><use href="#i-external"></use></svg></a></div>
</section>
<section class="download-section" id="download" aria-labelledby="download-title">
<div class="download-layout container">
<div class="download-copy"><p class="section-label">开始使用</p><h2 class="section-heading" id="download-title">下一句话,<br>用 Feelime 输入。</h2><p>两个安装包任选:精简版体积小,语音识别模型需额外下载;完整版内置模型,装好即用。<br>准备好后,就可以自在地离线输入了。</p></div>
<div class="download-options">
<a class="download-card" data-release-href href="https://github.com/feelime/feelime/releases/latest" aria-label="前往 GitHub Release 查看 Feelime 全部版本与更新日志"><svg class="icon icon-solid" aria-hidden="true"><use href="#i-github"></use></svg><div><h3>GitHub Release<span class="version-tag" data-release-version>v1.0.21</span></h3><p>最新版本与更新日志都在这里</p></div><svg class="icon" aria-hidden="true"><use href="#i-arrow"></use></svg></a>
<div class="download-card"><svg class="icon" aria-hidden="true"><use href="#i-download"></use></svg><div><h3>精简版安装包 · <span data-release-size="thin">69MB</span></h3><p>语音识别模型需额外下载,在应用内按需获取</p></div><div class="dl-actions"><a class="dl-btn" data-release-asset="thin" href="https://github.com/feelime/feelime/releases/latest" aria-label="从 GitHub 下载精简版安装包">GitHub</a><a class="dl-btn ghost" data-release-mirror="thin" href="https://github.com/feelime/feelime/releases/latest" aria-label="从 ghproxy 镜像下载精简版安装包">镜像加速</a></div></div>
<div class="download-card"><svg class="icon" aria-hidden="true"><use href="#i-download"></use></svg><div><h3>完整版安装包 · <span data-release-size="full">336MB</span></h3><p>内置离线语音识别模型,装好即用</p></div><div class="dl-actions"><a class="dl-btn" data-release-asset="full" href="https://github.com/feelime/feelime/releases/latest" aria-label="从 GitHub 下载完整版安装包">GitHub</a><a class="dl-btn ghost" data-release-mirror="full" href="https://github.com/feelime/feelime/releases/latest" aria-label="从 ghproxy 镜像下载完整版安装包">镜像加速</a></div></div>
<div class="download-card unavailable" aria-disabled="true"><svg class="icon" aria-hidden="true"><use href="#i-play"></use></svg><div><h3>Google Play</h3><p>稍后也能在这里找到我们</p></div><span class="coming-soon">即将上线</span></div>
</div>
</div>
</section>
</main>
<footer class="site-footer container"><div class="footer-brand"><img src="assets/feelime-logo.svg" width="28" height="28" alt=""><span>© 2026 Feelime</span></div><div class="footer-links"><a href="https://github.com/feelime/feelime">GitHub</a><a href="custom-keyboard.html">定制键盘说明</a><a href="https://feelime.github.io/feelime/privacy.html">隐私政策</a><a href="#top">回到顶部 ↑</a></div></footer>
<script>
'use strict';
// 最新版本号:GitHub 匿名 API 拉 latest release,成功后回填精确 tag。
// 任何失败都保持现状(链接本身指向 releases/latest,永远最新)。
(async () => {
try {
const res = await fetch('https://api.github.com/repos/feelime/feelime/releases/latest',
{headers: {Accept: 'application/vnd.github+json'}});
if (!res.ok) return;
const data = await res.json();
if (!data || typeof data.tag_name !== 'string') return;
document.querySelectorAll('[data-release-href]').forEach((el) => {
el.href = 'https://github.com/feelime/feelime/releases/tag/' + data.tag_name;
});
document.querySelectorAll('[data-release-version]').forEach((el) => {
el.textContent = data.tag_name;
});
// thin/full 直链 + 镜像兜底(ghproxy.net):按文件名后缀在 assets 里
// 找,拿不到就保持 releases/latest(不拼文件名,tag 内嵌版本易漂移)。
const assetUrls = {};
document.querySelectorAll('[data-release-asset]').forEach((el) => {
const asset = (data.assets || []).find(
a => a.name.endsWith('-' + el.dataset.releaseAsset + '.apk'));
if (!asset) return;
el.href = asset.browser_download_url;
assetUrls[el.dataset.releaseAsset] = asset.browser_download_url;
const mirror = document.querySelector(
'[data-release-mirror="' + el.dataset.releaseAsset + '"]');
if (mirror) mirror.href = 'https://ghproxy.net/' + asset.browser_download_url;
});
document.querySelectorAll('[data-release-size]').forEach((el) => {
const asset = (data.assets || []).find(
a => a.name.endsWith('-' + el.dataset.releaseSize + '.apk'));
if (asset) el.textContent = Math.round(asset.size / 1048576) + 'MB';
});
// 链路测速(后台):GitHub 直链与几个 ghproxy 系镜像竞速,谁先返回
// 响应头谁最快(no-cors 无法带 Range,abort 挡住 body,流量只一个
// RTT)。冠军产生后再给 300ms 宽限看有无并列,没有就掐掉其余收榜;
// 镜像胜出则推荐位切到镜像按钮,且镜像 href 改指胜出域名。全灭或
// GitHub 胜出保持默认(GitHub 优先 + ghproxy.net 兜底)。
const MIRROR_PREFIXES = ['https://ghproxy.net/', 'https://gh-proxy.com/',
'https://ghfast.top/']; // 2026-09 实测可用;mirror.ghproxy.com 已失效
const raceLinks = (candidates) => new Promise((resolve) => {
const racers = candidates.map(c => ({ ...c, ctrl: new AbortController() }));
const settle = (winner) => {
racers.forEach(r => r.ctrl.abort());
resolve(winner);
};
let champion = null;
racers.forEach((r) => {
const started = performance.now();
const timer = setTimeout(() => r.ctrl.abort(), 4500);
fetch(r.url, { mode: 'no-cors', cache: 'no-store', signal: r.ctrl.signal })
.then(() => {
clearTimeout(timer);
if (champion) return;
champion = { prefix: r.prefix || null,
ms: Math.round(performance.now() - started) };
// 冠军已定:300ms 宽限后收榜(其余没到就没必要再等)。
setTimeout(() => settle(champion), 300);
})
.catch(() => clearTimeout(timer))
.finally(() => clearTimeout(timer));
});
setTimeout(() => { if (!champion) settle(null); }, 4800);
});
if (assetUrls.thin) {
const winner = await raceLinks([{ key: 'github', url: assetUrls.thin }].concat(
MIRROR_PREFIXES.map(prefix => ({ key: 'mirror', prefix,
url: prefix + assetUrls.thin }))));
if (winner && winner.prefix) {
document.querySelectorAll('[data-release-asset]').forEach(
el => el.classList.add('ghost'));
document.querySelectorAll('[data-release-mirror]').forEach(el => {
el.classList.remove('ghost');
el.classList.add('recommended');
const base = assetUrls[el.dataset.releaseMirror];
if (base) el.href = winner.prefix + base;
});
}
}
} catch (_) { /* 保持 releases/latest 兜底链接 */ }
})();
// 部署缓存标记:本次访问的所有键盘资源使用相同时间戳。
document.documentElement.dataset.deployStamp = String(Date.now());
const frame = document.getElementById('kb-frame');
const previewSafeBottom = Math.max(0, Math.min(96,
Number(new URLSearchParams(location.search).get('safeBottom')) || 0));
const stage = document.getElementById('stage');
const logBody = document.getElementById('logBody');
const entries = [];
let keyboardMode = 'pinyin'; // 假引擎当前键盘(桥 hello 下发)
// 快捷设置方块的回读状态(真机由原生偏好下发;setQuickPref 在这里
// 模拟落盘并重推 hello,方块状态即可回读)。与 tools/keyboard-preview.html 同步。
let previewPrefs = { associationOn: true, keySound: false, keyHaptic: false,
candidateFont: 0, holdMs: 350, popupSnap: 1, dpScheme: 'ziranma',
oneHand: 0, sideContent: 0, uiLanguage: 'zh' };
function log(method, detail) {
const div = document.createElement('div');
div.className = 'entry';
div.innerHTML = '<b>' + method + '</b> ' + (detail || '');
logBody.prepend(div);
entries.push(div);
while (entries.length > 120) entries.shift().remove();
}
const esc = v => String(v).replace(/&/g, '&').replace(/</g, '<');
/* ---- 假引擎:让候选条 / composing 工具栏可以在页面上走通 ---- */
const FAKE_CANDS = {
nihao: ['你好', '你号', '拟好', '妮号', '你豪', '泥嚎'],
ni: ['你', '妮', '拟', '逆', '腻'],
nih: ['你哈', '拟化'],
niha: ['你哈'],
hao: ['好', '号', '耗'],
a: ['啊', '阿'],
zh: ['这', '着'],
'z': ['在', '再'],
};
let rawInput = '';
let revision = 0;
let deletedHead = null; // 删除自造词后的假引擎状态
/* T9/笔画演示词表:只覆盖最常见的几手输入,其余走占位候选。 */
const T9_CANDS = {
'64426': ['你好', '你号'], '9426': ['先', '现', '线'], '26': ['安', '按'],
};
const STROKE_CANDS = {
h: ['一'], hh: ['二'], hhh: ['三'], pn: ['人'], hspn: ['木'], hhsh: ['王'],
};
function pushEngineState(raw, composing) {
revision += 1;
if (raw !== rawInput) deletedHead = null;
let candidates = composing
? fakeCandidates(raw).map((text, i) => ({ id: 'c' + revision + '-' + i, text }))
: [];
if (deletedHead) candidates = candidates.filter(c => c.text !== deletedHead);
frame.contentWindow.Feelime.onEngineState({
phase: 'READY',
revision,
mode: keyboardMode,
composing: composing ? (raw || ' ') : '',
rawInput: raw,
candidates,
hasNextPage: false,
});
hostRender(composing ? raw : '');
}
/** 键盘视图高度变化后让 keyboard.js 重算行高(view resize 不触发 window
* resize,手动踢一下)。 */
function pushEngineRefresh() {
const win = frame.contentWindow;
if (win && typeof win.dispatchEvent === 'function') {
win.dispatchEvent(new win.Event('resize'));
}
}
/* ---- 假引擎(双拼 / 法语):自然码键位 → 全拼后查表;法语给音标词 ---- */
const DOUBLE_INITIALS = { v: 'zh', i: 'ch', u: 'sh' };
const DOUBLE_FINALS = {
q: ['iu'], w: ['ua', 'ia'], e: ['e'], r: ['uan', 'er'], t: ['ue', 've'],
y: ['ing', 'uai'], u: ['u'], i: ['i'], o: ['o', 'uo'], p: ['un'],
a: ['a'], s: ['ong', 'iong'], d: ['iang', 'uang'], f: ['en'], g: ['eng'],
h: ['ang'], j: ['an'], k: ['ao'], l: ['ai'],
z: ['ei'], x: ['ie'], c: ['iao'], v: ['ui', 'ü'], b: ['ou'], n: ['in'], m: ['ian'],
};
const PINYIN_WORDS = {
zhang: ['张', '章', '涨'], chang: ['长', '常', '唱'], shang: ['上', '商', '伤'],
zhou: ['周', '州', '粥'], chou: ['抽', '仇'], shou: ['手', '收', '首'],
xian: ['先', '西安', '县'], wang: ['王', '往', '网'], zhai: ['宅', '摘'],
chuan: ['船', '传'], shuo: ['说', '硕'], dui: ['对', '队'],
};
const FRENCH_WORDS = ['café', 'très', 'après', 'français', 'être', 'père', 'déjà', 'où'];
function doublePinyinToPinyin(raw) {
// 零声母(a/e/o 开头)直接按全拼处理。
const keys = raw.replace(/'/g, '').replace(/ /g, '');
const out = [];
for (let i = 0; i < keys.length; i += 2) {
const s = keys[i];
const f = keys[i + 1] || '';
if (['a', 'e', 'o'].includes(s)) { out.push(s + f); continue; }
const initial = DOUBLE_INITIALS[s] || s;
const finals = DOUBLE_FINALS[f];
if (!finals) return [keys];
out.push(...finals.map(fin => initial + fin));
}
return [...new Set(out)];
}
function fakeCandidates(raw) {
// Preview data only: production candidates always come from Rime.
const partial = { "x'an": ['西安', '相安', '心安'], "xi'an": ['西安'],
"xiang'an": ['相安', '相安无事'], "xin'an": ['心安', '新安'],
"c'shi": ['测试', '城市', '擦拭'], "ca'shi": ['擦拭', '擦试'],
"cai'shi": ['才是', '菜市', '采石'], cashi: ['擦拭', '擦试'] };
if (keyboardMode === 'pinyin' && partial[raw]) return partial[raw].slice();
const key = raw.replace(/ /g, '').replace(/'/g, '');
const table = FAKE_CANDS[key];
if (table) return table.slice();
if (keyboardMode === 't9') return (T9_CANDS[key] || [key + '(T9 演示)']).slice();
if (keyboardMode === 'stroke') return (STROKE_CANDS[key] || [key + '(笔画演示)']).slice();
if (keyboardMode === 'double-pinyin') {
const all = [];
doublePinyinToPinyin(raw).forEach(py => {
if (PINYIN_WORDS[py]) all.push(...PINYIN_WORDS[py]);
else if (FAKE_CANDS[py]) all.push(...FAKE_CANDS[py]);
});
if (all.length) return [...new Set(all)];
return [key + '(双拼占位)'];
}
if (keyboardMode === 'french') {
const hits = FRENCH_WORDS.filter(w => w.normalize('NFD').startsWith(key.normalize('NFD').slice(0, 2)));
return hits.length ? hits : [key + '…'];
}
if (keyboardMode === 'japanese') return ['こんにちは', 'コンニチハ', key + '(占位)'];
if (!raw) return [];
return [raw + '(占位)', raw + '2', raw + '3'];
}
/* ---- 宿主编辑器:展示输入效果(预览专用) ---- */
let hostCommitted = '';
let voiceLevelTimer = 0;
let voicePartial = '';
let voiceSegments = 0;
/* 面板演示数据(预览工具专用):剪贴板/常用语在内存里维护,桥调用
* 后像真机原生一样异步回推 onClipboard/onFavorites,否则面板永远空白。 */
let panelClip = [
{ id: 'c1', text: 'meeting 3pm, room 402', time: Date.now() - 600e3 },
{ id: 'c2', text: '这个需求周五前要上线', time: Date.now() - 3600e3 },
{ id: 'c3', text: 'https://example.com/pr/123', time: Date.now() - 86400e3 },
];
let favSeq = 3;
let panelFav = [
{ id: 'f1', text: '麻烦确认一下,谢谢', code: '', time: Date.now() - 86400e3 },
{ id: 'f2', text: '稍等,我看下', code: 'sd', time: Date.now() - 86400e3 * 2 },
{ id: 'f3', text: '收到', code: '', time: Date.now() - 86400e3 * 3 },
];
function pushPanelData(callback, payload) {
const win = frame.contentWindow;
if (!win || !win.Feelime || !win.Feelime[callback]) return;
setTimeout(() => win.Feelime[callback](payload), 120);
}
function hostRender(raw) {
const el = frame.contentDocument && frame.contentDocument.getElementById('hostEditor');
if (!el) return;
el.innerHTML =
'<span class="host-done">' + esc(hostCommitted).replace(/\n/g, '<br>') + '</span>' +
'<span class="host-caret"></span>' +
'<span class="host-raw">' + esc(raw || '') + '</span>';
el.scrollTop = el.scrollHeight;
}
function hostCommit(text) { hostCommitted += text; hostRender(rawInput); }
function hostBackspace() {
if (rawInput) return;
hostCommitted = hostCommitted.replace(/[\s\S]$/u, '');
hostRender('');
}
/* ---- 空操作桥:记录每一次调用,输入键喂给假引擎 ---- */
function makeBridge() {
const bridgeTable = {
keyboardReady(version, minApi, caps) {
log('keyboardReady', `键盘 ${esc(version)},能力 <code>${esc(caps)}</code>`);
},
requestState() {
setTimeout(pushHello, 0);
},
key(char) {
// T9 键面发数字(分词键发 ')、笔画发 h/s/p/n/z(通配符 *)。
const viaEngine = keyboardMode.includes('pinyin') || keyboardMode === 't9'
|| keyboardMode === 'stroke';
const viaKey = keyboardMode === 't9' ? /[0-9']/ : /[a-z'*]/;
log('key', esc(char) + (viaEngine ? ' <span class="dim">→ 引擎</span>' : ''));
if (viaEngine && viaKey.test(char)) {
rawInput += char;
pushEngineState(rawInput, true);
} else if (!viaEngine) {
// 英文/法/日直通模式:引擎直接上屏(模拟原生行为)。
hostCommit(char);
}
},
setComposition(keys) {
log('setComposition', esc(keys));
// Match native Reset + per-key engine echoes. A final-only fake
// hid the disappearing spelling column during repeated choices.
rawInput = '';
pushEngineState('', false);
for (const key of keys) {
rawInput += key;
pushEngineState(rawInput, true);
}
},
space() {
log('space', '<span class="dim">空格上屏</span>');
hostCommit(rawInput ? '' : ' ');
rawInput = '';
pushEngineState('', false);
},
backspace() {
log('backspace', '');
if (!rawInput) hostBackspace();
rawInput = rawInput.slice(0, -1);
pushEngineState(rawInput, rawInput.length > 0);
},
enter() {
log('enter', '');
hostCommitted += '\n';
rawInput = '';
pushEngineState('', false);
},
moveCursor(delta) { log('moveCursor', (delta > 0 ? '+' : '') + delta); },
setKeyboardHeight(cssPx) {
log('setKeyboardHeight', cssPx + 'css px <span class="dim">→ 视图整体变高/变矮</span>');
const kb = frame.contentDocument && frame.contentDocument.getElementById('softKeyboard');
const landscape = frame.clientWidth > frame.clientHeight;
const defaultHeight = Math.min(272, Math.round(frame.clientHeight * (landscape ? 0.5 : 0.45)));
if (kb) kb.style.height = ((Number(cssPx) || defaultHeight) + previewSafeBottom) + 'px';
setTimeout(pushEngineRefresh, 0);
},
keyEvent(keyCode, metaState) {
const names = { 111: 'Esc', 61: 'Tab', 122: 'Home', 123: 'End',
92: 'PgUp', 93: 'PgDn', 19: '↑', 20: '↓', 21: '←', 22: '→',
112: 'Del', 56: '.', 131: 'F4' };
const name = /^[A-Z]$/.test(String.fromCharCode(65 + keyCode - 29))
? String.fromCharCode(65 + keyCode - 29) : (names[keyCode] || '#' + keyCode);
const mods = [
(metaState & 0x1000) && 'Ctrl',
(metaState & 2) && 'Alt',
(metaState & 1) && 'Shift',
(metaState & 0x10000) && 'Meta',
].filter(Boolean).join('+');
log('keyEvent', `<b>${mods ? mods + '+' : ''}${name}</b> <span class="dim">→ 宿主编辑器</span>`);
},
chooseCandidate(rev, id) {
log('chooseCandidate', `rev=${rev} id=${esc(id)}`);
const cands = fakeCandidates(rawInput);
hostCommit(cands[0] || '');
rawInput = '';
pushEngineState('', false);
},
deleteHighlightedCandidate() {
// 模拟 librime 删自造词 —— 假引擎里首个候选必删成功。
log('deleteHighlightedCandidate', '<span class="dim">→ 候选池重建</span>');
const head = fakeCandidates(rawInput)[0];
if (head) deletedHead = head;
pushEngineState(rawInput, true);
},
deleteCandidate(rev, id) {
// 任意候选删除(假引擎按 id 定位)。
log('deleteCandidate', `rev=${rev} id=${esc(id)} <span class="dim">→ 候选池重建</span>`);
const cands = fakeCandidates(rawInput);
const hit = cands.find((t, i) => 'c' + revision + '-' + i === id);
if (hit) deletedHead = hit;
pushEngineState(rawInput, true);
},
pageNext(rev) { log('pageNext', 'rev=' + rev); },
pagePrevious(rev) { log('pagePrevious', 'rev=' + rev); },
selectMode(mode) {
log('selectMode', esc(mode) + ' <span class="dim">→ 重推 hello(真机原生行为)</span>');
keyboardMode = mode;
rawInput = '';
pushEngineState('', false);
// 真机上 selectMode 后原生会重新 pushBridgeHello 驱动重渲染。
setTimeout(pushHello, 0);
},
startVoice() {
log('startVoice', '<span class="dim">→ 模拟录音浮层</span>');
voiceLevelTimer = setInterval(() => {
const win = frame.contentWindow;
if (!win || !win.Feelime) return;
win.Feelime.onNativeState({
state: 'listening',
level: 0.25 + Math.abs(Math.sin(Date.now() / 300)) * 0.7,
partial: voicePartial,
});
// 每 ~2.5s 假装识别出一段。
if (Math.random() < 0.25) {
voicePartial = '模拟识别' + (voiceSegments += 1);
win.Feelime.onNativeState({ state: 'listening', level: 0.5, partial: voicePartial });
}
}, 300);
const win = frame.contentWindow;
win && win.Feelime && win.Feelime.onNativeState({ state: 'loading', level: 0 });
setTimeout(() => {
const w = frame.contentWindow;
w && w.Feelime && w.Feelime.onNativeState({ state: 'listening', level: 0.4, partial: voicePartial });
}, 400);
},
stopVoice() {
log('stopVoice', '');
if (voiceLevelTimer) { clearInterval(voiceLevelTimer); voiceLevelTimer = 0; }
const win = frame.contentWindow;
if (!win || !win.Feelime) return;
win.Feelime.onNativeState({ state: 'idle', level: 0 });
if (voicePartial) {
hostCommit(voicePartial);
voicePartial = '';
voiceSegments = 0;
hostRender(rawInput);
}
},
cancelVoice() {
log('cancelVoice', '<span class="dim">→ 丢弃本次识别</span>');
if (voiceLevelTimer) { clearInterval(voiceLevelTimer); voiceLevelTimer = 0; }
voicePartial = '';
voiceSegments = 0;
const win = frame.contentWindow;
if (win && win.Feelime) win.Feelime.onNativeState({ state: 'idle', level: 0, partial: '' });
hostRender(rawInput);
},
switchInputMethod() { log('switchInputMethod', '<span class="dim">→ 系统 IME picker</span>'); },
hideKeyboard() { log('hideKeyboard', ''); },
openSetup() { log('openSetup', ''); },
setQuickPref(key, value) {
log('setQuickPref', `${esc(String(key))}=${esc(String(value))} <span class="dim">→ 重推 hello</span>`);
if (key === 'uiLocale') {
// 原生 UiLanguage 落盘:hello 带回选择值 + 解析后的 locale。
previewPrefs.uiLanguage = value;
document.getElementById('previewLocale').value = value === 'auto' ? 'zh' : value;
setTimeout(pushHello, 0);
return;
}
const map = { association: 'associationOn', keySound: 'keySound',
keyHaptic: 'keyHaptic', candidateFont: 'candidateFont',
oneHand: 'oneHand', sideContent: 'sideContent',
holdMs: 'holdMs', popupSnap: 'popupSnap', dpScheme: 'dpScheme' };
const slot = map[key];
if (!slot) return;
previewPrefs[slot] = value === '1' || value === '0'
? value === '1'
: (Number.isNaN(Number(value)) ? value : Number(value));
setTimeout(pushHello, 0);
},
reloadKeyboard() { log('reloadKeyboard', ''); },
commitText(text) {
log('commitText', esc(text));
hostCommit(text);
},
panelFlush(session) { frame.contentWindow.Feelime.onPanelFlushed({session}); },
panelSelection(start, end, session) { log('panelSelection', `${start}, ${end}, ${session}`); },
panelInput(active) { log('panelInput', String(active)); },
getClipboard() {
log('getClipboard', '<span class="dim">→ 回推演示数据</span>');
pushPanelData('onClipboard', { items: panelClip });
},
clearClipboard() {
log('clearClipboard', '');
panelClip = [];
pushPanelData('onClipboard', { items: [] });
},
removeClipboard(id) {
log('removeClipboard', esc(id));
panelClip = panelClip.filter(i => i.id !== String(id));
pushPanelData('onClipboard', { items: panelClip });
},
getFavorites() {
log('getFavorites', '<span class="dim">→ 回推演示数据</span>');
pushPanelData('onFavorites', { items: panelFav });
},
favoritesAdd(text, code) {
log('favoritesAdd', esc(text) + ' ' + esc(code || ''));
panelFav.unshift({ id: 'f' + (++favSeq), text: String(text),
code: String(code || ''), time: Date.now() });
pushPanelData('onFavorites', { items: panelFav });
},
favoritesUpdate(id, text, code) {
log('favoritesUpdate', esc(id) + ' ' + esc(text) + ' ' + esc(code || ''));
const hit = panelFav.find(i => i.id === String(id));
if (hit) { hit.text = String(text); hit.code = String(code || ''); }
pushPanelData('onFavorites', { items: panelFav });
},
favoritesRemove(id) {
log('favoritesRemove', esc(id));
panelFav = panelFav.filter(i => i.id !== String(id));
pushPanelData('onFavorites', { items: panelFav });
},
favoritesMove(id, to) {
log('favoritesMove', esc(id) + ' → ' + esc(to));
const from = panelFav.findIndex(i => i.id === String(id));
if (from >= 0) {
const [row] = panelFav.splice(from, 1);
panelFav.splice(Number(to) || 0, 0, row);
}
pushPanelData('onFavorites', { items: panelFav });
},
};
// 未实现的方法一律记录(避免静默丢调用)。
return new Proxy(bridgeTable, {
get(target, prop) {
if (prop in target) return target[prop];
return (...args) => log(String(prop), esc(args.join(', ')));
},
});
}
function pushHello() {
const win = frame.contentWindow;
if (!win || !win.Feelime) return;
const landscape = frame.clientWidth > frame.clientHeight;
// The keyboard requires favorites-v2 (exact arg order) - a stale v1
// here made onBridgeHello bail and EVERYTHING hello
// carries (ready gate, floatBand, heightCeil) silently fell back.
const caps = ['candidate-revision-v1', 'clipboard-v1', 'commit-text-v1',
'compose-control-v1', 'unicode-compose-v1', 'cursor-repeat-v1', 'cursor-delta-v1', 'panel-compose-v1', 'favorites-v2', 'ime-control-v1',
'key-event-v1', 'keyboard-height-reset-v1', 'keyboard-update-status-v1', 'text-input-v1', 'voice-session-v1', 'voice-cancel-v1'];
win.Feelime.onBridgeHello({
nativeApiVersion: 1,
capabilities: caps,
uiLocale: document.getElementById('previewLocale').value,
theme: document.getElementById('previewTheme').value !== 'auto'
? document.getElementById('previewTheme').value
: window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
? 'light' : 'dark',
orientation: landscape ? 'landscape' : 'portrait',
// 手势条避让(真机由原生下发 px;预览默认 0)。
safeBottom: previewSafeBottom,
// 键盘底部留白(真机由 hello 下发;预览固定 0)。
bottomPad: 0,
// Match native floatBandPx: landscape 30%, portrait 40%, capped at 200dp.
floatBand: Math.min(200, Math.floor(frame.clientHeight * (landscape ? 0.3 : 0.4))),
// Height-card drag range, same semantics as the native clamp
// (portrait 45% of the frame height / landscape half) so the preview
// thumb starts where the device's would.
heightDefault: Math.min(272, Math.round(frame.clientHeight * (landscape ? 0.5 : 0.45))),
heightFloor: landscape ? 170 : 210,
heightCeil: Math.round(frame.clientHeight * (landscape ? 0.5 : 0.45)),
pageGenerationToken: 'tok-1',
mode: keyboardMode,
engineDataReady: { pinyin: true, 'double-pinyin': true, japanese: true,
french: true, russian: true, t9: true, stroke: true },
// 键盘侧偏好(真机由原生 hello 下发;预览由 setQuickPref 模拟落盘)。
uiLanguage: previewPrefs.uiLanguage,
associationOn: previewPrefs.associationOn,
keySound: previewPrefs.keySound,
keyHaptic: previewPrefs.keyHaptic,
candidateFont: previewPrefs.candidateFont,
oneHand: previewPrefs.oneHand,
sideContent: previewPrefs.sideContent,
holdMs: previewPrefs.holdMs,
popupSnap: previewPrefs.popupSnap,
dpScheme: previewPrefs.dpScheme,
});
}
/* ---- 组装 iframe 文档:真实 index.html + 内联 CSS/JS + 注入桥 ---- */
async function fetchFirst(file) {
// GitHub Pages 与本地 http server 共用同一条相对路径。
// cache-bust:手机浏览器会强缓存键盘文件,不带时间戳会一直跑旧版。
const bust = '?t=' + document.documentElement.dataset.deployStamp;
for (const root of ['kb-preview-src/']) {
const r = await fetch(root + file + bust, { cache: 'no-store' });
if (r.ok) return await r.text();
}
throw new Error('找不到 ' + file + '(请确认 kb-preview-src/ 目录可访问)');
}
async function build() {
const [html, css, js] = await Promise.all([
fetchFirst('index.html'),
fetchFirst('keyboard.css'),
fetchFirst('keyboard.js'),
]);
const inject = `<script>window.FeelimeNative = window.parent.__FeelimePreviewBridge;<\/script>`;
// Replacement MUST be a function: keyboard.js contains "$'" (currency
// glyphs), and String.replace would expand $-sequences in a string
// replacement, corrupting the inlined script.
const patched = html
.replace('<link rel="stylesheet" href="keyboard.css">', () => `<style>${css}</style>`)
// The escaped <\/script> in the LITERAL below matters: a plain
// closing tag inside a string would terminate THIS page's script
// block at HTML parse time (self-inflicted).
.replace('<script src="keyboard.js"><\/script>',
() => `${inject}<script>${js}<\/script>`)
.replace('</head>', () => `<style>
/* preview shell: 模拟原生侧的 IME 视图 —— 键盘贴底、上方是「宿主编辑器」;
视图总高与 FeelimeService.FixedHeightInputView 同语义(默认 272dp、
横屏 ≤ 屏高 1/2),拖动调节后由桥 setKeyboardHeight 改写。 */
html, body { background: #c7c9cf; }
#hostEditor {
position: absolute; left: 0; right: 0; top: 0;
min-height: 64px; max-height: 45%;
overflow-y: auto; box-sizing: border-box;
padding: 10px 12px 14px;
background: #ffffff; color: #222;
font: 15px/1.5 system-ui, sans-serif;
box-shadow: 0 1px 4px #0003;
word-break: break-all;
}
.host-raw { color: #23c890; text-decoration: underline; }
.host-caret { display: inline-block; width: 2px; height: 1.1em;
background: #333; vertical-align: text-bottom; margin: 0 1px; }
#softKeyboard { position: absolute; left: 0; right: 0; bottom: 0; }
</style></head>`);
if (patched === html) throw new Error('index.html 结构变化:内联替换未命中');
frame.srcdoc = patched;
frame.addEventListener('load', () => {
// 宿主编辑器 DOM 注入(body 顶部)。
const doc = frame.contentDocument;
if (doc && doc.body && !doc.getElementById('hostEditor')) {
const host = doc.createElement('div');
host.id = 'hostEditor';
host.innerHTML = '<span style="color:#98a">(模拟宿主 app 的输入框,输入内容会落在这里)</span>';
doc.body.insertBefore(host, doc.body.firstChild);
}
applyDefaultKbHeight();
// 视图高度刚被 JS 改写(native 侧是首次 layout 就绪),行高必须
// 按最终高度重算一遍。
setTimeout(pushEngineRefresh, 30);
pushHello();
if (!winReady()) {
showPreviewError(new Error('键盘脚本未能初始化,请重新加载。'));
return;
}
finishPreview(doc);
// 版本号从 VERSION 文件读(body 正则会抓到无关的 3.x.x,误导过)。
// 与键盘源码使用相同的本地目录与 cache-bust。
const prefetch = document.getElementById('prefetch');
(async () => {
for (const root of ['kb-preview-src/']) {
const r = await fetch(root + 'VERSION?t=' + document.documentElement.dataset.deployStamp, { cache: 'no-store' });
if (r.ok) {
prefetch.textContent = '已加载真实键盘(keyboard.js ' + (await r.text()).trim() + ')';
return;
}
}
prefetch.textContent = '已加载真实键盘(kb-preview-src)';
})().catch(() => { prefetch.textContent = '已加载真实键盘'; });
}, { once: true });
}
/* ---- 工具条 ---- */
/** 与 FixedHeightInputView 同语义的默认视图高度;拖动调节后由桥改写。 */
function applyDefaultKbHeight() {
const doc = frame.contentDocument;
const kb = doc && doc.getElementById('softKeyboard');
if (!kb) return;
// landscape allows half the screen (was a third).
const cap = Math.floor(frame.clientHeight / 2);
kb.style.height = (Math.min(272, cap) + previewSafeBottom) + 'px';