├── assets ├── dist │ ├── types.d.js │ ├── modules.js │ ├── blots │ │ └── image.js │ ├── register-modules.js │ ├── modules │ │ ├── smartLinks.js │ │ └── counterModule.js │ ├── ui │ │ └── toolbarCustomizer.js │ ├── upload-utils.js │ ├── controller.js │ └── imageUploader.js ├── tests │ ├── setup │ │ ├── __mocks__ │ │ │ ├── image.ts │ │ │ ├── styleMock.js │ │ │ └── quill.ts │ │ └── jest.setup.ts │ └── connect.test.ts ├── README.md ├── src │ ├── modules.ts │ ├── types.d.ts │ ├── blots │ │ └── image.ts │ ├── register-modules.ts │ ├── modules │ │ ├── smartLinks.ts │ │ └── counterModule.ts │ ├── ui │ │ └── toolbarCustomizer.ts │ ├── upload-utils.ts │ ├── controller.ts │ └── imageUploader.ts ├── tsconfig.json ├── babel.config.js └── package.json ├── phpstan.neon.dist ├── src ├── DTO │ ├── Fields │ │ ├── Interfaces │ │ │ ├── QuillInlineFieldInterface.php │ │ │ ├── QuillBlockFieldInterface.php │ │ │ └── QuillGroupInterface.php │ │ ├── InlineField │ │ │ ├── CodeField.php │ │ │ ├── LinkField.php │ │ │ ├── BoldField.php │ │ │ ├── CleanField.php │ │ │ ├── ImageField.php │ │ │ ├── StrikeField.php │ │ │ ├── VideoField.php │ │ │ ├── EmojiField.php │ │ │ ├── ItalicField.php │ │ │ ├── CodeBlockField.php │ │ │ ├── FormulaField.php │ │ │ ├── UnderlineField.php │ │ │ ├── BlockQuoteField.php │ │ │ └── TableField.php │ │ └── BlockField │ │ │ ├── ColorField.php │ │ │ ├── BackgroundColorField.php │ │ │ ├── DirectionField.php │ │ │ ├── HeaderField.php │ │ │ ├── IndentField.php │ │ │ ├── ScriptField.php │ │ │ ├── ListField.php │ │ │ ├── FontField.php │ │ │ ├── SizeField.php │ │ │ ├── AlignField.php │ │ │ └── HeaderGroupField.php │ ├── Options │ │ ├── StyleOption.php │ │ ├── ThemeOption.php │ │ ├── UploadHandlerOption.php │ │ └── DebugOption.php │ ├── Modules │ │ ├── SyntaxModule.php │ │ ├── SmartLinksModule.php │ │ ├── EmojiModule.php │ │ ├── ModuleInterface.php │ │ ├── ResizeModule.php │ │ ├── HistoryModule.php │ │ ├── CounterModule.php │ │ ├── TableModule.php │ │ ├── HtmlEditModule.php │ │ └── FullScreenModule.php │ └── QuillGroup.php ├── QuillJsBundle.php ├── Form │ ├── QuillAdminField.php │ └── QuillType.php ├── templates │ └── form.html.twig └── DependencyInjection │ └── QuillJsExtension.php ├── .gitignore ├── compose.yaml ├── tests ├── Hooks │ └── ByPassFinalHook.php ├── DTO │ ├── Fields │ │ ├── Inline │ │ │ ├── BoldInlineFieldTest.php │ │ │ ├── CleanInlineFieldTest.php │ │ │ ├── ItalicInlineFieldTest.php │ │ │ └── BlockQuoteInlineFieldTest.php │ │ └── Block │ │ │ ├── FormulaFieldTest.php │ │ │ ├── DirectionFieldTest.php │ │ │ ├── ColorFieldTest.php │ │ │ ├── BackgroundColorFieldTest.php │ │ │ ├── HeaderFieldTest.php │ │ │ ├── IndentFieldTest.php │ │ │ ├── ScriptFieldTest.php │ │ │ ├── HeaderGroupFieldTest.php │ │ │ ├── FontFieldTest.php │ │ │ ├── ListFieldTest.php │ │ │ ├── SizeFieldTest.php │ │ │ └── AlignFieldTest.php │ └── QuillGroupTest.php ├── Functional │ └── FormTypeTest.php └── Form │ └── QuillTypeTest.php ├── .github └── workflows │ ├── javascript-tests.yml │ ├── publish-npm.yml │ ├── quill-bundle.yml │ └── test-bundle-install.yml ├── phpunit.xml ├── LICENSE ├── docker └── php │ ├── Dockerfile │ └── scripts │ └── create-user.sh ├── CHANGELOG.md ├── .php-cs-fixer.dist.php ├── Makefile └── composer.json /assets/dist/types.d.js: -------------------------------------------------------------------------------- 1 | export {}; -------------------------------------------------------------------------------- /assets/tests/setup/__mocks__/image.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | // Mock pour le module image 3 | }; 4 | -------------------------------------------------------------------------------- /assets/tests/setup/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | // Mock pour les fichiers CSS 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | inferPrivatePropertyTypeFromConstructor: true 3 | level: 8 4 | paths: 5 | - src 6 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # @ehyiah/ux-quill 2 | 3 | JavaScript assets only of the [ehyiah/ux-quill symfony bundle](https://github.com/Ehyiah/ux-quill). 4 | 5 | This npm package is not meant to be directly installed. 6 | -------------------------------------------------------------------------------- /src/DTO/Fields/Interfaces/QuillInlineFieldInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | public function getOption(): array; 11 | } 12 | -------------------------------------------------------------------------------- /src/QuillJsBundle.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | public static function build(QuillInlineFieldInterface ...$fields): array; 11 | } 12 | -------------------------------------------------------------------------------- /src/DTO/Options/DebugOption.php: -------------------------------------------------------------------------------- 1 | { 4 | acc[moduleOption.name] = moduleOption.options; 5 | return acc; 6 | }, {}); 7 | return { 8 | ...modules, 9 | ...enabledModules 10 | }; 11 | } 12 | export default mergeModules; -------------------------------------------------------------------------------- /src/DTO/Fields/InlineField/CodeField.php: -------------------------------------------------------------------------------- 1 | { 5 | acc[moduleOption.name] = moduleOption.options; 6 | return acc; 7 | }, {}); 8 | 9 | return { ...modules, ...enabledModules }; 10 | } 11 | 12 | export default mergeModules; 13 | -------------------------------------------------------------------------------- /src/DTO/Fields/InlineField/EmojiField.php: -------------------------------------------------------------------------------- 1 | $options 11 | */ 12 | public function __construct( 13 | public string $name = self::NAME, 14 | public $options = [ 15 | 'linkRegex' => '/https?:\/\/[^\s]+/', 16 | ], 17 | ) { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/DTO/Modules/EmojiModule.php: -------------------------------------------------------------------------------- 1 | |int|string $options 16 | */ 17 | public function __construct(string $name, array|int|string $options); 18 | } 19 | -------------------------------------------------------------------------------- /src/DTO/Modules/ResizeModule.php: -------------------------------------------------------------------------------- 1 | $options 16 | */ 17 | public function __construct( 18 | public string $name = self::NAME, 19 | public $options = [], 20 | ) { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/DTO/Modules/HistoryModule.php: -------------------------------------------------------------------------------- 1 | $options 14 | */ 15 | public function __construct( 16 | public string $name = self::NAME, 17 | public $options = [ 18 | 'delay' => '1000', 19 | 'maxStack' => '100', 20 | 'userOnly' => 'false', 21 | ], 22 | ) { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Inline/BoldInlineFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 21 | 22 | $this->assertEquals('bold', $result); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Inline/CleanInlineFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 21 | 22 | $this->assertEquals('clean', $result); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Inline/ItalicInlineFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 21 | 22 | $this->assertEquals('italic', $result); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Inline/BlockQuoteInlineFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | 21 | $this->assertEquals('blockquote', $result); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/FormulaFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 21 | 22 | $expectedResult = 'formula'; 23 | 24 | $this->assertEquals($expectedResult, $result); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/DTO/Modules/CounterModule.php: -------------------------------------------------------------------------------- 1 | $options 11 | */ 12 | public function __construct( 13 | public string $name = self::NAME, 14 | public $options = [ 15 | 'words' => true, 16 | 'words_label' => 'Number of words : ', 17 | 'words_container' => '', 18 | 'characters' => true, 19 | 'characters_label' => 'Number of characters : ', 20 | 'characters_container' => '', 21 | ], 22 | ) { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "moduleResolution": "node", 5 | "noUnusedLocals": true, 6 | "rootDir": "src", 7 | "strict": true, 8 | "strictPropertyInitialization": false, 9 | "target": "es2020", 10 | "removeComments": true, 11 | "outDir": "dist", 12 | "baseUrl": "..", 13 | "noEmit": false, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "isolatedModules": true, 17 | "declaration": true, 18 | "emitDeclarationOnly": true, 19 | }, 20 | "exclude": ["tests"], 21 | "include": ["src", "node_modules/quill/dist"] 22 | } 23 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/ColorField.php: -------------------------------------------------------------------------------- 1 | options = $options; 17 | } 18 | 19 | /** 20 | * @return array> 21 | */ 22 | public function getOption(): array 23 | { 24 | $array = []; 25 | $array['color'] = $this->options; 26 | 27 | return $array; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/DirectionFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['direction' => 'rtl']; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/BackgroundColorField.php: -------------------------------------------------------------------------------- 1 | options = $options; 17 | } 18 | 19 | /** 20 | * @return array> 21 | */ 22 | public function getOption(): array 23 | { 24 | $array = []; 25 | $array['background'] = $this->options; 26 | 27 | return $array; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/DirectionField.php: -------------------------------------------------------------------------------- 1 | options = $option; 16 | } 17 | 18 | /** 19 | * @return string[] 20 | */ 21 | public function getOption(): array 22 | { 23 | $array = []; 24 | $array['direction'] = $this->options; 25 | 26 | return $array; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/HeaderField.php: -------------------------------------------------------------------------------- 1 | options = $options; 17 | } 18 | 19 | /** 20 | * @return int[] 21 | */ 22 | public function getOption(): array 23 | { 24 | $array = []; 25 | $array['header'] = $this->options; 26 | 27 | return $array; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | loose: true, 5 | modules: false, 6 | debug: false 7 | }], 8 | ['@babel/preset-typescript', { 9 | allowDeclareFields: true, 10 | rewriteImportExtensions:true, 11 | modules: false 12 | }], 13 | ], 14 | assumptions: { 15 | superIsCallableConstructor: false, 16 | }, 17 | targets: "> 0.20%, not dead", 18 | include: "src/*", 19 | plugins: [ 20 | ["module-resolver", { 21 | root: ['./src'], 22 | alias: { 23 | '@src': './src', 24 | '@ui': './src/ui', 25 | } 26 | }] 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /assets/src/types.d.ts: -------------------------------------------------------------------------------- 1 | import {AuthConfig} from './upload-utils'; 2 | 3 | export type ExtraOptions = { 4 | theme: string; 5 | debug: string|null; 6 | height: string|null; 7 | placeholder: string|null; 8 | upload_handler: uploadOptions; 9 | style: string; 10 | use_semantic_html: boolean; 11 | custom_icons?: {[key: string]: string}; 12 | read_only: boolean; 13 | } 14 | 15 | export type uploadOptions = { 16 | type: string; 17 | upload_endpoint: null|string; 18 | json_response_file_path: null|string; 19 | security?: AuthConfig 20 | } 21 | 22 | interface ModuleInterface { 23 | name: string; 24 | options: string|Array; 25 | } 26 | 27 | export type ModuleOptions = { 28 | name: string; 29 | options: Array 30 | } 31 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/IndentField.php: -------------------------------------------------------------------------------- 1 | option = $option; 17 | } 18 | 19 | /** 20 | * @return string[] 21 | */ 22 | public function getOption(): array 23 | { 24 | $array = []; 25 | $array['indent'] = $this->option; 26 | 27 | return $array; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/tests/setup/jest.setup.ts: -------------------------------------------------------------------------------- 1 | // Configuration pour Jest 2 | 3 | // Empêcher les erreurs de console pendant les tests 4 | global.console = { 5 | ...console, 6 | // Supprimer les logs en environnement de test 7 | log: jest.fn(), 8 | error: jest.fn(), 9 | warn: jest.fn(), 10 | }; 11 | 12 | // Mock pour window.matchMedia (non disponible dans jsdom) 13 | Object.defineProperty(window, 'matchMedia', { 14 | writable: true, 15 | value: jest.fn().mockImplementation(query => ({ 16 | matches: false, 17 | media: query, 18 | onchange: null, 19 | addListener: jest.fn(), 20 | removeListener: jest.fn(), 21 | addEventListener: jest.fn(), 22 | removeEventListener: jest.fn(), 23 | dispatchEvent: jest.fn(), 24 | })), 25 | }); 26 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/ScriptField.php: -------------------------------------------------------------------------------- 1 | option = $option; 17 | } 18 | 19 | /** 20 | * @return array|mixed[] 21 | */ 22 | public function getOption(): array 23 | { 24 | $array = []; 25 | $array['script'] = $this->option; 26 | 27 | return $array; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/ListField.php: -------------------------------------------------------------------------------- 1 | option = $option; 18 | } 19 | 20 | /** 21 | * @return string[] 22 | */ 23 | public function getOption(): array 24 | { 25 | $array = []; 26 | $array['list'] = $this->option; 27 | 28 | return $array; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/ColorFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 23 | 24 | $expectedResult = [ 25 | 'color' => [ 26 | 'green', 'red', 'yellow', 27 | ], 28 | ]; 29 | 30 | $this->assertEquals($expectedResult, $result); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /assets/tests/setup/__mocks__/quill.ts: -------------------------------------------------------------------------------- 1 | const MockQuill = jest.fn().mockImplementation(() => { 2 | return { 3 | on: jest.fn().mockImplementation((event, callback) => { 4 | if (event === 'text-change') { 5 | callback(); 6 | } 7 | }), 8 | root: { 9 | innerHTML: '

Test content

' 10 | } 11 | }; 12 | }); 13 | 14 | // Propriétés statiques 15 | MockQuill.register = jest.fn(); 16 | MockQuill.import = jest.fn().mockImplementation((name) => { 17 | if (name === 'formats/image') { 18 | return { 19 | formats: jest.fn(), 20 | prototype: { 21 | format: jest.fn() 22 | } 23 | }; 24 | } 25 | if (name.startsWith('attributors/style/')) { 26 | return {}; 27 | } 28 | return {}; 29 | }); 30 | 31 | export default MockQuill; 32 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/FontField.php: -------------------------------------------------------------------------------- 1 | options = $options; 21 | } 22 | 23 | /** 24 | * @return array> 25 | */ 26 | public function getOption(): array 27 | { 28 | $array = []; 29 | $array['font'] = $this->options; 30 | 31 | return $array; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/BackgroundColorFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 23 | 24 | $expectedResult = [ 25 | 'background' => [ 26 | 'green', 'red', 'yellow', 27 | ], 28 | ]; 29 | 30 | $this->assertEquals($expectedResult, $result); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /assets/dist/blots/image.js: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | const InlineBlot = Quill.import('blots/block'); 3 | class LoadingImage extends InlineBlot { 4 | static blotName = 'imageBlot'; 5 | static className = 'image-uploading'; 6 | static tagName = 'span'; 7 | static create(src) { 8 | const node = super.create(src); 9 | if (src === true) return node; 10 | const image = document.createElement('img'); 11 | image.setAttribute('src', src); 12 | node.appendChild(image); 13 | return node; 14 | } 15 | deleteAt(index, length) { 16 | super.deleteAt(index, length); 17 | this.cache = {}; 18 | } 19 | static value(domNode) { 20 | const { 21 | src, 22 | custom 23 | } = domNode.dataset; 24 | return { 25 | src, 26 | custom 27 | }; 28 | } 29 | } 30 | Quill.register({ 31 | 'formats/imageBlot': LoadingImage 32 | }); 33 | export default LoadingImage; -------------------------------------------------------------------------------- /assets/dist/register-modules.js: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | import ImageUploader from "./imageUploader.js"; 3 | Quill.register('modules/imageUploader', ImageUploader); 4 | import * as Emoji from 'quill2-emoji'; 5 | import 'quill2-emoji/dist/style.css'; 6 | Quill.register('modules/emoji', Emoji); 7 | import QuillResizeImage from 'quill-resize-image'; 8 | Quill.register('modules/resize', QuillResizeImage); 9 | import { SmartLinks } from "./modules/smartLinks.js"; 10 | Quill.register('modules/smartLinks', SmartLinks); 11 | import { Counter } from "./modules/counterModule.js"; 12 | Quill.register('modules/counter', Counter); 13 | import QuillToggleFullscreenButton from 'quill-toggle-fullscreen-button'; 14 | Quill.register('modules/toggleFullscreen', QuillToggleFullscreenButton); 15 | import htmlEditButton from 'quill-html-edit-button'; 16 | Quill.register('modules/htmlEditButton', htmlEditButton.default || htmlEditButton); -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/HeaderFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['header' => 1]; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | 24 | $field = new HeaderField(HeaderField::HEADER_OPTION_2); 25 | $result = $field->getOption(); 26 | $expectedResult = ['header' => 2]; 27 | 28 | $this->assertEquals($expectedResult, $result); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/src/blots/image.ts: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | 3 | const InlineBlot = Quill.import('blots/block'); 4 | 5 | class LoadingImage extends InlineBlot { 6 | static blotName = 'imageBlot'; 7 | static className = 'image-uploading'; 8 | static tagName = 'span'; 9 | 10 | static create(src) { 11 | const node = super.create(src); 12 | if (src === true) return node; 13 | 14 | const image = document.createElement('img'); 15 | image.setAttribute('src', src); 16 | node.appendChild(image); 17 | return node; 18 | } 19 | 20 | deleteAt(index, length) { 21 | super.deleteAt(index, length); 22 | this.cache = {}; 23 | } 24 | 25 | static value(domNode) { 26 | const { src, custom } = domNode.dataset; 27 | return { src, custom }; 28 | } 29 | } 30 | 31 | Quill.register({ 'formats/imageBlot': LoadingImage }); 32 | 33 | export default LoadingImage; 34 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/SizeField.php: -------------------------------------------------------------------------------- 1 | options = $options; 22 | } 23 | 24 | /** 25 | * @return array|mixed[] 26 | */ 27 | public function getOption(): array 28 | { 29 | $array = []; 30 | $array['size'] = $this->options; 31 | 32 | return $array; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /assets/src/register-modules.ts: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | 3 | import ImageUploader from './imageUploader.ts' 4 | Quill.register('modules/imageUploader', ImageUploader); 5 | 6 | import * as Emoji from 'quill2-emoji'; 7 | import 'quill2-emoji/dist/style.css'; 8 | Quill.register('modules/emoji', Emoji); 9 | 10 | import QuillResizeImage from 'quill-resize-image'; 11 | Quill.register('modules/resize', QuillResizeImage); 12 | 13 | import {SmartLinks} from './modules/smartLinks.ts'; 14 | Quill.register('modules/smartLinks', SmartLinks); 15 | 16 | import {Counter} from './modules/counterModule.ts'; 17 | Quill.register('modules/counter', Counter); 18 | 19 | import QuillToggleFullscreenButton from 'quill-toggle-fullscreen-button'; 20 | Quill.register('modules/toggleFullscreen', QuillToggleFullscreenButton); 21 | 22 | import htmlEditButton from 'quill-html-edit-button'; 23 | Quill.register('modules/htmlEditButton', htmlEditButton.default || htmlEditButton); 24 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/AlignField.php: -------------------------------------------------------------------------------- 1 | options = $options; 20 | } 21 | 22 | /** 23 | * @return array> 24 | */ 25 | public function getOption(): array 26 | { 27 | $array = []; 28 | $array['align'] = $this->options; 29 | 30 | return $array; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/IndentFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['indent' => +1]; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | 24 | $field = new IndentField(IndentField::INDENT_FIELD_OPTION_MINUS); 25 | $result = $field->getOption(); 26 | $expectedResult = ['indent' => -1]; 27 | 28 | $this->assertEquals($expectedResult, $result); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/javascript-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests JavaScript 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | defaults: 12 | run: 13 | working-directory: ./assets 14 | strategy: 15 | matrix: 16 | node-version: ["20.x", "22.x"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'yarn' 26 | cache-dependency-path: './assets/yarn.lock' 27 | 28 | - name: Install dependencies 29 | run: yarn install --frozen-lockfile 30 | 31 | - name: Run TypeScript tests 32 | run: yarn test 33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/ScriptFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['script' => 'sub']; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | 24 | $field = new ScriptField(ScriptField::SCRIPT_FIELD_OPTION_SUPER); 25 | $result = $field->getOption(); 26 | $expectedResult = ['script' => 'super']; 27 | 28 | $this->assertEquals($expectedResult, $result); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/HeaderGroupFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['header' => []]; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | 24 | $field = new HeaderGroupField(HeaderGroupField::HEADER_OPTION_1, HeaderGroupField::HEADER_OPTION_3); 25 | $result = $field->getOption(); 26 | $expectedResult = ['header' => [1, 3]]; 27 | 28 | $this->assertEquals($expectedResult, $result); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/DTO/Fields/BlockField/HeaderGroupField.php: -------------------------------------------------------------------------------- 1 | options = $options; 25 | } 26 | 27 | /** 28 | * @return array> 29 | */ 30 | public function getOption(): array 31 | { 32 | $array = []; 33 | $array['header'] = $this->options; 34 | 35 | return $array; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Matthieu Gostiaux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/FontFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 21 | 22 | $expectedResult = [ 23 | 'font' => [ 24 | ], 25 | ]; 26 | 27 | $this->assertEquals($expectedResult, $result); 28 | } 29 | 30 | /** 31 | * @covers ::getOption 32 | */ 33 | public function testGetOptionWithOneValue(): void 34 | { 35 | $field = new FontField(FontField::FONT_OPTION_SERIF); 36 | 37 | $result = $field->getOption(); 38 | 39 | $expectedResult = [ 40 | 'font' => ['serif'], 41 | ]; 42 | 43 | $this->assertEquals($expectedResult, $result); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docker/php/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1.0 2 | 3 | MAINTAINER Matthieu Gostiaux 4 | 5 | COPY --from=composer:2.8.8 /usr/bin/composer /usr/bin/composer 6 | 7 | RUN apt-get update && apt-get install -y --fix-missing \ 8 | openssl \ 9 | acl \ 10 | git \ 11 | zip \ 12 | unzip \ 13 | nano \ 14 | wget \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | RUN curl -sL https://deb.nodesource.com/setup_22.x -o node_setup.sh && \ 18 | bash node_setup.sh && \ 19 | apt-get install -y nodejs && \ 20 | npm install npm -g && \ 21 | rm -rf /var/lib/apt/lists/* 22 | 23 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 24 | RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list 25 | RUN apt-get update && apt-get install -y yarn && \ 26 | rm -rf /var/lib/apt/lists/* 27 | 28 | ARG APP_USER=www-data 29 | ARG APP_GROUP=www-data 30 | ARG APP_USER_ID=1000 31 | ARG APP_GROUP_ID=1000 32 | 33 | COPY ./scripts/ /tmp/scripts/ 34 | RUN chmod +x -R /tmp/scripts/ 35 | RUN /tmp/scripts/create-user.sh ${APP_USER} ${APP_GROUP} ${APP_USER_ID} ${APP_GROUP_ID} 36 | 37 | USER $APP_USER 38 | 39 | WORKDIR /var/www/symfony 40 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/ListFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['list' => 'ordered']; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | 24 | $field = new ListField(ListField::LIST_FIELD_OPTION_BULLET); 25 | $result = $field->getOption(); 26 | $expectedResult = ['list' => 'bullet']; 27 | 28 | $this->assertEquals($expectedResult, $result); 29 | 30 | $field = new ListField(ListField::LIST_FIELD_OPTION_CHECK); 31 | $result = $field->getOption(); 32 | $expectedResult = ['list' => 'check']; 33 | 34 | $this->assertEquals($expectedResult, $result); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Form/QuillAdminField.php: -------------------------------------------------------------------------------- 1 | addFormTheme('@QuillJs/form.html.twig', '@EasyAdmin/crud/form_theme.html.twig') 21 | ->setProperty($propertyName) 22 | ->setLabel($label) 23 | ->setFormType(QuillType::class) 24 | ->addWebpackEncoreEntries('quill-admin') 25 | ; 26 | } 27 | 28 | return (new self()) 29 | ->addFormTheme('@QuillJs/form.html.twig', '@EasyAdmin/crud/form_theme.html.twig') 30 | ->setProperty($propertyName) 31 | ->setLabel($label) 32 | ->setFormType(QuillType::class) 33 | ->addAssetMapperEntries('quill-admin') 34 | ; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.0.0 Break-changes 2 | 3 | ## PHP modifications 4 | - Move from twig template to JavaScript hydration of quill initial content. Move logic inside JavaScript for better compatibility with deltas, so initial content is now set in JavaScript and not in the template anymore (because of the table module) 5 | - Remove sanitizer options see the below points : (symfony/html-sanitizer is no longer required when installing the bundle) 6 | 1. If ``sanitize_html`` first level option was present, it was sanitizing HTML before passing it to the quill instance. It doesn't really make sense and was preventing the table module from working. 7 | 2. Remove from ``quill_extra_options`` the ``sanitizer`` option, use **symfony default** sanitizing process when saving data instead. See [Official doc here](#https://symfony.com/doc/current/html_sanitizer.html#sanitizing-html-from-form-input) 8 | - ``ModuleInterface`` add a __contruct method in interface and some comments. 9 | 10 | ## Javascript modification 11 | - set quill initial content (when editing) in quill initialization instead of twig template. 12 | 13 | # 2.1.0 Break-changes 14 | - RENAME ``path`` to ``upload_endpoint`` for easier understanding in ``upload_handler`` options. 15 | - MOVING ``modules`` to the top level (like quill_options or quill_extra_options) 16 | -------------------------------------------------------------------------------- /tests/DTO/QuillGroupTest.php: -------------------------------------------------------------------------------- 1 | ['green']], 33 | ['header' => [1, 3]], 34 | ]; 35 | 36 | $this->assertEquals($expectedResult, $result); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/SizeFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 20 | $expectedResult = ['size' => ['small']]; 21 | 22 | $this->assertEquals($expectedResult, $result); 23 | 24 | $field = new SizeField( 25 | SizeField::SIZE_FIELD_OPTION_SMALL, 26 | SizeField::SIZE_FIELD_OPTION_LARGE, 27 | SizeField::SIZE_FIELD_OPTION_HUGE, 28 | ); 29 | $result = $field->getOption(); 30 | $expectedResult = ['size' => ['small', 'large', 'huge']]; 31 | 32 | $this->assertEquals($expectedResult, $result); 33 | 34 | $field = new SizeField(SizeField::SIZE_FIELD_OPTION_NORMAL); 35 | $result = $field->getOption(); 36 | $expectedResult = ['size' => [false]]; 37 | 38 | $this->assertEquals($expectedResult, $result); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/DTO/Fields/Block/AlignFieldTest.php: -------------------------------------------------------------------------------- 1 | getOption(); 24 | 25 | $expectedResult = [ 26 | 'align' => [ 27 | AlignField::ALIGN_FIELD_OPTION_LEFT, 28 | AlignField::ALIGN_FIELD_OPTION_CENTER, 29 | ], 30 | ]; 31 | 32 | $this->assertEquals($expectedResult, $result); 33 | } 34 | 35 | /** 36 | * @covers ::getOption 37 | */ 38 | public function testGetOptionWithFalseValue(): void 39 | { 40 | $alignField = new AlignField(false); 41 | 42 | $result = $alignField->getOption(); 43 | 44 | $expectedResult = [ 45 | 'align' => [false], 46 | ]; 47 | 48 | $this->assertEquals($expectedResult, $result); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/publish-npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | - edited 8 | push: 9 | tags: 10 | - '*' 11 | 12 | jobs: 13 | publish-npm-package: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '22.x' 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - name: Install dependencies 26 | working-directory: ./assets 27 | run: yarn install 28 | 29 | - name: Get new tag version 30 | id: get_version 31 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 32 | 33 | - name: Set package.json version to tag version 34 | working-directory: ./assets 35 | run: | 36 | npm version --no-git-tag-version ${{ steps.get_version.outputs.VERSION }} 37 | env: 38 | NPM_CONFIG_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 39 | 40 | - name: Publish new version on NPM 41 | working-directory: ./assets 42 | run: yarn publish --access public 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/DTO/Modules/TableModule.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | public $options = [ 34 | self::MENU_OPTION => ['column', 'row', 'merge', 'table', 'cell', 'wrap', 'copy', 'delete'], 35 | self::LANGUAGE_TOOLBAR_TABLE_OPTION => 'true', // must be set to true to show the table toolbar options 36 | self::LANGUAGE_OPTION => self::LANGUAGE_OPTION_EN_US, 37 | ], 38 | ) { 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/DTO/Modules/HtmlEditModule.php: -------------------------------------------------------------------------------- 1 | |int|string 28 | */ 29 | public $options = [ 30 | self::DEBUG_OPTION => false, 31 | self::SYNTAX_OPTION => false, 32 | self::CLOSE_ON_CLICK_OVERLAY_OPTION => false, 33 | self::BUTTON_HTML_OPTION => '<>', 34 | self::BUTTON_TITLE_OPTION => 'Html source', 35 | self::MESSAGE_OPTION => 'Edit html source', 36 | self::OK_TEXT_OPTION => 'Save', 37 | self::CANCEL_TEXT_OPTION => 'Cancel', 38 | ], 39 | ) { 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /assets/dist/modules/smartLinks.js: -------------------------------------------------------------------------------- 1 | export class SmartLinks { 2 | regex; 3 | constructor(quill, options) { 4 | const regexFormatMatch = options.linkRegex.match(/^\/(.+)\/([gimuy]*)$/); 5 | const pattern = regexFormatMatch ? regexFormatMatch[1] : options.linkRegex; 6 | const flags = regexFormatMatch ? regexFormatMatch[2] || 'i' : 'i'; 7 | try { 8 | this.regex = new RegExp(pattern, flags); 9 | } catch (error) { 10 | console.error('Error with RegEx :', error); 11 | this.regex = new RegExp(''); 12 | return; 13 | } 14 | quill.on('text-change', delta => this.handleTextChange(quill, delta)); 15 | } 16 | handleTextChange(quill, delta) { 17 | const selection = quill.getSelection(false); 18 | if (!selection) return; 19 | const cursorIndex = selection.index; 20 | if (cursorIndex === null || cursorIndex === undefined) return; 21 | const [leaf] = quill.getLeaf(cursorIndex); 22 | if (!leaf) return; 23 | if (leaf.parent.domNode.localName === 'a') return; 24 | const value = leaf.value(); 25 | if (!value || typeof value !== 'string') return; 26 | const insert = delta?.ops?.find(op => op.insert)?.insert; 27 | const specialKeyPressed = insert === '\n' || insert === '\t'; 28 | const match = this.regex.exec(value); 29 | if (!match || !match[0]) return; 30 | const link = match[0]; 31 | const substringIndex = match.index; 32 | const leafIndex = quill.getIndex(leaf); 33 | const startIndex = leafIndex + substringIndex; 34 | const endIndex = startIndex + link.length; 35 | if (!specialKeyPressed && cursorIndex <= endIndex && cursorIndex > startIndex) return; 36 | quill.deleteText(startIndex, link.length, 'api'); 37 | quill.insertText(startIndex, link, 'link', link); 38 | } 39 | } -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | notPath('bootstrap.php') 5 | ->in(__DIR__ . '/src') 6 | ->in(__DIR__ . '/tests') 7 | ; 8 | 9 | return (new \PhpCsFixer\Config()) 10 | ->setRiskyAllowed(true) 11 | ->setRules([ 12 | '@PhpCsFixer' => true, 13 | '@DoctrineAnnotation' => true, 14 | '@PHP71Migration' => true, 15 | '@Symfony' => true, 16 | '@Symfony:risky' => true, 17 | 'cast_spaces' => ['space' => 'none'], 18 | 'concat_space' => ['spacing' => 'one'], 19 | 'escape_implicit_backslashes' => false, 20 | 'explicit_indirect_variable' => false, 21 | 'explicit_string_variable' => false, 22 | 'no_superfluous_elseif' => false, 23 | 'ordered_class_elements' => false, 24 | 'php_unit_internal_class' => false, 25 | 'phpdoc_order_by_value' => false, 26 | 'phpdoc_align' => ['align' => 'left'], 27 | 'phpdoc_summary' => false, 28 | 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], 29 | 'simple_to_complex_string_variable' => false, 30 | 'native_constant_invocation' => false, 31 | 'native_function_invocation' => false, 32 | 'general_phpdoc_annotation_remove' => ['annotations' => ['author', 'package']], 33 | 'global_namespace_import' => true, 34 | 'linebreak_after_opening_tag' => true, 35 | 'no_php4_constructor' => true, 36 | 'pow_to_exponentiation' => true, 37 | 'random_api_migration' => true, 38 | 'list_syntax' => ['syntax' => 'short'], 39 | 'method_chaining_indentation' => false, 40 | ]) 41 | ->setFinder($finder) 42 | ; 43 | -------------------------------------------------------------------------------- /src/templates/form.html.twig: -------------------------------------------------------------------------------- 1 | {% block quill_widget %} 2 | {%- set controller_name = (attr['data-controller']|default('') ~ ' ehyiah--ux-quill--quill')|trim -%} 3 | {% set data_set = "data-ehyiah--ux-quill--quill" %} 4 | 5 | {% if form.vars is defined %} 6 | {% if form.vars['quill_assets']['styleSheets'] is defined %} 7 | {% set style_sheets = form.vars['quill_assets']['styleSheets'] %} 8 | {% for style_sheet in style_sheets %} 9 | 10 | {% endfor %} 11 | {% endif %} 12 | 13 | {% if form.vars['quill_assets']['scripts'] is defined %} 14 | {% set scripts = form.vars['quill_assets']['scripts'] %} 15 | {% for script in scripts %} 16 | 17 | {% endfor %} 18 | {% endif %} 19 | {% endif %} 20 | 21 |
27 |
28 | 33 |
34 | 35 |
38 |
41 |
42 |
43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_COMPOSE?= docker compose 2 | EXEC?= $(DOCKER_COMPOSE) exec 3 | EXECYARN?= $(DOCKER_COMPOSE) exec -w /var/www/symfony/assets 4 | PHP?= $(EXEC) php 5 | COMPOSER?= $(PHP) composer 6 | CONSOLE?= $(PHP) php bin/console 7 | YARN?= $(EXECYARN) php yarn 8 | 9 | help: ## Display this help 10 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 11 | 12 | ##@ Installation 13 | install: build up vendor 14 | 15 | update: up vendor 16 | 17 | ##@ Docker 18 | build: ## Build the images 19 | $(DOCKER_COMPOSE) build --no-cache --build-arg APP_USER_ID=$$(id -u) --build-arg APP_USER=$$(id -u -n) 20 | 21 | up: ## Up the images 22 | $(DOCKER_COMPOSE) up -d --remove-orphans 23 | 24 | down: ## Down the images 25 | $(DOCKER_COMPOSE) down 26 | 27 | .PHONY: build up down update install vendor 28 | 29 | ##@ Composer 30 | vendor: composer.lock ## Install composer dependency 31 | $(COMPOSER) install 32 | 33 | ##@ Assets 34 | assets: node_modules ## Compile bundle assets for prod 35 | $(EXECYARN) php rm -rf ./dist 36 | $(YARN) build 37 | 38 | watch: node_modules ## Watch bundle assets 39 | $(YARN) watch 40 | 41 | node_modules: assets ## Install yarn dependency 42 | $(YARN) install 43 | 44 | .PHONY: assets watch node_modules 45 | 46 | ##@ Utility 47 | bash-php: ## Launch PHP bash 48 | $(PHP) bash 49 | 50 | .PHONY: bash-php 51 | 52 | ##@ CI 53 | ci: ## Launch csfixer and phpstan and javascript quality check 54 | $(YARN) lint 55 | $(COMPOSER) ci 56 | 57 | fixer-php: ## Launch csfixer no dry 58 | $(COMPOSER) phpcsfixer 59 | 60 | fixer-js: ## Corriger les problèmes de tests JavaScript 61 | $(YARN) lint-fix 62 | 63 | .PHONY: ci fixer-php phptests jstests fixer-js tests 64 | 65 | phptests: 66 | $(PHP) vendor/bin/phpunit 67 | 68 | jstests: ## Exécuter les tests JavaScript 69 | $(YARN) test 70 | 71 | tests: phptests jstests 72 | -------------------------------------------------------------------------------- /assets/dist/modules/counterModule.js: -------------------------------------------------------------------------------- 1 | export class Counter { 2 | quillContainer; 3 | wordsContainer; 4 | charactersContainer; 5 | charactersLabel; 6 | wordsLabel; 7 | constructor(quill, options) { 8 | this.quillContainer = quill.options.container; 9 | this.wordsLabel = options.words_label ?? 'Number of words: '; 10 | this.charactersLabel = options.characters_label ?? 'Number of characters: '; 11 | if (options.words) { 12 | this.wordsContainer = this.createContainer(options.words_container); 13 | this.count(quill, this.wordsContainer, this.wordsLabel, this.countWords); 14 | } 15 | if (options.characters) { 16 | this.charactersContainer = this.createContainer(options.characters_container); 17 | this.count(quill, this.charactersContainer, this.charactersLabel, this.countCharacters); 18 | } 19 | } 20 | createContainer(containerId) { 21 | let container; 22 | if (containerId) { 23 | try { 24 | container = document.querySelector('#' + containerId); 25 | } catch (e) { 26 | console.warn(`Container with id "${containerId}" not found. Creating a new one.`); 27 | } 28 | } 29 | if (!container) { 30 | container = document.createElement('div'); 31 | container.style.border = '1px solid #ccc'; 32 | container.style.borderWidth = '0px 1px 1px 1px'; 33 | container.style.padding = '5px 15px'; 34 | container.classList.add('quill-counter-container'); 35 | this.quillContainer.parentElement?.appendChild(container); 36 | } 37 | return container; 38 | } 39 | count(quill, container, label, countFunction) { 40 | const updateCount = () => { 41 | const text = quill.getText(); 42 | container.innerText = label + countFunction(text); 43 | }; 44 | updateCount(); 45 | quill.on('text-change', updateCount); 46 | } 47 | countWords(text) { 48 | return text.split(/\s+/).filter(Boolean).length; 49 | } 50 | countCharacters(text) { 51 | return text.length; 52 | } 53 | } -------------------------------------------------------------------------------- /docker/php/scripts/create-user.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APP_USER=$1 4 | APP_GROUP=$2 5 | APP_USER_ID=$3 6 | APP_GROUP_ID=$4 7 | 8 | new_user_id_exists=$(id ${APP_USER_ID} > /dev/null 2>&1; echo $?) 9 | if [ "$new_user_id_exists" = "0" ]; then 10 | (>&2 echo "ERROR: APP_USER_ID $APP_USER_ID already exists - Aborting!"); 11 | exit 1; 12 | fi 13 | 14 | new_group_id_exists=$(getent group ${APP_GROUP_ID} > /dev/null 2>&1; echo $?) 15 | if [ "$new_group_id_exists" = "0" ]; then 16 | (>&2 echo "ERROR: APP_GROUP_ID $APP_GROUP_ID already exists - Aborting!"); 17 | exit 1; 18 | fi 19 | 20 | old_user_id=$(id -u ${APP_USER}) 21 | old_user_exists=$(id -u ${APP_USER} > /dev/null 2>&1; echo $?) 22 | old_group_id=$(getent group ${APP_GROUP} | cut -d: -f3) 23 | old_group_exists=$(getent group ${APP_GROUP} > /dev/null 2>&1; echo $?) 24 | 25 | if [ "$old_group_id" != "${APP_GROUP_ID}" ]; then 26 | # create the group 27 | groupadd -f ${APP_GROUP} 28 | # and the correct id 29 | groupmod -g ${APP_GROUP_ID} ${APP_GROUP} 30 | if [ "$old_group_exists" = "0" ]; then 31 | # set the permissions of all "old" files and folder to the new group 32 | find / -group $old_group_id -exec chgrp -h ${APP_GROUP} {} \; || true 33 | fi 34 | fi 35 | 36 | if [ "$old_user_id" != "${APP_USER_ID}" ]; then 37 | # create the user if it does not exist 38 | if [ "$old_user_exists" != "0" ]; then 39 | useradd ${APP_USER} -g ${APP_GROUP} 40 | fi 41 | 42 | # make sure the home directory exists with the correct permissions 43 | mkdir -p /home/${APP_USER} && chmod 755 /home/${APP_USER} && chown ${APP_USER}:${APP_GROUP} /home/${APP_USER} 44 | 45 | # change the user id, set the home directory and make sure the user has a login shell 46 | usermod -u ${APP_USER_ID} -m -d /home/${APP_USER} ${APP_USER} -s $(which bash) 47 | 48 | if [ "$old_user_exists" = "0" ]; then 49 | # set the permissions of all "old" files and folder to the new user 50 | find / -user $old_user_id -exec chown -h ${APP_USER} {} \; || true 51 | fi 52 | fi 53 | -------------------------------------------------------------------------------- /assets/src/modules/smartLinks.ts: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | 3 | type SmartLinksOptions = { 4 | linkRegex: string; 5 | }; 6 | 7 | export class SmartLinks { 8 | private regex: RegExp; 9 | 10 | constructor(quill: Quill, options: SmartLinksOptions) { 11 | const regexFormatMatch = options.linkRegex.match(/^\/(.+)\/([gimuy]*)$/); 12 | const pattern = regexFormatMatch ? regexFormatMatch[1] : options.linkRegex; 13 | const flags = regexFormatMatch ? regexFormatMatch[2] || 'i' : 'i'; 14 | 15 | try { 16 | this.regex = new RegExp(pattern, flags); 17 | } catch (error) { 18 | console.error('Error with RegEx :', error); 19 | this.regex = new RegExp(''); 20 | return; 21 | } 22 | 23 | quill.on('text-change', (delta) => this.handleTextChange(quill, delta)); 24 | } 25 | 26 | private handleTextChange(quill: Quill, delta: any): void { 27 | const selection = quill.getSelection(false); 28 | if (!selection) return; 29 | 30 | const cursorIndex = selection.index; 31 | if (cursorIndex === null || cursorIndex === undefined) return; 32 | 33 | const [leaf] = quill.getLeaf(cursorIndex); 34 | if (!leaf) return; 35 | 36 | if (leaf.parent.domNode.localName === 'a') return; 37 | 38 | const value = leaf.value(); 39 | if (!value || typeof value !== 'string') return; 40 | 41 | const insert = delta?.ops?.find(op => op.insert)?.insert; 42 | const specialKeyPressed = insert === '\n' || insert === '\t'; 43 | 44 | const match = this.regex.exec(value); 45 | if (!match || !match[0]) return; 46 | 47 | const link = match[0]; 48 | const substringIndex = match.index; 49 | const leafIndex = quill.getIndex(leaf); 50 | const startIndex = leafIndex + substringIndex; 51 | const endIndex = startIndex + link.length; 52 | 53 | if (!specialKeyPressed && cursorIndex <= endIndex && cursorIndex > startIndex) return; 54 | 55 | quill.deleteText(startIndex, link.length, 'api'); 56 | quill.insertText(startIndex, link, 'link', link); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "symfony-bundle", 3 | "name": "ehyiah/ux-quill", 4 | "description": "Symfony UX Bundle to use Quill JS wysiwyg text editor with full and easy customisation", 5 | "keywords": [ 6 | "symfony-ux", 7 | "symfony ux", 8 | "symfony form", 9 | "symfony wysiwyg", 10 | "symfony quill", 11 | "symfony ux bundle", 12 | "quilljs", 13 | "quill", 14 | "wysiwyg", 15 | "easyadmin", 16 | "easyadmin wysiwyg" 17 | ], 18 | "license": "MIT", 19 | "minimum-stability": "stable", 20 | "prefer-stable": true, 21 | "authors": [ 22 | { 23 | "name": "Matthieu Gostiaux", 24 | "role": "Author", 25 | "email": "rei_eva@hotmail.com" 26 | } 27 | ], 28 | "require": { 29 | "php": ">=8.1.0", 30 | "symfony/stimulus-bundle": "^2.9.1", 31 | "symfony/twig-bundle": "^6.1|^7.0|^8.0", 32 | "symfony/form": "^6.1|^7.0|^8.0", 33 | "symfony/translation": "^6.1|^7.0|^8.0" 34 | }, 35 | "require-dev": { 36 | "phpstan/phpstan": "^2.1.17", 37 | "phpunit/phpunit": "^9.5", 38 | "friendsofphp/php-cs-fixer": "^3.1", 39 | "symfony/browser-kit": "^6.1|^7.0", 40 | "symfony/framework-bundle": "^6.1|^7.0", 41 | "symfony/asset-mapper": "^6.3|^7.0", 42 | "easycorp/easyadmin-bundle": "^4.7", 43 | "friendsoftwig/twigcs": "^6.4", 44 | "dg/bypass-finals": "^1.6" 45 | }, 46 | "autoload": { 47 | "psr-4": { 48 | "Ehyiah\\QuillJsBundle\\": "src/" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Ehyiah\\QuillJsBundle\\Tests\\": "tests/" 54 | } 55 | }, 56 | "extra": { 57 | "thanks": { 58 | "name": "symfony/ux", 59 | "url": "https://github.com/symfony/ux" 60 | } 61 | }, 62 | "scripts": { 63 | "phpcsfixer": "./vendor/bin/php-cs-fixer fix", 64 | "phpcsfixer-lint": "./vendor/bin/php-cs-fixer fix --dry-run --diff", 65 | "twig-cs-lint": "./vendor/bin/twigcs ./src/templates/", 66 | "phpstan": "./vendor/bin/phpstan --memory-limit=1G analyse", 67 | "rector": "./vendor/bin/rector process --dry-run", 68 | "rector-nocache": "./vendor/bin/rector process --dry-run --clear-cache", 69 | "rector-no-dry": "./vendor/bin/rector process", 70 | "ci": [ 71 | "@phpcsfixer-lint", 72 | "@phpstan", 73 | "@twig-cs-lint" 74 | ] 75 | }, 76 | "config": { 77 | "allow-plugins": { 78 | "phpstan/extension-installer": true 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /assets/src/modules/counterModule.ts: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | 3 | type CountOptions = { 4 | words?: boolean; 5 | words_label?: string; 6 | words_container?: string; 7 | characters?: boolean; 8 | characters_label?: string; 9 | characters_container?: string; 10 | } 11 | 12 | export class Counter { 13 | quillContainer: HTMLElement; 14 | wordsContainer: HTMLDivElement | null; 15 | charactersContainer: HTMLDivElement | null; 16 | charactersLabel: string; 17 | wordsLabel: string; 18 | 19 | constructor(quill: Quill, options: CountOptions) { 20 | this.quillContainer = quill.options.container; 21 | this.wordsLabel = options.words_label ?? 'Number of words: '; 22 | this.charactersLabel = options.characters_label ?? 'Number of characters: '; 23 | 24 | if (options.words) { 25 | this.wordsContainer = this.createContainer(options.words_container); 26 | this.count(quill, this.wordsContainer, this.wordsLabel, this.countWords); 27 | } 28 | 29 | if (options.characters) { 30 | this.charactersContainer = this.createContainer(options.characters_container); 31 | this.count(quill, this.charactersContainer, this.charactersLabel, this.countCharacters); 32 | } 33 | } 34 | 35 | private createContainer(containerId: string | undefined): HTMLDivElement { 36 | let container: HTMLDivElement | null; 37 | if (containerId) { 38 | try { 39 | container = document.querySelector('#' + containerId); 40 | } catch (e) { 41 | console.warn(`Container with id "${containerId}" not found. Creating a new one.`); 42 | } 43 | } 44 | 45 | if (!container) { 46 | container = document.createElement('div'); 47 | container.style.border = '1px solid #ccc' 48 | container.style.borderWidth = '0px 1px 1px 1px' 49 | container.style.padding = '5px 15px' 50 | container.classList.add('quill-counter-container') 51 | this.quillContainer.parentElement?.appendChild(container); 52 | } 53 | 54 | return container; 55 | } 56 | 57 | private count(quill: Quill, container: HTMLDivElement, label: string, countFunction: (text: string) => number) { 58 | const updateCount = () => { 59 | const text = quill.getText(); 60 | container.innerText = label + countFunction(text); 61 | }; 62 | 63 | updateCount(); 64 | 65 | quill.on('text-change', updateCount); 66 | } 67 | 68 | private countWords(text: string): number { 69 | return text.split(/\s+/).filter(Boolean).length; 70 | } 71 | 72 | private countCharacters(text: string): number { 73 | return text.length; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/DTO/Modules/FullScreenModule.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public $options = [ 15 | 'buttonTitle' => 'Toggle fullscreen mode', 16 | 'buttonHTML' => ' zoom Created with Sketch Beta. ', 17 | ], 18 | ) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/DependencyInjection/QuillJsExtension.php: -------------------------------------------------------------------------------- 1 | getParameter('kernel.bundles'); 21 | 22 | if (is_array($bundles) && isset($bundles['TwigBundle'])) { 23 | $container->prependExtensionConfig('twig', ['form_themes' => ['@QuillJs/form.html.twig']]); 24 | } 25 | 26 | if ($this->isAssetMapperAvailable($container)) { 27 | $container->prependExtensionConfig('framework', [ 28 | 'asset_mapper' => [ 29 | 'paths' => [ 30 | __DIR__ . '/../../assets/dist' => '@ehyiah/ux-quill', 31 | ], 32 | ], 33 | ]); 34 | } 35 | } 36 | 37 | public function load(array $configs, ContainerBuilder $container): void 38 | { 39 | $definition = new Definition(QuillType::class); 40 | $definition->setArgument('$translator', new Reference(TranslatorInterface::class)); 41 | $definition->addTag('form.type'); 42 | $definition->setPublic(false); 43 | 44 | $container->setDefinition('form.ux-quill-js', $definition); 45 | 46 | $bundles = $container->getParameter('kernel.bundles'); 47 | 48 | if (is_array($bundles) && isset($bundles['EasyAdminBundle'])) { 49 | $container 50 | ->setDefinition('form.ux-quill-js-admin', new Definition(QuillAdminField::class)) 51 | ->addTag('form.type_admin') 52 | ->setPublic(false) 53 | ; 54 | } 55 | } 56 | 57 | private function isAssetMapperAvailable(ContainerBuilder $container): bool 58 | { 59 | if (!interface_exists(AssetMapperInterface::class)) { 60 | return false; 61 | } 62 | $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); 63 | if (!is_array($bundlesMetadata)) { 64 | return false; 65 | } 66 | 67 | // check that FrameworkBundle 6.3 or higher is installed 68 | if (!isset($bundlesMetadata['FrameworkBundle'])) { 69 | return false; 70 | } 71 | 72 | return is_file($bundlesMetadata['FrameworkBundle']['path'] . '/Resources/config/asset_mapper.php'); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/quill-bundle.yml: -------------------------------------------------------------------------------- 1 | # https://help.github.com/en/actions 2 | name: CI process for Symfony UX Quill bundle 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | quality: 10 | name: Quill bundle (PHP ${{ matrix.php-versions }}) 11 | runs-on: ubuntu-22.04 12 | strategy: 13 | fail-fast: true 14 | matrix: 15 | php-versions: ['8.1', '8.2', '8.3'] 16 | 17 | steps: 18 | # —— Setup Github actions 🐙 ————————————————————————————————————————————— 19 | # https://github.com/actions/checkout (official) 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | ref: ${{ github.head_ref }} 24 | fetch-depth: 0 25 | 26 | # https://github.com/shivammathur/setup-php (community) 27 | - name: Setup PHP, extensions and composer with shivammathur/setup-php 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php-versions }} 31 | extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, dom, filter, gd, iconv, json 32 | env: 33 | update: true 34 | 35 | - name: Check PHP Version 36 | run: php -v 37 | 38 | # —— Composer 🧙‍️ ————————————————————————————————————————————————————————— 39 | - name: Validate composer.json and composer.lock 40 | run: composer validate 41 | 42 | - name: Cache Composer packages 43 | id: composer-cache 44 | uses: actions/cache@v4 45 | with: 46 | path: vendor 47 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 48 | restore-keys: | 49 | ${{ runner.os }}-php- 50 | 51 | - name: Install Dependencies 52 | run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist 53 | 54 | ## —— Coding standards ✨ ———————————————————————————————————————————————— 55 | - name: Coding standards checks (php-cs-fixer) 56 | run: ./vendor/bin/php-cs-fixer fix --dry-run --diff 57 | 58 | ## —— Static analysis ✨ ————————————————————————————————————————————————— 59 | - name: Static analysis of PHP code (PHPStan) 60 | run: ./vendor/bin/phpstan --memory-limit=1G analyse 61 | 62 | ## —— Twig cs fixer ✨ ————————————————————————————————————————————————— 63 | - name: Coding standards checks (twig-cs-fixer) 64 | run: ./vendor/bin/twigcs ./src/templates/ 65 | 66 | ## —— Tests ✅ ——————————————————————————————————————————————————————————— 67 | - name: Run functionnal and unit tests 68 | run: ./vendor/bin/phpunit 69 | 70 | security: 71 | name: composer security check (PHP ${{ matrix.php-versions }}) 72 | runs-on: ubuntu-22.04 73 | strategy: 74 | fail-fast: true 75 | matrix: 76 | php-versions: ['8.1', '8.2', '8.3'] 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: symfonycorp/security-checker-action@v4 80 | -------------------------------------------------------------------------------- /assets/dist/ui/toolbarCustomizer.js: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | export class ToolbarCustomizer { 3 | /** 4 | * @param customIcons Objet contenant les nouveaux SVG par nom d'icône 5 | * @param container Élément conteneur pour limiter la recherche (facultatif) 6 | */ 7 | static customizeIcons(customIcons, container) { 8 | if (!customIcons || Object.keys(customIcons).length === 0) { 9 | return; 10 | } 11 | setTimeout(() => { 12 | const toolbars = container ? container.querySelector('.ql-toolbar') ? [container.querySelector('.ql-toolbar')] : [] : Array.from(document.querySelectorAll('.ql-toolbar')); 13 | if (toolbars.length === 0) return; 14 | toolbars.forEach(toolbar => { 15 | const buttons = toolbar.querySelectorAll('button'); 16 | buttons.forEach(button => { 17 | const ariaLabel = button.getAttribute('aria-label'); 18 | const classMatch = Array.from(button.classList).find(cls => cls.startsWith('ql-'))?.replace('ql-', ''); 19 | let svgContent; 20 | 21 | // 1. search by aria-label 22 | if (ariaLabel && customIcons[ariaLabel]) { 23 | svgContent = customIcons[ariaLabel]; 24 | } 25 | // 2. search by ql-class 26 | else if (classMatch && customIcons[classMatch]) { 27 | svgContent = customIcons[classMatch]; 28 | } 29 | if (svgContent) { 30 | const existingSvg = button.querySelector('svg'); 31 | if (existingSvg) { 32 | existingSvg.remove(); 33 | } 34 | const tempDiv = document.createElement('div'); 35 | tempDiv.innerHTML = svgContent.trim(); 36 | const newSvg = tempDiv.firstChild; 37 | if (newSvg) { 38 | if (!newSvg.hasAttribute('width')) newSvg.setAttribute('width', '18'); 39 | if (!newSvg.hasAttribute('height')) newSvg.setAttribute('height', '18'); 40 | button.prepend(newSvg); 41 | } 42 | } 43 | }); 44 | }); 45 | }, 0); 46 | } 47 | 48 | /** 49 | * Try to personalize icons from quill registry first and return unprocessed icons. 50 | * 51 | * @param customIcons Objet contenant les nouveaux SVG par nom d'icône 52 | * 53 | * @returns Icônes qui n'ont pas pu être traitées par le registre global 54 | */ 55 | static customizeIconsFromQuillRegistry(customIcons) { 56 | if (!customIcons || Object.keys(customIcons).length === 0) { 57 | return {}; 58 | } 59 | const icons = Quill.import('ui/icons'); 60 | const unprocessedIcons = {}; 61 | Object.keys(customIcons).forEach(iconName => { 62 | if (icons[iconName] !== undefined) { 63 | icons[iconName] = customIcons[iconName]; 64 | } else { 65 | unprocessedIcons[iconName] = customIcons[iconName]; 66 | } 67 | }); 68 | return unprocessedIcons; 69 | } 70 | static debugToolbarButtons(container) { 71 | setTimeout(() => { 72 | const toolbars = container ? container.querySelector('.ql-toolbar') ? [container.querySelector('.ql-toolbar')] : [] : Array.from(document.querySelectorAll('.ql-toolbar')); 73 | toolbars.forEach((toolbar, i) => { 74 | console.group(`Toolbar #${i + 1}`); 75 | toolbar.querySelectorAll('button').forEach((btn, j) => { 76 | console.log(`Button #${j + 1}:`, { 77 | class: Array.from(btn.classList).join(', '), 78 | ariaLabel: btn.getAttribute('aria-label'), 79 | value: btn.getAttribute('value'), 80 | html: btn.outerHTML 81 | }); 82 | }); 83 | console.groupEnd(); 84 | }); 85 | }, 100); 86 | } 87 | } -------------------------------------------------------------------------------- /assets/dist/upload-utils.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | export const uploadStrategies = { 3 | 'form': uploadFileForm, 4 | 'json': uploadFileJson 5 | }; 6 | function applyAuthConfig(config, authConfig) { 7 | if (!authConfig) { 8 | return config; 9 | } 10 | const newConfig = { 11 | ...config 12 | }; 13 | if (!newConfig.headers) { 14 | newConfig.headers = {}; 15 | } 16 | switch (authConfig.type) { 17 | case 'jwt': 18 | if (authConfig.jwt_token) { 19 | newConfig.headers['Authorization'] = `Bearer ${authConfig.jwt_token}`; 20 | } else { 21 | console.error('JWT auth configured but no token provided'); 22 | } 23 | break; 24 | case 'basic': 25 | if (authConfig.username && authConfig.password) { 26 | const credentials = `${authConfig.username}:${authConfig.password}`; 27 | const encoded = typeof btoa === 'function' ? btoa(credentials) : Buffer.from(credentials).toString('base64'); 28 | newConfig.headers['Authorization'] = `Basic ${encoded}`; 29 | } else { 30 | console.error('Basic auth configured but missing credentials'); 31 | } 32 | break; 33 | case 'custom_header': 34 | if (authConfig.custom_header_value) { 35 | newConfig.headers[authConfig.custom_header] = authConfig.custom_header_value; 36 | } else { 37 | console.error('custom_header auth configured but no custom_header_value provided'); 38 | } 39 | break; 40 | } 41 | return newConfig; 42 | } 43 | export function uploadFileForm(uploadEndpoint, file, authConfig) { 44 | return new Promise((resolve, reject) => { 45 | const formData = new FormData(); 46 | formData.append('file', file); 47 | const config = applyAuthConfig({}, authConfig); 48 | axios.post(uploadEndpoint, formData, config).then(response => resolve(response)).catch(err => { 49 | console.error(err); 50 | reject('Upload failed'); 51 | }); 52 | }); 53 | } 54 | export function uploadFileJson(uploadEndpoint, file, authConfig) { 55 | return new Promise((resolve, reject) => { 56 | const reader = file => { 57 | return new Promise(resolve => { 58 | const fileReader = new FileReader(); 59 | fileReader.onload = () => resolve(fileReader.result); 60 | fileReader.readAsDataURL(file); 61 | }); 62 | }; 63 | reader(file).then(result => { 64 | const config = applyAuthConfig({ 65 | headers: { 66 | 'Content-Type': 'application/json' 67 | } 68 | }, authConfig); 69 | return axios.post(uploadEndpoint, result, config).then(response => resolve(response)).catch(err => { 70 | console.error(err); 71 | reject('Upload failed'); 72 | }); 73 | }); 74 | }); 75 | } 76 | export function handleUploadResponse(response, jsonResponseFilePath) { 77 | return new Promise((resolve, reject) => { 78 | const contentType = response.headers['content-type'] || ''; 79 | if (contentType.includes('application/json') && jsonResponseFilePath) { 80 | const pathParts = jsonResponseFilePath.split('.'); 81 | let result = response.data; 82 | try { 83 | for (const part of pathParts) { 84 | if (result && typeof result === 'object' && part in result) { 85 | result = result[part]; 86 | } else { 87 | throw new Error(`Invalid json path for response: '${jsonResponseFilePath}'. Property '${part}' not found.`); 88 | } 89 | } 90 | if (typeof result !== 'string') { 91 | result = String(result); 92 | } 93 | resolve(result); 94 | } catch (error) { 95 | console.error(error); 96 | if (error instanceof Error) { 97 | reject(`Error while processing upload response: ${error.message}`); 98 | } else { 99 | reject('Unknown error while processing upload response'); 100 | } 101 | } 102 | } else { 103 | const result = typeof response.data === 'string' ? response.data : JSON.stringify(response.data); 104 | resolve(result); 105 | } 106 | }); 107 | } -------------------------------------------------------------------------------- /src/DTO/QuillGroup.php: -------------------------------------------------------------------------------- 1 | , array|string> 38 | */ 39 | public static function build(QuillBlockFieldInterface|QuillInlineFieldInterface ...$fields): array 40 | { 41 | $array = []; 42 | foreach ($fields as $field) { 43 | if ($field instanceof QuillInlineFieldInterface) { 44 | $array[] = $field->getOption(); 45 | } 46 | if ($field instanceof QuillBlockFieldInterface) { 47 | foreach ($field->getOption() as $key => $option) { 48 | $array[][$key] = $option; 49 | } 50 | } 51 | } 52 | 53 | return $array; 54 | } 55 | 56 | /** 57 | * @return array, array|string> 58 | */ 59 | public static function buildWithAllFields(): array 60 | { 61 | $stylingFields = [ 62 | new BoldField(), 63 | new ItalicField(), 64 | new UnderlineField(), 65 | new StrikeField(), 66 | new BlockQuoteField(), 67 | new LinkField(), 68 | new SizeField(), 69 | new HeaderField(), 70 | new HeaderGroupField(), 71 | new ColorField(), 72 | new IndentField(), 73 | ]; 74 | 75 | $orgaFields = [ 76 | new AlignField(), 77 | new BackgroundColorField(), 78 | new ListField(), 79 | new ListField(ListField::LIST_FIELD_OPTION_BULLET), 80 | new ListField(ListField::LIST_FIELD_OPTION_CHECK), 81 | new FontField(), 82 | new DirectionField(), 83 | new CodeField(), 84 | new CodeBlockField(), 85 | new ScriptField(), 86 | new ScriptField(ScriptField::SCRIPT_FIELD_OPTION_SUPER), 87 | new FormulaField(), 88 | ]; 89 | 90 | $otherFields = [ 91 | new ImageField(), 92 | new VideoField(), 93 | new EmojiField(), 94 | new CleanField(), 95 | new TableField(), 96 | ]; 97 | 98 | $fields = array_merge($stylingFields, $orgaFields, $otherFields); 99 | 100 | return self::build(...$fields); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /assets/src/ui/toolbarCustomizer.ts: -------------------------------------------------------------------------------- 1 | import Quill from 'quill'; 2 | 3 | export class ToolbarCustomizer { 4 | /** 5 | * @param customIcons Objet contenant les nouveaux SVG par nom d'icône 6 | * @param container Élément conteneur pour limiter la recherche (facultatif) 7 | */ 8 | static customizeIcons(customIcons: {[key: string]: string} | undefined, container?: Element | null): void { 9 | if (!customIcons || Object.keys(customIcons).length === 0) { 10 | return; 11 | } 12 | 13 | setTimeout(() => { 14 | const toolbars = container 15 | ? (container.querySelector('.ql-toolbar') ? [container.querySelector('.ql-toolbar')] : []) 16 | : Array.from(document.querySelectorAll('.ql-toolbar')); 17 | 18 | if (toolbars.length === 0) return; 19 | 20 | toolbars.forEach(toolbar => { 21 | const buttons = toolbar.querySelectorAll('button'); 22 | 23 | buttons.forEach(button => { 24 | const ariaLabel = button.getAttribute('aria-label'); 25 | const classMatch = Array.from(button.classList) 26 | .find(cls => cls.startsWith('ql-'))?.replace('ql-', ''); 27 | 28 | let svgContent; 29 | 30 | // 1. search by aria-label 31 | if (ariaLabel && customIcons[ariaLabel]) { 32 | svgContent = customIcons[ariaLabel]; 33 | } 34 | // 2. search by ql-class 35 | else if (classMatch && customIcons[classMatch]) { 36 | svgContent = customIcons[classMatch]; 37 | } 38 | 39 | if (svgContent) { 40 | const existingSvg = button.querySelector('svg'); 41 | if (existingSvg) { 42 | existingSvg.remove(); 43 | } 44 | 45 | const tempDiv = document.createElement('div'); 46 | tempDiv.innerHTML = svgContent.trim(); 47 | const newSvg = tempDiv.firstChild as SVGElement; 48 | 49 | if (newSvg) { 50 | if (!newSvg.hasAttribute('width')) newSvg.setAttribute('width', '18'); 51 | if (!newSvg.hasAttribute('height')) newSvg.setAttribute('height', '18'); 52 | button.prepend(newSvg); 53 | } 54 | } 55 | }); 56 | }); 57 | }, 0); 58 | } 59 | 60 | /** 61 | * Try to personalize icons from quill registry first and return unprocessed icons. 62 | * 63 | * @param customIcons Objet contenant les nouveaux SVG par nom d'icône 64 | * 65 | * @returns Icônes qui n'ont pas pu être traitées par le registre global 66 | */ 67 | static customizeIconsFromQuillRegistry(customIcons: {[key: string]: string} | undefined): {[key: string]: string} { 68 | if (!customIcons || Object.keys(customIcons).length === 0) { 69 | return {}; 70 | } 71 | 72 | const icons = Quill.import('ui/icons'); 73 | const unprocessedIcons: {[key: string]: string} = {}; 74 | 75 | Object.keys(customIcons).forEach(iconName => { 76 | if (icons[iconName] !== undefined) { 77 | icons[iconName] = customIcons[iconName]; 78 | } else { 79 | unprocessedIcons[iconName] = customIcons[iconName]; 80 | } 81 | }); 82 | 83 | return unprocessedIcons; 84 | } 85 | 86 | static debugToolbarButtons(container?: Element | null): void { 87 | setTimeout(() => { 88 | const toolbars = container 89 | ? (container.querySelector('.ql-toolbar') ? [container.querySelector('.ql-toolbar')] : []) 90 | : Array.from(document.querySelectorAll('.ql-toolbar')); 91 | 92 | toolbars.forEach((toolbar, i) => { 93 | console.group(`Toolbar #${i + 1}`); 94 | toolbar.querySelectorAll('button').forEach((btn, j) => { 95 | console.log(`Button #${j + 1}:`, { 96 | class: Array.from(btn.classList).join(', '), 97 | ariaLabel: btn.getAttribute('aria-label'), 98 | value: btn.getAttribute('value'), 99 | html: btn.outerHTML 100 | }); 101 | }); 102 | console.groupEnd(); 103 | }); 104 | }, 100); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/Functional/FormTypeTest.php: -------------------------------------------------------------------------------- 1 | createMock(TranslatorInterface::class); 27 | 28 | $this->formFactory = Forms::createFormFactoryBuilder() 29 | ->addType(new QuillType($translator)) 30 | ->getFormFactory() 31 | ; 32 | } 33 | 34 | public function testCreateBasicQuillForm(): void 35 | { 36 | $form = $this->formFactory->createBuilder() 37 | ->add('content', QuillType::class) 38 | ->getForm() 39 | ; 40 | 41 | $this->assertNotNull($form); 42 | $this->assertTrue($form->has('content')); 43 | 44 | $contentField = $form->get('content'); 45 | $this->assertInstanceOf('\Symfony\Component\Form\FormInterface', $contentField); 46 | 47 | $formView = $form->createView(); 48 | 49 | $this->assertArrayHasKey('content', $formView->children); 50 | 51 | $contentView = $formView->children['content']; 52 | $this->assertTrue(in_array('quill', $contentView->vars['block_prefixes'], true)); 53 | } 54 | 55 | public function testCreateQuillFormWithCustomOptions(): void 56 | { 57 | $customOptions = [ 58 | 'quill_options' => [ 59 | ['bold', 'italic', 'underline'], 60 | ['image'], 61 | ['code-block'], 62 | ], 63 | 'quill_extra_options' => [ 64 | 'theme' => ThemeOption::QUILL_THEME_SNOW, 65 | 'placeholder' => 'Éditeur de texte riche', 66 | 'height' => '300px', 67 | ], 68 | 'modules' => [ 69 | new EmojiModule(), 70 | new SyntaxModule(), 71 | ], 72 | ]; 73 | 74 | $form = $this->formFactory->createBuilder() 75 | ->add('content', QuillType::class, $customOptions) 76 | ->getForm() 77 | ; 78 | 79 | $formView = $form->createView(); 80 | $contentView = $formView->children['content']; 81 | 82 | $this->assertArrayHasKey('attr', $contentView->vars); 83 | $this->assertArrayHasKey('quill_options', $contentView->vars['attr']); 84 | $this->assertArrayHasKey('quill_extra_options', $contentView->vars['attr']); 85 | $this->assertArrayHasKey('quill_modules_options', $contentView->vars['attr']); 86 | 87 | $quillOptions = json_decode($contentView->vars['attr']['quill_options'], true); 88 | $this->assertIsArray($quillOptions); 89 | $this->assertContains(['bold', 'italic', 'underline'], $quillOptions); 90 | 91 | $extraOptions = json_decode($contentView->vars['attr']['quill_extra_options'], true); 92 | $this->assertEquals(ThemeOption::QUILL_THEME_SNOW, $extraOptions['theme']); 93 | $this->assertEquals('Éditeur de texte riche', $extraOptions['placeholder']); 94 | $this->assertEquals('300px', $extraOptions['height']); 95 | 96 | $modulesOptions = json_decode($contentView->vars['attr']['quill_modules_options'], true); 97 | $this->assertIsArray($modulesOptions); 98 | 99 | $moduleNames = array_column($modulesOptions, 'name'); 100 | $this->assertContains(EmojiModule::NAME, $moduleNames); 101 | $this->assertContains(SyntaxModule::NAME, $moduleNames); 102 | 103 | $this->assertContains(ResizeModule::NAME, $moduleNames); 104 | } 105 | 106 | public function testFormSubmission(): void 107 | { 108 | $form = $this->formFactory->createBuilder() 109 | ->add('content', QuillType::class) 110 | ->getForm() 111 | ; 112 | 113 | $htmlContent = '

Voici un texte de test avec du formatage.

'; 114 | $formData = ['content' => $htmlContent]; 115 | 116 | $form->submit($formData); 117 | 118 | $this->assertTrue($form->isValid()); 119 | $this->assertTrue($form->isSynchronized()); 120 | 121 | $data = $form->getData(); 122 | 123 | $this->assertArrayHasKey('content', $data); 124 | $this->assertEquals($htmlContent, $data['content']); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /assets/tests/connect.test.ts: -------------------------------------------------------------------------------- 1 | import { Application, Controller } from '@hotwired/stimulus'; 2 | import QuillController from '../src/controller'; 3 | 4 | // Capturer les appels à new Quill() 5 | const mockQuillInstance = { 6 | on: jest.fn().mockImplementation((event, callback) => { 7 | if (event === 'text-change') { 8 | callback(); 9 | } 10 | }), 11 | root: { 12 | innerHTML: '

Test content

' 13 | }, 14 | clipboard: { 15 | convert: jest.fn().mockReturnValue({ ops: [] }) 16 | }, 17 | updateContents: jest.fn(), 18 | getModule: jest.fn().mockImplementation((name) => { 19 | if (name === 'toolbar') { 20 | return { 21 | addHandler: jest.fn() 22 | }; 23 | } 24 | return {}; 25 | }) 26 | }; 27 | 28 | // Les mocks doivent être définis avant l'import du module à tester 29 | jest.mock('quill', () => { 30 | // Mock Quill en tant que fonction constructeur avec des méthodes statiques 31 | const mockQuill = jest.fn().mockImplementation(() => mockQuillInstance); 32 | 33 | // Ajouter les méthodes statiques au mock 34 | mockQuill.register = jest.fn(); 35 | mockQuill.import = jest.fn().mockImplementation((name) => { 36 | if (name === 'formats/image') { 37 | return { 38 | formats: jest.fn().mockReturnValue({}), 39 | prototype: { 40 | format: jest.fn() 41 | } 42 | }; 43 | } 44 | if (name.startsWith('attributors/style/')) { 45 | return {}; 46 | } 47 | return {}; 48 | }); 49 | 50 | return mockQuill; 51 | }); 52 | 53 | // Mock du module de fusion 54 | jest.mock('../src/modules.ts', () => ({ 55 | __esModule: true, 56 | default: jest.fn().mockImplementation((moduleOptions, enabledModules) => { 57 | return { ...moduleOptions, ...enabledModules }; 58 | }) 59 | })); 60 | 61 | jest.mock('quill-table-better', () => ({ 62 | QuillTableBetter: jest.fn() 63 | })); 64 | 65 | jest.mock('quill2-emoji', () => ({})); 66 | jest.mock('quill-resize-image', () => ({})); 67 | jest.mock('../src/imageUploader.ts', () => ({})); 68 | jest.mock('../src/register-modules.ts', () => ({})); 69 | jest.mock('../src/upload-utils.ts', () => ({ 70 | handleUploadResponse: jest.fn(), 71 | uploadStrategies: { 72 | ajax: jest.fn(), 73 | fetch: jest.fn() 74 | } 75 | })); 76 | 77 | // Mock de la méthode dispatch du Controller 78 | const mockDispatch = jest.fn(); 79 | Controller.prototype.dispatch = mockDispatch; 80 | 81 | describe('QuillController - méthode connect', () => { 82 | let application: Application; 83 | let controller: QuillController; 84 | let element: HTMLElement; 85 | let inputElement: HTMLInputElement; 86 | let editorElement: HTMLDivElement; 87 | let Quill: any; 88 | 89 | beforeEach(() => { 90 | jest.clearAllMocks(); 91 | 92 | Quill = require('quill'); 93 | 94 | // Créer le DOM pour les tests 95 | element = document.createElement('div'); 96 | element.setAttribute('data-controller', 'quill'); 97 | 98 | // Créer l'input cible 99 | inputElement = document.createElement('input'); 100 | inputElement.setAttribute('data-quill-target', 'input'); 101 | element.appendChild(inputElement); 102 | 103 | // Créer le container de l'éditeur 104 | editorElement = document.createElement('div'); 105 | editorElement.setAttribute('data-quill-target', 'editorContainer'); 106 | element.appendChild(editorElement); 107 | 108 | // Ajouter au document 109 | document.body.appendChild(element); 110 | 111 | // Initialiser Stimulus correctement 112 | application = new Application(); 113 | application.register('quill', QuillController); 114 | application.start(); 115 | 116 | // Récupérer l'instance du controller 117 | controller = application.getControllerForElementAndIdentifier( 118 | element, 119 | 'quill' 120 | ) as unknown as QuillController; 121 | }); 122 | 123 | afterEach(() => { 124 | document.body.removeChild(element); 125 | application.stop(); 126 | }); 127 | 128 | describe('connect', () => { 129 | it('devrait initialiser Quill correctement et dispatcher des événements', () => { 130 | expect(mockDispatch).toHaveBeenCalledWith( 131 | 'options', 132 | expect.objectContaining({ 133 | detail: expect.any(Object), 134 | prefix: 'quill' 135 | }) 136 | ); 137 | 138 | expect(mockDispatch).toHaveBeenCalledWith( 139 | 'connect', 140 | expect.objectContaining({ 141 | detail: expect.any(Object), 142 | prefix: 'quill' 143 | }) 144 | ); 145 | 146 | expect(inputElement.value).toBe('

Test content

'); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /assets/dist/controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import Quill from 'quill'; 3 | import mergeModules from "./modules.js"; 4 | import { ToolbarCustomizer } from "./ui/toolbarCustomizer.js"; 5 | import { handleUploadResponse, uploadStrategies } from "./upload-utils.js"; 6 | import "./register-modules.js"; 7 | import QuillTableBetter from 'quill-table-better'; 8 | import 'quill-table-better/dist/quill-table-better.css'; 9 | const Image = Quill.import('formats/image'); 10 | const oldFormats = Image.formats; 11 | Image.formats = function (domNode) { 12 | const formats = oldFormats.call(this, domNode); 13 | if (domNode.hasAttribute('style')) { 14 | formats.style = domNode.getAttribute('style'); 15 | } 16 | return formats; 17 | }; 18 | Image.prototype.format = function (name, value) { 19 | value ? this.domNode.setAttribute(name, String(value)) : this.domNode.removeAttribute(name); 20 | }; 21 | export default class extends Controller { 22 | static targets = ['input', 'editorContainer']; 23 | static values = (() => ({ 24 | toolbarOptions: { 25 | type: Array, 26 | default: [] 27 | }, 28 | extraOptions: { 29 | type: Object, 30 | default: {} 31 | }, 32 | modulesOptions: { 33 | type: Array, 34 | default: [] 35 | } 36 | }))(); 37 | connect() { 38 | const options = this.buildQuillOptions(); 39 | this.dynamicModuleRegister(options); 40 | this.setupQuillStyles(options); 41 | this.setupUploadHandler(options); 42 | this.setupEditorHeight(); 43 | this.dispatchEvent('options', options); 44 | const unprocessedIcons = this.processIconReplacementFromQuillCore(); 45 | this.initializeQuill(options, unprocessedIcons); 46 | } 47 | buildQuillOptions() { 48 | const { 49 | debug, 50 | placeholder, 51 | theme, 52 | style 53 | } = this.extraOptionsValue; 54 | const readOnly = this.extraOptionsValue.read_only; 55 | const enabledModules = { 56 | 'toolbar': this.toolbarOptionsValue 57 | }; 58 | const mergedModules = mergeModules(this.modulesOptionsValue, enabledModules); 59 | return { 60 | debug, 61 | modules: mergedModules, 62 | placeholder, 63 | theme, 64 | style, 65 | readOnly 66 | }; 67 | } 68 | setupQuillStyles(options) { 69 | if (options.style === 'inline') { 70 | const styleAttributes = ['align', 'background', 'color', 'direction', 'font', 'size']; 71 | styleAttributes.forEach(attr => Quill.register(Quill.import(`attributors/style/${attr}`), true)); 72 | } 73 | } 74 | setupUploadHandler(options) { 75 | const config = this.extraOptionsValue.upload_handler; 76 | if (config?.upload_endpoint && uploadStrategies[config.type]) { 77 | const uploadFunction = file => uploadStrategies[config.type](config.upload_endpoint, file, config.security).then(response => handleUploadResponse(response, config.json_response_file_path)); 78 | Object.assign(options.modules, { 79 | imageUploader: { 80 | upload: uploadFunction 81 | } 82 | }); 83 | } 84 | } 85 | setupEditorHeight() { 86 | const height = this.extraOptionsValue.height; 87 | if (height !== null) { 88 | this.editorContainerTarget.style.height = height; 89 | } 90 | } 91 | initializeQuill(options, unprocessedIcons) { 92 | const quill = new Quill(this.editorContainerTarget, options); 93 | this.setupContentSync(quill); 94 | this.processUnprocessedIcons(unprocessedIcons); 95 | this.dispatchEvent('connect', quill); 96 | } 97 | setupContentSync(quill) { 98 | // set initial content as a delta for better compatibility and allow table-module to work 99 | const initialData = quill.clipboard.convert({ 100 | html: this.inputTarget.value 101 | }); 102 | this.dispatchEvent('hydrate:before', initialData); 103 | quill.updateContents(initialData); 104 | this.dispatchEvent('hydrate:after', quill); 105 | quill.on('text-change', () => { 106 | const quillContent = this.extraOptionsValue?.use_semantic_html ? quill.getSemanticHTML() : quill.root.innerHTML; 107 | const inputContent = this.inputTarget; 108 | inputContent.value = quillContent; 109 | this.bubbles(inputContent); 110 | }); 111 | } 112 | bubbles(inputContent) { 113 | inputContent.dispatchEvent(new Event('change', { 114 | bubbles: true 115 | })); 116 | } 117 | dispatchEvent(name, payload) { 118 | if (payload === void 0) { 119 | payload = {}; 120 | } 121 | this.dispatch(name, { 122 | detail: payload, 123 | prefix: 'quill' 124 | }); 125 | } 126 | processIconReplacementFromQuillCore() { 127 | let unprocessedIcons = {}; 128 | if (this.extraOptionsValue.custom_icons) { 129 | unprocessedIcons = ToolbarCustomizer.customizeIconsFromQuillRegistry(this.extraOptionsValue.custom_icons); 130 | } 131 | return unprocessedIcons; 132 | } 133 | processUnprocessedIcons(unprocessedIcons) { 134 | if (this.extraOptionsValue.custom_icons && Object.keys(unprocessedIcons).length > 0) { 135 | ToolbarCustomizer.customizeIcons(unprocessedIcons, this.editorContainerTarget.parentElement || undefined); 136 | } 137 | if (this.extraOptionsValue.debug === 'info' || this.extraOptionsValue.debug === 'log') { 138 | ToolbarCustomizer.debugToolbarButtons(this.editorContainerTarget.parentElement || undefined); 139 | } 140 | } 141 | dynamicModuleRegister(options) { 142 | const isTablePresent = options.modules.toolbar.flat(Infinity).some(item => typeof item === 'string' && item === 'table-better'); 143 | if (isTablePresent) { 144 | Quill.register('modules/table-better', QuillTableBetter); 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /assets/src/upload-utils.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | 3 | export type AuthConfig = { 4 | type: 'jwt' | 'basic' | 'custom_header'; 5 | jwt_token?: string; // Used for JWT 6 | username?: string; // Used for Basic Auth 7 | password?: string; // Used for Basic Auth 8 | custom_header?: string; // Used for sending a custom Header 9 | custom_header_value?: string; // Used for sending a custom Header 10 | }; 11 | 12 | type UploadFunction = ( 13 | uploadEndpoint: string, 14 | file: File, 15 | authConfig?: AuthConfig 16 | ) => Promise; 17 | 18 | export const uploadStrategies: Record = { 19 | 'form': uploadFileForm, 20 | 'json': uploadFileJson 21 | }; 22 | 23 | function applyAuthConfig(config: AxiosRequestConfig, authConfig?: AuthConfig): AxiosRequestConfig { 24 | if (!authConfig) { 25 | return config; 26 | } 27 | 28 | const newConfig = { ...config }; 29 | 30 | if (!newConfig.headers) { 31 | newConfig.headers = {}; 32 | } 33 | 34 | switch (authConfig.type) { 35 | case 'jwt': 36 | if (authConfig.jwt_token) { 37 | newConfig.headers['Authorization'] = `Bearer ${authConfig.jwt_token}`; 38 | } else { 39 | console.error('JWT auth configured but no token provided'); 40 | } 41 | break; 42 | 43 | case 'basic': 44 | if (authConfig.username && authConfig.password) { 45 | const credentials = `${authConfig.username}:${authConfig.password}`; 46 | const encoded = typeof btoa === 'function' 47 | ? btoa(credentials) 48 | : Buffer.from(credentials).toString('base64'); 49 | newConfig.headers['Authorization'] = `Basic ${encoded}`; 50 | } else { 51 | console.error('Basic auth configured but missing credentials'); 52 | } 53 | break; 54 | 55 | case 'custom_header': 56 | if (authConfig.custom_header_value) { 57 | newConfig.headers[authConfig.custom_header] = authConfig.custom_header_value; 58 | } else { 59 | console.error('custom_header auth configured but no custom_header_value provided'); 60 | } 61 | break; 62 | } 63 | 64 | return newConfig; 65 | } 66 | 67 | export function uploadFileForm( 68 | uploadEndpoint: string, 69 | file: File, 70 | authConfig?: AuthConfig 71 | ): Promise { 72 | return new Promise((resolve, reject) => { 73 | const formData = new FormData(); 74 | formData.append('file', file); 75 | 76 | const config = applyAuthConfig({}, authConfig); 77 | 78 | axios 79 | .post(uploadEndpoint, formData, config) 80 | .then(response => resolve(response)) 81 | .catch(err => { 82 | console.error(err); 83 | reject('Upload failed'); 84 | }); 85 | }); 86 | } 87 | 88 | export function uploadFileJson( 89 | uploadEndpoint: string, 90 | file: File, 91 | authConfig?: AuthConfig 92 | ): Promise { 93 | return new Promise((resolve, reject) => { 94 | const reader = (file: File): Promise => { 95 | return new Promise((resolve) => { 96 | const fileReader = new FileReader(); 97 | fileReader.onload = () => resolve(fileReader.result); 98 | fileReader.readAsDataURL(file); 99 | }); 100 | }; 101 | 102 | reader(file) 103 | .then(result => { 104 | const config = applyAuthConfig({ 105 | headers: { 106 | 'Content-Type': 'application/json', 107 | } 108 | }, authConfig); 109 | 110 | return axios 111 | .post(uploadEndpoint, result, config) 112 | .then(response => resolve(response)) 113 | .catch(err => { 114 | console.error(err); 115 | reject('Upload failed'); 116 | }); 117 | }); 118 | }); 119 | } 120 | 121 | export function handleUploadResponse( 122 | response: AxiosResponse, 123 | jsonResponseFilePath?: string | null 124 | ): Promise { 125 | return new Promise((resolve, reject) => { 126 | const contentType = response.headers['content-type'] || ''; 127 | 128 | if (contentType.includes('application/json') && jsonResponseFilePath) { 129 | const pathParts = jsonResponseFilePath.split('.'); 130 | let result = response.data; 131 | 132 | try { 133 | for (const part of pathParts) { 134 | if (result && typeof result === 'object' && part in result) { 135 | result = result[part]; 136 | } else { 137 | throw new Error(`Invalid json path for response: '${jsonResponseFilePath}'. Property '${part}' not found.`); 138 | } 139 | } 140 | 141 | if (typeof result !== 'string') { 142 | result = String(result); 143 | } 144 | 145 | resolve(result); 146 | } catch (error: unknown) { 147 | console.error(error); 148 | if (error instanceof Error) { 149 | reject(`Error while processing upload response: ${error.message}`); 150 | } else { 151 | reject('Unknown error while processing upload response'); 152 | } 153 | } 154 | } else { 155 | const result = typeof response.data === 'string' ? 156 | response.data : 157 | JSON.stringify(response.data); 158 | 159 | resolve(result); 160 | } 161 | }); 162 | } 163 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ehyiah/ux-quill", 3 | "description": "Symfony bundle to use Quill JS text editor", 4 | "version": "3.0.0", 5 | "license": "MIT", 6 | "main": "dist/controller.js", 7 | "symfony": { 8 | "controllers": { 9 | "quill": { 10 | "main": "dist/controller.js", 11 | "webpackMode": "eager", 12 | "fetch": "eager", 13 | "enabled": true, 14 | "autoimport": { 15 | "quill/dist/quill.snow.css": true, 16 | "quill/dist/quill.bubble.css": true 17 | } 18 | } 19 | }, 20 | "importmap": { 21 | "@hotwired/stimulus": "^3.0.0", 22 | "quill2-emoji": "^0.1.2", 23 | "quill2-emoji/dist/style.css": "^0.1.2", 24 | "quill": "2.0.3", 25 | "quill/dist/quill.snow.css": "2.0.3", 26 | "quill/dist/quill.bubble.css": "2.0.3", 27 | "axios": "^1.4.0", 28 | "quill-resize-image": "^1.0.5", 29 | "quill-table-better": "^1.2.1", 30 | "quill-table-better/dist/quill-table-better.css": "^1.2.1", 31 | "quill-toggle-fullscreen-button": "^0.1.4", 32 | "quill-html-edit-button": "^3.0.0" 33 | } 34 | }, 35 | "scripts": { 36 | "dev-server": "encore dev-server", 37 | "dev": "encore dev", 38 | "watch": "babel src --out-dir dist --source-maps --watch --extensions '.ts,.js'", 39 | "build": "babel src --extensions .ts -d dist", 40 | "lint": "yarn run eslint src", 41 | "lint-fix": "yarn run eslint src --fix", 42 | "build:types": "tsc --emitDeclarationOnly", 43 | "test": "jest", 44 | "test:simple": "jest tests/simple.test.ts", 45 | "test:watch": "jest --watch", 46 | "install-tests": "yarn add --dev @testing-library/jest-dom" 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.20.7", 50 | "@babel/core": "^7.20.12", 51 | "@babel/plugin-proposal-class-properties": "^7.18.6", 52 | "@babel/plugin-transform-modules-commonjs": "^7.23.3", 53 | "@babel/preset-env": "^7.20.2", 54 | "@babel/preset-typescript": "^7.18.6", 55 | "@hotwired/stimulus": "^3.2.1", 56 | "@symfony/webpack-encore": "^4.0.0", 57 | "@testing-library/dom": "^9.3.0", 58 | "@types/jest": "^29.5.2", 59 | "@types/quill": "^2.0.14", 60 | "@typescript-eslint/eslint-plugin": "^5.2.0", 61 | "@typescript-eslint/parser": "^5.2.0", 62 | "babel-plugin-module-resolver": "^5.0.2", 63 | "core-js": "^3.30.2", 64 | "eslint": "^8.1.0", 65 | "eslint-config-prettier": "^8.0.0", 66 | "eslint-plugin-jest": "^25.2.2", 67 | "jest": "^29.5.0", 68 | "jest-environment-jsdom": "^29.5.0", 69 | "ts-jest": "^29.1.0", 70 | "ts-loader": "^9.0.0", 71 | "typescript": "^4.9.5", 72 | "webpack": "^5.99.6", 73 | "webpack-cli": "^4.10.0", 74 | "webpack-notifier": "^1.15.0" 75 | }, 76 | "peerDependencies": { 77 | "@hotwired/stimulus": "^3.0.0", 78 | "axios": "^1.4.0", 79 | "eventemitter3": "^5.0.1", 80 | "file-loader": "^6.2.0", 81 | "lodash-es": "^4.17.21", 82 | "quill": "2.0.3", 83 | "quill-delta": "^5.1.0", 84 | "quill-resize-image": "^1.0.5", 85 | "quill-table-better": "^1.2.1", 86 | "quill-toggle-fullscreen-button": "^0.1.4", 87 | "quill2-emoji": "^0.1.2", 88 | "quill-html-edit-button": "^3.0.0" 89 | }, 90 | "dependencies": { 91 | "@hotwired/stimulus": "^3.0.0", 92 | "axios": "^1.4.0", 93 | "eventemitter3": "^5.0.1", 94 | "file-loader": "^6.2.0", 95 | "lodash-es": "^4.17.21", 96 | "quill": "2.0.3", 97 | "quill-delta": "^5.1.0", 98 | "quill-html-edit-button": "^3.0.0", 99 | "quill-resize-image": "^1.0.5", 100 | "quill-table-better": "^1.2.1", 101 | "quill-toggle-fullscreen-button": "^0.1.4", 102 | "quill2-emoji": "^0.1.2" 103 | }, 104 | "eslintConfig": { 105 | "root": true, 106 | "parser": "@typescript-eslint/parser", 107 | "plugins": [ 108 | "@typescript-eslint" 109 | ], 110 | "extends": [ 111 | "eslint:recommended", 112 | "prettier", 113 | "plugin:@typescript-eslint/eslint-recommended", 114 | "plugin:@typescript-eslint/recommended" 115 | ], 116 | "rules": { 117 | "@typescript-eslint/no-explicit-any": "off", 118 | "@typescript-eslint/no-empty-function": "off", 119 | "@typescript-eslint/ban-ts-comment": "off", 120 | "quotes": [ 121 | "error", 122 | "single" 123 | ] 124 | } 125 | }, 126 | "prettier": { 127 | "printWidth": 120, 128 | "trailingComma": "es5", 129 | "tabWidth": 4, 130 | "jsxBracketSameLine": true, 131 | "singleQuote": true 132 | }, 133 | "jest": { 134 | "preset": "ts-jest", 135 | "testEnvironment": "jsdom", 136 | "setupFilesAfterEnv": [ 137 | "/tests/setup/jest.setup.ts" 138 | ], 139 | "moduleNameMapper": { 140 | "\\.(css|less)$": "/tests/setup/__mocks__/styleMock.js", 141 | "^../src/blots/image$": "/tests/setup/__mocks__/image.ts", 142 | "^quill$": "/tests/setup/__mocks__/quill.ts" 143 | }, 144 | "testMatch": [ 145 | "/tests/**/*.test.ts" 146 | ], 147 | "moduleDirectories": [ 148 | "node_modules", 149 | "/src" 150 | ], 151 | "transform": { 152 | "^.+\\.tsx?$": "ts-jest" 153 | }, 154 | "transformIgnorePatterns": [ 155 | "/node_modules/(?!quill|quill-resize-image|quill2-emoji)" 156 | ], 157 | "rootDir": ".", 158 | "moduleFileExtensions": [ 159 | "ts", 160 | "js", 161 | "json" 162 | ], 163 | "modulePaths": [ 164 | "" 165 | ] 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /assets/dist/imageUploader.js: -------------------------------------------------------------------------------- 1 | import LoadingImage from "./blots/image.js"; 2 | const typedLoadingImage = LoadingImage; 3 | class ImageUploader { 4 | quill; 5 | options; 6 | range; 7 | placeholderDelta; 8 | fileHolder; 9 | constructor(quill, options) { 10 | this.quill = quill; 11 | this.options = options; 12 | // Initialisation avec un range par défaut 13 | this.range = { 14 | index: 0, 15 | length: 0 16 | }; 17 | this.placeholderDelta = { 18 | ops: [] 19 | }; 20 | if (typeof this.options.upload !== 'function') console.warn('[Missing config] upload function that returns a promise is required'); 21 | const toolbar = this.quill.getModule('toolbar'); 22 | if (toolbar) { 23 | toolbar.addHandler('image', this.selectLocalImage.bind(this)); 24 | } 25 | this.handleDrop = this.handleDrop.bind(this); 26 | this.handlePaste = this.handlePaste.bind(this); 27 | this.quill.root.addEventListener('drop', this.handleDrop, true); 28 | this.quill.root.addEventListener('paste', this.handlePaste, true); 29 | } 30 | selectLocalImage() { 31 | this.quill.focus(); 32 | const selection = this.quill.getSelection(); 33 | if (selection) { 34 | this.range = selection; 35 | } 36 | this.fileHolder = document.createElement('input'); 37 | this.fileHolder.setAttribute('type', 'file'); 38 | this.fileHolder.setAttribute('accept', 'image/*'); 39 | this.fileHolder.setAttribute('style', 'visibility:hidden'); 40 | this.fileHolder.onchange = this.fileChanged.bind(this); 41 | document.body.appendChild(this.fileHolder); 42 | this.fileHolder.click(); 43 | window.requestAnimationFrame(() => { 44 | document.body.removeChild(this.fileHolder); 45 | }); 46 | } 47 | handleDrop(evt) { 48 | if (evt.dataTransfer && evt.dataTransfer.files && evt.dataTransfer.files.length) { 49 | evt.stopPropagation(); 50 | evt.preventDefault(); 51 | if (document.caretRangeFromPoint) { 52 | const selection = document.getSelection(); 53 | const range = document.caretRangeFromPoint(evt.clientX, evt.clientY); 54 | if (selection && range) { 55 | selection.setBaseAndExtent(range.startContainer, range.startOffset, range.startContainer, range.startOffset); 56 | } 57 | } else { 58 | const selection = document.getSelection(); 59 | const range = document.caretPositionFromPoint?.(evt.clientX, evt.clientY); 60 | if (selection && range) { 61 | selection.setBaseAndExtent(range.offsetNode, range.offset, range.offsetNode, range.offset); 62 | } 63 | } 64 | this.quill.focus(); 65 | const selection = this.quill.getSelection(); 66 | if (selection) { 67 | this.range = selection; 68 | } 69 | const file = evt.dataTransfer.files[0]; 70 | setTimeout(() => { 71 | this.quill.focus(); 72 | const newSelection = this.quill.getSelection(); 73 | if (newSelection) { 74 | this.range = newSelection; 75 | } 76 | this.readAndUploadFile(file); 77 | }, 0); 78 | } 79 | } 80 | handlePaste(evt) { 81 | const clipboard = evt.clipboardData || window.clipboardData; 82 | 83 | // IE 11 is .files other browsers are .items 84 | if (clipboard && (clipboard.items || clipboard.files)) { 85 | const items = clipboard.items || clipboard.files; 86 | const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png|svg|webp)$/i; 87 | for (let i = 0; i < items.length; i++) { 88 | const item = items[i]; 89 | if (IMAGE_MIME_REGEX.test(item.type)) { 90 | const file = 'getAsFile' in item ? item.getAsFile() : item; 91 | if (file) { 92 | this.quill.focus(); 93 | const selection = this.quill.getSelection(); 94 | if (selection) { 95 | this.range = selection; 96 | } 97 | evt.preventDefault(); 98 | setTimeout(() => { 99 | this.quill.focus(); 100 | const newSelection = this.quill.getSelection(); 101 | if (newSelection) { 102 | this.range = newSelection; 103 | } 104 | this.readAndUploadFile(file); 105 | }, 0); 106 | } 107 | } 108 | } 109 | } 110 | } 111 | readAndUploadFile(file) { 112 | let isUploadReject = false; 113 | const fileReader = new FileReader(); 114 | fileReader.addEventListener('load', () => { 115 | if (!isUploadReject) { 116 | const base64ImageSrc = fileReader.result; 117 | this.insertBase64Image(base64ImageSrc); 118 | } 119 | }, false); 120 | if (file) { 121 | fileReader.readAsDataURL(file); 122 | } 123 | this.options.upload(file).then(imageUrl => { 124 | this.insertToEditor(imageUrl); 125 | }, error => { 126 | isUploadReject = true; 127 | this.removeBase64Image(); 128 | console.warn(error); 129 | }); 130 | } 131 | fileChanged() { 132 | let file = null; 133 | if (this.fileHolder.files && this.fileHolder.files.length > 0) { 134 | file = this.fileHolder.files[0]; 135 | } 136 | if (file) { 137 | this.readAndUploadFile(file); 138 | } 139 | } 140 | insertBase64Image(url) { 141 | const range = this.range; 142 | 143 | // Utiliser directement 'imageBlot' comme nom de blot 144 | this.placeholderDelta = this.quill.insertEmbed(range.index, 'imageBlot', `${url}`, 'user'); 145 | } 146 | insertToEditor(url) { 147 | const range = this.range; 148 | const lengthToDelete = this.calculatePlaceholderInsertLength(); 149 | 150 | // S'assurer que le delta est valide avant de tenter la suppression 151 | if (lengthToDelete > 0) { 152 | // Delete the placeholder image 153 | this.quill.deleteText(range.index, lengthToDelete, 'user'); 154 | } 155 | 156 | // Insert the server saved image 157 | this.quill.insertEmbed(range.index, 'image', `${url}`, 'user'); 158 | 159 | // Réinitialiser le placeholderDelta pour éviter les suppressions multiples 160 | this.placeholderDelta = { 161 | ops: [] 162 | }; 163 | range.index++; 164 | this.quill.setSelection(range, 'user'); 165 | } 166 | calculatePlaceholderInsertLength() { 167 | // Vérifier si placeholderDelta est défini et contient des opérations 168 | if (!this.placeholderDelta || !this.placeholderDelta.ops || !Array.isArray(this.placeholderDelta.ops)) { 169 | return 0; 170 | } 171 | return this.placeholderDelta.ops.reduce((accumulator, deltaOperation) => { 172 | // Vérifier si deltaOperation est défini 173 | if (deltaOperation && typeof deltaOperation === 'object') { 174 | const hasInsertProperty = Object.prototype.hasOwnProperty.call(deltaOperation, 'insert'); 175 | if (hasInsertProperty) accumulator++; 176 | } 177 | return accumulator; 178 | }, 0); 179 | } 180 | removeBase64Image() { 181 | const range = this.range; 182 | const lengthToDelete = this.calculatePlaceholderInsertLength(); 183 | if (lengthToDelete > 0) { 184 | this.quill.deleteText(range.index, lengthToDelete, 'user'); 185 | } 186 | 187 | // Réinitialiser placeholderDelta pour éviter les suppressions multiples 188 | this.placeholderDelta = { 189 | ops: [] 190 | }; 191 | } 192 | } 193 | window.ImageUploader = ImageUploader; 194 | export default ImageUploader; -------------------------------------------------------------------------------- /assets/src/controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import Quill from 'quill'; 3 | import * as Options from 'quill/core/quill'; 4 | import { ExtraOptions, ModuleOptions } from './types.d.ts'; 5 | import mergeModules from './modules.ts'; 6 | import { ToolbarCustomizer } from './ui/toolbarCustomizer.ts'; 7 | import { handleUploadResponse, uploadStrategies } from './upload-utils.ts'; 8 | 9 | import './register-modules.ts'; 10 | import QuillTableBetter from 'quill-table-better'; 11 | import 'quill-table-better/dist/quill-table-better.css'; 12 | 13 | interface DOMNode extends HTMLElement { 14 | getAttribute(name: string): string | null; 15 | setAttribute(name: string, value: string): void; 16 | removeAttribute(name: string): void; 17 | hasAttribute(name: string): boolean; 18 | } 19 | 20 | const Image = Quill.import('formats/image'); 21 | const oldFormats = Image.formats; 22 | 23 | Image.formats = function(domNode: DOMNode) { 24 | const formats = oldFormats.call(this, domNode); 25 | if (domNode.hasAttribute('style')) { 26 | formats.style = domNode.getAttribute('style'); 27 | } 28 | return formats; 29 | }; 30 | 31 | type ImageWithDOM = { 32 | domNode: DOMNode; 33 | format(name: string, value: string | boolean | null): void; 34 | }; 35 | 36 | Image.prototype.format = function(this: ImageWithDOM, name: string, value: string | boolean | null) { 37 | value ? this.domNode.setAttribute(name, String(value)) : this.domNode.removeAttribute(name); 38 | }; 39 | 40 | export default class extends Controller { 41 | declare readonly inputTarget: HTMLInputElement; 42 | declare readonly editorContainerTarget: HTMLDivElement; 43 | static targets = ['input', 'editorContainer']; 44 | 45 | declare readonly extraOptionsValue: ExtraOptions; 46 | declare readonly toolbarOptionsValue: HTMLDivElement; 47 | declare readonly modulesOptionsValue: ModuleOptions; 48 | static values = { 49 | toolbarOptions: { 50 | type: Array, 51 | default: [], 52 | }, 53 | extraOptions: { 54 | type: Object, 55 | default: {}, 56 | }, 57 | modulesOptions: { 58 | type: Array, 59 | default: [], 60 | } 61 | } 62 | 63 | connect() { 64 | const options = this.buildQuillOptions(); 65 | this.dynamicModuleRegister(options); 66 | this.setupQuillStyles(options); 67 | this.setupUploadHandler(options); 68 | this.setupEditorHeight(); 69 | 70 | this.dispatchEvent('options', options); 71 | 72 | const unprocessedIcons = this.processIconReplacementFromQuillCore(); 73 | 74 | this.initializeQuill(options, unprocessedIcons); 75 | } 76 | 77 | private buildQuillOptions(): Options { 78 | const { debug, placeholder, theme, style } = this.extraOptionsValue; 79 | const readOnly = this.extraOptionsValue.read_only; 80 | const enabledModules: Options = { 81 | 'toolbar': this.toolbarOptionsValue, 82 | }; 83 | const mergedModules = mergeModules(this.modulesOptionsValue, enabledModules); 84 | 85 | return { 86 | debug, 87 | modules: mergedModules, 88 | placeholder, 89 | theme, 90 | style, 91 | readOnly, 92 | }; 93 | } 94 | 95 | private setupQuillStyles(options: Options) { 96 | if (options.style === 'inline') { 97 | const styleAttributes = ['align', 'background', 'color', 'direction', 'font', 'size']; 98 | styleAttributes.forEach(attr => 99 | Quill.register(Quill.import(`attributors/style/${attr}`), true) 100 | ); 101 | } 102 | } 103 | 104 | private setupUploadHandler(options: Options) { 105 | const config = this.extraOptionsValue.upload_handler; 106 | 107 | if (config?.upload_endpoint && uploadStrategies[config.type]) { 108 | const uploadFunction = (file: File): Promise => uploadStrategies[config.type]( 109 | config.upload_endpoint, 110 | file, 111 | config.security, 112 | ).then(response => handleUploadResponse( 113 | response, 114 | config.json_response_file_path 115 | )); 116 | 117 | Object.assign(options.modules, { 118 | imageUploader: { 119 | upload: uploadFunction 120 | } 121 | }); 122 | } 123 | } 124 | 125 | private setupEditorHeight() { 126 | const height = this.extraOptionsValue.height; 127 | if (height !== null) { 128 | this.editorContainerTarget.style.height = height; 129 | } 130 | } 131 | 132 | private initializeQuill(options: Options, unprocessedIcons): void { 133 | const quill = new Quill(this.editorContainerTarget, options); 134 | this.setupContentSync(quill); 135 | 136 | this.processUnprocessedIcons(unprocessedIcons); 137 | 138 | this.dispatchEvent('connect', quill); 139 | } 140 | 141 | private setupContentSync(quill: Quill) { 142 | // set initial content as a delta for better compatibility and allow table-module to work 143 | const initialData = quill.clipboard.convert({html: this.inputTarget.value}) 144 | this.dispatchEvent('hydrate:before', initialData); 145 | quill.updateContents(initialData); 146 | this.dispatchEvent('hydrate:after', quill); 147 | 148 | quill.on('text-change', () => { 149 | const quillContent = this.extraOptionsValue?.use_semantic_html 150 | ? quill.getSemanticHTML() 151 | : quill.root.innerHTML; 152 | 153 | const inputContent = this.inputTarget; 154 | inputContent.value = quillContent; 155 | this.bubbles(inputContent); 156 | }); 157 | } 158 | 159 | private bubbles(inputContent: HTMLInputElement) 160 | { 161 | inputContent.dispatchEvent(new Event('change', { bubbles: true })); 162 | } 163 | 164 | private dispatchEvent(name: string, payload: any = {}) { 165 | this.dispatch(name, { detail: payload, prefix: 'quill' }); 166 | } 167 | 168 | private processIconReplacementFromQuillCore(): {[key: string]: string} { 169 | let unprocessedIcons = {}; 170 | if (this.extraOptionsValue.custom_icons) { 171 | unprocessedIcons = ToolbarCustomizer.customizeIconsFromQuillRegistry(this.extraOptionsValue.custom_icons); 172 | } 173 | 174 | return unprocessedIcons; 175 | } 176 | 177 | private processUnprocessedIcons(unprocessedIcons: {[key: string]: string}): void { 178 | if (this.extraOptionsValue.custom_icons && Object.keys(unprocessedIcons).length > 0) { 179 | ToolbarCustomizer.customizeIcons( 180 | unprocessedIcons, 181 | this.editorContainerTarget.parentElement || undefined 182 | ); 183 | } 184 | 185 | if (this.extraOptionsValue.debug === 'info' || this.extraOptionsValue.debug === 'log') { 186 | ToolbarCustomizer.debugToolbarButtons(this.editorContainerTarget.parentElement || undefined); 187 | } 188 | } 189 | 190 | private dynamicModuleRegister(options: Options) 191 | { 192 | const isTablePresent = options.modules.toolbar 193 | .flat(Infinity) 194 | .some(item => typeof item === 'string' && item === 'table-better'); 195 | 196 | if (isTablePresent) { 197 | Quill.register('modules/table-better', QuillTableBetter); 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /assets/src/imageUploader.ts: -------------------------------------------------------------------------------- 1 | import LoadingImage from './blots/image.ts'; 2 | import Quill from 'quill'; 3 | 4 | interface RangeStatic { 5 | index: number; 6 | length: number; 7 | } 8 | 9 | interface ImageBlot { 10 | blotName: string; 11 | } 12 | 13 | interface ImageUploaderOptions { 14 | upload: (file: File) => Promise; 15 | } 16 | 17 | interface CustomWindow extends Window { 18 | clipboardData?: DataTransfer; 19 | ImageUploader?: typeof ImageUploader; 20 | } 21 | 22 | interface CaretPosition { 23 | offsetNode: Node; 24 | offset: number; 25 | } 26 | 27 | const typedLoadingImage = LoadingImage as ImageBlot; 28 | 29 | interface CustomDocument extends Document { 30 | caretPositionFromPoint?(x: number, y: number): CaretPosition; 31 | caretRangeFromPoint?(x: number, y: number): Range; 32 | } 33 | 34 | declare const document: CustomDocument; 35 | declare const window: CustomWindow; 36 | 37 | class ImageUploader { 38 | quill: Quill; 39 | options: ImageUploaderOptions; 40 | range: RangeStatic; 41 | placeholderDelta: { ops: Array<{ insert?: any }> }; 42 | fileHolder: HTMLInputElement; 43 | 44 | constructor(quill: any, options: ImageUploaderOptions) { 45 | this.quill = quill; 46 | this.options = options; 47 | // Initialisation avec un range par défaut 48 | this.range = { index: 0, length: 0 }; 49 | this.placeholderDelta = { ops: [] }; 50 | 51 | if (typeof this.options.upload !== 'function') 52 | console.warn( 53 | '[Missing config] upload function that returns a promise is required' 54 | ); 55 | 56 | const toolbar = this.quill.getModule('toolbar'); 57 | if (toolbar) { 58 | toolbar.addHandler('image', this.selectLocalImage.bind(this)); 59 | } 60 | 61 | this.handleDrop = this.handleDrop.bind(this); 62 | this.handlePaste = this.handlePaste.bind(this); 63 | 64 | this.quill.root.addEventListener('drop', this.handleDrop, true); 65 | this.quill.root.addEventListener('paste', this.handlePaste, true); 66 | } 67 | 68 | selectLocalImage() { 69 | this.quill.focus(); 70 | const selection = this.quill.getSelection(); 71 | if (selection) { 72 | this.range = selection; 73 | } 74 | this.fileHolder = document.createElement('input'); 75 | this.fileHolder.setAttribute('type', 'file'); 76 | this.fileHolder.setAttribute('accept', 'image/*'); 77 | this.fileHolder.setAttribute('style', 'visibility:hidden'); 78 | 79 | this.fileHolder.onchange = this.fileChanged.bind(this); 80 | 81 | document.body.appendChild(this.fileHolder); 82 | 83 | this.fileHolder.click(); 84 | 85 | window.requestAnimationFrame(() => { 86 | document.body.removeChild(this.fileHolder); 87 | }); 88 | } 89 | 90 | handleDrop(evt: DragEvent) { 91 | if ( 92 | evt.dataTransfer && 93 | evt.dataTransfer.files && 94 | evt.dataTransfer.files.length 95 | ) { 96 | evt.stopPropagation(); 97 | evt.preventDefault(); 98 | if (document.caretRangeFromPoint) { 99 | const selection = document.getSelection(); 100 | const range = document.caretRangeFromPoint(evt.clientX, evt.clientY); 101 | if (selection && range) { 102 | selection.setBaseAndExtent( 103 | range.startContainer, 104 | range.startOffset, 105 | range.startContainer, 106 | range.startOffset 107 | ); 108 | } 109 | } else { 110 | const selection = document.getSelection(); 111 | const range = document.caretPositionFromPoint?.(evt.clientX, evt.clientY); 112 | if (selection && range) { 113 | selection.setBaseAndExtent( 114 | range.offsetNode, 115 | range.offset, 116 | range.offsetNode, 117 | range.offset 118 | ); 119 | } 120 | } 121 | 122 | this.quill.focus(); 123 | const selection = this.quill.getSelection(); 124 | if (selection) { 125 | this.range = selection; 126 | } 127 | const file = evt.dataTransfer.files[0]; 128 | 129 | setTimeout(() => { 130 | this.quill.focus(); 131 | const newSelection = this.quill.getSelection(); 132 | if (newSelection) { 133 | this.range = newSelection; 134 | } 135 | this.readAndUploadFile(file); 136 | }, 0); 137 | } 138 | } 139 | 140 | handlePaste(evt: ClipboardEvent) { 141 | const clipboard = evt.clipboardData || window.clipboardData; 142 | 143 | // IE 11 is .files other browsers are .items 144 | if (clipboard && ((clipboard.items as DataTransferItemList) || (clipboard.files as FileList))) { 145 | const items = clipboard.items || clipboard.files; 146 | const IMAGE_MIME_REGEX = /^image\/(jpe?g|gif|png|svg|webp)$/i; 147 | 148 | for (let i = 0; i < items.length; i++) { 149 | const item = items[i] as (DataTransferItem | File); 150 | if (IMAGE_MIME_REGEX.test(item.type)) { 151 | const file = 'getAsFile' in item ? item.getAsFile() : item as File; 152 | 153 | if (file) { 154 | this.quill.focus(); 155 | const selection = this.quill.getSelection(); 156 | if (selection) { 157 | this.range = selection; 158 | } 159 | evt.preventDefault(); 160 | setTimeout(() => { 161 | this.quill.focus(); 162 | const newSelection = this.quill.getSelection(); 163 | if (newSelection) { 164 | this.range = newSelection; 165 | } 166 | this.readAndUploadFile(file); 167 | }, 0); 168 | } 169 | } 170 | } 171 | } 172 | } 173 | 174 | readAndUploadFile(file: File) { 175 | let isUploadReject = false; 176 | 177 | const fileReader = new FileReader(); 178 | 179 | fileReader.addEventListener( 180 | 'load', 181 | () => { 182 | if (!isUploadReject) { 183 | const base64ImageSrc = fileReader.result as string; 184 | this.insertBase64Image(base64ImageSrc); 185 | } 186 | }, 187 | false 188 | ); 189 | 190 | if (file) { 191 | fileReader.readAsDataURL(file); 192 | } 193 | 194 | this.options.upload(file).then( 195 | (imageUrl) => { 196 | this.insertToEditor(imageUrl); 197 | }, 198 | (error) => { 199 | isUploadReject = true; 200 | this.removeBase64Image(); 201 | console.warn(error); 202 | } 203 | ); 204 | } 205 | 206 | fileChanged() { 207 | let file: File | null = null; 208 | if (this.fileHolder.files && this.fileHolder.files.length > 0) { 209 | file = this.fileHolder.files[0]; 210 | } 211 | if (file) { 212 | this.readAndUploadFile(file); 213 | } 214 | } 215 | 216 | insertBase64Image(url: string) { 217 | const range = this.range; 218 | 219 | // Utiliser directement 'imageBlot' comme nom de blot 220 | this.placeholderDelta = this.quill.insertEmbed( 221 | range.index, 222 | 'imageBlot', 223 | `${url}`, 224 | 'user' 225 | ); 226 | } 227 | 228 | insertToEditor(url: string) { 229 | const range = this.range; 230 | const lengthToDelete = this.calculatePlaceholderInsertLength(); 231 | 232 | // S'assurer que le delta est valide avant de tenter la suppression 233 | if (lengthToDelete > 0) { 234 | // Delete the placeholder image 235 | this.quill.deleteText(range.index, lengthToDelete, 'user'); 236 | } 237 | 238 | // Insert the server saved image 239 | this.quill.insertEmbed(range.index, 'image', `${url}`, 'user'); 240 | 241 | // Réinitialiser le placeholderDelta pour éviter les suppressions multiples 242 | this.placeholderDelta = { ops: [] }; 243 | 244 | range.index++; 245 | this.quill.setSelection(range, 'user'); 246 | } 247 | 248 | calculatePlaceholderInsertLength() { 249 | // Vérifier si placeholderDelta est défini et contient des opérations 250 | if (!this.placeholderDelta || !this.placeholderDelta.ops || !Array.isArray(this.placeholderDelta.ops)) { 251 | return 0; 252 | } 253 | 254 | return this.placeholderDelta.ops.reduce((accumulator, deltaOperation) => { 255 | // Vérifier si deltaOperation est défini 256 | if (deltaOperation && typeof deltaOperation === 'object') { 257 | const hasInsertProperty = Object.prototype.hasOwnProperty.call(deltaOperation, 'insert'); 258 | if (hasInsertProperty) 259 | accumulator++; 260 | } 261 | 262 | return accumulator; 263 | }, 0); 264 | } 265 | 266 | removeBase64Image() { 267 | const range = this.range; 268 | const lengthToDelete = this.calculatePlaceholderInsertLength(); 269 | 270 | if (lengthToDelete > 0) { 271 | this.quill.deleteText(range.index, lengthToDelete, 'user'); 272 | } 273 | 274 | // Réinitialiser placeholderDelta pour éviter les suppressions multiples 275 | this.placeholderDelta = { ops: [] }; 276 | } 277 | } 278 | 279 | window.ImageUploader = ImageUploader; 280 | export default ImageUploader; 281 | -------------------------------------------------------------------------------- /.github/workflows/test-bundle-install.yml: -------------------------------------------------------------------------------- 1 | name: Test Bundle Installation 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test_bundle_install: 12 | name: Install Bundle 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: true 16 | matrix: 17 | php-version: ['8.1', '8.2', '8.3', '8.4'] 18 | symfony-version: ['6.4', '7.0', '7.1', '7.2', '7.3'] 19 | exclude: 20 | - php-version: '8.1' 21 | symfony-version: '7.0' 22 | - php-version: '8.1' 23 | symfony-version: '7.1' 24 | - php-version: '8.1' 25 | symfony-version: '7.2' 26 | - php-version: '8.1' 27 | symfony-version: '7.3' 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup PHP 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: ${{ matrix.php-version }} 37 | extensions: mbstring, xml, ctype, iconv, intl, dom, filter, json 38 | coverage: none 39 | tools: composer:v2 40 | 41 | - name: Installation de la CLI Symfony 42 | run: | 43 | # Installation de la CLI Symfony pour les tests 44 | curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | sudo -E bash 45 | sudo apt install symfony-cli || echo "Impossible d'installer la CLI Symfony, on continuera avec le serveur PHP intégré" 46 | 47 | - name: Création d'un nouveau projet Symfony 48 | run: | 49 | composer create-project symfony/skeleton:"^${{ matrix.symfony-version }}" test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 50 | cd test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 51 | composer require webapp symfony/asset-mapper 52 | 53 | - name: Installation du bundle QuillJs 54 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 55 | run: | 56 | # Installer notre bundle depuis le dépôt local 57 | composer config repositories.local '{"type": "path", "url": "../", "options": {"symlink": true}}' 58 | composer require ehyiah/ux-quill:@dev 59 | 60 | # Vérifier que le bundle est correctement installé dans composer.json 61 | cat composer.json | grep ehyiah/ux-quill 62 | 63 | - name: Vérification de l'Asset Mapper 64 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 65 | run: | 66 | # Vérifier les entrées d'assets mappés 67 | bin/console debug:asset-map | grep -i quill 68 | 69 | - name: Vérification de l'installation du bundle 70 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 71 | run: | 72 | # Vérifier que le bundle est bien enregistré dans le kernel 73 | grep -r "Ehyiah\\\\QuillJsBundle" config/ 74 | 75 | # Vérifier que les services sont bien chargés 76 | bin/console debug:container | grep -i quill 77 | 78 | - name: Créer un formulaire test avec QuillJs 79 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 80 | run: | 81 | mkdir -p src/Form 82 | cat > src/Form/TestQuillType.php << 'EOL' 83 | add('content', QuillType::class, [ 96 | ]); 97 | } 98 | } 99 | EOL 100 | 101 | # Vérifier que le fichier a été créé 102 | cat src/Form/TestQuillType.php 103 | 104 | - name: Test de fonctionnement 105 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 106 | run: | 107 | # Créer un contrôleur pour tester le formulaire 108 | mkdir -p src/Controller 109 | cat > src/Controller/TestController.php << 'EOL' 110 | createForm(TestQuillType::class); 125 | 126 | return $this->render('test/index.html.twig', [ 127 | 'form' => $form->createView(), 128 | ]); 129 | } 130 | } 131 | EOL 132 | 133 | # Créer un template pour afficher le formulaire 134 | mkdir -p templates/test 135 | cat > templates/test/index.html.twig << 'EOL' 136 | {% extends 'base.html.twig' %} 137 | 138 | {% block title %}Test QuillJs{% endblock %} 139 | 140 | {% block body %} 141 |

Test du bundle QuillJs

142 | {{ form_start(form) }} 143 | {{ form_row(form.content) }} 144 | {{ form_end(form) }} 145 | {% endblock %} 146 | EOL 147 | 148 | # Vérifier la configuration d'importmap.php si elle existe 149 | cat config/importmap.php 2>/dev/null || echo "Fichier importmap.php introuvable" 150 | 151 | # Compiler les assets avec AssetMapper 152 | bin/console asset-map:compile 153 | 154 | # Préparer l'environnement pour le test 155 | bin/console cache:clear --env=prod 156 | bin/console debug:router 157 | 158 | # Recalculer le même port dynamique 159 | PHP_MAJOR=$(echo "${{ matrix.php-version }}" | cut -d'.' -f1) 160 | PHP_MINOR=$(echo "${{ matrix.php-version }}" | cut -d'.' -f2) 161 | SYM_MAJOR=$(echo "${{ matrix.symfony-version }}" | cut -d'.' -f1) 162 | SYM_MINOR=$(echo "${{ matrix.symfony-version }}" | cut -d'.' -f2) 163 | PORT=$((8000 + PHP_MAJOR * 1000 + PHP_MINOR * 100 + SYM_MAJOR * 10 + SYM_MINOR)) 164 | 165 | # Démarrer le serveur avec plus de vérifications 166 | SERVER_PORT=$PORT symfony server:start -d --no-tls --port=$PORT && echo "Serveur Symfony démarré sur le port $PORT" || \ 167 | (echo "Serveur Symfony n'a pas démarré, tentative avec le serveur PHP intégré" && \ 168 | php -S localhost:$PORT -t public & echo "Serveur PHP démarré sur le port $PORT") 169 | 170 | # Vérifier que le serveur répond 171 | sleep 5 # Attendre que le serveur démarre 172 | curl -v http://localhost:$PORT/ || (echo "❌ Le serveur ne répond pas" && exit 1) 173 | 174 | # Afficher les logs pour diagnostiquer les éventuels problèmes 175 | test -f var/log/dev.log && tail -n 50 var/log/dev.log || echo "Aucun fichier de log trouvé" 176 | 177 | # Vérifier la réponse HTTP avec détails (pour le diagnostic) 178 | curl -v http://localhost:$PORT/test-quill 179 | 180 | # Vérifier le code HTTP et échouer si ce n'est pas 200 181 | echo "Vérification que l'application renvoie bien un code HTTP 200..." 182 | HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$PORT/test-quill) 183 | echo "Statut HTTP: $HTTP_STATUS" 184 | if [ "$HTTP_STATUS" -ne 200 ]; then 185 | echo "❌ ERREUR: Le endpoint /test-quill n'a pas retourné un statut HTTP 200 (statut actuel: $HTTP_STATUS)" 186 | exit 1 187 | fi 188 | echo "✅ L'application répond avec un statut HTTP 200" 189 | 190 | - name: Vérification du rendu HTML QuillJs 191 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 192 | run: | 193 | echo "Vérification de la présence des éléments HTML du composant QuillJs..." 194 | 195 | # Recalculer le même port dynamique 196 | PHP_MAJOR=$(echo "${{ matrix.php-version }}" | cut -d'.' -f1) 197 | PHP_MINOR=$(echo "${{ matrix.php-version }}" | cut -d'.' -f2) 198 | SYM_MAJOR=$(echo "${{ matrix.symfony-version }}" | cut -d'.' -f1) 199 | SYM_MINOR=$(echo "${{ matrix.symfony-version }}" | cut -d'.' -f2) 200 | PORT=$((8000 + PHP_MAJOR * 1000 + PHP_MINOR * 100 + SYM_MAJOR * 10 + SYM_MINOR)) 201 | 202 | # Récupérer le contenu HTML de la page avec le port calculé 203 | HTML_CONTENT=$(curl -s http://localhost:$PORT/test-quill) 204 | 205 | # Créer un fichier temporaire unique pour éviter les conflits 206 | TEMP_FILE="quill_response_php${PHP_MAJOR}${PHP_MINOR}_sym${SYM_MAJOR}${SYM_MINOR}.html" 207 | echo "$HTML_CONTENT" > $TEMP_FILE 208 | 209 | # Vérifier la présence de la div quill-container 210 | if ! grep -q 'div class="quill-container"' $TEMP_FILE; then 211 | echo "❌ ERREUR: La div avec la classe 'quill-container' est absente" 212 | exit 1 213 | fi 214 | 215 | # Vérifier la présence de data-controller="ehyiah--ux-quill--quill" 216 | if ! grep -q 'data-controller="ehyiah--ux-quill--quill"' $TEMP_FILE; then 217 | echo "❌ ERREUR: L'attribut data-controller est absent ou incorrect" 218 | exit 1 219 | fi 220 | 221 | # Vérifier la présence de data-ehyiah--ux-quill--quill-toolbar-options-value 222 | if ! grep -q 'data-ehyiah--ux-quill--quill-toolbar-options-value' $TEMP_FILE; then 223 | echo "❌ ERREUR: L'attribut data-ehyiah--ux-quill--quill-toolbar-options-value est absent" 224 | exit 1 225 | fi 226 | 227 | # Vérifier la présence de data-ehyiah--ux-quill--quill-extra-options-value 228 | if ! grep -q 'data-ehyiah--ux-quill--quill-extra-options-value' $TEMP_FILE; then 229 | echo "❌ ERREUR: L'attribut data-ehyiah--ux-quill--quill-extra-options-value est absent" 230 | exit 1 231 | fi 232 | 233 | # Vérifier la présence de data-ehyiah--ux-quill--quill-modules-options-value 234 | if ! grep -q 'data-ehyiah--ux-quill--quill-modules-options-value' $TEMP_FILE; then 235 | echo "❌ ERREUR: L'attribut data-ehyiah--ux-quill--quill-modules-options-value est absent" 236 | exit 1 237 | fi 238 | 239 | rm $TEMP_FILE 240 | 241 | echo "✅ Tous les éléments HTML du composant QuillJs sont présents dans la page" 242 | 243 | - name: Rapport final 244 | working-directory: test-project-${{ matrix.php-version }}-${{ matrix.symfony-version }} 245 | run: | 246 | echo "✅ Installation du bundle ehyiah/ux-quill terminée avec succès sur Symfony ${{ matrix.symfony-version }} et PHP ${{ matrix.php-version }} avec AssetMapper" 247 | -------------------------------------------------------------------------------- /src/Form/QuillType.php: -------------------------------------------------------------------------------- 1 | vars['attr']['quill_options'] = json_encode($options['quill_options']); 37 | 38 | $fields = $options['quill_options']; 39 | $modules = $options['modules']; 40 | 41 | foreach ($this->getAutomaticModulesToConfigure() as $config) { 42 | $this->addAutoModuleIfRequired($fields, $modules, $config['moduleName'], $config['fieldIdentifier'], $config['moduleClass']); 43 | } 44 | 45 | $extraOptions = $options['quill_extra_options']; 46 | 47 | // Handle callable (closure) for quill_extra_options (Symfony 8 compatibility) 48 | if (is_callable($extraOptions)) { 49 | $extraResolver = new OptionsResolver(); 50 | $extraOptions($extraResolver); 51 | $extraOptions = $extraResolver->resolve([]); 52 | } 53 | 54 | if (isset($extraOptions['placeholder']) && $extraOptions['placeholder'] instanceof TranslatableInterface) { 55 | $extraOptions['placeholder'] = $extraOptions['placeholder']->trans($this->translator); 56 | } 57 | 58 | $view->vars['attr']['quill_extra_options'] = json_encode($extraOptions); 59 | $view->vars['attr']['quill_modules_options'] = json_encode($modules); 60 | 61 | $assets = $this->getBuiltInAssets($fields, $modules, $extraOptions); 62 | $view->vars['quill_assets'] = $assets; 63 | } 64 | 65 | public function configureOptions(OptionsResolver $resolver): void 66 | { 67 | $resolver->setDefaults([ 68 | 'label' => false, 69 | 'error_bubbling' => true, 70 | 'quill_options' => [['bold', 'italic']], 71 | 'modules' => [], 72 | 'quill_extra_options' => function (OptionsResolver $extraResolver) { 73 | $extraResolver 74 | ->setDefault('upload_handler', function (OptionsResolver $spoolResolver): void { 75 | $spoolResolver->setDefaults([ 76 | 'type' => 'form', 77 | 'upload_endpoint' => null, 78 | 'json_response_file_path' => null, 79 | 'security' => function (OptionsResolver $securityResolver) { 80 | $securityResolver->setDefaults([ 81 | 'type' => null, 82 | 'jwt_token' => null, 83 | 'username' => null, 84 | 'password' => null, 85 | 'custom_header' => null, 86 | 'custom_header_value' => null, 87 | ]); 88 | $securityResolver->setAllowedTypes('type', ['string']); 89 | $securityResolver->setAllowedValues('type', ['basic', 'jwt']); 90 | $securityResolver->setAllowedTypes('jwt_token', ['string', 'null']); 91 | $securityResolver->setAllowedTypes('username', ['string', 'null']); 92 | $securityResolver->setAllowedTypes('password', ['string', 'null']); 93 | $securityResolver->setAllowedTypes('custom_header', ['string', 'null']); 94 | $securityResolver->setAllowedTypes('custom_header_value', ['string', 'null']); 95 | }, 96 | ]); 97 | $spoolResolver->setAllowedTypes('type', ['string', 'null']); 98 | $spoolResolver->setAllowedValues('type', ['json', 'form', null]); 99 | $spoolResolver->setAllowedTypes('upload_endpoint', ['string', 'null']); 100 | $spoolResolver->setAllowedTypes('json_response_file_path', ['string', 'null']); 101 | $spoolResolver->setAllowedTypes('security', ['array', 'null']); 102 | $spoolResolver->setDefault('security', null); 103 | }) 104 | ; 105 | $extraResolver 106 | ->setDefault('debug', DebugOption::DEBUG_OPTION_ERROR) 107 | ->setAllowedTypes('debug', 'string') 108 | ->setAllowedValues('debug', [DebugOption::DEBUG_OPTION_ERROR, DebugOption::DEBUG_OPTION_WARNING, DebugOption::DEBUG_OPTION_LOG, DebugOption::DEBUG_OPTION_INFO]) 109 | ; 110 | $extraResolver 111 | ->setDefault('height', '200px') 112 | ->setAllowedTypes('height', ['string', 'null']) 113 | ->setAllowedValues('height', function (?string $value) { 114 | if (null === $value) { 115 | return true; 116 | } 117 | 118 | return preg_match('/(\d+)(px$|em$|ex$|%$)/', $value); 119 | }) 120 | ; 121 | $extraResolver 122 | ->setDefault('theme', 'snow') 123 | ->setAllowedTypes('theme', 'string') 124 | ->setAllowedValues('theme', [ThemeOption::QUILL_THEME_SNOW, ThemeOption::QUILL_THEME_BUBBLE]) 125 | ; 126 | $extraResolver 127 | ->setDefault('placeholder', 'Quill editor') 128 | ->setAllowedTypes('placeholder', ['string', TranslatableMessage::class, TranslatableInterface::class]) 129 | ; 130 | $extraResolver 131 | ->setDefault('style', StyleOption::QUILL_STYLE_CLASS) 132 | ->setAllowedTypes('style', 'string') 133 | ->setAllowedValues('style', [StyleOption::QUILL_STYLE_INLINE, StyleOption::QUILL_STYLE_CLASS]) 134 | ; 135 | $extraResolver 136 | ->setDefault('modules', []) 137 | ->setAllowedTypes('modules', ['array']) 138 | ; 139 | $extraResolver 140 | ->setDefault('use_semantic_html', false) 141 | ->setAllowedTypes('use_semantic_html', 'bool') 142 | ->setAllowedValues('use_semantic_html', [true, false]) 143 | ; 144 | $extraResolver 145 | ->setDefault('custom_icons', []) 146 | ; 147 | $extraResolver 148 | ->setDefault('read_only', false) 149 | ->setAllowedTypes('read_only', 'bool') 150 | ; 151 | $extraResolver 152 | ->setDefault('assets', []) 153 | ->setAllowedTypes('assets', ['array']) 154 | ; 155 | }, 156 | ]); 157 | 158 | $resolver->setAllowedTypes('quill_options', ['array']); 159 | $resolver->setAllowedTypes('quill_extra_options', ['array', 'callable']); 160 | $resolver->setAllowedTypes('modules', ['array']); 161 | $resolver->setAllowedValues('modules', function (array $values) { 162 | foreach ($values as $value) { 163 | if (!$value instanceof ModuleInterface) { 164 | return false; 165 | } 166 | } 167 | 168 | return true; 169 | }); 170 | } 171 | 172 | public function getBlockPrefix(): string 173 | { 174 | return 'quill'; 175 | } 176 | 177 | public function getParent(): string 178 | { 179 | return TextareaType::class; 180 | } 181 | 182 | /** 183 | * @return array> 184 | */ 185 | private function getAutomaticModulesToConfigure(): array 186 | { 187 | return [ 188 | [ 189 | 'moduleName' => EmojiModule::NAME, 190 | 'fieldIdentifier' => (new EmojiField())->getOption(), 191 | 'moduleClass' => EmojiModule::class, 192 | ], 193 | [ 194 | 'moduleName' => ResizeModule::NAME, 195 | 'fieldIdentifier' => (new ImageField())->getOption(), 196 | 'moduleClass' => ResizeModule::class, 197 | ], 198 | [ 199 | 'moduleName' => SyntaxModule::NAME, 200 | 'fieldIdentifier' => (new CodeBlockField())->getOption(), 201 | 'moduleClass' => SyntaxModule::class, 202 | ], 203 | [ 204 | 'moduleName' => TableModule::NAME, 205 | 'fieldIdentifier' => 'table-better', 206 | 'moduleClass' => TableModule::class, 207 | ], 208 | ]; 209 | } 210 | 211 | /** 212 | * Ajoute un module au tableau des modules s'il n'existe pas déjà et si un champ correspondant est présent 213 | * permet une configuration par défaut des modules lorsque ceux-ci sont nécessaires 214 | * Si le module a été mis par l'utilisateur, alors la version de l'utilisateur sera conservée 215 | * 216 | * @param array $fields Tableau des champs à vérifier 217 | * @param array|object> $modules Tableau des modules à compléter 218 | * @param string $moduleName Nom du module à vérifier 219 | * @param string $fieldIdentifier Identifiant du champ à rechercher 220 | * @param string $moduleClass Classe du module à instancier 221 | */ 222 | private function addAutoModuleIfRequired(array $fields, array &$modules, string $moduleName, string $fieldIdentifier, string $moduleClass): void 223 | { 224 | if (in_array($moduleName, array_column($modules, 'name'), true)) { 225 | return; 226 | } 227 | 228 | if (in_array($fieldIdentifier, $fields, true)) { 229 | $modules[] = new $moduleClass(); 230 | 231 | return; 232 | } 233 | 234 | foreach ($fields as $field) { 235 | if (is_array($field) 236 | && (in_array($fieldIdentifier, $field, true) 237 | || isset($field[$fieldIdentifier]) 238 | || array_key_exists($fieldIdentifier, $field))) { 239 | $modules[] = new $moduleClass(); 240 | 241 | return; 242 | } 243 | } 244 | } 245 | 246 | /** 247 | * @param array $fields 248 | * @param array $modules 249 | * @param array $extraOptions 250 | * 251 | * @return array 252 | */ 253 | private function getBuiltInAssets(array $fields, array $modules, array $extraOptions): array 254 | { 255 | $assets['styleSheets'] = []; 256 | $assets['scripts'] = []; 257 | 258 | $formulaFieldOption = (new FormulaField())->getOption(); 259 | foreach ($fields as $fieldGroup) { 260 | $hasFormula = is_array($fieldGroup) 261 | ? in_array($formulaFieldOption, $fieldGroup, true) 262 | : $fieldGroup === $formulaFieldOption; 263 | 264 | if ($hasFormula) { 265 | $assets['styleSheets']['katex'] = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css'; 266 | $assets['scripts']['katex'] = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js'; 267 | } 268 | } 269 | 270 | foreach ($modules as $module) { 271 | if ($module instanceof SyntaxModule) { 272 | $assets['styleSheets']['highlight'] = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css'; 273 | $assets['scripts']['highlight'] = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js'; 274 | } 275 | if ($module instanceof HtmlEditModule && (isset($module->options['syntax']) && true === $module->options['syntax'])) { 276 | $assets['styleSheets']['highlight'] = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; 277 | $assets['scripts']['highlight'] = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js'; 278 | $assets['scripts']['highlight2'] = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js'; 279 | } 280 | } 281 | 282 | if (isset($extraOptions['assets']) && count($extraOptions['assets']) > 0) { 283 | $assets = $this->getCustomAssets($extraOptions['assets'], $assets); 284 | } 285 | 286 | return $assets; 287 | } 288 | 289 | /** 290 | * @param array $customAssets 291 | * @param array $assets 292 | * 293 | * @return array 294 | */ 295 | private function getCustomAssets(array $customAssets, array $assets): array 296 | { 297 | if (isset($customAssets['styleSheets'])) { 298 | $assets['styleSheets'] = array_merge($assets['styleSheets'], $customAssets['styleSheets']); 299 | } 300 | if (isset($customAssets['scripts'])) { 301 | $assets['scripts'] = array_merge($assets['scripts'], $customAssets['scripts']); 302 | } 303 | 304 | return $assets; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /tests/Form/QuillTypeTest.php: -------------------------------------------------------------------------------- 1 | createMock(TranslatorInterface::class); 32 | $this->quillType = new QuillType($translator); 33 | $this->form = $this->createMock(FormInterface::class); 34 | $this->formView = new FormView(); 35 | } 36 | 37 | /** 38 | * @covers ::buildView 39 | * 40 | * @dataProvider provideOptionsToBuildView 41 | */ 42 | public function testBuildView($options, $expectedOptions): void 43 | { 44 | $this->quillType->buildView($this->formView, $this->form, $options); 45 | 46 | $this->assertCount(3, $this->formView->vars); 47 | $this->assertArrayHasKey('attr', $this->formView->vars); 48 | $this->assertArrayHasKey('quill_assets', $this->formView->vars); 49 | $this->assertCount(2, $this->formView->vars['quill_assets']); 50 | $this->assertArrayHasKey('styleSheets', $this->formView->vars['quill_assets']); 51 | if (isset($this->formView->vars['quill_assets']['styleSheets']['highlight'])) { 52 | $this->assertEquals('https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', $this->formView->vars['quill_assets']['styleSheets']['highlight']); 53 | } 54 | $this->assertArrayHasKey('scripts', $this->formView->vars['quill_assets']); 55 | if (isset($this->formView->vars['quill_assets']['scripts']['highlight'])) { 56 | $this->assertEquals('https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', $this->formView->vars['quill_assets']['scripts']['highlight']); 57 | } 58 | $this->assertCount(3, $this->formView->vars['attr']); 59 | $this->assertArrayHasKey('quill_options', $this->formView->vars['attr']); 60 | $this->assertArrayHasKey('quill_extra_options', $this->formView->vars['attr']); 61 | $this->assertArrayHasKey('quill_modules_options', $this->formView->vars['attr']); 62 | 63 | $this->assertEquals(json_encode($expectedOptions['quill_options']), $this->formView->vars['attr']['quill_options']); 64 | $this->assertEquals(json_encode($expectedOptions['quill_extra_options']), $this->formView->vars['attr']['quill_extra_options']); 65 | $this->assertEquals(json_encode($expectedOptions['modules']), $this->formView->vars['attr']['quill_modules_options']); 66 | } 67 | 68 | public static function provideOptionsToBuildView(): Generator 69 | { 70 | yield [ 71 | [ 72 | 'quill_options' => [ 73 | ['bold', 'italic'], 74 | ['bold', 'underline'], 75 | ['code-block'], 76 | ['image'], 77 | ['emoji'], 78 | ], 79 | 'quill_extra_options' => [ 80 | ], 81 | 'modules' => [], 82 | ], 83 | [ 84 | 'quill_options' => [ 85 | ['bold', 'italic'], 86 | ['bold', 'underline'], 87 | ['code-block'], 88 | ['image'], 89 | ['emoji'], 90 | ], 91 | 'quill_extra_options' => [ 92 | ], 93 | 'modules' => [ 94 | new EmojiModule(), 95 | new ResizeModule(), 96 | new SyntaxModule(), 97 | ], 98 | ], 99 | ]; 100 | yield [ 101 | [ 102 | 'quill_options' => [ 103 | ['bold', 'italic'], 104 | ['bold', 'underline'], 105 | ], 106 | 'quill_extra_options' => [ 107 | ], 108 | 'modules' => [], 109 | ], 110 | [ 111 | 'quill_options' => [ 112 | ['bold', 'italic'], 113 | ['bold', 'underline'], 114 | ], 115 | 'quill_extra_options' => [ 116 | ], 117 | 'modules' => [ 118 | ], 119 | ], 120 | ]; 121 | } 122 | 123 | /** 124 | * @covers ::configureOptions 125 | */ 126 | public function testConfigureOptions(): void 127 | { 128 | $translator = $this->createMock(TranslatorInterface::class); 129 | $quillType = new QuillType($translator); 130 | 131 | $resolver = new OptionsResolver(); 132 | $quillType->configureOptions($resolver); 133 | 134 | $this->assertCount(5, $resolver->getDefinedOptions()); 135 | $this->assertTrue($resolver->hasDefault('label')); 136 | $this->assertTrue($resolver->hasDefault('error_bubbling')); 137 | $this->assertTrue($resolver->hasDefault('quill_options')); 138 | $this->assertTrue($resolver->hasDefault('quill_extra_options')); 139 | $this->assertTrue($resolver->hasDefault('modules')); 140 | } 141 | 142 | /** 143 | * @covers ::getBlockPrefix 144 | */ 145 | public function testGetBlockPrefix(): void 146 | { 147 | $translator = $this->createMock(TranslatorInterface::class); 148 | $quillType = new QuillType($translator); 149 | 150 | $this->assertEquals('quill', $quillType->getBlockPrefix()); 151 | } 152 | 153 | /** 154 | * @covers ::getParent 155 | */ 156 | public function testGetParent(): void 157 | { 158 | $translator = $this->createMock(TranslatorInterface::class); 159 | $quillType = new QuillType($translator); 160 | 161 | $this->assertEquals(TextareaType::class, $quillType->getParent()); 162 | } 163 | 164 | /** 165 | * @covers ::buildView 166 | * 167 | * @dataProvider provideFormulaFieldOptions 168 | */ 169 | public function testBuildViewWithFormulaFieldAddsKatexAssets(array $options, string $testCase): void 170 | { 171 | $this->quillType->buildView($this->formView, $this->form, $options); 172 | 173 | $this->assertArrayHasKey('quill_assets', $this->formView->vars, "Failed for test case: {$testCase}"); 174 | $this->assertArrayHasKey('styleSheets', $this->formView->vars['quill_assets'], "Failed for test case: {$testCase}"); 175 | $this->assertArrayHasKey('scripts', $this->formView->vars['quill_assets'], "Failed for test case: {$testCase}"); 176 | 177 | $this->assertArrayHasKey('katex', $this->formView->vars['quill_assets']['styleSheets'], "KaTeX stylesheet missing for test case: {$testCase}"); 178 | $this->assertArrayHasKey('katex', $this->formView->vars['quill_assets']['scripts'], "KaTeX script missing for test case: {$testCase}"); 179 | 180 | $this->assertEquals( 181 | 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css', 182 | $this->formView->vars['quill_assets']['styleSheets']['katex'], 183 | "Failed for test case: {$testCase}" 184 | ); 185 | $this->assertEquals( 186 | 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', 187 | $this->formView->vars['quill_assets']['scripts']['katex'], 188 | "Failed for test case: {$testCase}" 189 | ); 190 | } 191 | 192 | public static function provideFormulaFieldOptions(): Generator 193 | { 194 | yield 'formula in array' => [ 195 | [ 196 | 'quill_options' => [ 197 | ['bold', 'italic'], 198 | ['formula', 'underline'], 199 | ], 200 | 'quill_extra_options' => [], 201 | 'modules' => [], 202 | ], 203 | 'formula in array', 204 | ]; 205 | 206 | yield 'formula as direct element' => [ 207 | [ 208 | 'quill_options' => [ 209 | ['bold', 'italic'], 210 | 'formula', 211 | ], 212 | 'quill_extra_options' => [], 213 | 'modules' => [], 214 | ], 215 | 'formula as direct element', 216 | ]; 217 | } 218 | 219 | /** 220 | * @covers ::buildView 221 | * 222 | * @dataProvider provideModulesForHighlightAssets 223 | */ 224 | public function testBuildViewWithSyntaxAndHtmlEditModulesHandlesHighlightAssets( 225 | array $options, 226 | array $expectedStyleSheets, 227 | array $expectedScripts, 228 | string $testCase, 229 | ): void { 230 | $this->quillType->buildView($this->formView, $this->form, $options); 231 | 232 | $this->assertArrayHasKey('quill_assets', $this->formView->vars, "Failed for test case: {$testCase}"); 233 | $assets = $this->formView->vars['quill_assets']; 234 | 235 | $this->assertArrayHasKey('styleSheets', $assets, "Failed for test case: {$testCase}"); 236 | $this->assertArrayHasKey('scripts', $assets, "Failed for test case: {$testCase}"); 237 | 238 | foreach ($expectedStyleSheets as $key => $expectedUrl) { 239 | $this->assertArrayHasKey($key, $assets['styleSheets'], "Stylesheet '{$key}' missing for test case: {$testCase}"); 240 | $this->assertEquals( 241 | $expectedUrl, 242 | $assets['styleSheets'][$key], 243 | "Stylesheet '{$key}' URL mismatch for test case: {$testCase}" 244 | ); 245 | } 246 | 247 | foreach (array_keys($assets['styleSheets']) as $key) { 248 | $this->assertArrayHasKey($key, $expectedStyleSheets, "Unexpected stylesheet '{$key}' for test case: {$testCase}"); 249 | } 250 | 251 | foreach ($expectedScripts as $key => $expectedUrl) { 252 | $this->assertArrayHasKey($key, $assets['scripts'], "Script '{$key}' missing for test case: {$testCase}"); 253 | $this->assertEquals( 254 | $expectedUrl, 255 | $assets['scripts'][$key], 256 | "Script '{$key}' URL mismatch for test case: {$testCase}" 257 | ); 258 | } 259 | 260 | foreach (array_keys($assets['scripts']) as $key) { 261 | $this->assertArrayHasKey($key, $expectedScripts, "Unexpected script '{$key}' for test case: {$testCase}"); 262 | } 263 | } 264 | 265 | public static function provideModulesForHighlightAssets(): Generator 266 | { 267 | yield 'no modules at all' => [ 268 | [ 269 | 'quill_options' => [['bold', 'italic']], 270 | 'quill_extra_options' => [], 271 | 'modules' => [], 272 | ], 273 | [], // expected styleSheets 274 | [], // expected scripts 275 | 'no modules at all', 276 | ]; 277 | 278 | yield 'only SyntaxModule' => [ 279 | [ 280 | 'quill_options' => [['bold', 'italic']], 281 | 'quill_extra_options' => [], 282 | 'modules' => [new SyntaxModule()], 283 | ], 284 | [ 285 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', 286 | ], 287 | [ 288 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 289 | ], 290 | 'only SyntaxModule', 291 | ]; 292 | 293 | yield 'only HtmlEditModule with syntax false' => [ 294 | [ 295 | 'quill_options' => [['bold', 'italic']], 296 | 'quill_extra_options' => [], 297 | 'modules' => [new HtmlEditModule()], 298 | ], 299 | [], // no highlight assets expected 300 | [], 301 | 'only HtmlEditModule with syntax false', 302 | ]; 303 | 304 | yield 'only HtmlEditModule with syntax true' => [ 305 | [ 306 | 'quill_options' => [['bold', 'italic']], 307 | 'quill_extra_options' => [], 308 | 'modules' => [ 309 | new HtmlEditModule(options: ['syntax' => true]), 310 | ], 311 | ], 312 | [ 313 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 314 | ], 315 | [ 316 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 317 | 'highlight2' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js', 318 | ], 319 | 'only HtmlEditModule with syntax true', 320 | ]; 321 | 322 | yield 'HtmlEditModule without syntax option' => [ 323 | [ 324 | 'quill_options' => [['bold', 'italic']], 325 | 'quill_extra_options' => [], 326 | 'modules' => [ 327 | new HtmlEditModule(options: ['debug' => true]), 328 | ], 329 | ], 330 | [], // no highlight assets expected (syntax defaults to false) 331 | [], 332 | 'HtmlEditModule without syntax option', 333 | ]; 334 | 335 | yield 'SyntaxModule before HtmlEditModule with syntax false' => [ 336 | [ 337 | 'quill_options' => [['bold', 'italic']], 338 | 'quill_extra_options' => [], 339 | 'modules' => [ 340 | new SyntaxModule(), 341 | new HtmlEditModule(options: ['syntax' => false]), 342 | ], 343 | ], 344 | [ 345 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', 346 | ], 347 | [ 348 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 349 | ], 350 | 'SyntaxModule before HtmlEditModule with syntax false', 351 | ]; 352 | 353 | yield 'SyntaxModule before HtmlEditModule with syntax true - HtmlEdit overwrites' => [ 354 | [ 355 | 'quill_options' => [['bold', 'italic']], 356 | 'quill_extra_options' => [], 357 | 'modules' => [ 358 | new SyntaxModule(), 359 | new HtmlEditModule(options: ['syntax' => true]), 360 | ], 361 | ], 362 | [ 363 | // HtmlEditModule overwrites the SyntaxModule stylesheet 364 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 365 | ], 366 | [ 367 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 368 | 'highlight2' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js', 369 | ], 370 | 'SyntaxModule before HtmlEditModule with syntax true - HtmlEdit overwrites', 371 | ]; 372 | 373 | yield 'HtmlEditModule with syntax true before SyntaxModule - SyntaxModule overwrites' => [ 374 | [ 375 | 'quill_options' => [['bold', 'italic']], 376 | 'quill_extra_options' => [], 377 | 'modules' => [ 378 | new HtmlEditModule(options: ['syntax' => true]), 379 | new SyntaxModule(), 380 | ], 381 | ], 382 | [ 383 | // SyntaxModule overwrites the HtmlEditModule stylesheet 384 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', 385 | ], 386 | [ 387 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 388 | 'highlight2' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js', 389 | ], 390 | 'HtmlEditModule with syntax true before SyntaxModule - SyntaxModule overwrites', 391 | ]; 392 | 393 | yield 'multiple SyntaxModule instances' => [ 394 | [ 395 | 'quill_options' => [['bold', 'italic']], 396 | 'quill_extra_options' => [], 397 | 'modules' => [ 398 | new SyntaxModule(), 399 | new SyntaxModule(), 400 | new SyntaxModule(), 401 | ], 402 | ], 403 | [ 404 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', 405 | ], 406 | [ 407 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 408 | ], 409 | 'multiple SyntaxModule instances', 410 | ]; 411 | 412 | yield 'multiple HtmlEditModule instances with different syntax values' => [ 413 | [ 414 | 'quill_options' => [['bold', 'italic']], 415 | 'quill_extra_options' => [], 416 | 'modules' => [ 417 | new HtmlEditModule(options: ['syntax' => false]), 418 | new HtmlEditModule(options: ['syntax' => true]), 419 | new HtmlEditModule(options: ['syntax' => false]), 420 | ], 421 | ], 422 | [ 423 | // Last HtmlEditModule with syntax true wins 424 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 425 | ], 426 | [ 427 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 428 | 'highlight2' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js', 429 | ], 430 | 'multiple HtmlEditModule instances with different syntax values', 431 | ]; 432 | 433 | yield 'complex scenario with other modules' => [ 434 | [ 435 | 'quill_options' => [['bold', 'italic']], 436 | 'quill_extra_options' => [], 437 | 'modules' => [ 438 | new EmojiModule(), 439 | new SyntaxModule(), 440 | new ResizeModule(), 441 | new HtmlEditModule(options: ['syntax' => true]), 442 | ], 443 | ], 444 | [ 445 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 446 | ], 447 | [ 448 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 449 | 'highlight2' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js', 450 | ], 451 | 'complex scenario with other modules', 452 | ]; 453 | } 454 | 455 | /** 456 | * @covers ::buildView 457 | */ 458 | public function testBuildViewWithDefaultQuillExtraOptionsAsClosure(): void 459 | { 460 | $resolver = new OptionsResolver(); 461 | $this->quillType->configureOptions($resolver); 462 | 463 | // Resolve options without providing quill_extra_options (uses default closure) 464 | $options = $resolver->resolve([ 465 | 'quill_options' => [['bold', 'italic']], 466 | 'modules' => [], 467 | ]); 468 | 469 | $this->quillType->buildView($this->formView, $this->form, $options); 470 | 471 | $this->assertArrayHasKey('attr', $this->formView->vars); 472 | $this->assertArrayHasKey('quill_extra_options', $this->formView->vars['attr']); 473 | 474 | $extraOptions = json_decode($this->formView->vars['attr']['quill_extra_options'], true); 475 | $this->assertIsArray($extraOptions); 476 | $this->assertArrayHasKey('debug', $extraOptions); 477 | $this->assertArrayHasKey('height', $extraOptions); 478 | $this->assertArrayHasKey('theme', $extraOptions); 479 | $this->assertArrayHasKey('placeholder', $extraOptions); 480 | $this->assertEquals('error', $extraOptions['debug']); 481 | $this->assertEquals('200px', $extraOptions['height']); 482 | $this->assertEquals('snow', $extraOptions['theme']); 483 | $this->assertEquals('Quill editor', $extraOptions['placeholder']); 484 | } 485 | 486 | /** 487 | * @covers ::buildView 488 | * 489 | * @dataProvider provideCustomAssetsOptions 490 | */ 491 | public function testBuildViewWithCustomAssets( 492 | array $options, 493 | array $expectedStyleSheets, 494 | array $expectedScripts, 495 | string $testCase, 496 | ): void { 497 | $this->quillType->buildView($this->formView, $this->form, $options); 498 | 499 | $this->assertArrayHasKey('quill_assets', $this->formView->vars, "Failed for test case: {$testCase}"); 500 | $assets = $this->formView->vars['quill_assets']; 501 | 502 | $this->assertArrayHasKey('styleSheets', $assets, "Failed for test case: {$testCase}"); 503 | $this->assertArrayHasKey('scripts', $assets, "Failed for test case: {$testCase}"); 504 | 505 | $this->assertCount( 506 | count($expectedStyleSheets), 507 | $assets['styleSheets'], 508 | "StyleSheets count mismatch for test case: {$testCase}. Expected: " . json_encode($expectedStyleSheets) . ', Got: ' . json_encode($assets['styleSheets']) 509 | ); 510 | 511 | foreach ($expectedStyleSheets as $key => $expectedUrl) { 512 | $this->assertArrayHasKey($key, $assets['styleSheets'], "Stylesheet '{$key}' missing for test case: {$testCase}"); 513 | $this->assertEquals( 514 | $expectedUrl, 515 | $assets['styleSheets'][$key], 516 | "Stylesheet '{$key}' URL mismatch for test case: {$testCase}" 517 | ); 518 | } 519 | 520 | $this->assertCount( 521 | count($expectedScripts), 522 | $assets['scripts'], 523 | "Scripts count mismatch for test case: {$testCase}. Expected: " . json_encode($expectedScripts) . ', Got: ' . json_encode($assets['scripts']) 524 | ); 525 | 526 | foreach ($expectedScripts as $key => $expectedUrl) { 527 | $this->assertArrayHasKey($key, $assets['scripts'], "Script '{$key}' missing for test case: {$testCase}"); 528 | $this->assertEquals( 529 | $expectedUrl, 530 | $assets['scripts'][$key], 531 | "Script '{$key}' URL mismatch for test case: {$testCase}" 532 | ); 533 | } 534 | } 535 | 536 | public static function provideCustomAssetsOptions(): Generator 537 | { 538 | yield 'no custom assets' => [ 539 | [ 540 | 'quill_options' => [['bold', 'italic']], 541 | 'quill_extra_options' => [], 542 | 'modules' => [], 543 | ], 544 | [], // expected styleSheets 545 | [], // expected scripts 546 | 'no custom assets', 547 | ]; 548 | 549 | yield 'empty custom assets array' => [ 550 | [ 551 | 'quill_options' => [['bold', 'italic']], 552 | 'quill_extra_options' => [ 553 | 'assets' => [], 554 | ], 555 | 'modules' => [], 556 | ], 557 | [], 558 | [], 559 | 'empty custom assets array', 560 | ]; 561 | 562 | yield 'custom stylesheets only' => [ 563 | [ 564 | 'quill_options' => [['bold', 'italic']], 565 | 'quill_extra_options' => [ 566 | 'assets' => [ 567 | 'styleSheets' => [ 568 | 'custom1' => 'https://example.com/custom1.css', 569 | 'custom2' => 'https://example.com/custom2.css', 570 | ], 571 | ], 572 | ], 573 | 'modules' => [], 574 | ], 575 | [ 576 | 'custom1' => 'https://example.com/custom1.css', 577 | 'custom2' => 'https://example.com/custom2.css', 578 | ], 579 | [], 580 | 'custom stylesheets only', 581 | ]; 582 | 583 | yield 'custom scripts only' => [ 584 | [ 585 | 'quill_options' => [['bold', 'italic']], 586 | 'quill_extra_options' => [ 587 | 'assets' => [ 588 | 'scripts' => [ 589 | 'custom1' => 'https://example.com/custom1.js', 590 | 'custom2' => 'https://example.com/custom2.js', 591 | ], 592 | ], 593 | ], 594 | 'modules' => [], 595 | ], 596 | [], 597 | [ 598 | 'custom1' => 'https://example.com/custom1.js', 599 | 'custom2' => 'https://example.com/custom2.js', 600 | ], 601 | 'custom scripts only', 602 | ]; 603 | 604 | yield 'custom stylesheets and scripts' => [ 605 | [ 606 | 'quill_options' => [['bold', 'italic']], 607 | 'quill_extra_options' => [ 608 | 'assets' => [ 609 | 'styleSheets' => [ 610 | 'customCss' => 'https://example.com/custom.css', 611 | ], 612 | 'scripts' => [ 613 | 'customJs' => 'https://example.com/custom.js', 614 | ], 615 | ], 616 | ], 617 | 'modules' => [], 618 | ], 619 | [ 620 | 'customCss' => 'https://example.com/custom.css', 621 | ], 622 | [ 623 | 'customJs' => 'https://example.com/custom.js', 624 | ], 625 | 'custom stylesheets and scripts', 626 | ]; 627 | 628 | yield 'custom assets combined with built-in formula assets' => [ 629 | [ 630 | 'quill_options' => [ 631 | ['bold', 'italic'], 632 | ['formula'], 633 | ], 634 | 'quill_extra_options' => [ 635 | 'assets' => [ 636 | 'styleSheets' => [ 637 | 'custom' => 'https://example.com/custom.css', 638 | ], 639 | 'scripts' => [ 640 | 'custom' => 'https://example.com/custom.js', 641 | ], 642 | ], 643 | ], 644 | 'modules' => [], 645 | ], 646 | [ 647 | 'katex' => 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css', 648 | 'custom' => 'https://example.com/custom.css', 649 | ], 650 | [ 651 | 'katex' => 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', 652 | 'custom' => 'https://example.com/custom.js', 653 | ], 654 | 'custom assets combined with built-in formula assets', 655 | ]; 656 | 657 | yield 'custom assets combined with SyntaxModule' => [ 658 | [ 659 | 'quill_options' => [['bold', 'italic']], 660 | 'quill_extra_options' => [ 661 | 'assets' => [ 662 | 'styleSheets' => [ 663 | 'myTheme' => 'https://example.com/my-theme.css', 664 | ], 665 | 'scripts' => [ 666 | 'myPlugin' => 'https://example.com/my-plugin.js', 667 | ], 668 | ], 669 | ], 670 | 'modules' => [new SyntaxModule()], 671 | ], 672 | [ 673 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css', 674 | 'myTheme' => 'https://example.com/my-theme.css', 675 | ], 676 | [ 677 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 678 | 'myPlugin' => 'https://example.com/my-plugin.js', 679 | ], 680 | 'custom assets combined with SyntaxModule', 681 | ]; 682 | 683 | yield 'custom assets overwriting built-in highlight from SyntaxModule' => [ 684 | [ 685 | 'quill_options' => [['bold', 'italic']], 686 | 'quill_extra_options' => [ 687 | 'assets' => [ 688 | 'styleSheets' => [ 689 | 'highlight' => 'https://example.com/my-custom-highlight.css', 690 | ], 691 | 'scripts' => [ 692 | 'highlight' => 'https://example.com/my-custom-highlight.js', 693 | ], 694 | ], 695 | ], 696 | 'modules' => [new SyntaxModule()], 697 | ], 698 | [ 699 | // Custom assets overwrite built-in highlight because array_merge keeps last value for duplicate keys 700 | 'highlight' => 'https://example.com/my-custom-highlight.css', 701 | ], 702 | [ 703 | 'highlight' => 'https://example.com/my-custom-highlight.js', 704 | ], 705 | 'custom assets overwriting built-in highlight from SyntaxModule', 706 | ]; 707 | 708 | yield 'custom assets with HtmlEditModule syntax true' => [ 709 | [ 710 | 'quill_options' => [['bold', 'italic']], 711 | 'quill_extra_options' => [ 712 | 'assets' => [ 713 | 'styleSheets' => [ 714 | 'customHighlight' => 'https://example.com/highlight-theme.css', 715 | ], 716 | 'scripts' => [ 717 | 'highlight2' => 'https://example.com/custom-xml-lang.js', 718 | ], 719 | ], 720 | ], 721 | 'modules' => [ 722 | new HtmlEditModule(options: ['syntax' => true]), 723 | ], 724 | ], 725 | [ 726 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 727 | 'customHighlight' => 'https://example.com/highlight-theme.css', 728 | ], 729 | [ 730 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 731 | // Custom assets overwrite highlight2 from HtmlEditModule 732 | 'highlight2' => 'https://example.com/custom-xml-lang.js', 733 | ], 734 | 'custom assets with HtmlEditModule syntax true', 735 | ]; 736 | 737 | yield 'complex scenario with all asset types' => [ 738 | [ 739 | 'quill_options' => [ 740 | ['bold', 'italic'], 741 | ['formula'], 742 | ], 743 | 'quill_extra_options' => [ 744 | 'assets' => [ 745 | 'styleSheets' => [ 746 | 'bootstrap' => 'https://cdn.example.com/bootstrap.css', 747 | 'custom' => 'https://example.com/custom.css', 748 | ], 749 | 'scripts' => [ 750 | 'jquery' => 'https://cdn.example.com/jquery.js', 751 | 'custom' => 'https://example.com/custom.js', 752 | ], 753 | ], 754 | ], 755 | 'modules' => [ 756 | new SyntaxModule(), 757 | new HtmlEditModule(options: ['syntax' => true]), 758 | ], 759 | ], 760 | [ 761 | // HtmlEditModule overwrites SyntaxModule highlight stylesheet 762 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', 763 | 'katex' => 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css', 764 | 'bootstrap' => 'https://cdn.example.com/bootstrap.css', 765 | 'custom' => 'https://example.com/custom.css', 766 | ], 767 | [ 768 | 'highlight' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 769 | 'highlight2' => 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/xml.min.js', 770 | 'katex' => 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js', 771 | 'jquery' => 'https://cdn.example.com/jquery.js', 772 | 'custom' => 'https://example.com/custom.js', 773 | ], 774 | 'complex scenario with all asset types', 775 | ]; 776 | } 777 | } 778 | --------------------------------------------------------------------------------