├── ext_conf_template.txt ├── Resources ├── Public │ ├── Screenshots │ │ ├── screenshot1.png │ │ └── usersettings.png │ ├── Icons │ │ ├── pen-solid.svg │ │ └── Extension.svg │ ├── JavaScript │ │ └── popover.js │ └── Styles │ │ └── basic.css └── Private │ └── Language │ └── locallang_adminpanel.xlf ├── ext_tables.php ├── feedback.md ├── Configuration ├── RequestMiddlewares.php └── Services.yaml ├── ext_emconf.php ├── composer.json ├── Classes ├── Event │ └── EditPanelActionEvent.php ├── Configuration.php ├── Middleware │ └── FrontendEditInitiator.php ├── ContentObject │ └── EditableFluidTemplateContentObject.php ├── ViewHelpers │ └── PanelViewHelper.php ├── Service │ └── AccessService.php ├── Edit │ ├── Permissions.php │ └── EditPanel.php ├── Utility │ └── ContentElementOrder.php └── EventListener │ └── DefaultEditPanelActionEventListener.php └── Readme.md /ext_conf_template.txt: -------------------------------------------------------------------------------- 1 | # cat=basic/enable/20; type=string; label=How enabled for users 2 | renderCheck = usersettings -------------------------------------------------------------------------------- /Resources/Public/Screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgringer/feediting/HEAD/Resources/Public/Screenshots/screenshot1.png -------------------------------------------------------------------------------- /Resources/Public/Screenshots/usersettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/georgringer/feediting/HEAD/Resources/Public/Screenshots/usersettings.png -------------------------------------------------------------------------------- /ext_tables.php: -------------------------------------------------------------------------------- 1 | 'Enable FE Editing', 6 | 'type' => 'check', 7 | ]; 8 | if (!isset($GLOBALS['TYPO3_USER_SETTINGS']['showitem'])) { 9 | $GLOBALS['TYPO3_USER_SETTINGS']['showitem'] = ''; 10 | } 11 | \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addFieldsToUserSettings('--div--;EXT:feediting,feediting'); 12 | 13 | -------------------------------------------------------------------------------- /Resources/Private/Language/locallang_adminpanel.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Feedit 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /feedback.md: -------------------------------------------------------------------------------- 1 | # I need feedback! 2 | 3 | This extension is currently in development and I need feedback - especially from editors and integrators! 4 | 5 | I would like answers to the following questions: 6 | 7 | - Are you editor, developer, integrator? 8 | - Is this extension useful to you? 9 | - What is missing? E.g. specific actions in the toolbar 10 | - Additional comments 11 | 12 | Please report at https://github.com/georgringer/feediting/issues or via slack, mail, phone! 13 | 14 | Thanks a lot! -------------------------------------------------------------------------------- /Configuration/RequestMiddlewares.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'typo3/cms-frontendedit/initiator' => [ 6 | 'target' => \GeorgRinger\Feediting\Middleware\FrontendEditInitiator::class, 7 | 'after' => [ 8 | 'typo3/cms-adminpanel/initiator', 9 | 'typo3/cms-frontend/page-resolver', 10 | ], 11 | 'before' => [ 12 | 'typo3/cms-frontend/prepare-tsfe-rendering', 13 | ], 14 | ], 15 | ] 16 | ]; 17 | -------------------------------------------------------------------------------- /Resources/Public/Icons/pen-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ext_emconf.php: -------------------------------------------------------------------------------- 1 | 'Feediting', 5 | 'description' => '', 6 | 'category' => 'fe', 7 | 'author' => 'Georg Ringer', 8 | 'author_email' => 'mail@ringer.it', 9 | 'state' => 'alpha', 10 | 'version' => '0.0.1', 11 | 'constraints' => [ 12 | 'depends' => [ 13 | 'typo3' => '12.4.0-12.4.99', 14 | 'backend' => '12.4.0-12.4.99', 15 | 'frontend' => '12.4.0-12.4.99', 16 | ], 17 | 'conflicts' => [], 18 | 'suggests' => [], 19 | ], 20 | ]; 21 | -------------------------------------------------------------------------------- /Configuration/Services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | GeorgRinger\Feediting\: 8 | resource: '../Classes/*' 9 | 10 | GeorgRinger\Feediting\Edit\Clipboard: 11 | public: true 12 | 13 | # GeorgRinger\Feediting\Event\EditPanelActionEvent: 14 | # public: true 15 | 16 | GeorgRinger\Feediting\ContentObject\EditableFluidTemplateContentObject: 17 | tags: 18 | - name: frontend.contentobject 19 | identifier: 'FLUIDTEMPLATE' 20 | 21 | GeorgRinger\Feediting\EventListener\DefaultEditPanelActionEventListener: 22 | tags: 23 | - name: event.listener 24 | identifier: 'tx_feediting_defaulteditpanelactioneventlistener' 25 | event: GeorgRinger\Feediting\Event\EditPanelActionEvent -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "georgringer/feediting", 3 | "type": "typo3-cms-extension", 4 | "authors": [ 5 | { 6 | "name": "Georg Ringer", 7 | "role": "Developer" 8 | } 9 | ], 10 | "require": { 11 | "php": "^8.2", 12 | "typo3/cms-backend": "^12.4", 13 | "typo3/cms-beuser": "^12.4", 14 | "typo3/cms-core": "^12.4", 15 | "typo3/cms-frontend": "^12.4" 16 | }, 17 | "suggest": { 18 | }, 19 | "license": [ 20 | "GPL-2.0-or-later" 21 | ], 22 | "autoload": { 23 | "psr-4": { 24 | "GeorgRinger\\Feediting\\": "Classes" 25 | } 26 | }, 27 | "extra": { 28 | "typo3/cms": { 29 | "extension-key": "feediting" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Classes/Event/EditPanelActionEvent.php: -------------------------------------------------------------------------------- 1 | actions; 25 | } 26 | 27 | public function setActions(array $actions): void 28 | { 29 | $this->actions = $actions; 30 | } 31 | 32 | public function addAction(string $action): void 33 | { 34 | $this->actions[] = $action; 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /Classes/Configuration.php: -------------------------------------------------------------------------------- 1 | get('feediting'); 18 | 19 | $renderCheck = strtolower($extensionSettings['renderCheck'] ?? ''); 20 | if ($renderCheck === 'adminpanel' && ExtensionManagementUtility::isLoaded('admin_panel')) { 21 | $this->renderCheck = 'adminpanel'; 22 | } else { 23 | $this->renderCheck = $renderCheck; 24 | } 25 | } 26 | 27 | public function getRenderCheck(): string 28 | { 29 | return $this->renderCheck; 30 | } 31 | } -------------------------------------------------------------------------------- /Classes/Middleware/FrontendEditInitiator.php: -------------------------------------------------------------------------------- 1 | enabled()) { 21 | $GLOBALS['TSFE']->set_no_cache('EXT:feediting in action', true); 22 | } 23 | } 24 | return $handler->handle($request); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Classes/ContentObject/EditableFluidTemplateContentObject.php: -------------------------------------------------------------------------------- 1 | cObj->currentRecord); 16 | $tableName = $split[0] ?? ''; 17 | $recordId = (int)($split[1] ?? 0); 18 | $content = parent::render($conf); 19 | 20 | if (!$tableName || !$recordId) { 21 | return $content; 22 | } 23 | 24 | $editPanel = GeneralUtility::makeInstance(EditPanel::class, $this->request, $tableName, (int)$recordId, $this->cObj->data); 25 | $contentWithEditPanel = $editPanel->render($content); 26 | if (empty($contentWithEditPanel)) { 27 | return $content; 28 | } 29 | 30 | return $contentWithEditPanel; 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /Resources/Public/Icons/Extension.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Classes/ViewHelpers/PanelViewHelper.php: -------------------------------------------------------------------------------- 1 | registerArgument('table', 'string', 'The table to be edited', true); 18 | $this->registerArgument('uid', 'int', 'Id', true); 19 | } 20 | 21 | public function render(): string 22 | { 23 | $recordId = (int)$this->arguments['uid']; 24 | $tableName = $this->arguments['table']; 25 | $row = BackendUtility::getRecord($tableName, $recordId); 26 | if (!$row) { 27 | return ''; 28 | } 29 | 30 | $editPanel = GeneralUtility::makeInstance(EditPanel::class, $this->renderingContext->getRequest(), $tableName, $recordId, $row); 31 | $contentWithEditPanel = $editPanel->render(''); 32 | if (empty($contentWithEditPanel)) { 33 | return ''; 34 | } 35 | return $contentWithEditPanel; 36 | } 37 | } -------------------------------------------------------------------------------- /Classes/Service/AccessService.php: -------------------------------------------------------------------------------- 1 | configuration = new Configuration(); 18 | } 19 | 20 | public function enabled(): bool 21 | { 22 | $beUser = $this->getBackendUser(); 23 | if (!$beUser) { 24 | return false; 25 | } 26 | 27 | switch ($this->configuration->getRenderCheck()) { 28 | case 'usersettings': 29 | return (bool)($beUser->uc['feediting'] ?? false); 30 | case 'adminpanel': 31 | $adminPanelRequestId = $this->getRequest()->getAttribute('adminPanelRequestId'); 32 | return is_string($adminPanelRequestId); 33 | } 34 | 35 | return false; 36 | } 37 | 38 | protected function getBackendUser(): ?FrontendBackendUserAuthentication 39 | { 40 | return $GLOBALS['BE_USER']; 41 | } 42 | 43 | protected function getRequest(): ServerRequestInterface 44 | { 45 | return $GLOBALS['TYPO3_REQUEST']; 46 | } 47 | } -------------------------------------------------------------------------------- /Classes/Edit/Permissions.php: -------------------------------------------------------------------------------- 1 | getBackendUser(); 16 | return $user !== null && $user->user['uid'] > 0; 17 | } 18 | 19 | public function editPage(int $pageId): bool 20 | { 21 | $user = $this->getBackendUser(); 22 | if (!$user) { 23 | return false; 24 | } 25 | $row = BackendUtility::getRecord('pages', $pageId); 26 | return $user->doesUserHaveAccess($row, Permission::PAGE_EDIT); 27 | } 28 | 29 | public function editElement(string $tableName, array $row): bool 30 | { 31 | $user = $this->getBackendUser(); 32 | if (!$user) { 33 | return false; 34 | } 35 | $conf = [ 36 | 'allow' => 'edit, new, delete, hide, move, localize, versions, permissions, info, history, workspace, recordInfo', 37 | 'onlyCurrentPid' => false, 38 | ]; 39 | return $user->allowedToEdit($tableName, $row, $conf, true); 40 | } 41 | 42 | protected function getBackendUser(): ?BackendUserAuthentication 43 | { 44 | return $GLOBALS['BE_USER'] ?? null; 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # TYPO3 Extension "feediting" 2 | 3 | This extension provides the good old edit pencil icons in the frontend to have a fast experience to edit content. 4 | 5 | - No in-place editing, no hassle with custom frontend implementations 6 | - Link to the known and powerful backend editing 7 | - Works with all content elements, pages and records 8 | 9 | **Be aware:** This extension is heavly under development. Especially the UI is not final and will change. 10 | However, I am more than happy to get feedback and ideas. Please report at https://github.com/georgringer/feediting/issues. 11 | 12 | ![screenshot1.png](Resources/Public/Screenshots/screenshot1.png) 13 | 14 | ## Requirements 15 | 16 | - TYPO3 12 LTS 17 | 18 | ## Installation 19 | 20 | Install this extension via composer `composer require georgringer/feediting` 21 | 22 | ## Usage 23 | 24 | 1. Log in as backend user 25 | 2. Enable/Disable the feature in the user settings 26 | 27 | ![User Settings](Resources/Public/Screenshots/usersettings.png) 28 | 29 | ### Usage in Extensions 30 | 31 | Use custom ViewHelper, see example below for `EXT:news` 32 | 33 | ```html 34 | 37 |
38 |

39 | {newsItem.title} 40 | 41 |

42 |
43 | 44 | ``` -------------------------------------------------------------------------------- /Classes/Utility/ContentElementOrder.php: -------------------------------------------------------------------------------- 1 | [], 16 | 'next' => [], 17 | 'prevUid' => [], 18 | ]; 19 | 20 | public function getList(int $pid, int $colpos, int $language = 0) 21 | { 22 | $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content'); 23 | $queryResult = $queryBuilder->select('uid', 'header', 'sorting', 'pid') 24 | ->from('tt_content') 25 | ->where( 26 | $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, Connection::PARAM_INT)), 27 | $queryBuilder->expr()->eq('colPos', $queryBuilder->createNamedParameter($colpos, Connection::PARAM_INT)), 28 | $queryBuilder->expr()->eq('sys_language_uid', $queryBuilder->createNamedParameter($language, Connection::PARAM_INT)), 29 | ) 30 | ->orderBy('sorting', 'ASC') 31 | ->executeQuery(); 32 | 33 | $prevUid = 0; 34 | $prevPrevUid = 0; 35 | // Get first two rows and initialize prevPrevUid and prevUid if on page > 1 36 | // $row = $queryResult->fetchAssociative(); 37 | $row = $queryResult->fetchAssociative(); 38 | 39 | $prevPrevUid = ((int)$row['pid']); 40 | $prevUid = $row['uid']; 41 | $backendUser = $GLOBALS['BE_USER']; 42 | // Accumulate rows here 43 | while ($row = $queryResult->fetchAssociative()) { 44 | // In offline workspace, look for alternative record 45 | BackendUtility::workspaceOL('tt_content', $row, $backendUser->workspace, true); 46 | if (is_array($row)) { 47 | if ($prevUid) { 48 | $this->currentTable['prev'][$row['uid']] = $prevPrevUid; 49 | $this->currentTable['next'][$prevUid] = '-' . $row['uid']; 50 | $this->currentTable['prevUid'][$row['uid']] = $prevUid; 51 | } 52 | $prevPrevUid = isset($this->currentTable['prev'][$row['uid']]) ? -$prevUid : $row['pid']; 53 | $prevUid = $row['uid']; 54 | } 55 | } 56 | 57 | return $this->currentTable; 58 | } 59 | 60 | } -------------------------------------------------------------------------------- /Resources/Public/JavaScript/popover.js: -------------------------------------------------------------------------------- 1 | function isInViewport(element) { 2 | const rect = element.getBoundingClientRect(); 3 | const html = document.documentElement; 4 | return rect.top >= 0 && 5 | rect.left >= 0 && 6 | rect.bottom <= (window.innerHeight || html.clientHeight) && 7 | rect.right <= (window.innerWidth || html.clientWidth); 8 | } 9 | 10 | class Popover { 11 | constructor(trigger, { position = 'top', className = 'popover' }) { 12 | this.trigger = trigger; 13 | this.position = top; 14 | this.className = className; 15 | this.orderedPositions = ['top', 'right', 'bottom', 'left']; 16 | 17 | const popoverTemplate = document.querySelector(`[data-popover=${trigger.dataset.popoverTarget}]`); 18 | this.popover = document.createElement('div'); 19 | this.popover.innerHTML = popoverTemplate.innerHTML; 20 | 21 | Object.assign(this.popover.style, { 22 | position: 'fixed' 23 | }); 24 | 25 | this.popover.classList.add(className); 26 | 27 | this.handleWindowEvent = () => { 28 | if (this.isVisible) { 29 | this.show(); 30 | } 31 | }; 32 | 33 | this.handleDocumentEvent = (evt) => { 34 | if (this.isVisible && evt.target !== this.trigger && evt.target !== this.popover) { 35 | this.popover.remove(); 36 | } 37 | }; 38 | } 39 | 40 | get isVisible() { 41 | return document.body.contains(this.popover); 42 | } 43 | 44 | show() { 45 | // document.addEventListener('click', this.handleDocumentEvent); 46 | window.addEventListener('scroll', this.handleWindowEvent); 47 | window.addEventListener('resize', this.handleWindowEvent); 48 | 49 | document.body.appendChild(this.popover); 50 | 51 | const { top: triggerTop, left: triggerLeft } = this.trigger.getBoundingClientRect(); 52 | const { offsetHeight: triggerHeight, offsetWidth: triggerWidth } = this.trigger; 53 | const { offsetHeight: popoverHeight, offsetWidth: popoverWidth } = this.popover; 54 | 55 | const positionIndex = this.orderedPositions.indexOf(this.position); 56 | 57 | const positions = { 58 | top: { 59 | name: 'top', 60 | top: triggerTop - popoverHeight, 61 | left: triggerLeft - ((popoverWidth - triggerWidth) / 2) 62 | }, 63 | right: { 64 | name: 'right', 65 | top: triggerTop - ((popoverHeight - triggerHeight) / 2), 66 | left: triggerLeft + triggerWidth 67 | }, 68 | bottom: { 69 | name: 'bottom', 70 | top: triggerTop + triggerHeight, 71 | left: triggerLeft - ((popoverWidth - triggerWidth) / 2) 72 | }, 73 | left: { 74 | name: 'left', 75 | top: triggerTop - ((popoverHeight - triggerHeight) / 2), 76 | left: triggerLeft - popoverWidth 77 | } 78 | }; 79 | 80 | const position = this.orderedPositions 81 | .slice(positionIndex) 82 | .concat(this.orderedPositions.slice(0, positionIndex)) 83 | .map(pos => positions[pos]) 84 | .find(pos => { 85 | this.popover.style.top = `${pos.top}px`; 86 | this.popover.style.left = `${pos.left}px`; 87 | return isInViewport(this.popover); 88 | }); 89 | 90 | this.orderedPositions.forEach(pos => { 91 | this.popover.classList.remove(`${this.className}--${pos}`); 92 | }); 93 | 94 | if (position) { 95 | this.popover.classList.add(`${this.className}--${position.name}`); 96 | } else { 97 | this.popover.style.top = positions.bottom.top; 98 | this.popover.style.left = positions.bottom.left; 99 | this.popover.classList.add(`${this.className}--bottom`); 100 | } 101 | } 102 | 103 | destroy() { 104 | this.popover.remove(); 105 | 106 | document.removeEventListener('click', this.handleDocumentEvent); 107 | window.removeEventListener('scroll', this.handleWindowEvent); 108 | window.removeEventListener('resize', this.handleWindowEvent); 109 | } 110 | 111 | toggle() { 112 | if (this.isVisible) { 113 | this.destroy(); 114 | } else { 115 | this.show(); 116 | } 117 | } 118 | } 119 | 120 | // DEMO 121 | 122 | 123 | const triggers = document.getElementsByClassName('feediting-popover-trigger'); 124 | // foreach triggers 125 | Array.from(triggers).forEach(trigger => { 126 | 127 | let popover = new Popover(trigger, { position: 'bottom' }); 128 | 129 | 130 | 131 | trigger.addEventListener('click', () => popover.toggle()); 132 | }); 133 | 134 | -------------------------------------------------------------------------------- /Classes/Edit/EditPanel.php: -------------------------------------------------------------------------------- 1 | getBackendUser()) { 32 | return; 33 | } 34 | $accessService = GeneralUtility::makeInstance(AccessService::class); 35 | if (!$accessService->enabled()) { 36 | return; 37 | } 38 | 39 | $this->enabled = true; 40 | $this->eventDispatcher = GeneralUtility::makeInstance(EventDispatcherInterface::class); 41 | $this->permissions = GeneralUtility::makeInstance(Permissions::class); 42 | $pageRow = $this->request->getAttribute('frontend.controller')->page; 43 | // $moduleName = BackendUtility::getPagesTSconfig($row['pid'])['mod.']['newContentElementWizard.']['override'] ?? 'new_content_element_wizard'; 44 | // $perms = $this->getBackendUser()->calcPerms($tsfe->page); 45 | $this->permissionsOfPage = $this->getBackendUser()->calcPerms($pageRow); 46 | } 47 | 48 | public function render(string $content): string 49 | { 50 | if (!$this->enabled) { 51 | return ''; 52 | } 53 | $isPageTable = $this->tableName === 'pages'; 54 | $allowed = $isPageTable ? $this->permissions->editPage($this->row['pid']) : $this->permissions->editElement($this->tableName, $this->row); 55 | if (!$allowed) { 56 | return ''; 57 | } 58 | 59 | return $this->renderPanel($content); 60 | } 61 | 62 | protected function collectActions(): array 63 | { 64 | $data = []; 65 | 66 | $event = $this->eventDispatcher->dispatch( 67 | new EditPanelActionEvent( 68 | $this->request, 69 | $this->permissionsOfPage, 70 | $this->tableName, 71 | $this->recordId, 72 | $this->row, $data), 73 | 74 | ); 75 | return $event->getActions(); 76 | } 77 | 78 | protected function renderPanel(string $content): string 79 | { 80 | $data = $this->collectActions(); 81 | if (empty($data)) { 82 | return ''; 83 | } 84 | 85 | $assetCollector = GeneralUtility::makeInstance(AssetCollector::class); 86 | $assetCollector->addStylesheet('feediting', 'EXT:feediting/Resources/Public/Styles/basic.css'); 87 | $assetCollector->addJavaScript('feediting', 'EXT:feediting/Resources/Public/JavaScript/popover.js'); 88 | 89 | array_walk($data, static function (string &$value) { 90 | $value = '' . $value . ''; 91 | }); 92 | 93 | $infos = [ 94 | BackendUtility::getRecordTitle($this->tableName, $this->row), 95 | ]; 96 | if ($this->tableName === 'tt_content') { 97 | $infos[] = BackendUtility::getProcessedValue($this->tableName, 'CType', $this->row['CType']); 98 | } 99 | 100 | $identifier = 'trigger' . md5(json_encode($data)); 101 | $elementInformation = '
' . htmlspecialchars(implode(LF, $infos)) . '[' . $this->recordId . ']
'; 102 | $panel = ' 103 |
104 | 107 | 108 | 114 |
'; 115 | return '
' . $content . $panel . '
'; 116 | } 117 | 118 | protected function getBackendUser(): ?FrontendBackendUserAuthentication 119 | { 120 | return $GLOBALS['BE_USER']; 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /Resources/Public/Styles/basic.css: -------------------------------------------------------------------------------- 1 | 2 | .tx-feediting-fluidtemplate { 3 | transition: opacity 200ms; 4 | /*opacity: 1;*/ 5 | position: relative; 6 | } 7 | 8 | .tx-feediting-fluidtemplate .popover-container{ 9 | position: absolute; 10 | top:-50px; 11 | left:50%; 12 | transform: translateX(-50%); 13 | } 14 | 15 | /*body:hover:not(.tx-feediting-fluidtemplate):hover .tx-feediting-fluidtemplate {*/ 16 | /* opacity: 0.5;*/ 17 | /*}*/ 18 | 19 | /*.tx-feediting-fluidtemplate:hover {*/ 20 | /* opacity: 1 !important;*/ 21 | /*}*/ 22 | 23 | .tx-feediting-fluidtemplate-pages:hover { 24 | outline: none; 25 | box-shadow: none; 26 | } 27 | 28 | .tx-feediting-panel { 29 | color: #333 !important; 30 | background-color: rgb(255, 135, 0); 31 | border-radius: 4px; 32 | } 33 | 34 | .tx-feediting-panel a { 35 | text-decoration: none !important; 36 | display: initial !important; 37 | position: initial !important; 38 | } 39 | 40 | .tx-feediting-panel .tx-feediting-type { 41 | font-weight: bold; 42 | padding: 10px; 43 | color:white; 44 | font-size: 20px; 45 | text-align: center; 46 | } 47 | 48 | .tx-feediting-panel .tx-feediting-type span { 49 | font-style: italic; 50 | } 51 | 52 | .tx-feediting-panel .tx-feediting-actions { 53 | padding: 10px; 54 | display:flex; 55 | gap:20px; 56 | flex-direction: row; 57 | justify-content: center; 58 | flex-wrap: wrap; 59 | } 60 | 61 | .tx-feediting-element { 62 | background-color:white; 63 | border-radius: 4px; 64 | } 65 | 66 | .tx-feediting-element a:link,.tx-feediting-element a:visited { 67 | color: inherit; 68 | padding:10px; 69 | display:block !important; 70 | font-size: 14px; 71 | } 72 | 73 | .tx-feediting-element a:hover,.tx-feediting-element a:active, .tx-feediting-element a:focus { 74 | color:rgb(255, 135, 0); 75 | } 76 | 77 | .tx-feediting-element:hover { 78 | 79 | } 80 | 81 | .icon-size-small svg { 82 | display: inline-block; 83 | height: 16px; 84 | line-height: 16px; 85 | width: 16px; 86 | } 87 | 88 | 89 | .tx-feediting-dropdown { 90 | position: relative; 91 | display: inline-block; 92 | } 93 | 94 | .tx-feediting-dropdown > input[type="checkbox"] { 95 | position: absolute; 96 | left: -100vw; 97 | } 98 | 99 | .tx-feediting-dropdown > label, 100 | .tx-feediting-dropdown > a[role="button"] { 101 | display: inline-block; 102 | padding: 6px 15px; 103 | color: #333; 104 | line-height: 1.5em; 105 | text-decoration: none; 106 | border: 1px solid #8c8c8c; 107 | cursor: pointer; 108 | -webkit-border-radius: 3px; 109 | -moz-border-radius: 3px; 110 | border-radius: 3px; 111 | } 112 | 113 | .tx-feediting-dropdown > label:hover, 114 | .tx-feediting-dropdown > a[role="button"]:hover, 115 | .tx-feediting-dropdown > a[role="button"]:focus { 116 | border-color: #333; 117 | } 118 | 119 | .tx-feediting-dropdown > label:after, 120 | .tx-feediting-dropdown > a[role="button"]:after { 121 | content: "x"; 122 | display: inline-block; 123 | margin-left: 6px; 124 | } 125 | 126 | .tx-feediting-dropdown > ul { 127 | position: absolute; 128 | z-index: 999; 129 | display: block; 130 | left: -100vw; 131 | top: calc(1.5em + 14px); 132 | border: 1px solid #8c8c8c; 133 | background: #fff; 134 | padding: 6px 0; 135 | margin: 0; 136 | list-style: none; 137 | width: 100%; 138 | -webkit-border-radius: 3px; 139 | -moz-border-radius: 3px; 140 | border-radius: 3px; 141 | -webkit-box-shadow: 0 3px 8px rgba(0, 0, 0, .15); 142 | -moz-box-shadow: 0 3px 8px rgba(0, 0, 0, .15); 143 | box-shadow: 0 3px 8px rgba(0, 0, 0, .15); 144 | } 145 | 146 | .tx-feediting-dropdown > ul a { 147 | display: block; 148 | padding: 6px 15px; 149 | text-decoration: none; 150 | color: #333; 151 | } 152 | 153 | .tx-feediting-dropdown > ul a:hover, 154 | .tx-feediting-dropdown > ul a:focus { 155 | background: #ececec; 156 | } 157 | 158 | .tx-feediting-dropdown > input[type="checkbox"]:checked ~ ul, 159 | .tx-feediting-dropdown > ul:target { 160 | left: 0; 161 | } 162 | 163 | .tx-feediting-dropdown > [type="checkbox"]:checked + label:after, 164 | .tx-feediting-dropdown > ul:target ~ a:after { 165 | content: "\f0d8"; 166 | } 167 | 168 | .tx-feediting-dropdown a.close { 169 | display: none; 170 | } 171 | 172 | .tx-feediting-dropdown > ul:target ~ a.close { 173 | display: block; 174 | position: absolute; 175 | left: 0; 176 | top: 0; 177 | height: 100%; 178 | width: 100%; 179 | text-indent: -100vw; 180 | z-index: 1000; 181 | } 182 | 183 | 184 | /*@keyframes slide-top {*/ 185 | /* 0% {*/ 186 | /* opacity: 0;*/ 187 | /* transform: translateY(-15%)*/ 188 | /* }*/ 189 | /* 100% {*/ 190 | /* opacity: 1;*/ 191 | /* transform: translateY(0)*/ 192 | /* }*/ 193 | /*}*/ 194 | 195 | @keyframes slide-right { 196 | 0% { 197 | opacity: 0; 198 | transform: translateX(15%) 199 | } 200 | 100% { 201 | opacity: 1; 202 | transform: translateX(0) 203 | } 204 | } 205 | 206 | @keyframes slide-bottom { 207 | 0% { 208 | opacity: 0; 209 | transform: translateY(15%) 210 | } 211 | 100% { 212 | opacity: 1; 213 | transform: translateY(0) 214 | } 215 | } 216 | 217 | @keyframes slide-left { 218 | 0% { 219 | opacity: 0; 220 | transform: translateX(-15%) 221 | } 222 | 100% { 223 | opacity: 1; 224 | transform: translateX(0) 225 | } 226 | } 227 | 228 | .popover { 229 | border-radius: 4px; 230 | background: #fff; 231 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); 232 | min-width: 90vw; 233 | z-index:9999 234 | } 235 | 236 | .popover--top { 237 | margin-top: -16px; 238 | animation: .4s slide-top 239 | } 240 | 241 | .popover--top::before, .popover--top::after { 242 | content: ""; 243 | position: absolute; 244 | top: 100%; 245 | left: 50%; 246 | margin-left: -8px; 247 | border: 8px solid transparent; 248 | border-top-color: #fff 249 | } 250 | 251 | .popover--top::before { 252 | margin-top: 1px; 253 | border-top-color: #6a6a6a 254 | } 255 | 256 | .popover--right { 257 | margin-left: 16px; 258 | animation: .4s slide-right 259 | } 260 | 261 | .popover--right::before, .popover--right::after { 262 | content: ""; 263 | position: absolute; 264 | top: 50%; 265 | right: 100%; 266 | margin-top: -8px; 267 | border: 8px solid transparent; 268 | border-right-color: #fff 269 | } 270 | 271 | .popover--right::before { 272 | margin-right: 1px; 273 | border-right-color: #6a6a6a 274 | } 275 | 276 | .popover--bottom { 277 | margin-top: 16px; 278 | animation: .4s slide-bottom 279 | } 280 | 281 | .popover--bottom::before, .popover--bottom::after { 282 | content: ""; 283 | position: absolute; 284 | bottom: 100%; 285 | left: 50%; 286 | margin-left: -8px; 287 | border: 8px solid transparent; 288 | border-bottom-color: rgb(255, 135, 0); 289 | } 290 | 291 | .popover--bottom::before { 292 | margin-bottom: 1px; 293 | border-bottom-color: rgb(255, 135, 0); 294 | } 295 | 296 | .popover--left { 297 | margin-left: -16px; 298 | animation: .4s slide-left 299 | } 300 | 301 | .popover--left::before, .popover--left::after { 302 | content: ""; 303 | position: absolute; 304 | top: 50%; 305 | left: 100%; 306 | margin-top: -8px; 307 | border: 8px solid transparent; 308 | border-left-color: #fff 309 | } 310 | 311 | .popover--left::before { 312 | margin-left: 1px; 313 | border-left-color: #6a6a6a 314 | } 315 | 316 | .feediting-popover-trigger { 317 | display: block; 318 | position: relative; 319 | cursor: pointer; 320 | margin: 0 auto; 321 | 322 | appearance: none; 323 | background-color: white; 324 | border:4px solid rgb(255, 135, 0); 325 | border-radius: 100%; 326 | width:50px; 327 | height:50px; 328 | } 329 | 330 | .pages .feediting-popover-trigger { 331 | border: 4px solid #0080c9; 332 | } 333 | 334 | .pages.tx-feediting-panel { 335 | background-color: #0080c9; 336 | } 337 | 338 | .popover-container.pages { 339 | top: auto !important; 340 | bottom: 10px; 341 | transform: none; 342 | z-index: 9999; 343 | } 344 | -------------------------------------------------------------------------------- /Classes/EventListener/DefaultEditPanelActionEventListener.php: -------------------------------------------------------------------------------- 1 | uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); 35 | $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class); 36 | } 37 | 38 | public function __invoke(EditPanelActionEvent $event) 39 | { 40 | $this->tableName = $event->table; 41 | $this->recordId = $event->id; 42 | $this->row = $event->row; 43 | $this->permissionsOfPage = $event->permissionsOfPage; 44 | $this->tsfe = $event->request->getAttribute('frontend.controller'); 45 | $actions = $event->getActions(); 46 | 47 | $this->edit($actions); 48 | $this->move($actions); 49 | if ($this->tableName === 'tt_content') { 50 | $this->moveUpDown($actions); 51 | } 52 | if ($this->tableName === 'pages') { 53 | $this->newPage($actions); 54 | $this->linkToListView($actions); 55 | $this->newContent($actions); 56 | } elseif ($this->tableName !== 'tt_content') { 57 | $this->newRecord($actions); 58 | } 59 | $this->history($actions); 60 | $this->info($actions); 61 | 62 | 63 | $event->setActions($actions); 64 | } 65 | 66 | protected function edit(array &$data) 67 | { 68 | $link = $this->uriBuilder->buildUriFromRoute( 69 | 'record_edit', 70 | [ 71 | 'edit[' . $this->tableName . '][' . $this->recordId . ']' => 'edit', 72 | 'noView' => 1, 73 | 'returnUrl' => $this->getReturnUrl(), 74 | ] 75 | ); 76 | $data[] = $this->generateLink($link, 'actions-page-open', 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.edit'); 77 | } 78 | 79 | protected function move(array &$data): void 80 | { 81 | if ($this->tableName === 'pages') { 82 | $link = $this->uriBuilder->buildUriFromRoute( 83 | 'move_page', 84 | [ 85 | 'uid' => $this->recordId, 86 | 'returnUrl' => $this->getReturnUrl(), 87 | ] 88 | ); 89 | } else { 90 | $link = $this->uriBuilder->buildUriFromRoute( 91 | 'move_element', 92 | [ 93 | 'table' => $this->tableName, 94 | 'uid' => $this->recordId, 95 | 'returnUrl' => $this->getReturnUrl(), 96 | ] 97 | ); 98 | } 99 | $data[] = $this->generateLink($link, 'actions-document-move', 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:move', true); 100 | } 101 | 102 | protected function moveUpDown(array &$data): void 103 | { 104 | $order = GeneralUtility::makeInstance(ContentElementOrder::class); 105 | $list = $order->getList($this->row['pid'], $this->row['colPos'], $this->row['sys_language_uid']); 106 | foreach (['up', 'down'] as $direction) { 107 | $checkKey = $direction === 'up' ? 'prev' : 'next'; 108 | if (!isset($list[$checkKey][$this->row['uid']])) { 109 | continue; 110 | } 111 | $params = []; 112 | $params['redirect'] = $this->getReturnUrl(); 113 | $params['returnUrl'] = $this->getReturnUrl(); 114 | $params['cmd'][$this->tableName][$this->recordId]['move'] = $list[$checkKey][$this->row['uid']]; 115 | $url = $this->uriBuilder->buildUriFromRoute('tce_db', $params); 116 | 117 | $data[] = $this->generateLink($url, 'actions-move-' . $direction, 'LLL:EXT:core/Resources/Private/Language/locallang_tsfe.xlf:p_move' . ucfirst($direction)); 118 | } 119 | } 120 | 121 | protected function history(array &$data) 122 | { 123 | // History 124 | $link = $this->uriBuilder->buildUriFromRoute( 125 | 'record_history', 126 | [ 127 | 'element' => $this->tableName . ':' . $this->recordId, 128 | 'returnUrl' => $this->getReturnUrl(), 129 | ] 130 | ); 131 | $data[] = $this->generateLink($link, 'actions-document-history-open', 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.history', true); 132 | } 133 | 134 | protected function info(array &$data): void 135 | { 136 | $link = $this->uriBuilder->buildUriFromRoute( 137 | 'show_item', 138 | [ 139 | 'table' => $this->tableName, 140 | 'uid' => $this->recordId, 141 | 'returnUrl' => $this->getReturnUrl(), 142 | ] 143 | ); 144 | $data[] = $this->generateLink($link, 'actions-document-info', 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.info'); 145 | } 146 | 147 | protected function newPage(array &$data): void 148 | { 149 | if ($this->permissionsOfPage & Permission::PAGE_NEW) { 150 | $link = $this->uriBuilder->buildUriFromRoute( 151 | 'db_new', 152 | [ 153 | 'id' => $this->recordId, 154 | 'pagesOnly' => 1, 155 | 'returnUrl' => GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL'), 156 | ] 157 | ); 158 | $data[] = $this->generateLink($link, 'actions-page-new', 'LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:newPage2'); 159 | } 160 | } 161 | 162 | protected function newContent(array &$data) 163 | { 164 | if (!($this->permissionsOfPage & Permission::CONTENT_EDIT)) { 165 | return; 166 | } 167 | $backendLayoutview = GeneralUtility::makeInstance(BackendLayoutView::class); 168 | $layout = $backendLayoutview->getSelectedBackendLayout($this->tsfe->page['uid']); 169 | $items = []; 170 | if ($layout && !empty($layout['__items'])) { 171 | $items = $layout['__items']; 172 | } 173 | 174 | if (empty($items)) { 175 | return; 176 | } 177 | // todo set ctype, ...? 178 | // todo which language? 179 | 180 | $defVals = []; 181 | $links = []; 182 | $identifier = 'dropdown-' . md5($this->tableName . $this->recordId); 183 | foreach ($items as $item) { 184 | $link = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [ 185 | 'edit' => [ 186 | 'tt_content' => [ 187 | $this->tsfe->page['uid'] => 'new', 188 | ], 189 | ], 190 | 'returnUrl' => GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL'), 191 | 'defVals' => [ 192 | 'tt_content' => array_replace($defVals, [ 193 | 'colPos' => $item['value'], 194 | 'sys_language_uid' => $this->tsfe->page['sys_language_uid'], 195 | ]), 196 | ], 197 | ]); 198 | $links[] = sprintf('
  • %s
  • ', htmlspecialchars($link), $item['label']); 199 | } 200 | if (!empty($links)) { 201 | $data[] = '
    202 | 203 | 207 | 210 |
    '; 211 | } 212 | } 213 | 214 | protected function newRecord(array &$data) 215 | { 216 | $targetPageRow = BackendUtility::getRecord('pages', $this->row['pid']); 217 | $permissionsOfTargetPage = $this->getBackendUser()->calcPerms($targetPageRow); 218 | if (!($permissionsOfTargetPage & Permission::CONTENT_EDIT)) { 219 | return; 220 | } 221 | $link = $this->uriBuilder->buildUriFromRoute('record_edit', [ 222 | 'edit' => [ 223 | $this->tableName => [ 224 | $targetPageRow['uid'] => 'new', 225 | ], 226 | ], 227 | 'returnUrl' => GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL'), 228 | 'defVals' => [], 229 | ]); 230 | $tableTitle = $this->getLanguageService()->sL($GLOBALS['TCA'][$this->tableName]['ctrl']['title']) ?: $GLOBALS['TCA'][$this->tableName]['ctrl']['title'] ?: $this->tableName; 231 | $targetPageTitle = BackendUtility::getRecordTitle('pages', $targetPageRow); 232 | $label = sprintf($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNewRecord'), $tableTitle, $targetPageTitle); 233 | $data[] = $this->generateLink($link, 'actions-add', $label); 234 | } 235 | 236 | protected function linkToListView(array &$data) 237 | { 238 | // Open list view 239 | if ($this->getBackendUser()->check('modules', 'web_list')) { 240 | $link = $this->uriBuilder->buildUriFromRoute( 241 | 'web_list', 242 | [ 243 | 'id' => $this->recordId, 244 | 'returnUrl' => GeneralUtility::getIndpEnv('REQUEST_URI'), 245 | ] 246 | ); 247 | $data[] = $this->generateLink($link, 'actions-system-list-open', 'LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:goToListModule'); 248 | } 249 | } 250 | 251 | 252 | protected function clipboard(array &$data) 253 | { 254 | $isSel = 'xxx'; 255 | $copyUrl = $this->clipboard->selUrlDB($this->tableName, $this->recordId, true, true); 256 | $icon = $isSel === 'copy' ? 'actions-edit-copy-release' : 'actions-edit-copy'; 257 | $data[] = ' 258 | 259 | ' . $icon . ' 260 | '; 261 | } 262 | 263 | 264 | protected function generateLink(UriInterface $link, string $iconIdentifier, string $linkLabel, bool $lightbox = false): string 265 | { 266 | $icon = $this->iconFactory->getIcon($iconIdentifier, Icon::SIZE_SMALL)->render(); 267 | $label = $this->getLanguageService()->sL($linkLabel) ?: $linkLabel; 268 | $label = sprintf('%s %s', $icon, htmlspecialchars($label)); 269 | $classList = []; 270 | if ($lightbox) { 271 | $classList[] = 'tx-feediting-lightbox'; 272 | } 273 | return '' . $label . ''; 274 | } 275 | 276 | protected function getLanguageService(): LanguageService 277 | { 278 | return $GLOBALS['LANG']; 279 | } 280 | 281 | protected function getBackendUser(): ?FrontendBackendUserAuthentication 282 | { 283 | return $GLOBALS['BE_USER']; 284 | } 285 | 286 | protected function getReturnUrl(): string 287 | { 288 | return GeneralUtility::getIndpEnv('TYPO3_REQUEST_URL') . '#tx-feediting' . $this->recordId; 289 | } 290 | 291 | } --------------------------------------------------------------------------------