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>')
|
||||
})
|
||||
})
|
||||
@@ -446,6 +446,14 @@ The value travels as a string of HTML, or null. Anything else in a save body is
|
||||
|
||||
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up, so no save path stores an unsanitized value.
|
||||
|
||||
In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, underline, a level 3 heading, bullet and numbered lists, quotes, links, undo and redo. The editor code loads on first use, so forms without a rich text field never download it.
|
||||
|
||||
- **Untouched values stay as stored.** Until the administrator edits the text, the form sends the loaded string unchanged. Opening and saving a form never rewrites imported HTML, even markup the editor would format differently. After the first edit the value is the editor's HTML, and an editor holding only an empty paragraph sends an empty string.
|
||||
- **Edit HTML.** The toolbar's HTML view edits the source in a plain text area and sends the text as typed. Use it for markup the editor cannot show: when the stored HTML contains elements other than `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `s`, `h3`, `ul`, `ol`, `li`, `blockquote`, `a`, `div` and `span`, a note above the text says so, and the HTML view changes it without losing it.
|
||||
- **Links** accept `http`, `https`, `mailto`, `tel` and relative URLs. Ctrl+K (Cmd+K) opens the link box.
|
||||
- **After a save** the notices appear under the field and the editor shows the sanitized value the server stored.
|
||||
- If the editor code cannot be loaded, the field falls back to the HTML view, so the record can still be edited and saved.
|
||||
|
||||
## What a save may write
|
||||
|
||||
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). A [repeater](#repeater)'s list of rows is the other exception: it is decoded row by row before the projection. The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.
|
||||
|
||||
@@ -335,6 +335,24 @@ richeditor:
|
||||
one: ":count HTML comment was removed"
|
||||
other: ":count HTML comments were removed"
|
||||
markup_changed: The HTML filter changed some markup.
|
||||
toolbar: Formatting
|
||||
bold: Bold
|
||||
italic: Italic
|
||||
underline: Underline
|
||||
heading: Heading
|
||||
bullet_list: Bullet list
|
||||
ordered_list: Numbered list
|
||||
quote: Quote
|
||||
link: Link
|
||||
unlink: Remove link
|
||||
undo: Undo
|
||||
redo: Redo
|
||||
source: Edit HTML
|
||||
link_url: Link URL
|
||||
link_apply: Apply link
|
||||
link_invalid: Links must use http, https, mailto, tel or a relative URL.
|
||||
lossy_note: Some formatting in this text cannot be shown in the editor. Use “Edit HTML” to change it without losing it.
|
||||
load_failed: The editor could not be loaded. You can edit the HTML directly.
|
||||
repeater:
|
||||
max_items: Remove items until there are at most :max.
|
||||
min_items: Add items until there are at least :min.
|
||||
|
||||
@@ -363,6 +363,24 @@ richeditor:
|
||||
many: "Usunięto :count komentarzy HTML"
|
||||
other: "Usunięto :count komentarza HTML"
|
||||
markup_changed: Filtr HTML zmienił część znaczników.
|
||||
toolbar: Formatowanie
|
||||
bold: Pogrubienie
|
||||
italic: Kursywa
|
||||
underline: Podkreślenie
|
||||
heading: Nagłówek
|
||||
bullet_list: Lista punktowana
|
||||
ordered_list: Lista numerowana
|
||||
quote: Cytat
|
||||
link: Link
|
||||
unlink: Usuń link
|
||||
undo: Cofnij
|
||||
redo: Ponów
|
||||
source: Edytuj HTML
|
||||
link_url: Adres linku
|
||||
link_apply: Zastosuj link
|
||||
link_invalid: Link musi używać http, https, mailto, tel albo adresu względnego.
|
||||
lossy_note: Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty.
|
||||
load_failed: Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio.
|
||||
repeater:
|
||||
max_items: Usuń pozycje, aby zostało ich najwyżej :max.
|
||||
min_items: Dodaj pozycje, aby było ich co najmniej :min.
|
||||
|
||||
Reference in New Issue
Block a user