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:
2026-10-12 15:56:59 +02:00
parent 2181c2b5f6
commit cb2987c146
17 changed files with 1668 additions and 2 deletions

535
admin/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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`)

View 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>

View File

@@ -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). */

View 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>

View 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()
}

View 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
}

View 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))
}

View 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" }
}

View File

@@ -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ń"
},

View File

@@ -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). */

View 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>')
})
})

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.