@layers-app/editor 0.7.48 → 0.7.50

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.
Files changed (219) hide show
  1. package/README.md +830 -840
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.d.ts +16 -11
  4. package/dist/index.js +2 -2
  5. package/dist/{layers.B9jC1uEF.js → layers.1F1nIa5G.js} +1 -1
  6. package/dist/{layers.CzP0RBWK.js → layers.1mjftL5_.js} +1 -1
  7. package/dist/{layers.DsQYlSSa.js → layers.1nccyZ0e.js} +1 -1
  8. package/dist/{layers.BtYjjZLM.js → layers.1pRcTAar.js} +1 -1
  9. package/dist/layers.2pNC5vyF.js +6 -0
  10. package/dist/{layers.CTAkgg4R.js → layers.2pSBvthX.js} +1 -1
  11. package/dist/{layers.D6CzgZCn.js → layers.3JXckrih.js} +1 -1
  12. package/dist/{layers.C4DDZaZs.js → layers.45jDKhSX.js} +1 -1
  13. package/dist/{layers.CNfAcY6Y.js → layers.4j2l65V8.js} +1 -1
  14. package/dist/{layers.Dop4IJqp.js → layers.4sLD6Mq9.js} +1 -1
  15. package/dist/{layers.zF6RU9h5.js → layers.58yPULfi.js} +1 -1
  16. package/dist/{layers.bdZaUQNB.js → layers.6E1FN3Gw.js} +1 -1
  17. package/dist/{layers.R8OXBVYp.js → layers.B0dL3fXU.js} +1 -1
  18. package/dist/{layers.fCcqSwty.js → layers.B4Xxyv-P.js} +1 -1
  19. package/dist/{layers.BtPwy6-y.js → layers.B4dWK-K8.js} +1 -1
  20. package/dist/{layers.DMQHD7SZ.js → layers.B6HKvTiY.js} +1 -1
  21. package/dist/{layers.q8HcJw1z.js → layers.B6p_mCsn.js} +1 -1
  22. package/dist/layers.B7TvkfLg.js +1 -0
  23. package/dist/{layers.i2bcEL4B.js → layers.B7hwts8I.js} +1 -1
  24. package/dist/{layers.DqkMTScX.js → layers.B8Hn_OAw.js} +1 -1
  25. package/dist/{layers.BrFRnEbi.js → layers.B8gGx5xB.js} +1 -1
  26. package/dist/{layers.Ds-YCD3f.js → layers.BAmQXqE2.js} +1 -1
  27. package/dist/{layers.DhkSgCGH.js → layers.BB2g2hcV.js} +1 -1
  28. package/dist/{layers.tjc2yfO8.js → layers.BBKe52Uo.js} +1 -1
  29. package/dist/{layers.BE5c8LWk.js → layers.BC_ZzUWF.js} +1 -1
  30. package/dist/{layers.CW3RPoeB.js → layers.BDScHFFb.js} +1 -1
  31. package/dist/{layers.my0yWZmR.js → layers.BDY-rgOH.js} +1 -1
  32. package/dist/{layers.3jBLdifJ.js → layers.BFcaKMVs.js} +1 -1
  33. package/dist/{layers.COeBHfJh.js → layers.BGJqh0OM.js} +1 -1
  34. package/dist/{layers.DtEAEu5Y.js → layers.BHwltXyg.js} +1 -1
  35. package/dist/{layers.BXVqLjUh.js → layers.BKPBoKkb.js} +1 -1
  36. package/dist/{layers.CKTftm3f.js → layers.BLPNAoB-.js} +1 -1
  37. package/dist/{layers.BCmyFF5n.js → layers.BO5Rnnbi.js} +1 -1
  38. package/dist/{layers.DjzsPlhY.js → layers.BOEO7h2L.js} +1 -1
  39. package/dist/{layers.C239vlHa.js → layers.BP97fFOU.js} +3 -3
  40. package/dist/{layers.CvFNr8Uu.js → layers.BR9pTmlh.js} +1 -1
  41. package/dist/{layers.D9Q1WK3D.js → layers.BRQ8Rakg.js} +1 -1
  42. package/dist/{layers.BHstW68e.js → layers.BSyWYa3T.js} +3 -3
  43. package/dist/{layers.DR3nuNQY.js → layers.BV5BtGLJ.js} +4 -4
  44. package/dist/{layers.9vsoRdca.js → layers.BXa2oBIA.js} +1 -1
  45. package/dist/layers.BXpIsVts.js +1 -0
  46. package/dist/{layers.DwKuuHvW.js → layers.BZaCE8PX.js} +1 -1
  47. package/dist/{layers.CJFGEsgQ.js → layers.Bbofe3QV.js} +1 -1
  48. package/dist/{layers.48nWXuMb.js → layers.BdCm5nsP.js} +3 -3
  49. package/dist/{layers.CCqA6oPh.js → layers.Bdab4W1k.js} +1 -1
  50. package/dist/{layers.Cn30_EV4.js → layers.BdbCAcMB.js} +2 -2
  51. package/dist/{layers.DJYCnlhb.js → layers.BdltJpW4.js} +1 -1
  52. package/dist/{layers.rUzSMgAT.js → layers.Be7-Mg8o.js} +1 -1
  53. package/dist/{layers.OxWb2lcf.js → layers.BgCxoaRC.js} +1 -1
  54. package/dist/{layers.DngDRzM2.js → layers.Bgc68Fpd.js} +1 -1
  55. package/dist/{layers.CqHFUbCh.js → layers.BgyArnWi.js} +1 -1
  56. package/dist/{layers.CitJ-LcP.js → layers.BjY7SGpJ.js} +1 -1
  57. package/dist/{layers.DvEpZu0k.js → layers.BkW7EiM5.js} +5 -5
  58. package/dist/{layers.CMiTJnad.js → layers.BlkzlESM.js} +1 -1
  59. package/dist/{layers.Ckdux2po.js → layers.Bn1xaFL_.js} +6 -6
  60. package/dist/{layers.BRWktvhi.js → layers.BoVtCUMM.js} +1 -1
  61. package/dist/{layers.BPwJ27pe.js → layers.BqRYYYDY.js} +2 -2
  62. package/dist/{layers.DwrUaEvN.js → layers.Bqs3EtDP.js} +1 -1
  63. package/dist/layers.BraKlhHd.js +276 -0
  64. package/dist/{layers.BdBmAIrs.js → layers.Bs28qDoH.js} +2 -2
  65. package/dist/{layers.CGoSj1av.js → layers.BsJtor_a.js} +5 -5
  66. package/dist/{layers.lY3SrA49.js → layers.BtLgDqWJ.js} +1 -1
  67. package/dist/{layers.LiJvf-Xh.js → layers.BuI2oSla.js} +1 -1
  68. package/dist/{layers.DIa1shbs.js → layers.BuIZNx04.js} +1 -1
  69. package/dist/{layers.Umldr18w.js → layers.BwGwij_7.js} +7 -7
  70. package/dist/{layers._d6q9aen.js → layers.BwouAPMu.js} +1 -1
  71. package/dist/{layers.8L97LNWM.js → layers.Bxq-2tXA.js} +1 -1
  72. package/dist/layers.C0-S-Ikq.js +1 -0
  73. package/dist/{layers.BKqs5g_B.js → layers.C0dli79q.js} +1 -1
  74. package/dist/{layers.KFn2whtk.js → layers.C3de1GwK.js} +1 -1
  75. package/dist/{layers.BZbUfGYp.js → layers.C3fbshH1.js} +1 -1
  76. package/dist/{layers.Ctocb96g.js → layers.C4nKGTUz.js} +1 -1
  77. package/dist/{layers.SdU27KL4.js → layers.C50u5ySP.js} +1 -1
  78. package/dist/{layers.DSMzl4c7.js → layers.C7-iHZCJ.js} +1 -1
  79. package/dist/{layers.DUGRqDUB.js → layers.C9zmEpRR.js} +24 -24
  80. package/dist/{layers.BeIBKBJG.js → layers.CA9VoM2H.js} +1 -1
  81. package/dist/{layers._beiaL7T.js → layers.CACUEJaG.js} +1 -1
  82. package/dist/{layers.CFs5s8T3.js → layers.CAqgsL0i.js} +1 -1
  83. package/dist/{layers.rddzpnjy.js → layers.CCo5f0hk.js} +2 -2
  84. package/dist/{layers.CVuk5gp9.js → layers.CDikJZ2W.js} +1 -1
  85. package/dist/{layers.BsoRVzDP.js → layers.CDx3lD3k.js} +2 -2
  86. package/dist/{layers.BAdbzzKU.js → layers.CEKs_5eg.js} +1 -1
  87. package/dist/{layers.4NkfZAG8.js → layers.CF4DS7sM.js} +1 -1
  88. package/dist/layers.CFPwQcye.js +296 -0
  89. package/dist/{layers.D0dYIayl.js → layers.CFtL25G1.js} +1 -1
  90. package/dist/{layers.yZYCg_En.js → layers.CG6bKjYX.js} +1 -1
  91. package/dist/{layers.Ct7Z5o-U.js → layers.CGq2KScB.js} +1 -1
  92. package/dist/{layers.DL2VBsfE.js → layers.CHCH4-qZ.js} +1 -1
  93. package/dist/{layers.R3a4GOcm.js → layers.CHqJrpuR.js} +1 -1
  94. package/dist/{layers.DIhuprWj.js → layers.CNgaTRJ-.js} +1 -1
  95. package/dist/{layers.DSXwzG0l.js → layers.CQ1NYJyu.js} +1 -1
  96. package/dist/{layers.CfGk77zR.js → layers.CQPWr4ja.js} +1 -1
  97. package/dist/{layers.CRxZsox1.js → layers.CRyKXZWJ.js} +1 -1
  98. package/dist/{layers.BW7u2_1T.js → layers.CYwVCSVL.js} +3 -3
  99. package/dist/{layers.CYHiWIYN.js → layers.C_K6g_RZ.js} +3 -3
  100. package/dist/{layers.C5kfbdNQ.js → layers.Caik8bd0.js} +3 -3
  101. package/dist/{layers.ClVfKZ9h.js → layers.CcYMOS8s.js} +1 -1
  102. package/dist/{layers.CzBOl45X.js → layers.CdL5EoO0.js} +1 -1
  103. package/dist/{layers.DnRoER-T.js → layers.CeyKROa7.js} +1 -1
  104. package/dist/{layers.DJ64BwjI.js → layers.CfScX4Xp.js} +1 -1
  105. package/dist/{layers.BjauJJl_.js → layers.CfvOQRhs.js} +1 -1
  106. package/dist/{layers.BFRwalLF.js → layers.ChEBIivl.js} +1 -1
  107. package/dist/{layers.DOq-L3gG.js → layers.ChclGF62.js} +1 -1
  108. package/dist/{layers.orP2yP91.js → layers.CihLa4Cq.js} +3 -3
  109. package/dist/{layers.DBmtL6ZZ.js → layers.CjbTAdEW.js} +1 -1
  110. package/dist/{layers.Cl2CJFNx.js → layers.ClRPqAmB.js} +1 -1
  111. package/dist/{layers.DUWcbGsB.js → layers.CnZCSA5C.js} +4 -4
  112. package/dist/{layers.DQD1IQ6W.js → layers.Co6P5CE7.js} +1 -1
  113. package/dist/layers.CrglsE47.js +129 -0
  114. package/dist/{layers.9Ei77wJG.js → layers.CuVAs93H.js} +1 -1
  115. package/dist/{layers.CI9mIisM.js → layers.Cuc-6v66.js} +1 -1
  116. package/dist/{layers.CU6rRFWz.js → layers.Cv2tWVj6.js} +1 -1
  117. package/dist/{layers.Bz2bAm_o.js → layers.CwWX8zGN.js} +1 -1
  118. package/dist/{layers.D5n5uB-V.js → layers.CwpSHhII.js} +1 -1
  119. package/dist/{layers.BvhD2Hex.js → layers.Cxv6CQpf.js} +1 -1
  120. package/dist/{layers.DJuBjYUv.js → layers.D0_V79x-.js} +1 -1
  121. package/dist/{layers.RZvZoVxE.js → layers.D0orgThI.js} +1 -1
  122. package/dist/{layers.BE1X7OZN.js → layers.D1aeIyNe.js} +1 -1
  123. package/dist/{layers.CJb1-QAi.js → layers.D2v5C1Zh.js} +1 -1
  124. package/dist/{layers.iSx5HVBW.js → layers.D4ux331b.js} +1 -1
  125. package/dist/{layers.BKutBTDw.js → layers.D4wawipo.js} +1 -1
  126. package/dist/{layers.n5OStkpA.js → layers.D6ye796m.js} +3 -3
  127. package/dist/layers.D7Q4uE4E.js +28299 -0
  128. package/dist/{layers.QOZfLJOU.js → layers.DAXmK7To.js} +4 -4
  129. package/dist/{layers.Bkv-oShk.js → layers.DB4p71Eg.js} +1 -1
  130. package/dist/{layers.CRYQyJW8.js → layers.DCX8NEnp.js} +1 -1
  131. package/dist/{layers.DEjh8HqA.js → layers.DCqBdD35.js} +1 -1
  132. package/dist/{layers.BLa-XNid.js → layers.DDwbrkJ7.js} +1 -1
  133. package/dist/{layers.BAxiKghG.js → layers.DEqoN5t9.js} +2 -2
  134. package/dist/{layers.CcZMrh49.js → layers.DEsG2AJ8.js} +1 -1
  135. package/dist/{layers.CPkIaDFg.js → layers.DHeprtVO.js} +1 -1
  136. package/dist/{layers.ClLKdjmI.js → layers.DHfNY0sD.js} +1 -1
  137. package/dist/{layers.BEXIxuBF.js → layers.DIpmB2fY.js} +1 -1
  138. package/dist/{layers.CAo4nRSe.js → layers.DJ-TXXK-.js} +1 -1
  139. package/dist/{layers.sHJHq9SR.js → layers.DJ1pPC0B.js} +1 -1
  140. package/dist/layers.DJCmZhRu.js +8 -0
  141. package/dist/{layers.DnNdcruU.js → layers.DL_yuNyM.js} +1 -1
  142. package/dist/{layers.B2hK-O3y.js → layers.DMOBZ1PN.js} +1 -1
  143. package/dist/{layers.Df7-26ey.js → layers.DNpvQTAN.js} +4 -4
  144. package/dist/{layers.DXWOVogE.js → layers.DQ1WWqpA.js} +1 -1
  145. package/dist/{layers.DHWPE8JP.js → layers.DQRY7Guo.js} +1 -1
  146. package/dist/{layers.Bp2T5L9r.js → layers.DRRS2jlo.js} +8 -8
  147. package/dist/{layers.C-9tvKsH.js → layers.DSpUxWRG.js} +1 -1
  148. package/dist/{layers.6JZIYhnG.js → layers.DTlPaqWP.js} +4 -4
  149. package/dist/{layers.DL3G4Rrb.js → layers.DU7ahD_K.js} +1 -1
  150. package/dist/{layers.Dsyqs-lC.js → layers.DWssEV6g.js} +1 -1
  151. package/dist/{layers.CUuGcjpi.js → layers.DXYNzQcn.js} +1 -1
  152. package/dist/{layers.DcGOrQq5.js → layers.D_OFJwBR.js} +1 -1
  153. package/dist/{layers.DbRchKwe.js → layers.D_bmwAO7.js} +1 -1
  154. package/dist/{layers.CaPMI1KH.js → layers.Db13D9Lm.js} +1 -1
  155. package/dist/{layers.D5T2vOzx.js → layers.Db35yVXF.js} +4 -4
  156. package/dist/{layers.E-_P3Hgt.js → layers.DdzFDfrQ.js} +1 -1
  157. package/dist/{layers.C_poBEYa.js → layers.DeYs-AEk.js} +1 -1
  158. package/dist/{layers.Bm3vF-Ge.js → layers.Di4vAFIL.js} +1 -1
  159. package/dist/{layers.C_H8CF1f.js → layers.Diok03ZC.js} +1 -1
  160. package/dist/layers.DjjL4__-.js +1 -0
  161. package/dist/{layers.BLqn32Nb.js → layers.Dmt21zPO.js} +1 -1
  162. package/dist/{layers.CefLOoC8.js → layers.Dnp1wYox.js} +1 -1
  163. package/dist/{layers.Bp-6Ampr.js → layers.Dp3X_nKL.js} +1 -1
  164. package/dist/{layers.qaJdLFHI.js → layers.DqApeiVR.js} +1 -1
  165. package/dist/{layers.Upsmcdyi.js → layers.Dqq8v1Fa.js} +1 -1
  166. package/dist/{layers.Dpw_9ymJ.js → layers.Dr5P-2nS.js} +1 -1
  167. package/dist/{layers.CbWg4Qj_.js → layers.DsILMPxx.js} +1 -1
  168. package/dist/{layers.CXmM9Bvj.js → layers.DsIzryKP.js} +1 -1
  169. package/dist/{layers.DiHUvl0o.js → layers.DvFYAtyT.js} +1 -1
  170. package/dist/{layers.BDlIrLZ6.js → layers.Dy0_d8sy.js} +1 -1
  171. package/dist/{layers.C_8cg569.js → layers.DyXmvM5f.js} +1 -1
  172. package/dist/{layers.CFKLwcKm.js → layers.EhBvVbsw.js} +1 -1
  173. package/dist/{layers.CnmwOTKT.js → layers.G_rEgJ84.js} +3 -3
  174. package/dist/{layers.BTYE7zXl.js → layers.GsapfAQ1.js} +1 -1
  175. package/dist/{layers.By9AhVvm.js → layers.J6whSTQH.js} +1 -1
  176. package/dist/{layers.E2Pg6Xkc.js → layers.KVhv5i6f.js} +1 -1
  177. package/dist/{layers.DQ3x1v0k.js → layers.L84VNWVC.js} +3 -3
  178. package/dist/{layers.Bl4j3Wpr.js → layers.LJHQMlT0.js} +1 -1
  179. package/dist/{layers.Bp180gHt.js → layers.Le84-6v2.js} +1 -1
  180. package/dist/{layers.a_6-TFT0.js → layers.OT2d5b4q.js} +1 -1
  181. package/dist/{layers.BGNKNfpx.js → layers.R7RkrwMg.js} +4 -4
  182. package/dist/{layers.D8gdqeYc.js → layers.RKdvjOr6.js} +2 -2
  183. package/dist/{layers.B4eocW2U.js → layers.SnwkWMxe.js} +1 -1
  184. package/dist/{layers.Cu_3bkpd.js → layers.UBfu9gOa.js} +1 -1
  185. package/dist/{layers.BRbQRhez.js → layers.V076uva3.js} +1 -1
  186. package/dist/{layers.CZpHZoB0.js → layers.X49qbjji.js} +1 -1
  187. package/dist/{layers.CH7sIJoO.js → layers.XHFonTzg.js} +1 -1
  188. package/dist/{layers.ovEMwqYP.js → layers.bcVe3ETQ.js} +1 -1
  189. package/dist/{layers.B9vco1Tm.js → layers.edZiHsGP.js} +1 -1
  190. package/dist/{layers.C_Z4dR-0.js → layers.fll1ACjX.js} +1 -1
  191. package/dist/{layers.BHGjfW2R.js → layers.gB6oLdoR.js} +3 -3
  192. package/dist/{layers.DhlqqsI-.js → layers.jC6aocln.js} +1 -1
  193. package/dist/{layers.BZO72u-D.js → layers.jWkWors_.js} +1 -1
  194. package/dist/{layers.DBYWjvtj.js → layers.jcz-f-1N.js} +1 -1
  195. package/dist/{layers.lgAtpBQI.js → layers.keI5Goq3.js} +1 -1
  196. package/dist/{layers.DsjBnsF_.js → layers.nhJMOX68.js} +1 -1
  197. package/dist/{layers.BXwHF1c0.js → layers.pdLO1I3r.js} +1 -1
  198. package/dist/{layers.BOm3l9Y2.js → layers.q3MMzbLf.js} +1 -1
  199. package/dist/{layers.BM2O77Wh.js → layers.t7qLpJLj.js} +1 -1
  200. package/dist/{layers.vO27jqdn.js → layers.uAlglow-.js} +1 -1
  201. package/dist/{layers.-XXFcZ-Q.js → layers.vC_TTmtv.js} +1 -1
  202. package/dist/{layers.DHoGHrwb.js → layers.vuHWhvQ2.js} +3 -3
  203. package/dist/{layers.Dj-z0EfD.js → layers.wB6eAqy3.js} +1 -1
  204. package/dist/{layers.CtAhktPZ.js → layers.wJ0Ei-KJ.js} +1 -1
  205. package/dist/{layers.Dfl1_GY-.js → layers.wxKwp3ix.js} +1 -1
  206. package/dist/{layers.CX1TPIiJ.js → layers.xiQfa4bG.js} +4 -4
  207. package/dist/{layers.CmF7I89w.js → layers.xiVInG4V.js} +1 -1
  208. package/dist/{layers.BYA27mAA.js → layers.yLGauwjn.js} +1 -1
  209. package/dist/{layers.Cye7d0Ye.js → layers.ytE8hnTP.js} +1 -1
  210. package/dist/{layers.B3ebXuRR.js → layers.z6oZNgry.js} +4 -4
  211. package/package.json +160 -166
  212. package/dist/layers.8I_XubBt.js +0 -1
  213. package/dist/layers.BFl0k28S.js +0 -6
  214. package/dist/layers.BSgSDW2J.js +0 -1
  215. package/dist/layers.CCNu5Rkf.js +0 -1
  216. package/dist/layers.CFSawE7c.js +0 -8
  217. package/dist/layers.CM9U_WY6.js +0 -304
  218. package/dist/layers.G1XGzBxR.js +0 -297
  219. package/dist/layers.fldW-Ogr.js +0 -30328
package/README.md CHANGED
@@ -1,840 +1,830 @@
1
- # LayersTextEditor
2
-
3
- LayersTextEditor is a text editor for web applications written in JavaScript, with a focus on reliability, accessibility, and performance.
4
-
5
- <details>
6
- <summary>
7
- 🚀 Quick Start
8
- </summary>
9
-
10
- ## Installation
11
-
12
- To install the package, run one of the following commands:
13
-
14
- ### Use npm:
15
-
16
- ```bash
17
- npm install @layers-app/editor
18
- ```
19
-
20
- > If you plan to use the Swagger node, install the additional package: `npm install swagger-ui-react`.
21
-
22
- ### Use yarn:
23
-
24
- ```bash
25
- yarn add @layers-app/editor
26
- ```
27
-
28
- ### Initializing the text editor
29
-
30
- ```
31
- import { Editor } from '@layers-app/editor';
32
- ```
33
-
34
- By default, LayersTextEditor works with an object and can return either an object or HTML.
35
-
36
- Example with an object:
37
-
38
- ```js
39
- const text = 'Hello world';
40
-
41
- const json = {
42
- root: {
43
- children: [
44
- {
45
- children: [
46
- {
47
- detail: 0,
48
- format: 0,
49
- mode: 'normal',
50
- style: '',
51
- text: text,
52
- type: 'text',
53
- version: 1,
54
- },
55
- ],
56
- direction: 'ltr',
57
- format: '',
58
- indent: 0,
59
- type: 'paragraph',
60
- version: 1,
61
- },
62
- ],
63
- direction: 'ltr',
64
- format: '',
65
- indent: 0,
66
- type: 'root',
67
- version: 1,
68
- },
69
- };
70
-
71
- const onChange = (
72
- data, // json
73
- ) => <Editor initialContent={json} onChange={onChange} />;
74
- ```
75
-
76
- You can also pass an HTML string to the editor.
77
-
78
- Example with HTML:
79
-
80
- ```
81
- const html = `
82
- <h2 dir="ltr" style="text-align: left;">
83
- <span style="background-color: rgb(248, 231, 28); font-family: &quot;Trebuchet MS&quot;; white-space: pre-wrap;">Hello</span>
84
- </h2>
85
- <h2 dir="ltr">
86
- <br>
87
- </h2>
88
- <p dir="ltr">
89
- <br>
90
- </p>
91
- <p dir="ltr">
92
- <span style="font-size: 21px; white-space: pre-wrap;">world</span>
93
- </p>
94
- `
95
-
96
- const onChange = (data) => // json
97
-
98
- <Editor initialContent={html} onChange={onChange} />
99
- ```
100
-
101
- The output of the data in the `onChange` function is controlled by the **outputFormat** property. **outputFormat** can be either "html" or "json". Example with **outputFormat**:
102
-
103
- ```
104
- const html = `
105
- <h2 dir="ltr" style="text-align: left;">
106
- <span style="background-color: rgb(248, 231, 28); font-family: &quot;Trebuchet MS&quot;; white-space: pre-wrap;">Hello</span>
107
- </h2>
108
- <h2 dir="ltr">
109
- <br>
110
- </h2>
111
- <p dir="ltr">
112
- <br>
113
- </p>
114
- <p dir="ltr">
115
- <span style="font-size: 21px; white-space: pre-wrap;">world</span>
116
- </p>
117
- `
118
-
119
- const onChange = (data: string, text?: string) => {
120
- // data - html from editor
121
- // text - text from editor
122
- }
123
-
124
-
125
- <Editor initialContent={html} outputFormat="html" onChange={onChange} />
126
- ```
127
-
128
- </details>
129
-
130
- <details>
131
- <summary>
132
- 🎨 StylesProvider
133
- </summary>
134
-
135
- Use **StylesProvider** to add styling to your HTML content.
136
-
137
- ```
138
- <StylesProvider>
139
- <div
140
- dangerouslySetInnerHTML={{ __html: '<p>Your html here</p>' }}
141
- />
142
- </StylesProvider>
143
- ```
144
-
145
- </details>
146
-
147
- <details>
148
- <summary>
149
- 🖼️ File upload
150
- </summary>
151
-
152
- ## Image upload
153
-
154
- To start working with image uploads, use the **fetchUploadMedia** function, which takes three parameters: **file**, **success**, and **error**. After successfully uploading the image to your service, you should call the **success** function and pass two required arguments: the **URL** of the image and its **ID**.
155
- Optional: You can also pass two optional parameters: **signal** and **onProgress**. The **signal** allows you to cancel an ongoing upload using an AbortController, and **onProgress** provides the current upload progress in percent — useful for displaying a progress bar or loading state.
156
-
157
- ```
158
- const fetchUploadMedia = async (
159
- file: File,
160
- success: (url: string, id: string) => void,
161
- error?: (error?: Error) => void
162
- ) => {
163
- const formData = new FormData();
164
- formData.append('File', file);
165
- formData.append('FileAccessModifier', '0');
166
-
167
- try {
168
- const response = await fetch('/api/v1/Files/Upload', {
169
- method: 'POST',
170
- body: formData,
171
- credentials: 'include'
172
- });
173
-
174
- if (!response.ok) {
175
- throw new Error('File upload failed');
176
- }
177
-
178
- const data = await response.json();
179
- const { Id, Url } = data;
180
-
181
- success(Url, Id);
182
- } catch (err) {
183
- if (error) {
184
- if (err instanceof Error) {
185
- error(err);
186
- } else {
187
- error(new Error('An unknown error occurred'));
188
- }
189
- }
190
- }
191
- };
192
-
193
- const fetchUploadMedia = async (
194
- file: File,
195
- success: (url: string, id: string, data: any) => void,
196
- error?: (err: Error) => void,
197
- signal?: AbortSignal,
198
- onProgress?: (percent: number) => void,
199
- ) => {
200
- const formData = new FormData();
201
- formData.append('File', file);
202
- formData.append('FileAccessModifier', '0');
203
-
204
- const xhr = new XMLHttpRequest();
205
- xhr.open('POST', '/api/v1/Files/Upload');
206
- xhr.withCredentials = true;
207
-
208
- if (signal) signal.addEventListener('abort', () => xhr.abort());
209
- if (onProgress) {
210
- xhr.upload.onprogress = (e) => {
211
- if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
212
- };
213
- }
214
-
215
- xhr.onload = () => {
216
- try {
217
- const data = JSON.parse(xhr.responseText);
218
- success(`/v1/attachments/${data.id}`, data.id, data);
219
- } catch {
220
- error?.(new Error('Invalid response'));
221
- }
222
- };
223
-
224
- xhr.onerror = () => error?.(new Error('Upload error')));
225
- xhr.send(formData);
226
- };
227
-
228
-
229
- <Editor
230
- ...props
231
- fetchUploadMedia={fetchUploadMedia}
232
- />
233
- ```
234
-
235
- ## Image Deletion
236
-
237
- To have greater control over image deletion, pass an optional function **fetchDeleteMedia** to the editor, which accepts three parameters: **id**, **success**, and **error**. After successfully deleting the image from your service, the **success** function should be called.
238
-
239
- ```
240
- const fetchDeleteMedia = async (
241
- id: string,
242
- success: () => void,
243
- error?: (error?: Error) => void
244
- ) => {
245
- const body = { Ids: [id] };
246
-
247
- try {
248
- const response = await fetch('/api/v1/Documents/Delete', {
249
- method: 'POST',
250
- headers: {
251
- 'Content-Type': 'application/json'
252
- },
253
- body: JSON.stringify(body),
254
- credentials: 'include'
255
- });
256
-
257
- await response.json();
258
- success();
259
- } catch (err) {
260
- if (error) {
261
- if (err instanceof Error) {
262
- error(err);
263
- } else {
264
- error(new Error('An unknown error occurred'));
265
- }
266
- }
267
- }
268
- };
269
-
270
- <Editor
271
- ...props
272
- fetchUploadMedia={fetchUploadMedia}
273
- fetchDeleteMedia={fetchUploadMedia}
274
- />
275
- ```
276
-
277
- ## Additional options for working with image uploads.
278
-
279
- ```
280
- import { Editor, Dropzone } from "@sinups/editor-dsd";
281
-
282
- const Content = () => (
283
- <Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
284
- {/*
285
- The components Dropzone.Accept, Dropzone.Reject, and Dropzone.Idle are visible only when the user performs specific actions:
286
-
287
- Dropzone.Accept is visible only when the user drags a file that can be accepted into the drop zone.
288
- Dropzone.Reject is visible only when the user drags a file that cannot be accepted into the drop zone.
289
- Dropzone.Idle is visible when the user is not dragging any file into the drop zone.
290
- */}
291
- <Dropzone.Accept>
292
- <IconUpload
293
- style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-blue-6)' }}
294
- stroke={1.5}
295
- />
296
- </Dropzone.Accept>
297
- <Dropzone.Reject>
298
- <IconX
299
- style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-red-6)' }}
300
- stroke={1.5}
301
- />
302
- </Dropzone.Reject>
303
- <Dropzone.Idle>
304
- <IconPhoto
305
- style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-dimmed)' }}
306
- stroke={1.5}
307
- />
308
- </Dropzone.Idle>
309
-
310
- <div>
311
- <Text size="xl" inline>
312
- Drag images here or click to select files
313
- </Text>
314
- <Text size="sm" c="dimmed" inline mt={7}>
315
- Attach as many files as you want, each file must not exceed{' '} {maxFileSize} МБ.
316
- </Text>
317
- </div>
318
- </Group>
319
- );
320
-
321
- <Editor
322
- ...props
323
- fetchUploadMedia={fetchUploadMedia}
324
- contentModalUploadImage={Content}
325
- maxFileSize={5}
326
- maxImageSizeError={() => {}}
327
- />
328
- ```
329
-
330
- ## File upload
331
-
332
- For uploading a file or audio, you might need the third parameter "data".
333
-
334
- ```
335
- const fetchUploadMedia = async (
336
- file: File,
337
- success: (url: string, id: string, data?: {
338
- contentType: string;
339
- fileSize: string;
340
- originalFileName: string;
341
- }) => void,
342
- error?: (error?: Error) => void
343
- ) => {
344
- const formData = new FormData();
345
- formData.append('File', file);
346
- formData.append('FileAccessModifier', '0');
347
-
348
- try {
349
- const response = await fetch('/api/v1/Files/Upload', {
350
- method: 'POST',
351
- body: formData,
352
- credentials: 'include'
353
- });
354
-
355
- if (!response.ok) {
356
- throw new Error('File upload failed');
357
- }
358
-
359
- const data = await response.json();
360
- const { Id, Url } = data;
361
-
362
- success(Url, Id, data);
363
- } catch (err) {
364
- if (error) {
365
- if (err instanceof Error) {
366
- error(err);
367
- } else {
368
- error(new Error('An unknown error occurred'));
369
- }
370
- }
371
- }
372
- };
373
- ```
374
-
375
- </details>
376
-
377
- <details>
378
- <summary>
379
- 🤖 AI
380
- </summary>
381
-
382
- ## Connect AI
383
-
384
- ```
385
- const fetchPromptResult = async (
386
- prompt: string,
387
- success: (data: string) => void,
388
- error?: () => void,
389
- ) => {
390
- try {
391
- const response = await fetch(
392
- 'https://domain/api/v1/openai/call-any-prompt',
393
- {
394
- method: 'POST',
395
- headers: {
396
- 'Content-Type': 'application/json',
397
-
398
- Authorization: 'token',
399
- },
400
- body: JSON.stringify({ prompt }),
401
- },
402
- );
403
-
404
- if (!response.ok) {
405
- const errText = await response.text();
406
- console.error('server error', errText);
407
- throw new Error('API failed');
408
- }
409
-
410
- const data = await response.json();
411
-
412
- success(data.content);
413
- } catch (err) {
414
- if (error) {
415
- error();
416
- console.error('Error');
417
- }
418
- }
419
- };
420
-
421
- <Editor
422
- ...props
423
- fetchPromptResult={fetchPromptResult}
424
- />
425
- ```
426
-
427
- </details>
428
-
429
- <details>
430
- <summary>
431
- 📊 Analytics (onTrack)
432
- </summary>
433
-
434
- ## Event Tracking
435
-
436
- The editor can track user actions via the `onTrack` callback. This allows the host application to send analytics events to any provider (Yandex.Metrika, Google Analytics, Mixpanel, etc.) without the editor depending on any specific analytics SDK.
437
-
438
- ### Basic Usage
439
-
440
- ```tsx
441
- import { trackGoal } from '@layers/hooks/useAnalytics';
442
-
443
- <Editor
444
- {...props}
445
- onTrack={trackGoal}
446
- />
447
- ```
448
-
449
- ### Custom Handler
450
-
451
- ```tsx
452
- const handleTrack = (event: string, params?: Record<string, unknown>) => {
453
- console.log('Editor event:', event, params);
454
-
455
- // Send to your analytics provider
456
- myAnalytics.track(event, params);
457
- };
458
-
459
- <Editor
460
- {...props}
461
- onTrack={handleTrack}
462
- />
463
- ```
464
-
465
- ### Type Signature
466
-
467
- ```ts
468
- type EditorTrackFn = (
469
- event: string,
470
- params?: Record<string, unknown>,
471
- ) => void;
472
- ```
473
-
474
- ### Tracked Events
475
-
476
- | Event | Trigger | Plugin |
477
- |-------|---------|--------|
478
- | `block_table_created` | User inserts a table | BlockFormatDropDown |
479
- | `block_image_added` | User inserts an image | BlockFormatDropDown |
480
- | `block_code_created` | User inserts a code block | BlockFormatDropDown |
481
- | `block_layout_used` | User inserts a grid/layout | BlockFormatDropDown |
482
- | `block_collapse_created` | User inserts a toggle/collapsible | BlockFormatDropDown |
483
- | `block_link_inserted` | User inserts a link | ToolbarPlugin |
484
- | `block_comment_added` | User adds an inline comment | ToolbarPlugin |
485
- | `ai_menu_opened` | User clicks the AI toolbar button | ToolbarPlugin/AI |
486
- | `block_heading_created` | User creates a heading (h1-h4) | BlockFormatDropDown |
487
- | `list_bullet_created` | User creates a bullet list | BlockFormatDropDown |
488
- | `list_number_created` | User creates a numbered list | BlockFormatDropDown |
489
- | `list_check_created` | User creates a checklist | BlockFormatDropDown |
490
- | `block_quote_created` | User creates a quote block | BlockFormatDropDown |
491
- | `block_divider_inserted` | User inserts a horizontal rule | BlockFormatDropDown |
492
- | `block_child_docs_inserted` | User inserts child documents block | BlockFormatDropDown |
493
- | `block_embed_added` | User adds an embed/integration | BlockFormatDropDown |
494
- | `media_audio_added` | User adds an audio block | BlockFormatDropDown |
495
- | `media_file_added` | User adds a file block | BlockFormatDropDown |
496
- | `table_row_added` | User inserts a table row | TableActionMenuPlugin |
497
- | `table_column_added` | User inserts a table column | TableActionMenuPlugin |
498
- | `table_row_deleted` | User deletes a table row | TableActionMenuPlugin |
499
- | `table_column_deleted` | User deletes a table column | TableActionMenuPlugin |
500
- | `block_duplicated` | User duplicates a block | DraggableBlockPlugin |
501
- | `block_deleted` | User deletes a block | DraggableBlockPlugin |
502
- | `text_code_toggled` | User toggles inline code format | ToolbarPlugin |
503
- | `text_formatting_cleared` | User clears all text formatting | ToolbarPlugin |
504
-
505
- > Events fire from both the toolbar dropdown menu and the slash command menu (`/`).
506
-
507
- ### What Happens Without onTrack?
508
-
509
- If `onTrack` is not provided, **nothing happens** — all tracking calls use optional chaining (`onTrack?.('event')`) and are silently skipped. The editor works exactly the same with or without analytics. There is no error, no console warning, and no performance impact.
510
-
511
- This means:
512
- - **No analytics SDK required** — the editor is a standalone package with zero analytics dependencies
513
- - **Safe to omit** — if you don't need tracking, simply don't pass the prop
514
- - **No counter needed** — the editor itself never calls `ym()`, `gtag()`, or any external API directly
515
-
516
- ### How It Works Internally
517
-
518
- 1. `onTrack` is passed as a prop to `<Editor>` and stored in React Context
519
- 2. Plugins access it via `useContext(Context)` and call `onTrack?.('event_name')`
520
- 3. The host app decides what to do with the event (send to Metrika, log, ignore)
521
-
522
- ```
523
- Host App Editor Package
524
- ───────── ──────────────
525
- trackGoal() ──→ onTrack prop ──→ Context.onTrack
526
- │ │
527
- │ Plugins call:
528
- │ onTrack?.('block_table_created')
529
- │ │
530
- ◄────────────────────────────────┘
531
-
532
- ym(ID, 'reachGoal', 'block_table_created')
533
- dataLayer.push({ event: 'block_table_created' })
534
- ```
535
-
536
- </details>
537
-
538
-
539
- <details>
540
- <summary>
541
- 👥 Collaboration
542
- </summary>
543
-
544
- ```jsx
545
- <Editor
546
- {...props}
547
- ws={{
548
- url: 'https://wss.dudoc.io/', // WebSocket URL
549
- id: '322323', // Unique document ID
550
- user: userProfile, // Current user
551
- getActiveUsers: (users) => {
552
- // Returns active users editing the document
553
- setActiveUsers(users);
554
- },
555
- }}
556
- />
557
- ```
558
-
559
- </details>
560
-
561
- <details>
562
- <summary>
563
- 📝 Additional options
564
- </summary>
565
-
566
- ## Reset editor content
567
-
568
- ```
569
-
570
- import { CLEAR_EDITOR_COMMAND } from './EditorLexical';
571
-
572
- <>
573
- <button
574
- onClick={() => {
575
- if (editorRef.current) {
576
- editorRef.current.update(() => {
577
- editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
578
- });
579
- }
580
- }}
581
- >
582
- Reset
583
- </button>
584
- <Editor
585
- ...props
586
- editorRef={editorRef}
587
- />
588
- <>
589
- ```
590
-
591
- </details>
592
-
593
- <details>
594
- <summary>
595
- 🧪 Testing
596
- </summary>
597
-
598
- ## Testing Overview
599
-
600
- This project includes comprehensive testing with both **unit tests** (Vitest) and **end-to-end tests** (Playwright). The testing setup ensures reliability across different browsers and environments.
601
-
602
- ### Prerequisites
603
-
604
- Before running tests, make sure you have installed all dependencies:
605
-
606
- ```bash
607
- npm install
608
- ```
609
-
610
- ## Unit Tests (Vitest)
611
-
612
- Unit tests are written with **Vitest** and **jsdom** for testing individual components and utilities.
613
-
614
- ### Run Unit Tests
615
-
616
- ```bash
617
- # Run all unit tests
618
- npm run test-unit
619
-
620
- # Run unit tests in watch mode (auto-rerun on changes)
621
- npm run test-unit-watch
622
- ```
623
-
624
- ### Unit Test Files Location
625
-
626
- - `__tests__/unit/` - Unit test files
627
- - Test files follow the pattern: `*.test.ts` or `*.test.tsx`
628
-
629
- ## End-to-End Tests (Playwright)
630
-
631
- E2E tests use **Playwright** to test the complete application flow in real browsers.
632
-
633
- ### Run E2E Tests
634
-
635
- ```bash
636
- # Run all E2E tests (WebKit only for CI optimization)
637
- npm run test:e2e
638
-
639
- # Run E2E tests with UI mode (interactive)
640
- npm run test:e2e:ui
641
-
642
- # Run E2E tests in debug mode
643
- npm run test:e2e:debug
644
-
645
- # Run E2E tests in headed mode (visible browser)
646
- npm run test:e2e:headed
647
- ```
648
-
649
- ### E2E Test Files Location
650
-
651
- - `__tests__/e2e/` - End-to-end test files
652
- - `__tests__/regression/` - Regression test files
653
- - Test files follow the pattern: `*.spec.js`, `*.spec.mjs`, or `*.spec.ts`
654
-
655
- ### Browser Support
656
-
657
- - **WebKit** (Safari) - Primary browser for CI/CD
658
- - **Chromium** and **Firefox** - Available for local testing
659
-
660
- ## Test Server
661
-
662
- The test server automatically starts when running E2E tests:
663
-
664
- ```bash
665
- # Manual test server start (if needed)
666
- npm run start-test-server
667
- ```
668
-
669
- - **URL**: `http://localhost:3000`
670
- - **Mode**: Full editor mode with all features enabled
671
- - **Environment**: `VITE_LAYERS=true`
672
-
673
- ## Test Configuration
674
-
675
- ### Playwright Configuration
676
-
677
- - **Config file**: `playwright.config.js`
678
- - **Test directory**: `./__tests__/e2e/`
679
- - **Browser**: WebKit (optimized for CI)
680
- - **Base URL**: `http://localhost:3000`
681
- - **Timeout**: 90 seconds per test
682
- - **Retries**: 2 retries in CI, 0 locally
683
-
684
- ### Vitest Configuration
685
-
686
- - **Config file**: `vitest.config.mts`
687
- - **Environment**: jsdom
688
- - **Setup file**: `vitest.setup.mts`
689
- - **Coverage**: V8 provider
690
-
691
- ## CI/CD Testing
692
-
693
- Tests run automatically on:
694
-
695
- - **Push** to `main` or `dev` branches
696
- - **Pull requests** to `main` or `dev` branches
697
- - **Manual trigger** via GitHub Actions
698
-
699
- ### GitHub Actions Workflow
700
-
701
- - **File**: `.github/workflows/tests.yml`
702
- - **Runner**: Ubuntu Latest
703
- - **Node.js**: Version 20
704
- - **Browser caching**: Playwright browsers cached for faster runs
705
- - **Artifacts**: Test reports and traces uploaded on completion
706
-
707
- ## Test Examples
708
-
709
- ### Basic E2E Test Structure
710
-
711
- ```javascript
712
- // __tests__/e2e/example.spec.mjs
713
- import { expect, test } from '@playwright/test';
714
-
715
- import { focusEditor } from '../utils/index.mjs';
716
-
717
- test('Can type text in editor', async ({ page }) => {
718
- await page.goto('/');
719
- await focusEditor(page);
720
-
721
- const editor = page.locator('[contenteditable="true"]').first();
722
- await editor.type('Hello World');
723
-
724
- await expect(editor).toContainText('Hello World');
725
- });
726
- ```
727
-
728
- ### Unit Test Structure
729
-
730
- ```typescript
731
- // __tests__/unit/example.test.ts
732
- import { describe, it, expect } from 'vitest';
733
- import { render } from '@testing-library/react';
734
- import { Editor } from '../src/Editor';
735
-
736
- describe('Editor Component', () => {
737
- it('renders without crashing', () => {
738
- const { container } = render(<Editor />);
739
- expect(container).toBeTruthy();
740
- });
741
- });
742
- ```
743
-
744
- ## Debugging Tests
745
-
746
- ### Debug E2E Tests
747
-
748
- ```bash
749
- # Run with Playwright Inspector
750
- npm run test:e2e:debug
751
-
752
- # Run specific test file
753
- npx playwright test __tests__/e2e/TextEntry.spec.mjs --debug
754
-
755
- # Run with headed browser
756
- npm run test:e2e:headed
757
- ```
758
-
759
- ### View Test Reports
760
-
761
- ```bash
762
- # Open HTML report (after running tests)
763
- npx playwright show-report
764
-
765
- # View test traces (for failed tests)
766
- npx playwright show-trace test-results/[test-name]/trace.zip
767
- ```
768
-
769
- ## Test Utilities
770
-
771
- Common test utilities are available in `__tests__/utils/index.mjs`:
772
-
773
- - `focusEditor(page)` - Focus the main editor
774
- - `selectAll(page)` - Select all text in editor
775
- - `moveLeft(page, count)` - Move cursor left
776
- - `selectCharacters(page, count)` - Select specific number of characters
777
- - `waitForSelector(page, selector)` - Wait for element to appear
778
-
779
- ## Performance
780
-
781
- ### Test Execution Times
782
-
783
- - **Unit Tests**: ~10-30 seconds
784
- - **E2E Tests (first run)**: ~3-4 minutes (includes browser installation)
785
- - **E2E Tests (cached)**: ~1-2 minutes (uses cached browsers)
786
-
787
- ### Optimization Features
788
-
789
- - **Browser Caching**: Playwright browsers cached in CI
790
- - **Single Worker**: Prevents race conditions in CI
791
- - **WebKit Only**: Faster than multi-browser matrix
792
- - **Smart Retries**: Auto-retry flaky tests
793
-
794
- ## Troubleshooting
795
-
796
- ### Common Issues
797
-
798
- 1. **Port conflicts**: Ensure port 3000 is available
799
- 2. **Browser installation**: Run `npx playwright install` if needed
800
- 3. **Test timeouts**: Check if test server is running properly
801
- 4. **Certificate errors**: Tests use HTTP to avoid HTTPS certificate issues
802
-
803
- ### Reset Test Environment
804
-
805
- ```bash
806
- # Clear Playwright cache
807
- npx playwright install --force
808
-
809
- # Reset node_modules
810
- rm -rf node_modules package-lock.json
811
- npm install
812
- ```
813
-
814
- </details>
815
-
816
- <details>
817
- <summary>
818
- ⚙️ Properties
819
- </summary>
820
-
821
- ```
822
- onChange: (value: string | object) => undefined - A function that triggers every time the editor content changes and returns an HTML string or an object depending on the outputFormat property.
823
- debounce?: number - Defines how often the onChange function is called, in milliseconds.
824
- onBlur: (value: string | object) => undefined - A function that triggers when the editor loses focus and returns an HTML string or an object depending on the outputFormat property.
825
- outputFormat?: 'html' | 'json' - The outputFormat property defines whether we want to output an HTML string or a JSON object. The default is JSON.
826
- initialContent: string | object - The initial content for the editor.
827
- maxHeight?: number - Sets the height of the editor. The default is 100%.
828
- mode?: 'simple' | 'default' | 'full' | 'editor' - The editor mode. Depending on the chosen mode, functionality may be restricted or extended. The default is default.
829
- fetchUploadMedia?: (file: File, success: (url: string, id: string, error?: (error?: Error) => void) => void) - Function to upload an image to your service.
830
- fetchDeleteMedia?: (id: string, success: () => void, error?: (error?: Error) => void) - Helper function to delete an image.
831
- maxFileSize?: number - The maximum image size in megabytes.
832
- contentModalUploadImage?: React.FunctionComponent - A React component to replace content in DropZone.
833
- maxImageSizeError?: () => void - A function that is called if the image exceeds the maxFileSize.
834
- disable?: boolean - Toggles the editor into read-only mode.
835
- ws?: { url: string, id: string, user: { color: string, name: string }, getActiveUsers: (users) => void } - WebSocket settings: URL, document ID, current user details, and function to return active users editing the document.
836
- editorRef?: { current: EditorType | null } - Reference to the editor.
837
- onTrack?: (event: string, params?: Record<string, unknown>) => void - Optional callback for analytics event tracking. Called when the user performs tracked actions (insert table, add image, open AI menu, etc.). If not provided, tracking is silently skipped. See the "Analytics (onTrack)" section for the full list of events.
838
- ```
839
-
840
- </details>
1
+ # LayersTextEditor
2
+
3
+ LayersTextEditor is a text editor for web applications written in JavaScript, with a focus on reliability, accessibility, and performance.
4
+
5
+ <details>
6
+ <summary>
7
+ 🚀 Quick Start
8
+ </summary>
9
+
10
+ ## Installation
11
+
12
+ To install the package, run one of the following commands:
13
+
14
+ ### Use npm:
15
+
16
+ ```bash
17
+ npm install @layers-app/editor
18
+ ```
19
+
20
+ > If you plan to use the Swagger node, install the additional package: `npm install swagger-ui-react`.
21
+
22
+ ### Use yarn:
23
+
24
+ ```bash
25
+ yarn add @layers-app/editor
26
+ ```
27
+
28
+ ### Initializing the text editor
29
+
30
+ ```
31
+ import { Editor } from '@layers-app/editor';
32
+ ```
33
+
34
+ By default, LayersTextEditor works with an object and can return either an object or HTML.
35
+
36
+ Example with an object:
37
+
38
+ ```js
39
+ const text = 'Hello world';
40
+
41
+ const json = {
42
+ root: {
43
+ children: [
44
+ {
45
+ children: [
46
+ {
47
+ detail: 0,
48
+ format: 0,
49
+ mode: 'normal',
50
+ style: '',
51
+ text: text,
52
+ type: 'text',
53
+ version: 1,
54
+ },
55
+ ],
56
+ direction: 'ltr',
57
+ format: '',
58
+ indent: 0,
59
+ type: 'paragraph',
60
+ version: 1,
61
+ },
62
+ ],
63
+ direction: 'ltr',
64
+ format: '',
65
+ indent: 0,
66
+ type: 'root',
67
+ version: 1,
68
+ },
69
+ };
70
+
71
+ const onChange = (
72
+ data, // json
73
+ ) => <Editor initialContent={json} onChange={onChange} />;
74
+ ```
75
+
76
+ You can also pass an HTML string to the editor.
77
+
78
+ Example with HTML:
79
+
80
+ ```
81
+ const html = `
82
+ <h2 dir="ltr" style="text-align: left;">
83
+ <span style="background-color: rgb(248, 231, 28); font-family: &quot;Trebuchet MS&quot;; white-space: pre-wrap;">Hello</span>
84
+ </h2>
85
+ <h2 dir="ltr">
86
+ <br>
87
+ </h2>
88
+ <p dir="ltr">
89
+ <br>
90
+ </p>
91
+ <p dir="ltr">
92
+ <span style="font-size: 21px; white-space: pre-wrap;">world</span>
93
+ </p>
94
+ `
95
+
96
+ const onChange = (data) => // json
97
+
98
+ <Editor initialContent={html} onChange={onChange} />
99
+ ```
100
+
101
+ The output of the data in the `onChange` function is controlled by the **outputFormat** property. **outputFormat** can be either "html" or "json". Example with **outputFormat**:
102
+
103
+ ```
104
+ const html = `
105
+ <h2 dir="ltr" style="text-align: left;">
106
+ <span style="background-color: rgb(248, 231, 28); font-family: &quot;Trebuchet MS&quot;; white-space: pre-wrap;">Hello</span>
107
+ </h2>
108
+ <h2 dir="ltr">
109
+ <br>
110
+ </h2>
111
+ <p dir="ltr">
112
+ <br>
113
+ </p>
114
+ <p dir="ltr">
115
+ <span style="font-size: 21px; white-space: pre-wrap;">world</span>
116
+ </p>
117
+ `
118
+
119
+ const onChange = (data: string, text?: string) => {
120
+ // data - html from editor
121
+ // text - text from editor
122
+ }
123
+
124
+
125
+ <Editor initialContent={html} outputFormat="html" onChange={onChange} />
126
+ ```
127
+
128
+ </details>
129
+
130
+ <details>
131
+ <summary>
132
+ 🎨 StylesProvider
133
+ </summary>
134
+
135
+ Use **StylesProvider** to add styling to your HTML content.
136
+
137
+ ```
138
+ <StylesProvider>
139
+ <div
140
+ dangerouslySetInnerHTML={{ __html: '<p>Your html here</p>' }}
141
+ />
142
+ </StylesProvider>
143
+ ```
144
+
145
+ </details>
146
+
147
+ <details>
148
+ <summary>
149
+ 🖼️ File upload
150
+ </summary>
151
+
152
+ ## Image upload
153
+
154
+ To start working with image uploads, use the **fetchUploadMedia** function, which takes three parameters: **file**, **success**, and **error**. After successfully uploading the image to your service, you should call the **success** function and pass two required arguments: the **URL** of the image and its **ID**.
155
+ Optional: You can also pass two optional parameters: **signal** and **onProgress**. The **signal** allows you to cancel an ongoing upload using an AbortController, and **onProgress** provides the current upload progress in percent — useful for displaying a progress bar or loading state.
156
+ ```
157
+ const fetchUploadMedia = async (
158
+ file: File,
159
+ success: (url: string, id: string) => void,
160
+ error?: (error?: Error) => void
161
+ ) => {
162
+ const formData = new FormData();
163
+ formData.append('File', file);
164
+ formData.append('FileAccessModifier', '0');
165
+
166
+ try {
167
+ const response = await fetch('/api/v1/Files/Upload', {
168
+ method: 'POST',
169
+ body: formData,
170
+ credentials: 'include'
171
+ });
172
+
173
+ if (!response.ok) {
174
+ throw new Error('File upload failed');
175
+ }
176
+
177
+ const data = await response.json();
178
+ const { Id, Url } = data;
179
+
180
+ success(Url, Id);
181
+ } catch (err) {
182
+ if (error) {
183
+ if (err instanceof Error) {
184
+ error(err);
185
+ } else {
186
+ error(new Error('An unknown error occurred'));
187
+ }
188
+ }
189
+ }
190
+ };
191
+
192
+ const fetchUploadMedia = async (
193
+ file: File,
194
+ success: (url: string, id: string, data: any) => void,
195
+ error?: (err: Error) => void,
196
+ signal?: AbortSignal,
197
+ onProgress?: (percent: number) => void,
198
+ ) => {
199
+ const formData = new FormData();
200
+ formData.append('File', file);
201
+ formData.append('FileAccessModifier', '0');
202
+
203
+ const xhr = new XMLHttpRequest();
204
+ xhr.open('POST', '/api/v1/Files/Upload');
205
+ xhr.withCredentials = true;
206
+
207
+ if (signal) signal.addEventListener('abort', () => xhr.abort());
208
+ if (onProgress) {
209
+ xhr.upload.onprogress = (e) => {
210
+ if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
211
+ };
212
+ }
213
+
214
+ xhr.onload = () => {
215
+ try {
216
+ const data = JSON.parse(xhr.responseText);
217
+ success(`/v1/attachments/${data.id}`, data.id, data);
218
+ } catch {
219
+ error?.(new Error('Invalid response'));
220
+ }
221
+ };
222
+
223
+ xhr.onerror = () => error?.(new Error('Upload error')));
224
+ xhr.send(formData);
225
+ };
226
+
227
+
228
+ <Editor
229
+ ...props
230
+ fetchUploadMedia={fetchUploadMedia}
231
+ />
232
+ ```
233
+
234
+ ## Image Deletion
235
+
236
+ To have greater control over image deletion, pass an optional function **fetchDeleteMedia** to the editor, which accepts three parameters: **id**, **success**, and **error**. After successfully deleting the image from your service, the **success** function should be called.
237
+
238
+ ```
239
+ const fetchDeleteMedia = async (
240
+ id: string,
241
+ success: () => void,
242
+ error?: (error?: Error) => void
243
+ ) => {
244
+ const body = { Ids: [id] };
245
+
246
+ try {
247
+ const response = await fetch('/api/v1/Documents/Delete', {
248
+ method: 'POST',
249
+ headers: {
250
+ 'Content-Type': 'application/json'
251
+ },
252
+ body: JSON.stringify(body),
253
+ credentials: 'include'
254
+ });
255
+
256
+ await response.json();
257
+ success();
258
+ } catch (err) {
259
+ if (error) {
260
+ if (err instanceof Error) {
261
+ error(err);
262
+ } else {
263
+ error(new Error('An unknown error occurred'));
264
+ }
265
+ }
266
+ }
267
+ };
268
+
269
+ <Editor
270
+ ...props
271
+ fetchUploadMedia={fetchUploadMedia}
272
+ fetchDeleteMedia={fetchUploadMedia}
273
+ />
274
+ ```
275
+
276
+ ## Additional options for working with image uploads.
277
+
278
+ ```
279
+ import { Editor, Dropzone } from "@sinups/editor-dsd";
280
+
281
+ const Content = () => (
282
+ <Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
283
+ {/*
284
+ The components Dropzone.Accept, Dropzone.Reject, and Dropzone.Idle are visible only when the user performs specific actions:
285
+
286
+ Dropzone.Accept is visible only when the user drags a file that can be accepted into the drop zone.
287
+ Dropzone.Reject is visible only when the user drags a file that cannot be accepted into the drop zone.
288
+ Dropzone.Idle is visible when the user is not dragging any file into the drop zone.
289
+ */}
290
+ <Dropzone.Accept>
291
+ <IconUpload
292
+ style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-blue-6)' }}
293
+ stroke={1.5}
294
+ />
295
+ </Dropzone.Accept>
296
+ <Dropzone.Reject>
297
+ <IconX
298
+ style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-red-6)' }}
299
+ stroke={1.5}
300
+ />
301
+ </Dropzone.Reject>
302
+ <Dropzone.Idle>
303
+ <IconPhoto
304
+ style={{ width: rem(52), height: rem(52), color: 'var(--mantine-color-dimmed)' }}
305
+ stroke={1.5}
306
+ />
307
+ </Dropzone.Idle>
308
+
309
+ <div>
310
+ <Text size="xl" inline>
311
+ Drag images here or click to select files
312
+ </Text>
313
+ <Text size="sm" c="dimmed" inline mt={7}>
314
+ Attach as many files as you want, each file must not exceed{' '} {maxFileSize} МБ.
315
+ </Text>
316
+ </div>
317
+ </Group>
318
+ );
319
+
320
+ <Editor
321
+ ...props
322
+ fetchUploadMedia={fetchUploadMedia}
323
+ contentModalUploadImage={Content}
324
+ maxFileSize={5}
325
+ maxImageSizeError={() => {}}
326
+ />
327
+ ```
328
+
329
+ ## File upload
330
+
331
+ For uploading a file or audio, you might need the third parameter "data".
332
+
333
+ ```
334
+ const fetchUploadMedia = async (
335
+ file: File,
336
+ success: (url: string, id: string, data?: {
337
+ contentType: string;
338
+ fileSize: string;
339
+ originalFileName: string;
340
+ }) => void,
341
+ error?: (error?: Error) => void
342
+ ) => {
343
+ const formData = new FormData();
344
+ formData.append('File', file);
345
+ formData.append('FileAccessModifier', '0');
346
+
347
+ try {
348
+ const response = await fetch('/api/v1/Files/Upload', {
349
+ method: 'POST',
350
+ body: formData,
351
+ credentials: 'include'
352
+ });
353
+
354
+ if (!response.ok) {
355
+ throw new Error('File upload failed');
356
+ }
357
+
358
+ const data = await response.json();
359
+ const { Id, Url } = data;
360
+
361
+ success(Url, Id, data);
362
+ } catch (err) {
363
+ if (error) {
364
+ if (err instanceof Error) {
365
+ error(err);
366
+ } else {
367
+ error(new Error('An unknown error occurred'));
368
+ }
369
+ }
370
+ }
371
+ };
372
+ ```
373
+ </details>
374
+
375
+
376
+ <details>
377
+ <summary>
378
+ 🤖 AI
379
+ </summary>
380
+
381
+ ## Connect AI
382
+
383
+
384
+ ```
385
+ const fetchPromptResult = async (
386
+ prompt: string,
387
+ success: (data: string) => void,
388
+ error?: () => void,
389
+ ) => {
390
+ try {
391
+ const response = await fetch(
392
+ 'https://domain/api/v1/openai/call-any-prompt',
393
+ {
394
+ method: 'POST',
395
+ headers: {
396
+ 'Content-Type': 'application/json',
397
+
398
+ Authorization: 'token',
399
+ },
400
+ body: JSON.stringify({ prompt }),
401
+ },
402
+ );
403
+
404
+ if (!response.ok) {
405
+ const errText = await response.text();
406
+ console.error('server error', errText);
407
+ throw new Error('API failed');
408
+ }
409
+
410
+ const data = await response.json();
411
+
412
+ success(data.content);
413
+ } catch (err) {
414
+ if (error) {
415
+ error();
416
+ console.error('Error');
417
+ }
418
+ }
419
+ };
420
+
421
+ <Editor
422
+ ...props
423
+ fetchPromptResult={fetchPromptResult}
424
+ />
425
+ ```
426
+ </details>
427
+
428
+
429
+ <details>
430
+ <summary>
431
+ 📊 Analytics (onTrack)
432
+ </summary>
433
+
434
+ ## Event Tracking
435
+
436
+ The editor can track user actions via the `onTrack` callback. This allows the host application to send analytics events to any provider (Yandex.Metrika, Google Analytics, Mixpanel, etc.) without the editor depending on any specific analytics SDK.
437
+
438
+ ### Basic Usage
439
+
440
+ ```tsx
441
+ import { trackGoal } from '@layers/hooks/useAnalytics';
442
+
443
+ <Editor
444
+ {...props}
445
+ onTrack={trackGoal}
446
+ />
447
+ ```
448
+
449
+ ### Custom Handler
450
+
451
+ ```tsx
452
+ const handleTrack = (event: string, params?: Record<string, unknown>) => {
453
+ console.log('Editor event:', event, params);
454
+
455
+ // Send to your analytics provider
456
+ myAnalytics.track(event, params);
457
+ };
458
+
459
+ <Editor
460
+ {...props}
461
+ onTrack={handleTrack}
462
+ />
463
+ ```
464
+
465
+ ### Type Signature
466
+
467
+ ```ts
468
+ type EditorTrackFn = (
469
+ event: string,
470
+ params?: Record<string, unknown>,
471
+ ) => void;
472
+ ```
473
+
474
+ ### Tracked Events
475
+
476
+ | Event | Trigger | Plugin |
477
+ |-------|---------|--------|
478
+ | `block_table_created` | User inserts a table | BlockFormatDropDown |
479
+ | `block_image_added` | User inserts an image | BlockFormatDropDown |
480
+ | `block_code_created` | User inserts a code block | BlockFormatDropDown |
481
+ | `block_layout_used` | User inserts a grid/layout | BlockFormatDropDown |
482
+ | `block_collapse_created` | User inserts a toggle/collapsible | BlockFormatDropDown |
483
+ | `block_link_inserted` | User inserts a link | ToolbarPlugin |
484
+ | `block_comment_added` | User adds an inline comment | ToolbarPlugin |
485
+ | `ai_menu_opened` | User clicks the AI toolbar button | ToolbarPlugin/AI |
486
+ | `block_heading_created` | User creates a heading (h1-h4) | BlockFormatDropDown |
487
+ | `list_bullet_created` | User creates a bullet list | BlockFormatDropDown |
488
+ | `list_number_created` | User creates a numbered list | BlockFormatDropDown |
489
+ | `list_check_created` | User creates a checklist | BlockFormatDropDown |
490
+ | `block_quote_created` | User creates a quote block | BlockFormatDropDown |
491
+ | `block_divider_inserted` | User inserts a horizontal rule | BlockFormatDropDown |
492
+ | `block_child_docs_inserted` | User inserts child documents block | BlockFormatDropDown |
493
+ | `block_embed_added` | User adds an embed/integration | BlockFormatDropDown |
494
+ | `media_audio_added` | User adds an audio block | BlockFormatDropDown |
495
+ | `media_file_added` | User adds a file block | BlockFormatDropDown |
496
+ | `table_row_added` | User inserts a table row | TableActionMenuPlugin |
497
+ | `table_column_added` | User inserts a table column | TableActionMenuPlugin |
498
+ | `table_row_deleted` | User deletes a table row | TableActionMenuPlugin |
499
+ | `table_column_deleted` | User deletes a table column | TableActionMenuPlugin |
500
+ | `block_duplicated` | User duplicates a block | DraggableBlockPlugin |
501
+ | `block_deleted` | User deletes a block | DraggableBlockPlugin |
502
+ | `text_code_toggled` | User toggles inline code format | ToolbarPlugin |
503
+ | `text_formatting_cleared` | User clears all text formatting | ToolbarPlugin |
504
+
505
+ > Events fire from both the toolbar dropdown menu and the slash command menu (`/`).
506
+
507
+ ### What Happens Without onTrack?
508
+
509
+ If `onTrack` is not provided, **nothing happens** — all tracking calls use optional chaining (`onTrack?.('event')`) and are silently skipped. The editor works exactly the same with or without analytics. There is no error, no console warning, and no performance impact.
510
+
511
+ This means:
512
+ - **No analytics SDK required** — the editor is a standalone package with zero analytics dependencies
513
+ - **Safe to omit** — if you don't need tracking, simply don't pass the prop
514
+ - **No counter needed** — the editor itself never calls `ym()`, `gtag()`, or any external API directly
515
+
516
+ ### How It Works Internally
517
+
518
+ 1. `onTrack` is passed as a prop to `<Editor>` and stored in React Context
519
+ 2. Plugins access it via `useContext(Context)` and call `onTrack?.('event_name')`
520
+ 3. The host app decides what to do with the event (send to Metrika, log, ignore)
521
+
522
+ ```
523
+ Host App Editor Package
524
+ ───────── ──────────────
525
+ trackGoal() ──→ onTrack prop ──→ Context.onTrack
526
+ │ │
527
+ │ Plugins call:
528
+ │ onTrack?.('block_table_created')
529
+ │ │
530
+ ◄────────────────────────────────┘
531
+
532
+ ym(ID, 'reachGoal', 'block_table_created')
533
+ dataLayer.push({ event: 'block_table_created' })
534
+ ```
535
+
536
+ </details>
537
+
538
+
539
+ <details>
540
+ <summary>
541
+ 👥 Collaboration
542
+ </summary>
543
+
544
+ ```jsx
545
+ <Editor
546
+ {...props}
547
+ ws={{
548
+ url: 'https://wss.dudoc.io/', // WebSocket URL
549
+ id: '322323', // Unique document ID
550
+ user: userProfile, // Current user
551
+ getActiveUsers: (users) => {
552
+ // Returns active users editing the document
553
+ setActiveUsers(users);
554
+ },
555
+ }}
556
+ />
557
+ ```
558
+
559
+ </details>
560
+
561
+ <details>
562
+ <summary>
563
+ 📝 Additional options
564
+ </summary>
565
+
566
+ ## Reset editor content
567
+
568
+ ```
569
+
570
+ import { CLEAR_EDITOR_COMMAND } from './EditorLexical';
571
+
572
+ <>
573
+ <button
574
+ onClick={() => {
575
+ if (editorRef.current) {
576
+ editorRef.current.update(() => {
577
+ editorRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
578
+ });
579
+ }
580
+ }}
581
+ >
582
+ Reset
583
+ </button>
584
+ <Editor
585
+ ...props
586
+ editorRef={editorRef}
587
+ />
588
+ <>
589
+ ```
590
+
591
+ </details>
592
+
593
+ <details>
594
+ <summary>
595
+ 🧪 Testing
596
+ </summary>
597
+
598
+ ## Testing Overview
599
+
600
+ This project includes comprehensive testing with both **unit tests** (Vitest) and **end-to-end tests** (Playwright). The testing setup ensures reliability across different browsers and environments.
601
+
602
+ ### Prerequisites
603
+
604
+ Before running tests, make sure you have installed all dependencies:
605
+
606
+ ```bash
607
+ npm install
608
+ ```
609
+
610
+ ## Unit Tests (Vitest)
611
+
612
+ Unit tests are written with **Vitest** and **jsdom** for testing individual components and utilities.
613
+
614
+ ### Run Unit Tests
615
+
616
+ ```bash
617
+ # Run all unit tests
618
+ npm run test-unit
619
+
620
+ # Run unit tests in watch mode (auto-rerun on changes)
621
+ npm run test-unit-watch
622
+ ```
623
+
624
+ ### Unit Test Files Location
625
+ - `__tests__/unit/` - Unit test files
626
+ - Test files follow the pattern: `*.test.ts` or `*.test.tsx`
627
+
628
+ ## End-to-End Tests (Playwright)
629
+
630
+ E2E tests use **Playwright** to test the complete application flow in real browsers.
631
+
632
+ ### Run E2E Tests
633
+
634
+ ```bash
635
+ # Run all E2E tests (WebKit only for CI optimization)
636
+ npm run test:e2e
637
+
638
+ # Run E2E tests with UI mode (interactive)
639
+ npm run test:e2e:ui
640
+
641
+ # Run E2E tests in debug mode
642
+ npm run test:e2e:debug
643
+
644
+ # Run E2E tests in headed mode (visible browser)
645
+ npm run test:e2e:headed
646
+ ```
647
+
648
+ ### E2E Test Files Location
649
+ - `__tests__/e2e/` - End-to-end test files
650
+ - `__tests__/regression/` - Regression test files
651
+ - Test files follow the pattern: `*.spec.js`, `*.spec.mjs`, or `*.spec.ts`
652
+
653
+ ### Browser Support
654
+ - **WebKit** (Safari) - Primary browser for CI/CD
655
+ - **Chromium** and **Firefox** - Available for local testing
656
+
657
+ ## Test Server
658
+
659
+ The test server automatically starts when running E2E tests:
660
+
661
+ ```bash
662
+ # Manual test server start (if needed)
663
+ npm run start-test-server
664
+ ```
665
+
666
+ - **URL**: `http://localhost:3000`
667
+ - **Mode**: Full editor mode with all features enabled
668
+ - **Environment**: `VITE_LAYERS=true`
669
+
670
+ ## Test Configuration
671
+
672
+ ### Playwright Configuration
673
+ - **Config file**: `playwright.config.js`
674
+ - **Test directory**: `./__tests__/e2e/`
675
+ - **Browser**: WebKit (optimized for CI)
676
+ - **Base URL**: `http://localhost:3000`
677
+ - **Timeout**: 90 seconds per test
678
+ - **Retries**: 2 retries in CI, 0 locally
679
+
680
+ ### Vitest Configuration
681
+ - **Config file**: `vitest.config.mts`
682
+ - **Environment**: jsdom
683
+ - **Setup file**: `vitest.setup.mts`
684
+ - **Coverage**: V8 provider
685
+
686
+ ## CI/CD Testing
687
+
688
+ Tests run automatically on:
689
+ - **Push** to `main` or `dev` branches
690
+ - **Pull requests** to `main` or `dev` branches
691
+ - **Manual trigger** via GitHub Actions
692
+
693
+ ### GitHub Actions Workflow
694
+ - **File**: `.github/workflows/tests.yml`
695
+ - **Runner**: Ubuntu Latest
696
+ - **Node.js**: Version 20
697
+ - **Browser caching**: Playwright browsers cached for faster runs
698
+ - **Artifacts**: Test reports and traces uploaded on completion
699
+
700
+ ## Test Examples
701
+
702
+ ### Basic E2E Test Structure
703
+
704
+ ```javascript
705
+ // __tests__/e2e/example.spec.mjs
706
+ import { test, expect } from '@playwright/test';
707
+ import { focusEditor } from '../utils/index.mjs';
708
+
709
+ test('Can type text in editor', async ({ page }) => {
710
+ await page.goto('/');
711
+ await focusEditor(page);
712
+
713
+ const editor = page.locator('[contenteditable="true"]').first();
714
+ await editor.type('Hello World');
715
+
716
+ await expect(editor).toContainText('Hello World');
717
+ });
718
+ ```
719
+
720
+ ### Unit Test Structure
721
+
722
+ ```typescript
723
+ // __tests__/unit/example.test.ts
724
+ import { describe, it, expect } from 'vitest';
725
+ import { render } from '@testing-library/react';
726
+ import { Editor } from '../src/Editor';
727
+
728
+ describe('Editor Component', () => {
729
+ it('renders without crashing', () => {
730
+ const { container } = render(<Editor />);
731
+ expect(container).toBeTruthy();
732
+ });
733
+ });
734
+ ```
735
+
736
+ ## Debugging Tests
737
+
738
+ ### Debug E2E Tests
739
+
740
+ ```bash
741
+ # Run with Playwright Inspector
742
+ npm run test:e2e:debug
743
+
744
+ # Run specific test file
745
+ npx playwright test __tests__/e2e/TextEntry.spec.mjs --debug
746
+
747
+ # Run with headed browser
748
+ npm run test:e2e:headed
749
+ ```
750
+
751
+ ### View Test Reports
752
+
753
+ ```bash
754
+ # Open HTML report (after running tests)
755
+ npx playwright show-report
756
+
757
+ # View test traces (for failed tests)
758
+ npx playwright show-trace test-results/[test-name]/trace.zip
759
+ ```
760
+
761
+ ## Test Utilities
762
+
763
+ Common test utilities are available in `__tests__/utils/index.mjs`:
764
+
765
+ - `focusEditor(page)` - Focus the main editor
766
+ - `selectAll(page)` - Select all text in editor
767
+ - `moveLeft(page, count)` - Move cursor left
768
+ - `selectCharacters(page, count)` - Select specific number of characters
769
+ - `waitForSelector(page, selector)` - Wait for element to appear
770
+
771
+ ## Performance
772
+
773
+ ### Test Execution Times
774
+ - **Unit Tests**: ~10-30 seconds
775
+ - **E2E Tests (first run)**: ~3-4 minutes (includes browser installation)
776
+ - **E2E Tests (cached)**: ~1-2 minutes (uses cached browsers)
777
+
778
+ ### Optimization Features
779
+ - **Browser Caching**: Playwright browsers cached in CI
780
+ - **Single Worker**: Prevents race conditions in CI
781
+ - **WebKit Only**: Faster than multi-browser matrix
782
+ - **Smart Retries**: Auto-retry flaky tests
783
+
784
+ ## Troubleshooting
785
+
786
+ ### Common Issues
787
+
788
+ 1. **Port conflicts**: Ensure port 3000 is available
789
+ 2. **Browser installation**: Run `npx playwright install` if needed
790
+ 3. **Test timeouts**: Check if test server is running properly
791
+ 4. **Certificate errors**: Tests use HTTP to avoid HTTPS certificate issues
792
+
793
+ ### Reset Test Environment
794
+
795
+ ```bash
796
+ # Clear Playwright cache
797
+ npx playwright install --force
798
+
799
+ # Reset node_modules
800
+ rm -rf node_modules package-lock.json
801
+ npm install
802
+ ```
803
+
804
+ </details>
805
+
806
+ <details>
807
+ <summary>
808
+ ⚙️ Properties
809
+ </summary>
810
+
811
+ ```
812
+ onChange: (value: string | object) => undefined - A function that triggers every time the editor content changes and returns an HTML string or an object depending on the outputFormat property.
813
+ debounce?: number - Defines how often the onChange function is called, in milliseconds.
814
+ onBlur: (value: string | object) => undefined - A function that triggers when the editor loses focus and returns an HTML string or an object depending on the outputFormat property.
815
+ outputFormat?: 'html' | 'json' - The outputFormat property defines whether we want to output an HTML string or a JSON object. The default is JSON.
816
+ initialContent: string | object - The initial content for the editor.
817
+ maxHeight?: number - Sets the height of the editor. The default is 100%.
818
+ mode?: 'simple' | 'default' | 'full' | 'editor' - The editor mode. Depending on the chosen mode, functionality may be restricted or extended. The default is default.
819
+ fetchUploadMedia?: (file: File, success: (url: string, id: string, error?: (error?: Error) => void) => void) - Function to upload an image to your service.
820
+ fetchDeleteMedia?: (id: string, success: () => void, error?: (error?: Error) => void) - Helper function to delete an image.
821
+ maxFileSize?: number - The maximum image size in megabytes.
822
+ contentModalUploadImage?: React.FunctionComponent - A React component to replace content in DropZone.
823
+ maxImageSizeError?: () => void - A function that is called if the image exceeds the maxFileSize.
824
+ disable?: boolean - Toggles the editor into read-only mode.
825
+ ws?: { url: string, id: string, user: { color: string, name: string }, getActiveUsers: (users) => void } - WebSocket settings: URL, document ID, current user details, and function to return active users editing the document.
826
+ editorRef?: { current: EditorType | null } - Reference to the editor.
827
+ onTrack?: (event: string, params?: Record<string, unknown>) => void - Optional callback for analytics event tracking. Called when the user performs tracked actions (insert table, add image, open AI menu, etc.). If not provided, tracking is silently skipped. See the "Analytics (onTrack)" section for the full list of events.
828
+ ```
829
+
830
+ </details>