diff --git a/admin/package-lock.json b/admin/package-lock.json index b54dfa1..bc90598 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -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", diff --git a/admin/package.json b/admin/package.json index 6316004..c7cc45a 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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", diff --git a/admin/src/components/form/FormField.vue b/admin/src/components/form/FormField.vue index 154ea13..b8fa5de 100644 --- a/admin/src/components/form/FormField.vue +++ b/admin/src/components/form/FormField.vue @@ -53,7 +53,7 @@ const describedBy = computed(() => {{ field.label }} * - + {{ field.label || field.name }} * () /** 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`) diff --git a/admin/src/components/form/fields/RichEditorField.vue b/admin/src/components/form/fields/RichEditorField.vue new file mode 100644 index 0000000..888069a --- /dev/null +++ b/admin/src/components/form/fields/RichEditorField.vue @@ -0,0 +1,424 @@ + + + + + + {{ t('backend::lang.list.empty_value') }} + + + + + {{ t('backend::lang.richeditor.load_failed') }} + + + + + + + + + + + { if (!open) linkOpen = false }" + @apply="applyLink" + @remove="removeLink" + @closed="onLinkClosed" + > + + + + + + + + + + + + + + {{ t('backend::lang.richeditor.source') }} + + + + + {{ t('backend::lang.richeditor.lossy_note') }} + + + + + + diff --git a/admin/src/components/form/registry.ts b/admin/src/components/form/registry.ts index 9c8d9c3..bebc8cd 100644 --- a/admin/src/components/form/registry.ts +++ b/admin/src/components/form/registry.ts @@ -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([ ['mltext', MLTextField], ['mltextarea', MLTextareaField], ['mlmarkdown', MLMarkdownField], + ['richeditor', RichEditorField], ]) /** Types whose control shows the label itself (toggle cards, relation manager). */ diff --git a/admin/src/components/form/richeditor/LinkPopover.vue b/admin/src/components/form/richeditor/LinkPopover.vue new file mode 100644 index 0000000..66e88f5 --- /dev/null +++ b/admin/src/components/form/richeditor/LinkPopover.vue @@ -0,0 +1,133 @@ + + + + + + + + + + {{ t('backend::lang.richeditor.link_url') }} + + + + {{ t('backend::lang.richeditor.link_invalid') }} + + + + {{ t('backend::lang.richeditor.unlink') }} + + + {{ t('backend::lang.richeditor.link_apply') }} + + + + + + diff --git a/admin/src/components/form/richeditor/editor.ts b/admin/src/components/form/richeditor/editor.ts new file mode 100644 index 0000000..1ac682f --- /dev/null +++ b/admin/src/components/form/richeditor/editor.ts @@ -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 + /** 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() +} diff --git a/admin/src/components/form/richeditor/html.ts b/admin/src/components/form/richeditor/html.ts new file mode 100644 index 0000000..4bbab94 --- /dev/null +++ b/admin/src/components/form/richeditor/html.ts @@ -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 +} diff --git a/admin/src/components/form/richeditor/lossy.ts b/admin/src/components/form/richeditor/lossy.ts new file mode 100644 index 0000000..1b1c031 --- /dev/null +++ b/admin/src/components/form/richeditor/lossy.ts @@ -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 = 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() + 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)) +} diff --git a/admin/tests/fixtures/companions.form-schema.json b/admin/tests/fixtures/companions.form-schema.json new file mode 100644 index 0000000..b2bf244 --- /dev/null +++ b/admin/tests/fixtures/companions.form-schema.json @@ -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" } +} diff --git a/admin/tests/fixtures/lang.json b/admin/tests/fixtures/lang.json index 14ecc7d..35a06c7 100644 --- a/admin/tests/fixtures/lang.json +++ b/admin/tests/fixtures/lang.json @@ -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ń" }, diff --git a/admin/tests/fixtures/typed.ts b/admin/tests/fixtures/typed.ts index cad4f30..dc90e3b 100644 --- a/admin/tests/fixtures/typed.ts +++ b/admin/tests/fixtures/typed.ts @@ -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). */ diff --git a/admin/tests/form/RichEditorField.test.ts b/admin/tests/form/RichEditorField.test.ts new file mode 100644 index 0000000..0920e63 --- /dev/null +++ b/admin/tests/form/RichEditorField.test.ts @@ -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 = 'Hello bold world\nBox' + +function record(description: string | null) { + return { data: { id: 1, title: 'Docs', description }, meta: {} } +} + +function routes(overrides: Record = {}): Record { + return { + [`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture }, + [`GET ${RECORD}`]: { body: record(STORED) }, + ...overrides, + } +} + +async function editorReady(wrapper: VueWrapper): Promise { + 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 | null = null + const { wrapper, calls } = await mountApp( + '/acme/companions/links/1', + routes({ + [`PUT ${RECORD}`]: async (request) => { + body = (await request.json()) as Record + 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 = 'CellText' + 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('[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 | null = null + const sanitized = 'Hello bold world' + const { wrapper } = await mountApp( + '/acme/companions/links/1', + routes({ + [`GET ${RECORD}`]: { body: record('Hello bold world') }, + [`PUT ${RECORD}`]: async (request) => { + body = (await request.json()) as Record + 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('Hello bold world') + expect(wrapper.find('[data-field="description"] [data-field-notice]').text()).toBe('1 element was removed: b') + expect(wrapper.find('[data-richeditor-area]').html()).toContain('Hello bold world') + }) + + it('RichEditorField edit emits editor HTML', async () => { + const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField + const wrapper = mount(RichEditorField, { + props: { field, modelValue: 'Hello', 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('Hello') + expect(wrapper.find('[data-richeditor-area]').html()).toContain('Hello') + + // The HTML view emits the text as typed. + await wrapper.find('[data-tool="source"]').trigger('click') + await wrapper.find('[data-richeditor-source]').setValue('Typed here') + const afterSource = wrapper.emitted('update:modelValue') ?? [] + expect(afterSource[afterSource.length - 1]![0]).toBe('Typed here') + }) + + 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: 'Hi bad ok', + 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('ab')).toBe(false) + expect(isLossy('ab')).toBe(true) + expect(isLossy('')).toBe(false) + }) + + it('unwraps unknown elements and drops scripts for display', () => { + expect(displayHtml('a')).toBe('a') + }) +}) diff --git a/docs/backend/forms.md b/docs/backend/forms.md index 37a7cc6..fe9cd85 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -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. diff --git a/modules/phrasebook/backend/lang/en/lang.yaml b/modules/phrasebook/backend/lang/en/lang.yaml index 5aac081..fd1ea06 100644 --- a/modules/phrasebook/backend/lang/en/lang.yaml +++ b/modules/phrasebook/backend/lang/en/lang.yaml @@ -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. diff --git a/modules/phrasebook/backend/lang/pl/lang.yaml b/modules/phrasebook/backend/lang/pl/lang.yaml index a6cd085..1e2b471 100644 --- a/modules/phrasebook/backend/lang/pl/lang.yaml +++ b/modules/phrasebook/backend/lang/pl/lang.yaml @@ -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.
+ {{ t('backend::lang.richeditor.load_failed') }} +
+ + {{ t('backend::lang.richeditor.lossy_note') }} +
+ + {{ t('backend::lang.richeditor.link_invalid') }} +
Hello bold world
Text
Hello
Typed here
Hi bad ok
a