feat(17-02): richeditor field on TipTap with the untouched rule
- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel and relative URL policy, Edit HTML view, lossy note, skeleton while the lazily imported TipTap chunk loads, HTML view fallback when it fails - untouched values are submitted byte-identical; after an edit the value is the editor HTML (empty paragraph -> ""); a value from outside (the save's sanitized answer) reloads the editor - read-only renders through a client display allowlist, never as stored - TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check - richeditor.* phrase keys (en, pl), smoke specs, forms docs
This commit is contained in:
535
admin/package-lock.json
generated
535
admin/package-lock.json
generated
@@ -10,6 +10,9 @@
|
||||
"@fontsource/dm-sans": "5.3.0",
|
||||
"@internationalized/date": "3.12.4",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"reka-ui": "2.9.10",
|
||||
"vue": "3.5.35",
|
||||
@@ -1537,6 +1540,375 @@
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
|
||||
"integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz",
|
||||
"integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
|
||||
"integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz",
|
||||
"integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
|
||||
"integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz",
|
||||
"integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
|
||||
"integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz",
|
||||
"integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz",
|
||||
"integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz",
|
||||
"integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz",
|
||||
"integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz",
|
||||
"integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
|
||||
"integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz",
|
||||
"integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz",
|
||||
"integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz",
|
||||
"integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz",
|
||||
"integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz",
|
||||
"integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz",
|
||||
"integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
|
||||
"integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz",
|
||||
"integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz",
|
||||
"integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz",
|
||||
"integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz",
|
||||
"integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.42.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz",
|
||||
"integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/extension-blockquote": "3.31.4",
|
||||
"@tiptap/extension-bold": "3.31.4",
|
||||
"@tiptap/extension-bullet-list": "3.31.4",
|
||||
"@tiptap/extension-code": "3.31.4",
|
||||
"@tiptap/extension-code-block": "3.31.4",
|
||||
"@tiptap/extension-document": "3.31.4",
|
||||
"@tiptap/extension-dropcursor": "3.31.4",
|
||||
"@tiptap/extension-gapcursor": "3.31.4",
|
||||
"@tiptap/extension-hard-break": "3.31.4",
|
||||
"@tiptap/extension-heading": "3.31.4",
|
||||
"@tiptap/extension-horizontal-rule": "3.31.4",
|
||||
"@tiptap/extension-italic": "3.31.4",
|
||||
"@tiptap/extension-link": "3.31.4",
|
||||
"@tiptap/extension-list": "3.31.4",
|
||||
"@tiptap/extension-list-item": "3.31.4",
|
||||
"@tiptap/extension-list-keymap": "3.31.4",
|
||||
"@tiptap/extension-ordered-list": "3.31.4",
|
||||
"@tiptap/extension-paragraph": "3.31.4",
|
||||
"@tiptap/extension-strike": "3.31.4",
|
||||
"@tiptap/extension-text": "3.31.4",
|
||||
"@tiptap/extension-underline": "3.31.4",
|
||||
"@tiptap/extensions": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -3033,6 +3405,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||
@@ -3232,6 +3610,12 @@
|
||||
"integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -3380,6 +3764,145 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
|
||||
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
|
||||
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
|
||||
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.12",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.6",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
|
||||
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.12",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -3497,6 +4020,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
|
||||
@@ -4199,6 +4728,12 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"@fontsource/dm-sans": "5.3.0",
|
||||
"@internationalized/date": "3.12.4",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"reka-ui": "2.9.10",
|
||||
"vue": "3.5.35",
|
||||
|
||||
@@ -53,7 +53,7 @@ const describedBy = computed(() =>
|
||||
<span v-if="labelsGroup && field.label" :id="`${controlId}-label`" class="font-semibold">
|
||||
{{ field.label }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</span>
|
||||
<label v-else-if="!selfLabelled && !labelsGroup" :for="controlId" class="font-semibold">
|
||||
<label v-else-if="!selfLabelled && !labelsGroup" :id="`${controlId}-label`" :for="controlId" class="font-semibold">
|
||||
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
|
||||
</label>
|
||||
<FieldRenderer
|
||||
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
/** Types rendered by their own control in its read-only mode. */
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor'])
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor'])
|
||||
|
||||
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
||||
const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
424
admin/src/components/form/fields/RichEditorField.vue
Normal file
424
admin/src/components/form/fields/RichEditorField.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, type Component, type ComponentPublicInstance } from 'vue'
|
||||
import { Bold, Code, Heading3, Info, Italic, Link, List, ListOrdered, Quote, Redo2, Underline, Undo2, Unlink } from '@lucide/vue'
|
||||
import { ToolbarButton, ToolbarRoot, ToolbarSeparator } from 'reka-ui'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlClass, type FieldControlProps } from '../control'
|
||||
import { displayHtml } from '../richeditor/html'
|
||||
import { isLossy } from '../richeditor/lossy'
|
||||
import LinkPopover from '../richeditor/LinkPopover.vue'
|
||||
import type { Editor } from '../richeditor/editor'
|
||||
|
||||
// type: richeditor (D-10, UI-SPEC RichEditorField): an HTML WYSIWYG on
|
||||
// TipTap, which loads as its own chunk (a skeleton meanwhile; when the chunk
|
||||
// fails, the HTML view with richeditor.load_failed). Value contract:
|
||||
// - untouched rule: nothing is emitted until the administrator edits, so a
|
||||
// loaded value is saved byte-identical and never re-serialized by TipTap;
|
||||
// - after an edit in the editor the value is its HTML, "" for an empty
|
||||
// paragraph; the HTML view emits the textarea text as typed;
|
||||
// - a new value from outside (a save's answer, the sanitized HTML) replaces
|
||||
// the document and makes the field untouched again.
|
||||
// Read-only (preview) shows the value through displayHtml, never as stored.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
type EditorModule = typeof import('../richeditor/editor')
|
||||
type Status = 'loading' | 'ready' | 'failed'
|
||||
|
||||
const readOnly = computed(() => !!props.field.readOnly)
|
||||
const stored = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const display = computed(() => (readOnly.value ? displayHtml(stored.value) : ''))
|
||||
|
||||
const status = ref<Status>('loading')
|
||||
const source = ref(false)
|
||||
const sourceText = ref('')
|
||||
const lossy = ref(isLossy(stored.value))
|
||||
const mount = ref<HTMLElement | null>(null)
|
||||
const linkButton = ref<ComponentPublicInstance | null>(null)
|
||||
const editor = shallowRef<Editor | null>(null)
|
||||
// Bumped on every editor transaction, so the toolbar state recomputes.
|
||||
const tick = ref(0)
|
||||
const linkOpen = ref(false)
|
||||
const linkHref = ref('')
|
||||
|
||||
let module: EditorModule | null = null
|
||||
let disposed = false
|
||||
// The last value this field emitted: its echo through modelValue is not an
|
||||
// outside change.
|
||||
let lastEmitted: string | null = null
|
||||
// The HTML view's text when it opened: leaving it unchanged re-parses nothing.
|
||||
let sourceOpenedWith = ''
|
||||
|
||||
const labelId = computed(() => `${props.controlId}-label`)
|
||||
const containerClass = computed(() => [
|
||||
controlClass(props.invalid).replace(' px-3.5', ''),
|
||||
'overflow-hidden focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]',
|
||||
])
|
||||
|
||||
function areaAttributes(): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
class: 'summer-markdown min-h-[160px] max-h-[480px] overflow-y-auto px-3.5 py-2.5 outline-none',
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-labelledby': labelId.value,
|
||||
'data-richeditor-area': '',
|
||||
}
|
||||
if (!source.value) {
|
||||
out.id = props.controlId
|
||||
}
|
||||
if (props.describedBy) {
|
||||
out['aria-describedby'] = props.describedBy
|
||||
}
|
||||
if (props.invalid) {
|
||||
out['aria-invalid'] = 'true'
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function setValue(value: string): void {
|
||||
lastEmitted = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function onEditorChange(): void {
|
||||
if (editor.value && module) {
|
||||
setValue(module.editorValue(editor.value))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (readOnly.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
module = await import('../richeditor/editor')
|
||||
} catch {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
status.value = 'failed'
|
||||
sourceText.value = stored.value
|
||||
sourceOpenedWith = stored.value
|
||||
source.value = true
|
||||
return
|
||||
}
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
status.value = 'ready'
|
||||
await nextTick()
|
||||
if (disposed || !mount.value) {
|
||||
return
|
||||
}
|
||||
editor.value = module.createRichEditor({
|
||||
element: mount.value,
|
||||
content: stored.value,
|
||||
attributes: areaAttributes(),
|
||||
onChange: onEditorChange,
|
||||
onTransaction: () => {
|
||||
tick.value++
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
editor.value?.destroy()
|
||||
editor.value = null
|
||||
})
|
||||
|
||||
// A value from outside (load, the save's answer): show it, untouched again.
|
||||
watch(stored, (value) => {
|
||||
if (value === lastEmitted) {
|
||||
return
|
||||
}
|
||||
lastEmitted = null
|
||||
lossy.value = isLossy(value)
|
||||
if (source.value) {
|
||||
sourceText.value = value
|
||||
sourceOpenedWith = value
|
||||
}
|
||||
if (editor.value && module) {
|
||||
module.loadContent(editor.value, value)
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.invalid, () => props.describedBy, source], () => {
|
||||
editor.value?.setOptions({ editorProps: { attributes: areaAttributes() } })
|
||||
})
|
||||
|
||||
interface Tool {
|
||||
key: string
|
||||
icon: Component
|
||||
/** Full phrase key: the SPA key check reads literals only. */
|
||||
label: string
|
||||
run: (editor: Editor) => void
|
||||
active?: (editor: Editor) => boolean
|
||||
enabled?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
const groups: Tool[][] = [
|
||||
[
|
||||
{ key: 'bold', icon: Bold, label: 'backend::lang.richeditor.bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
||||
{ key: 'italic', icon: Italic, label: 'backend::lang.richeditor.italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
||||
{
|
||||
key: 'underline',
|
||||
icon: Underline,
|
||||
label: 'backend::lang.richeditor.underline',
|
||||
run: (e) => e.chain().focus().toggleUnderline().run(),
|
||||
active: (e) => e.isActive('underline'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'heading',
|
||||
icon: Heading3,
|
||||
label: 'backend::lang.richeditor.heading',
|
||||
run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
active: (e) => e.isActive('heading', { level: 3 }),
|
||||
},
|
||||
{
|
||||
key: 'bullet_list',
|
||||
icon: List,
|
||||
label: 'backend::lang.richeditor.bullet_list',
|
||||
run: (e) => e.chain().focus().toggleBulletList().run(),
|
||||
active: (e) => e.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
key: 'ordered_list',
|
||||
icon: ListOrdered,
|
||||
label: 'backend::lang.richeditor.ordered_list',
|
||||
run: (e) => e.chain().focus().toggleOrderedList().run(),
|
||||
active: (e) => e.isActive('orderedList'),
|
||||
},
|
||||
{ key: 'quote', icon: Quote, label: 'backend::lang.richeditor.quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
||||
],
|
||||
]
|
||||
|
||||
const history: Tool[] = [
|
||||
{ key: 'undo', icon: Undo2, label: 'backend::lang.richeditor.undo', run: (e) => e.chain().focus().undo().run(), enabled: (e) => e.can().undo() },
|
||||
{ key: 'redo', icon: Redo2, label: 'backend::lang.richeditor.redo', run: (e) => e.chain().focus().redo().run(), enabled: (e) => e.can().redo() },
|
||||
]
|
||||
|
||||
/** The toolbar's view of the editor; recomputed after every transaction. */
|
||||
const view = computed(() => {
|
||||
void tick.value
|
||||
const current = editor.value
|
||||
if (!current || source.value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
editor: current,
|
||||
inLink: current.isActive('link'),
|
||||
hasSelection: !current.state.selection.empty,
|
||||
}
|
||||
})
|
||||
|
||||
function pressed(tool: Tool): 'true' | 'false' | undefined {
|
||||
if (!tool.active) {
|
||||
return undefined
|
||||
}
|
||||
return view.value && tool.active(view.value.editor) ? 'true' : 'false'
|
||||
}
|
||||
|
||||
function disabled(tool: Tool): boolean {
|
||||
const state = view.value
|
||||
return !state || (tool.enabled !== undefined && !tool.enabled(state.editor))
|
||||
}
|
||||
|
||||
function run(tool: Tool): void {
|
||||
if (view.value && !disabled(tool)) {
|
||||
tool.run(view.value.editor)
|
||||
}
|
||||
}
|
||||
|
||||
const linkEnabled = computed(() => !!view.value && (view.value.hasSelection || view.value.inLink))
|
||||
|
||||
function openLink(): void {
|
||||
if (!view.value || !linkEnabled.value) {
|
||||
return
|
||||
}
|
||||
const href = view.value.editor.getAttributes('link').href
|
||||
linkHref.value = typeof href === 'string' ? href : ''
|
||||
linkOpen.value = true
|
||||
}
|
||||
|
||||
function applyLink(url: string): void {
|
||||
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
||||
linkOpen.value = false
|
||||
}
|
||||
|
||||
function removeLink(): void {
|
||||
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
linkOpen.value = false
|
||||
}
|
||||
|
||||
function onLinkClosed(changed: boolean): void {
|
||||
linkOpen.value = false
|
||||
if (changed) {
|
||||
editor.value?.commands.focus()
|
||||
return
|
||||
}
|
||||
const element = linkButton.value?.$el as HTMLElement | undefined
|
||||
element?.focus()
|
||||
}
|
||||
|
||||
function toggleSource(): void {
|
||||
if (!source.value) {
|
||||
linkOpen.value = false
|
||||
sourceText.value = stored.value
|
||||
sourceOpenedWith = stored.value
|
||||
source.value = true
|
||||
return
|
||||
}
|
||||
// Re-parse only when the text changed; the value stays that text until
|
||||
// the next edit in the editor.
|
||||
if (editor.value && module && sourceText.value !== sourceOpenedWith) {
|
||||
module.loadContent(editor.value, sourceText.value)
|
||||
}
|
||||
source.value = false
|
||||
}
|
||||
|
||||
function onSourceInput(event: Event): void {
|
||||
sourceText.value = (event.target as HTMLTextAreaElement).value
|
||||
setValue(sourceText.value)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k' && view.value) {
|
||||
event.preventDefault()
|
||||
openLink()
|
||||
}
|
||||
}
|
||||
|
||||
const toolClass =
|
||||
'inline-flex size-8 items-center justify-center rounded-pager text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text disabled:cursor-not-allowed disabled:opacity-40'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="readOnly"
|
||||
:id="controlId"
|
||||
data-richeditor-readonly
|
||||
class="rounded-control border border-border bg-subtle px-3.5 py-2.5"
|
||||
>
|
||||
<div v-if="display !== ''" class="summer-markdown" v-html="display" />
|
||||
<span v-else class="text-muted">{{ t('backend::lang.list.empty_value') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'loading'" data-richeditor-loading aria-busy="true" class="h-[200px] rounded-control bg-skel" />
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<p v-if="status === 'failed'" data-richeditor-load-failed class="text-[13px] text-muted">
|
||||
{{ t('backend::lang.richeditor.load_failed') }}
|
||||
</p>
|
||||
<div :data-richeditor="field.name" :class="containerClass" @keydown="onKeydown">
|
||||
<ToolbarRoot
|
||||
v-if="status === 'ready'"
|
||||
:aria-label="t('backend::lang.richeditor.toolbar')"
|
||||
:aria-controls="controlId"
|
||||
class="flex flex-wrap items-center gap-1 border-b border-border bg-subtle p-1"
|
||||
>
|
||||
<template v-for="(group, index) in groups" :key="index">
|
||||
<ToolbarSeparator v-if="index > 0" class="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
v-for="tool in group"
|
||||
:key="tool.key"
|
||||
:data-tool="tool.key"
|
||||
:aria-label="t(tool.label)"
|
||||
:title="t(tool.label)"
|
||||
:aria-pressed="pressed(tool)"
|
||||
:disabled="disabled(tool)"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="run(tool)"
|
||||
>
|
||||
<component :is="tool.icon" :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</template>
|
||||
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
|
||||
<LinkPopover
|
||||
:open="linkOpen"
|
||||
:href="linkHref"
|
||||
:in-link="!!view?.inLink"
|
||||
:id-prefix="controlId"
|
||||
@update:open="(open) => { if (!open) linkOpen = false }"
|
||||
@apply="applyLink"
|
||||
@remove="removeLink"
|
||||
@closed="onLinkClosed"
|
||||
>
|
||||
<ToolbarButton
|
||||
ref="linkButton"
|
||||
data-tool="link"
|
||||
:aria-label="t('backend::lang.richeditor.link')"
|
||||
:title="t('backend::lang.richeditor.link')"
|
||||
:aria-pressed="view?.inLink ? 'true' : 'false'"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="linkOpen ? 'true' : 'false'"
|
||||
:disabled="!linkEnabled"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="openLink"
|
||||
>
|
||||
<Link :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</LinkPopover>
|
||||
<ToolbarButton
|
||||
data-tool="unlink"
|
||||
:aria-label="t('backend::lang.richeditor.unlink')"
|
||||
:title="t('backend::lang.richeditor.unlink')"
|
||||
:disabled="!view?.inLink"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="removeLink"
|
||||
>
|
||||
<Unlink :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarSeparator class="mx-1 h-5 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
v-for="tool in history"
|
||||
:key="tool.key"
|
||||
:data-tool="tool.key"
|
||||
:aria-label="t(tool.label)"
|
||||
:title="t(tool.label)"
|
||||
:disabled="disabled(tool)"
|
||||
:class="toolClass"
|
||||
@mousedown.prevent
|
||||
@click="run(tool)"
|
||||
>
|
||||
<component :is="tool.icon" :size="16" aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
data-tool="source"
|
||||
:aria-pressed="source ? 'true' : 'false'"
|
||||
class="ml-auto inline-flex h-8 items-center gap-1.5 rounded-pager px-2 text-[13px] text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text aria-pressed:bg-sel aria-pressed:text-text"
|
||||
@mousedown.prevent
|
||||
@click="toggleSource"
|
||||
>
|
||||
<Code :size="16" aria-hidden="true" />
|
||||
{{ t('backend::lang.richeditor.source') }}
|
||||
</ToolbarButton>
|
||||
</ToolbarRoot>
|
||||
<p
|
||||
v-if="lossy && status === 'ready'"
|
||||
role="note"
|
||||
data-richeditor-lossy
|
||||
class="flex items-start gap-2 border-b border-border bg-surface px-3.5 py-2.5 text-[13px] text-muted"
|
||||
>
|
||||
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.richeditor.lossy_note') }}</span>
|
||||
</p>
|
||||
<div v-show="!source" ref="mount" />
|
||||
<textarea
|
||||
v-if="source"
|
||||
:id="controlId"
|
||||
data-richeditor-source
|
||||
:name="field.name"
|
||||
:value="sourceText"
|
||||
spellcheck="false"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="block min-h-[160px] w-full resize-y bg-surface px-3.5 py-2.5 font-mono text-[13px] text-text outline-none"
|
||||
@input="onSourceInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,6 +22,9 @@
|
||||
// MLField locale switcher around it, when the type is translatable), holds a
|
||||
// form value and is labelled as a group. Look such fields up with rendererForField and
|
||||
// isRegisteredField, which know the field, not only its type.
|
||||
// Phase 17 adds the richeditor control (D-10): an HTML string value edited
|
||||
// with a lazily loaded TipTap editor, sent unchanged until the administrator
|
||||
// edits it.
|
||||
import type { Component } from 'vue'
|
||||
import type { FormField } from '../../api/types'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
@@ -34,6 +37,7 @@ import PasswordField from './fields/PasswordField.vue'
|
||||
import MLPluginField from './fields/MLPluginField.vue'
|
||||
import PluginField from './fields/PluginField.vue'
|
||||
import PermissionEditorField from './fields/PermissionEditorField.vue'
|
||||
import RichEditorField from './fields/RichEditorField.vue'
|
||||
import RelationManager from '../relation/RelationManager.vue'
|
||||
import RelationField from './fields/RelationField.vue'
|
||||
import SwitchField from './fields/SwitchField.vue'
|
||||
@@ -79,6 +83,7 @@ const renderers = new Map<string, Component>([
|
||||
['mltext', MLTextField],
|
||||
['mltextarea', MLTextareaField],
|
||||
['mlmarkdown', MLMarkdownField],
|
||||
['richeditor', RichEditorField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
|
||||
133
admin/src/components/form/richeditor/LinkPopover.vue
Normal file
133
admin/src/components/form/richeditor/LinkPopover.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from 'reka-ui'
|
||||
import { t } from '../../../app/i18n'
|
||||
import Button from '../../ui/Button.vue'
|
||||
import { controlClass } from '../control'
|
||||
import { isAllowedLink } from './html'
|
||||
|
||||
// The richeditor link popover (UI-SPEC RichEditorField, "Link popover"),
|
||||
// anchored on the toolbar's Link button (the default slot). Enter applies,
|
||||
// Esc closes; on close the field decides where focus goes (`closed` tells it
|
||||
// whether a link was applied or removed). A URL outside the client policy
|
||||
// (http, https, mailto, tel or relative) shows richeditor.link_invalid and
|
||||
// is not applied; the server sanitizer is still the gate.
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
/** The current link's href, or "" when the selection is not in a link. */
|
||||
href: string
|
||||
/** Whether the selection is inside a link: offers "Remove link". */
|
||||
inLink: boolean
|
||||
/** Prefix of the element ids. */
|
||||
idPrefix: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
apply: [url: string]
|
||||
remove: []
|
||||
/** Esc or a click outside asks to close. */
|
||||
'update:open': [open: boolean]
|
||||
/** The popover has closed; `changed` is true after apply or remove. */
|
||||
closed: [changed: boolean]
|
||||
}>()
|
||||
|
||||
const url = ref('')
|
||||
const invalid = ref(false)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
let changed = false
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
url.value = props.href
|
||||
invalid.value = false
|
||||
changed = false
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
},
|
||||
)
|
||||
|
||||
function apply(): void {
|
||||
const value = url.value.trim()
|
||||
if (!isAllowedLink(value)) {
|
||||
invalid.value = true
|
||||
input.value?.focus()
|
||||
return
|
||||
}
|
||||
changed = true
|
||||
emit('apply', value)
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
changed = true
|
||||
emit('remove')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
// The popover sits in a form: Enter applies the link, it never submits.
|
||||
event.preventDefault()
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean): void {
|
||||
emit('update:open', open)
|
||||
}
|
||||
|
||||
function onCloseAutoFocus(event: Event): void {
|
||||
// There is no trigger to return to: the field focuses the Link button or
|
||||
// the editor itself.
|
||||
event.preventDefault()
|
||||
emit('closed', changed)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot :open="open" @update:open="onOpenChange">
|
||||
<PopoverAnchor as-child>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-richeditor-link
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
class="z-50 flex w-[320px] flex-col gap-2 rounded-inner border border-border bg-surface p-4 text-text shadow-menu"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
>
|
||||
<label :for="`${idPrefix}-link-url`" class="font-semibold">{{ t('backend::lang.richeditor.link_url') }}</label>
|
||||
<input
|
||||
:id="`${idPrefix}-link-url`"
|
||||
ref="input"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="invalid ? `${idPrefix}-link-error` : undefined"
|
||||
@input="invalid = false"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<p v-if="invalid" :id="`${idPrefix}-link-error`" class="flex items-center gap-1.5 text-[13px] text-danger">
|
||||
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
|
||||
<span>{{ t('backend::lang.richeditor.link_invalid') }}</span>
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button v-if="inLink" variant="ghost" size="sm" data-action="richeditor-unlink" @click="remove">
|
||||
{{ t('backend::lang.richeditor.unlink') }}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" data-action="richeditor-link-apply" @click="apply">
|
||||
{{ t('backend::lang.richeditor.link_apply') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
71
admin/src/components/form/richeditor/editor.ts
Normal file
71
admin/src/components/form/richeditor/editor.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// The TipTap editor of the richeditor field (D-10). RichEditorField loads
|
||||
// this module with a dynamic import, so TipTap is its own chunk and a form
|
||||
// without a richeditor never downloads it. The editor mounts on an element
|
||||
// the field owns (no @tiptap/vue-3) with StarterKit configured as the
|
||||
// UI-SPEC says: headings level 3 only; no code, code block or horizontal
|
||||
// rule; links that never open on click, never autolink and take only the
|
||||
// policy URLs; no trailing node (it would append a paragraph and count as an
|
||||
// edit); TipTap's injected CSS off, the content uses .summer-markdown.
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { LINK_SCHEMES, isAllowedLink } from './html'
|
||||
|
||||
export type { Editor }
|
||||
|
||||
export interface RichEditorOptions {
|
||||
/** The element the editing area mounts in. */
|
||||
element: HTMLElement
|
||||
/** The stored HTML, parsed once; the field keeps the string itself. */
|
||||
content: string
|
||||
/** Attributes of the editing area (class, role, aria-*). */
|
||||
attributes: Record<string, string>
|
||||
/** Called after every transaction that changed the document. */
|
||||
onChange: () => void
|
||||
/** Called after every transaction (toolbar state). */
|
||||
onTransaction: () => void
|
||||
}
|
||||
|
||||
/** Builds the field's editor on options.element. */
|
||||
export function createRichEditor(options: RichEditorOptions): Editor {
|
||||
return new Editor({
|
||||
element: options.element,
|
||||
injectCSS: false,
|
||||
content: options.content,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [3] },
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
trailingNode: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
protocols: [...LINK_SCHEMES],
|
||||
HTMLAttributes: { target: null, rel: null, class: null },
|
||||
isAllowedUri: (url) => isAllowedLink(url),
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: { attributes: options.attributes },
|
||||
onUpdate: ({ transaction, appendedTransactions }) => {
|
||||
if (transaction.docChanged || appendedTransactions.some((tr) => tr.docChanged)) {
|
||||
options.onChange()
|
||||
}
|
||||
},
|
||||
onTransaction: () => options.onTransaction(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the document with html without an update event and outside the
|
||||
* undo history, so neither the untouched rule nor undo sees the load.
|
||||
*/
|
||||
export function loadContent(editor: Editor, html: string): void {
|
||||
editor.chain().setMeta('addToHistory', false).setContent(html, { emitUpdate: false }).run()
|
||||
}
|
||||
|
||||
/** The editor's value: its HTML, or "" when it holds only an empty paragraph. */
|
||||
export function editorValue(editor: Editor): string {
|
||||
return editor.isEmpty ? '' : editor.getHTML()
|
||||
}
|
||||
104
admin/src/components/form/richeditor/html.ts
Normal file
104
admin/src/components/form/richeditor/html.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Link and display policy of the richeditor field (D-10, T-17-10, T-17-11).
|
||||
// The server sanitizer (seaglass) is the gate on every save; these client
|
||||
// checks keep the editor from offering what the gate would remove and keep a
|
||||
// stored value that predates the gate from running in the admin.
|
||||
import { EDITOR_TAGS, inertFragment } from './lossy'
|
||||
|
||||
/** Link schemes the editor applies; anything else must be a relative URL. */
|
||||
export const LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']
|
||||
|
||||
// Browsers ignore ASCII control characters, spaces and DEL inside a scheme,
|
||||
// so "java\tscript:" is read with them removed.
|
||||
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
||||
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
||||
|
||||
/**
|
||||
* Whether url may be a link href: an http, https, mailto or tel URL, or a
|
||||
* relative one (a path starting with /, #, ? or ./, or a bare relative path).
|
||||
*/
|
||||
export function isAllowedLink(url: string | null | undefined): boolean {
|
||||
if (url == null) {
|
||||
return false
|
||||
}
|
||||
const cleaned = url.replace(URL_IGNORED, '')
|
||||
if (cleaned === '') {
|
||||
return false
|
||||
}
|
||||
const scheme = URL_SCHEME.exec(cleaned)
|
||||
if (scheme) {
|
||||
return LINK_SCHEMES.includes(scheme[1]!.toLowerCase())
|
||||
}
|
||||
try {
|
||||
new URL(cleaned, 'https://admin.invalid/')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Elements dropped with their content when a value is displayed. */
|
||||
const DROPPED = new Set([
|
||||
'script',
|
||||
'style',
|
||||
'template',
|
||||
'noscript',
|
||||
'iframe',
|
||||
'frame',
|
||||
'frameset',
|
||||
'object',
|
||||
'embed',
|
||||
'applet',
|
||||
'svg',
|
||||
'math',
|
||||
'title',
|
||||
'head',
|
||||
'meta',
|
||||
'link',
|
||||
'base',
|
||||
])
|
||||
|
||||
function clean(parent: ParentNode): void {
|
||||
for (const node of Array.from(parent.childNodes)) {
|
||||
if (node.nodeType === Node.COMMENT_NODE) {
|
||||
node.remove()
|
||||
continue
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
continue
|
||||
}
|
||||
const element = node as Element
|
||||
const name = element.localName.toLowerCase()
|
||||
if (DROPPED.has(name)) {
|
||||
element.remove()
|
||||
continue
|
||||
}
|
||||
clean(element)
|
||||
if (!EDITOR_TAGS.has(name)) {
|
||||
// Another element keeps its text: its children take its place.
|
||||
element.replaceWith(...Array.from(element.childNodes))
|
||||
continue
|
||||
}
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const keep = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
|
||||
if (!keep) {
|
||||
element.removeAttribute(attribute.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-only rendering of a stored value: only the editor's elements,
|
||||
* only allowed link hrefs, no other attribute, no comment. It is what may be
|
||||
* bound as HTML in the admin; the stored value itself is never bound.
|
||||
*/
|
||||
export function displayHtml(html: string): string {
|
||||
if (html === '') {
|
||||
return ''
|
||||
}
|
||||
const fragment = inertFragment(html)
|
||||
clean(fragment)
|
||||
const holder = document.createElement('div')
|
||||
holder.append(fragment)
|
||||
return holder.innerHTML
|
||||
}
|
||||
52
admin/src/components/form/richeditor/lossy.ts
Normal file
52
admin/src/components/form/richeditor/lossy.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// What the richeditor's TipTap schema can show (UI-SPEC RichEditorField,
|
||||
// "Lossy note"). Stored HTML with any other element still opens: the field
|
||||
// keeps the stored string until the administrator edits (the untouched
|
||||
// rule), and the HTML view edits it without loss. This module has no TipTap
|
||||
// import, so the field can decide before the editor chunk loads.
|
||||
|
||||
/** Elements the editor shows; compared by lower-cased tag name. */
|
||||
export const EDITOR_TAGS: ReadonlySet<string> = new Set([
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'u',
|
||||
's',
|
||||
'h3',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'a',
|
||||
'div',
|
||||
'span',
|
||||
])
|
||||
|
||||
/**
|
||||
* Parses html into an inert template: nothing in it runs or loads (no
|
||||
* scripts, no image requests), so stored markup can be inspected safely.
|
||||
*/
|
||||
export function inertFragment(html: string): DocumentFragment {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
return template.content
|
||||
}
|
||||
|
||||
/** The distinct lower-cased tag names of html's elements, in document order. */
|
||||
export function tagNames(html: string): string[] {
|
||||
if (html === '') {
|
||||
return []
|
||||
}
|
||||
const out = new Set<string>()
|
||||
for (const element of Array.from(inertFragment(html).querySelectorAll('*'))) {
|
||||
out.add(element.localName.toLowerCase())
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
/** Whether html holds an element the editor cannot show (the lossy note). */
|
||||
export function isLossy(html: string): boolean {
|
||||
return tagNames(html).some((name) => !EDITOR_TAGS.has(name))
|
||||
}
|
||||
27
admin/tests/fixtures/companions.form-schema.json
vendored
Normal file
27
admin/tests/fixtures/companions.form-schema.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Link",
|
||||
"modelClass": "Link",
|
||||
"fields": [
|
||||
{ "name": "title", "type": "text", "label": "Title", "span": "left" },
|
||||
{ "name": "description", "type": "richeditor", "label": "Description", "comment": "Shown under the title." }
|
||||
],
|
||||
"messages": {
|
||||
"create": { "other": "New link" },
|
||||
"update": { "other": "Edit link" },
|
||||
"saved": { "other": "Saved" },
|
||||
"deleteConfirm": { "other": "Delete this record?" },
|
||||
"deleted": { "other": "Record deleted" },
|
||||
"preview": { "other": "Link details" },
|
||||
"edit": { "other": "Edit link" }
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/companions/links",
|
||||
"create": { "redirect": "acme/companions/links/:id", "redirectClose": "acme/companions/links" },
|
||||
"update": { "redirect": "acme/companions/links", "redirectClose": "acme/companions/links" }
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"assets": { "scripts": [], "styles": [] }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
75
admin/tests/fixtures/lang.json
vendored
75
admin/tests/fixtures/lang.json
vendored
@@ -609,6 +609,81 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.richeditor.bold": {
|
||||
"other": "Pogrubienie"
|
||||
},
|
||||
"backend::lang.richeditor.bullet_list": {
|
||||
"other": "Lista punktowana"
|
||||
},
|
||||
"backend::lang.richeditor.heading": {
|
||||
"other": "Nagłówek"
|
||||
},
|
||||
"backend::lang.richeditor.italic": {
|
||||
"other": "Kursywa"
|
||||
},
|
||||
"backend::lang.richeditor.link": {
|
||||
"other": "Link"
|
||||
},
|
||||
"backend::lang.richeditor.link_apply": {
|
||||
"other": "Zastosuj link"
|
||||
},
|
||||
"backend::lang.richeditor.link_invalid": {
|
||||
"other": "Link musi używać http, https, mailto, tel albo adresu względnego."
|
||||
},
|
||||
"backend::lang.richeditor.link_url": {
|
||||
"other": "Adres linku"
|
||||
},
|
||||
"backend::lang.richeditor.load_failed": {
|
||||
"other": "Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio."
|
||||
},
|
||||
"backend::lang.richeditor.lossy_note": {
|
||||
"other": "Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty."
|
||||
},
|
||||
"backend::lang.richeditor.markup_changed": {
|
||||
"other": "Filtr HTML zmienił część znaczników."
|
||||
},
|
||||
"backend::lang.richeditor.ordered_list": {
|
||||
"other": "Lista numerowana"
|
||||
},
|
||||
"backend::lang.richeditor.quote": {
|
||||
"other": "Cytat"
|
||||
},
|
||||
"backend::lang.richeditor.redo": {
|
||||
"other": "Ponów"
|
||||
},
|
||||
"backend::lang.richeditor.removed_attributes": {
|
||||
"one": "Usunięto :count atrybut: :names",
|
||||
"few": "Usunięto :count atrybuty: :names",
|
||||
"many": "Usunięto :count atrybutów: :names",
|
||||
"other": "Usunięto :count atrybutu: :names"
|
||||
},
|
||||
"backend::lang.richeditor.removed_comments": {
|
||||
"one": "Usunięto :count komentarz HTML",
|
||||
"few": "Usunięto :count komentarze HTML",
|
||||
"many": "Usunięto :count komentarzy HTML",
|
||||
"other": "Usunięto :count komentarza HTML"
|
||||
},
|
||||
"backend::lang.richeditor.removed_elements": {
|
||||
"one": "Usunięto :count element: :names",
|
||||
"few": "Usunięto :count elementy: :names",
|
||||
"many": "Usunięto :count elementów: :names",
|
||||
"other": "Usunięto :count elementu: :names"
|
||||
},
|
||||
"backend::lang.richeditor.source": {
|
||||
"other": "Edytuj HTML"
|
||||
},
|
||||
"backend::lang.richeditor.toolbar": {
|
||||
"other": "Formatowanie"
|
||||
},
|
||||
"backend::lang.richeditor.underline": {
|
||||
"other": "Podkreślenie"
|
||||
},
|
||||
"backend::lang.richeditor.undo": {
|
||||
"other": "Cofnij"
|
||||
},
|
||||
"backend::lang.richeditor.unlink": {
|
||||
"other": "Usuń link"
|
||||
},
|
||||
"backend::lang.settings.back": {
|
||||
"other": "Wróć do ustawień"
|
||||
},
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -7,6 +7,7 @@ import type { components } from '../../src/api/schema'
|
||||
import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import companionsFormSchemaJson from './companions.form-schema.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import extensionListSchemaJson from './extension.list-schema.json'
|
||||
import extensionPartialJson from './extension.partial.json'
|
||||
@@ -46,6 +47,8 @@ export const deferredRelationSchemaFixture: {
|
||||
} = deferredRelationSchemaJson
|
||||
/** A protected file list: a captioned image, a pending image and a PDF (Phase 12.2). */
|
||||
export const deferredFilesFixture: S['cabana.Envelope-array_cabana_FileItem'] = deferredFilesJson
|
||||
/** A link form with the Phase 17 field types: richeditor, repeater and a dependent dropdown. */
|
||||
export const companionsFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = companionsFormSchemaJson
|
||||
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
||||
|
||||
190
admin/tests/form/RichEditorField.test.ts
Normal file
190
admin/tests/form/RichEditorField.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import RichEditorField from '../../src/components/form/fields/RichEditorField.vue'
|
||||
import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html'
|
||||
import { isLossy } from '../../src/components/form/richeditor/lossy'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06
|
||||
// completes the coverage; these prove the tracer slice end to end.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
|
||||
// b, div and attributes: markup TipTap would re-serialize if it were asked.
|
||||
const STORED = '<P class="lead">Hello <b>bold</b> world</P>\n<div data-x="1" style="color: red">Box</div><!-- kept -->'
|
||||
|
||||
function record(description: string | null) {
|
||||
return { data: { id: 1, title: 'Docs', description }, meta: {} }
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record(STORED) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function editorReady(wrapper: VueWrapper): Promise<void> {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(wrapper.find('[data-richeditor-area]').exists()).toBe(true)
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('RichEditorField', () => {
|
||||
it('RichEditorField tracer: untouched value is submitted byte-identical', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: record(body.description as string) }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
|
||||
// The editor shows the stored text; b and div are inside the editor's set.
|
||||
expect(wrapper.find('[data-richeditor-area]').text()).toContain('Hello bold world')
|
||||
expect(wrapper.find('[data-richeditor-lossy]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-richeditor-area]').attributes('aria-labelledby')).toBe('field-description-label')
|
||||
expect(wrapper.find('#field-description-label').text()).toBe('Description')
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(body).not.toBeNull()
|
||||
expect(body!.description).toBe(STORED)
|
||||
expect(body!.description === STORED).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the lossy note for markup outside the editor schema and keeps the HTML view lossless', async () => {
|
||||
const lossy = '<table><tr><td>Cell</td></tr></table><p>Text</p>'
|
||||
const { wrapper } = await mountApp('/acme/companions/links/1', routes({ [`GET ${RECORD}`]: { body: record(lossy) } }), {
|
||||
attach: true,
|
||||
})
|
||||
await editorReady(wrapper)
|
||||
expect(wrapper.find('[data-richeditor-lossy]').attributes('role')).toBe('note')
|
||||
|
||||
await wrapper.find('[data-tool="source"]').trigger('click')
|
||||
const textarea = wrapper.find<HTMLTextAreaElement>('[data-richeditor-source]')
|
||||
expect(textarea.element.value).toBe(lossy)
|
||||
expect(textarea.attributes('id')).toBe('field-description')
|
||||
})
|
||||
|
||||
it('sends the editor HTML after an edit and shows what the server removed', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const sanitized = '<h3>Hello bold world</h3>'
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`GET ${RECORD}`]: { body: record('<p>Hello <b>bold</b> world</p>') },
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return {
|
||||
body: {
|
||||
data: { id: 1, title: 'Docs', description: sanitized },
|
||||
meta: { notices: [{ field: 'description', message: '1 element was removed: b' }] },
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
|
||||
await wrapper.find('[data-tool="heading"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-tool="heading"]').attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(body!.description).toBe('<h3>Hello <strong>bold</strong> world</h3>')
|
||||
expect(wrapper.find('[data-field="description"] [data-field-notice]').text()).toBe('1 element was removed: b')
|
||||
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello bold world</h3>')
|
||||
})
|
||||
|
||||
it('RichEditorField edit emits editor HTML', async () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
||||
const wrapper = mount(RichEditorField, {
|
||||
props: { field, modelValue: '<p>Hello</p>', controlId: 'f-body' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(wrapper.find('[data-richeditor-loading]').attributes('aria-busy')).toBe('true')
|
||||
await editorReady(wrapper)
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
|
||||
await wrapper.find('[data-tool="bold"]').trigger('click')
|
||||
await wrapper.find('[data-tool="heading"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(emitted.length).toBeGreaterThan(0)
|
||||
const last = emitted[emitted.length - 1]![0]
|
||||
expect(last).toBe('<h3>Hello</h3>')
|
||||
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello</h3>')
|
||||
|
||||
// The HTML view emits the text as typed.
|
||||
await wrapper.find('[data-tool="source"]').trigger('click')
|
||||
await wrapper.find('[data-richeditor-source]').setValue('<p>Typed <em>here</em></p>')
|
||||
const afterSource = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(afterSource[afterSource.length - 1]![0]).toBe('<p>Typed <em>here</em></p>')
|
||||
})
|
||||
|
||||
it('renders read-only values through the display policy, never as stored', () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField
|
||||
const wrapper = mount(RichEditorField, {
|
||||
props: {
|
||||
field,
|
||||
modelValue: '<p onclick="x()">Hi <a href="javascript:alert(1)">bad</a> <a href="/ok">ok</a></p><script>window.__re = 1</script>',
|
||||
controlId: 'f-body',
|
||||
},
|
||||
})
|
||||
const box = wrapper.find('[data-richeditor-readonly]')
|
||||
expect(box.find('script').exists()).toBe(false)
|
||||
expect(box.find('p').attributes('onclick')).toBeUndefined()
|
||||
expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok'])
|
||||
expect((window as unknown as { __re?: number }).__re).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('richeditor policy helpers', () => {
|
||||
it('accepts policy links only', () => {
|
||||
for (const url of ['https://example.test/a', 'http://x.test', 'mailto:a@example.test', 'tel:+48123', '/path', '#top', '?q=1', './rel', 'docs/page']) {
|
||||
expect(isAllowedLink(url), url).toBe(true)
|
||||
}
|
||||
for (const url of ['javascript:alert(1)', 'java\tscript:alert(1)', 'data:text/html,x', 'ftp://x.test', '', ' ']) {
|
||||
expect(isAllowedLink(url), url).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('compares tag names case-insensitively for the lossy note', () => {
|
||||
expect(isLossy('<P>a</P><DIV><SPAN>b</SPAN></DIV>')).toBe(false)
|
||||
expect(isLossy('<p>a</p><H2>b</H2>')).toBe(true)
|
||||
expect(isLossy('')).toBe(false)
|
||||
})
|
||||
|
||||
it('unwraps unknown elements and drops scripts for display', () => {
|
||||
expect(displayHtml('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user