diff --git a/build/reaction-links/block.json b/build/reaction-links/block.json new file mode 100644 index 00000000..f247323a --- /dev/null +++ b/build/reaction-links/block.json @@ -0,0 +1,14 @@ +{ + "name": "webmention/reaction-links", + "title": "Reaction Links: Microformats2 reaction classes for links", + "category": "widgets", + "icon": "format-status", + "keywords": [ + "reaction", + "microformats", + "indieweb", + "webmention" + ], + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css" +} \ No newline at end of file diff --git a/build/reaction-links/index-rtl.css b/build/reaction-links/index-rtl.css new file mode 100644 index 00000000..3ab3ce18 --- /dev/null +++ b/build/reaction-links/index-rtl.css @@ -0,0 +1 @@ +.webmention-reaction-setting.block-editor-link-control__setting{border-top:1px solid #ddd;flex-wrap:nowrap;margin-top:0;padding-bottom:16px;padding-top:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__label{flex-shrink:0;padding-left:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__select{flex:1;max-width:180px} diff --git a/build/reaction-links/index.asset.php b/build/reaction-links/index.asset.php new file mode 100644 index 00000000..76d449f9 --- /dev/null +++ b/build/reaction-links/index.asset.php @@ -0,0 +1 @@ + array('wp-data', 'wp-dom-ready', 'wp-i18n'), 'version' => '7cb8bf9f7714645e1a13'); diff --git a/build/reaction-links/index.css b/build/reaction-links/index.css new file mode 100644 index 00000000..c82da9b6 --- /dev/null +++ b/build/reaction-links/index.css @@ -0,0 +1 @@ +.webmention-reaction-setting.block-editor-link-control__setting{border-top:1px solid #ddd;flex-wrap:nowrap;margin-top:0;padding-bottom:16px;padding-top:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__label{flex-shrink:0;padding-right:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__select{flex:1;max-width:180px} diff --git a/build/reaction-links/index.js b/build/reaction-links/index.js new file mode 100644 index 00000000..7a680010 --- /dev/null +++ b/build/reaction-links/index.js @@ -0,0 +1 @@ +(()=>{"use strict";var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var r in n)e.o(n,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:n[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.domReady;var n=e.n(t);const r=window.wp.data,o=window.wp.i18n,i=[{value:"",label:(0,o.__)("None","webmention")},{value:"u-in-reply-to",label:(0,o.__)("Reply","webmention")},{value:"u-like-of",label:(0,o.__)("Like","webmention")},{value:"u-repost-of",label:(0,o.__)("Repost","webmention")},{value:"u-bookmark-of",label:(0,o.__)("Bookmark","webmention")},{value:"u-tag-of",label:(0,o.__)("Tag","webmention")}],l=i.map(e=>e.value).filter(e=>e);let c=null,u=null;function a(){const e=function(){const e=(0,r.select)("core/block-editor").getSelectedBlock();if(!e)return null;const{clientId:t,attributes:n}=e,o=(0,r.select)("core/block-editor"),i="function"==typeof o.getSelectionStart,l=i?o.getSelectionStart():null;let u=l?.attributeKey;if(l?.clientId&&l.clientId!==t)return null;if(u&&(c={clientId:t,attributeKey:u}),u||c?.clientId===t&&(u=c.attributeKey),!u){if(i)return null;if(!Object.prototype.hasOwnProperty.call(n,"content"))return null;u="content"}const a=function(e){let t=e;return"object"==typeof t&&t?.toHTMLString&&(t=t.toHTMLString()),"string"==typeof t?t:null}(n[u]);return a?{clientId:t,attributeKey:u,content:a}:null}();if(!e)return null;const t=function(e){const t=(new window.DOMParser).parseFromString(`
${e}
`,"text/html").body.firstChild;return t?{container:t,anchors:Array.from(t.querySelectorAll("a"))}:null}(e.content);if(!t)return null;const n=function(){const e=document.querySelector(".block-editor-link-control__search-input input");if(e&&e.value)return e.value;const t=document.querySelector(".block-editor-link-control__search-item-info a");if(t){const e=t.getAttribute("href");if(e)return e.trim()}return null}();let o=function(e){const t=document.querySelector(`[data-block="${e}"]`),n=t?.ownerDocument?.defaultView||document.defaultView,r=n?.getSelection?n.getSelection():null;if(!r||0===r.rangeCount)return null;let o=r.anchorNode;if(!o||t&&!t.contains(o))return null;if(3===o.nodeType&&(o=o.parentElement),!o||1!==o.nodeType||"function"!=typeof o.closest)return null;const i=o.closest("a");if(!i)return null;const l=i.closest('[contenteditable="true"]');if(!l)return null;const c=Array.from(l.querySelectorAll("a")).indexOf(i);return c<0?null:{index:c,href:i.getAttribute("href")?.trim()||""}}(e.clientId);o?u={clientId:e.clientId,attributeKey:e.attributeKey,...o}:u?.clientId===e.clientId&&u?.attributeKey===e.attributeKey&&(o=u);const i=function(e,t,n){if(t&&t.index(e.getAttribute("href")||"").trim()===t.href);if(n>=0)return n}return n?e.findIndex(e=>(e.getAttribute("href")||"").trim()===n.trim()):-1}(t.anchors,o,n);return i<0?null:{...e,...t,targetAnchorIndex:i}}function s(){const e=document.querySelector(".block-editor-link-control__settings");if(!e)return;const t=a(),n=e.querySelector(".webmention-reaction-setting");if(!t)return void(n&&n.remove());const c=`${t.clientId}:${t.attributeKey}:${t.targetAnchorIndex}`,u=function(e){const t=e.anchors[e.targetAnchorIndex];return t?function(e){if(!e)return"";const t=e.trim().split(/\s+/);for(const e of l)if(t.includes(e))return e;return""}(t.getAttribute("class")):""}(t);if(n){if(n.dataset.target===c){const e=n.querySelector("select");return void(e&&e.value!==u&&(e.value=u))}n.remove()}const s=function(e,t){const n=document.createElement("div");n.className="block-editor-link-control__setting webmention-reaction-setting",n.dataset.target=e;const c=document.createElement("label");c.className="webmention-reaction-setting__label",c.setAttribute("for","webmention-reaction-select"),c.textContent=(0,o.__)("Reaction","webmention"),n.appendChild(c);const u=document.createElement("select");return u.id="webmention-reaction-select",u.className="webmention-reaction-setting__select components-select-control__input",i.forEach(e=>{const n=document.createElement("option");n.value=e.value,n.textContent=e.label,n.selected=e.value===t,u.appendChild(n)}),u.addEventListener("change",e=>{!function(e){const t=a();if(!t)return;const n=t.anchors[t.targetAnchorIndex];if(!n)return;const o=n.getAttribute("class")||"";l.forEach(e=>{n.classList.remove(e)}),e&&n.classList.add(e),0===n.classList.length&&n.removeAttribute("class"),o!==(n.getAttribute("class")||"")&&(0,r.dispatch)("core/block-editor").updateBlockAttributes(t.clientId,{[t.attributeKey]:t.container.innerHTML})}(e.target.value)}),n.appendChild(u),n}(c,u);e.appendChild(s)}function d(e,t){let n;return function(...r){clearTimeout(n),n=setTimeout(()=>e.apply(this,r),t)}}n()(()=>{const e=d(s,50),t=d(a,50),n=new window.MutationObserver(()=>{e()});document.addEventListener("selectionchange",t),n.observe(document.body,{childList:!0,subtree:!0}),window.addEventListener("beforeunload",()=>{n.disconnect(),document.removeEventListener("selectionchange",t)})})})(); \ No newline at end of file diff --git a/build/rsvp/block.json b/build/rsvp/block.json new file mode 100644 index 00000000..668bd179 --- /dev/null +++ b/build/rsvp/block.json @@ -0,0 +1,15 @@ +{ + "name": "webmention/rsvp", + "title": "RSVP: Microformats2 RSVP format for event responses", + "category": "widgets", + "icon": "calendar-alt", + "keywords": [ + "rsvp", + "microformats", + "indieweb", + "webmention", + "event" + ], + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css" +} \ No newline at end of file diff --git a/build/rsvp/index-rtl.css b/build/rsvp/index-rtl.css new file mode 100644 index 00000000..a0111293 --- /dev/null +++ b/build/rsvp/index-rtl.css @@ -0,0 +1 @@ +.webmention-rsvp-popover .webmention-rsvp-popover__buttons{display:flex;gap:4px;padding:8px}data.p-rsvp{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;text-decoration-thickness:2px;text-underline-offset:2px} diff --git a/build/rsvp/index.asset.php b/build/rsvp/index.asset.php new file mode 100644 index 00000000..c3d11329 --- /dev/null +++ b/build/rsvp/index.asset.php @@ -0,0 +1 @@ + array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-rich-text'), 'version' => 'a767f7b502cf0dee8b77'); diff --git a/build/rsvp/index.css b/build/rsvp/index.css new file mode 100644 index 00000000..a0111293 --- /dev/null +++ b/build/rsvp/index.css @@ -0,0 +1 @@ +.webmention-rsvp-popover .webmention-rsvp-popover__buttons{display:flex;gap:4px;padding:8px}data.p-rsvp{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;text-decoration-thickness:2px;text-underline-offset:2px} diff --git a/build/rsvp/index.js b/build/rsvp/index.js new file mode 100644 index 00000000..0b5d8096 --- /dev/null +++ b/build/rsvp/index.js @@ -0,0 +1 @@ +(()=>{"use strict";const e=window.wp.richText,t=window.wp.blockEditor,l=window.wp.element,o=window.wp.components,a=window.wp.i18n,n=window.wp.primitives,i=window.ReactJSXRuntime;var s=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{d:"M16.5 7.5 10 13.9l-2.5-2.4-1 1 3.5 3.6 7.5-7.6z"})}),w=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{d:"m13.06 12 6.47-6.47-1.06-1.06L12 10.94 5.53 4.47 4.47 5.53 10.94 12l-6.47 6.47 1.06 1.06L12 13.06l6.47 6.47 1.06-1.06L13.06 12Z"})}),r=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{d:"M12 4a8 8 0 1 1 .001 16.001A8 8 0 0 1 12 4Zm0 1.5a6.5 6.5 0 1 0-.001 13.001A6.5 6.5 0 0 0 12 5.5Zm.75 11h-1.5V15h1.5v1.5Zm-.445-9.234a3 3 0 0 1 .445 5.89V14h-1.5v-1.25c0-.57.452-.958.917-1.01A1.5 1.5 0 0 0 12 8.75a1.5 1.5 0 0 0-1.5 1.5H9a3 3 0 0 1 3.305-2.984Z"})}),v=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{fillRule:"evenodd",d:"M9.706 8.646a.25.25 0 01-.188.137l-4.626.672a.25.25 0 00-.139.427l3.348 3.262a.25.25 0 01.072.222l-.79 4.607a.25.25 0 00.362.264l4.138-2.176a.25.25 0 01.233 0l4.137 2.175a.25.25 0 00.363-.263l-.79-4.607a.25.25 0 01.072-.222l3.347-3.262a.25.25 0 00-.139-.427l-4.626-.672a.25.25 0 01-.188-.137l-2.069-4.192a.25.25 0 00-.448 0L9.706 8.646zM12 7.39l-.948 1.921a1.75 1.75 0 01-1.317.957l-2.12.308 1.534 1.495c.412.402.6.982.503 1.55l-.362 2.11 1.896-.997a1.75 1.75 0 011.629 0l1.895.997-.362-2.11a1.75 1.75 0 01.504-1.55l1.533-1.495-2.12-.308a1.75 1.75 0 01-1.317-.957L12 7.39z",clipRule:"evenodd"})}),c=(0,i.jsx)(n.SVG,{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:(0,i.jsx)(n.Path,{d:"M12 4c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8Zm3.8 10.7-1.1 1.1-2.7-2.7-2.7 2.7-1.1-1.1 2.7-2.7-2.7-2.7 1.1-1.1 2.7 2.7 2.7-2.7 1.1 1.1-2.7 2.7 2.7 2.7Z"})});const m="webmention/rsvp",u=[{value:"yes",label:(0,a.__)("Yes","webmention"),icon:s},{value:"no",label:(0,a.__)("No","webmention"),icon:w},{value:"maybe",label:(0,a.__)("Maybe","webmention"),icon:r},{value:"interested",label:(0,a.__)("Interested","webmention"),icon:v}];(0,e.registerFormatType)(m,{title:"RSVP",tagName:"data",className:"p-rsvp",attributes:{value:"value"},edit:({isActive:n,value:s,onChange:w})=>{const[r,v]=(0,l.useState)(!1),d=function(t){const l=(0,e.getActiveFormat)(t,m);return l?.attributes?.value?l.attributes.value:l?.unregisteredAttributes?.value?l.unregisteredAttributes.value:""}(s),p=u.find(e=>e.value===d),b=p?`RSVP: ${p.label}`:"RSVP";return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(t.RichTextToolbarButton,{icon:"calendar-alt",title:b,onClick:()=>v(!r),isActive:n}),r&&(0,i.jsx)(o.Popover,{className:"webmention-rsvp-popover",position:"bottom center",onClose:()=>v(!1),children:(0,i.jsxs)("div",{className:"webmention-rsvp-popover__buttons",children:[u.map(t=>(0,i.jsx)(o.Button,{icon:t.icon,label:t.label,showTooltip:!0,isPressed:d===t.value,onClick:()=>{w((0,e.applyFormat)(s,{type:m,attributes:{value:t.value}})),v(!1)}},t.value)),n&&(0,i.jsx)(o.Button,{icon:c,label:(0,a.__)("Remove","webmention"),showTooltip:!0,onClick:()=>{w((0,e.removeFormat)(s,m)),v(!1)}})]})})]})}})})(); \ No newline at end of file diff --git a/includes/class-block.php b/includes/class-block.php index 08630e8e..269fe1c7 100644 --- a/includes/class-block.php +++ b/includes/class-block.php @@ -9,6 +9,9 @@ class Block { public static function init() { // Add editor plugin. \add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) ); + + // Add RSVP styles inside editor iframe. + \add_action( 'enqueue_block_assets', array( self::class, 'enqueue_block_assets' ) ); } /** @@ -21,8 +24,92 @@ public static function enqueue_editor_assets() { if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) { return; } + + // Enqueue the main editor plugin. $asset_data = include WEBMENTION_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php'; $plugin_url = plugins_url( 'build/editor-plugin/plugin.js', WEBMENTION_PLUGIN_FILE ); wp_enqueue_script( 'webmention-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true ); + + // Enqueue the reaction links extension. + self::enqueue_reaction_links_assets(); + + // Enqueue the RSVP format. + self::enqueue_rsvp_assets(); + } + + /** + * Enqueue the reaction links extension assets. + */ + public static function enqueue_reaction_links_assets() { + $asset_file = WEBMENTION_PLUGIN_DIR . 'build/reaction-links/index.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + return; + } + + $asset_data = include $asset_file; + + wp_enqueue_script( + 'webmention-reaction-links', + plugins_url( 'build/reaction-links/index.js', WEBMENTION_PLUGIN_FILE ), + $asset_data['dependencies'], + $asset_data['version'], + true + ); + + wp_enqueue_style( + 'webmention-reaction-links', + plugins_url( 'build/reaction-links/index.css', WEBMENTION_PLUGIN_FILE ), + array(), + $asset_data['version'] + ); + } + + /** + * Enqueue the RSVP format assets. + */ + public static function enqueue_rsvp_assets() { + $asset_file = WEBMENTION_PLUGIN_DIR . 'build/rsvp/index.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + return; + } + + $asset_data = include $asset_file; + + wp_enqueue_script( + 'webmention-rsvp', + plugins_url( 'build/rsvp/index.js', WEBMENTION_PLUGIN_FILE ), + $asset_data['dependencies'], + $asset_data['version'], + true + ); + } + + /** + * Enqueue block assets for editor content area (inside iframe). + * + * RSVP styles need to load inside the editor iframe to style + * the data.p-rsvp elements in the content. + */ + public static function enqueue_block_assets() { + if ( ! is_admin() ) { + return; + } + + $asset_file = WEBMENTION_PLUGIN_DIR . 'build/rsvp/index.asset.php'; + + if ( ! file_exists( $asset_file ) ) { + return; + } + + $asset_data = include $asset_file; + + wp_enqueue_style( + 'webmention-rsvp-editor', + plugins_url( 'build/rsvp/index.css', WEBMENTION_PLUGIN_FILE ), + array(), + $asset_data['version'] + ); } } diff --git a/src/reaction-links/block.json b/src/reaction-links/block.json new file mode 100644 index 00000000..de2faf02 --- /dev/null +++ b/src/reaction-links/block.json @@ -0,0 +1,9 @@ +{ + "name": "webmention/reaction-links", + "title": "Reaction Links: Microformats2 reaction classes for links", + "category": "widgets", + "icon": "format-status", + "keywords": [ "reaction", "microformats", "indieweb", "webmention" ], + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css" +} diff --git a/src/reaction-links/editor.scss b/src/reaction-links/editor.scss new file mode 100644 index 00000000..aa6b94c8 --- /dev/null +++ b/src/reaction-links/editor.scss @@ -0,0 +1,23 @@ +/** + * Microformats Reaction Links - Editor Styles + * + * Matches WordPress block-editor-link-control__setting design + */ + +.webmention-reaction-setting.block-editor-link-control__setting { + flex-wrap: nowrap; + padding-top: 16px; + padding-bottom: 16px; + border-top: 1px solid #ddd; + margin-top: 0; + + .webmention-reaction-setting__label { + flex-shrink: 0; + padding-right: 16px; + } + + .webmention-reaction-setting__select { + flex: 1; + max-width: 180px; + } +} diff --git a/src/reaction-links/index.js b/src/reaction-links/index.js new file mode 100644 index 00000000..c8b815fd --- /dev/null +++ b/src/reaction-links/index.js @@ -0,0 +1,544 @@ +/** + * Microformats Reaction Links Extension + * + * Extends the WordPress block editor link popover to add microformats2 + * reaction classes (u-in-reply-to, u-like-of, etc.) directly to anchor elements. + */ +import domReady from '@wordpress/dom-ready'; +import { select, dispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +import './editor.scss'; + +/** + * Microformats reaction types + */ +const REACTION_TYPES = [ + { + value: '', + label: __( 'None', 'webmention' ), + }, + { + value: 'u-in-reply-to', + label: __( 'Reply', 'webmention' ), + }, + { + value: 'u-like-of', + label: __( 'Like', 'webmention' ), + }, + { + value: 'u-repost-of', + label: __( 'Repost', 'webmention' ), + }, + { + value: 'u-bookmark-of', + label: __( 'Bookmark', 'webmention' ), + }, + { + value: 'u-tag-of', + label: __( 'Tag', 'webmention' ), + }, +]; + +const REACTION_CLASSES = REACTION_TYPES.map( ( t ) => t.value ).filter( + ( v ) => v +); + +/** + * Debounce delay in milliseconds for MutationObserver callbacks. + */ +const DEBOUNCE_DELAY = 50; +let lastRichTextSelection = null; +let lastAnchorSelection = null; + +/** + * Get the currently selected block + */ +function getSelectedBlock() { + return select( 'core/block-editor' ).getSelectedBlock(); +} + +/** + * Parse reaction class from a class string + * + * @param {string} classStr Class attribute string. + * @return {string} Parsed reaction class. + */ +function parseReactionClass( classStr ) { + if ( ! classStr ) { + return ''; + } + + const classTokens = classStr.trim().split( /\s+/ ); + + for ( const cls of REACTION_CLASSES ) { + if ( classTokens.includes( cls ) ) { + return cls; + } + } + return ''; +} + +/** + * Get the URL being edited from the link popover + */ +function getCurrentLinkUrl() { + const urlInput = document.querySelector( + '.block-editor-link-control__search-input input' + ); + if ( urlInput && urlInput.value ) { + return urlInput.value; + } + + const urlDisplayLink = document.querySelector( + '.block-editor-link-control__search-item-info a' + ); + if ( urlDisplayLink ) { + const href = urlDisplayLink.getAttribute( 'href' ); + if ( href ) { + return href.trim(); + } + } + + return null; +} + +/** + * Normalize rich text values to an HTML string. + * + * @param {*} value Raw attribute value. + * @return {?string} HTML string when supported, otherwise null. + */ +function getHtmlString( value ) { + let content = value; + + if ( typeof content === 'object' && content?.toHTMLString ) { + content = content.toHTMLString(); + } + + return typeof content === 'string' ? content : null; +} + +/** + * Get the selected rich text attribute and its HTML content. + */ +function getEditableRichTextContext() { + const block = getSelectedBlock(); + if ( ! block ) { + return null; + } + + const { clientId, attributes } = block; + const blockEditorStore = select( 'core/block-editor' ); + const hasSelectionApi = + typeof blockEditorStore.getSelectionStart === 'function'; + const selectionStart = hasSelectionApi + ? blockEditorStore.getSelectionStart() + : null; + let attributeKey = selectionStart?.attributeKey; + + if ( selectionStart?.clientId && selectionStart.clientId !== clientId ) { + return null; + } + + if ( attributeKey ) { + lastRichTextSelection = { + clientId, + attributeKey, + }; + } + + if ( ! attributeKey ) { + if ( lastRichTextSelection?.clientId === clientId ) { + attributeKey = lastRichTextSelection.attributeKey; + } + } + + if ( ! attributeKey ) { + // If the selection API is available but there is no rich-text attribute, + // this block shape is not supported by this extension. + if ( hasSelectionApi ) { + return null; + } + + if ( ! Object.prototype.hasOwnProperty.call( attributes, 'content' ) ) { + return null; + } + + attributeKey = 'content'; + } + + const content = getHtmlString( attributes[ attributeKey ] ); + if ( ! content ) { + return null; + } + + return { + clientId, + attributeKey, + content, + }; +} + +/** + * Parse anchors from an HTML string. + * + * @param {string} content HTML content from a rich-text attribute. + * @return {?Object} Parsed container and anchor list. + */ +function parseContentAnchors( content ) { + const parser = new window.DOMParser(); + const doc = parser.parseFromString( + `
${ content }
`, + 'text/html' + ); + const container = doc.body.firstChild; + + if ( ! container ) { + return null; + } + + return { + container, + anchors: Array.from( container.querySelectorAll( 'a' ) ), + }; +} + +/** + * Get the currently selected anchor from the editor and its index. + * + * @param {string} clientId Current block client ID. + * @return {?Object} Selected anchor position and href. + */ +function getSelectedAnchorInEditor( clientId ) { + const selectedBlockEl = document.querySelector( + `[data-block="${ clientId }"]` + ); + const view = + selectedBlockEl?.ownerDocument?.defaultView || document.defaultView; + const selection = view?.getSelection ? view.getSelection() : null; + + if ( ! selection || selection.rangeCount === 0 ) { + return null; + } + + let node = selection.anchorNode; + + if ( ! node || ( selectedBlockEl && ! selectedBlockEl.contains( node ) ) ) { + return null; + } + + if ( node.nodeType === 3 ) { + node = node.parentElement; + } + + if ( ! node || node.nodeType !== 1 || typeof node.closest !== 'function' ) { + return null; + } + + const anchor = node.closest( 'a' ); + if ( ! anchor ) { + return null; + } + + const editableRoot = anchor.closest( '[contenteditable="true"]' ); + if ( ! editableRoot ) { + return null; + } + + const anchorIndex = Array.from( + editableRoot.querySelectorAll( 'a' ) + ).indexOf( anchor ); + if ( anchorIndex < 0 ) { + return null; + } + + return { + index: anchorIndex, + href: anchor.getAttribute( 'href' )?.trim() || '', + }; +} + +/** + * Resolve the single target anchor index in parsed content. + * + * @param {Array} anchors Parsed anchor elements from block attributes. + * @param {?Object} selectedAnchor Selected anchor position from editor DOM. + * @param {?string} targetUrl URL currently shown in the link popover. + * @return {number} Anchor index, or -1 when not resolvable. + */ +function getTargetAnchorIndex( anchors, selectedAnchor, targetUrl ) { + if ( selectedAnchor && selectedAnchor.index < anchors.length ) { + const indexedAnchorHref = ( + anchors[ selectedAnchor.index ].getAttribute( 'href' ) || '' + ).trim(); + if ( + ! selectedAnchor.href || + indexedAnchorHref === selectedAnchor.href + ) { + return selectedAnchor.index; + } + } + + if ( selectedAnchor?.href ) { + const selectedHrefIndex = anchors.findIndex( + ( anchor ) => + ( anchor.getAttribute( 'href' ) || '' ).trim() === + selectedAnchor.href + ); + if ( selectedHrefIndex >= 0 ) { + return selectedHrefIndex; + } + } + + if ( targetUrl ) { + return anchors.findIndex( + ( anchor ) => + ( anchor.getAttribute( 'href' ) || '' ).trim() === + targetUrl.trim() + ); + } + + return -1; +} + +/** + * Build a full reaction editing context for the selected link. + */ +function getReactionContext() { + const richTextContext = getEditableRichTextContext(); + if ( ! richTextContext ) { + return null; + } + + const parsed = parseContentAnchors( richTextContext.content ); + if ( ! parsed ) { + return null; + } + + const targetUrl = getCurrentLinkUrl(); + let selectedAnchor = getSelectedAnchorInEditor( richTextContext.clientId ); + + if ( selectedAnchor ) { + lastAnchorSelection = { + clientId: richTextContext.clientId, + attributeKey: richTextContext.attributeKey, + ...selectedAnchor, + }; + } else if ( + lastAnchorSelection?.clientId === richTextContext.clientId && + lastAnchorSelection?.attributeKey === richTextContext.attributeKey + ) { + selectedAnchor = lastAnchorSelection; + } + + const targetAnchorIndex = getTargetAnchorIndex( + parsed.anchors, + selectedAnchor, + targetUrl + ); + + if ( targetAnchorIndex < 0 ) { + return null; + } + + return { + ...richTextContext, + ...parsed, + targetAnchorIndex, + }; +} + +/** + * Get current reaction from the selected anchor in block content. + * + * @param {Object} reactionContext Reaction context for the selected link. + * @return {string} Active reaction class. + */ +function getCurrentReactionFromBlock( reactionContext ) { + const targetAnchor = + reactionContext.anchors[ reactionContext.targetAnchorIndex ]; + if ( ! targetAnchor ) { + return ''; + } + + return parseReactionClass( targetAnchor.getAttribute( 'class' ) ); +} + +/** + * Apply reaction class to the anchor in block content + * + * @param {string} reaction Reaction class to apply. + */ +function applyReaction( reaction ) { + const reactionContext = getReactionContext(); + if ( ! reactionContext ) { + return; + } + + const targetAnchor = + reactionContext.anchors[ reactionContext.targetAnchorIndex ]; + if ( ! targetAnchor ) { + return; + } + + const previousClassAttribute = targetAnchor.getAttribute( 'class' ) || ''; + + // Remove existing reaction classes + REACTION_CLASSES.forEach( ( cls ) => { + targetAnchor.classList.remove( cls ); + } ); + + // Add new reaction class + if ( reaction ) { + targetAnchor.classList.add( reaction ); + } + + // Clean up empty class attribute + if ( targetAnchor.classList.length === 0 ) { + targetAnchor.removeAttribute( 'class' ); + } + + const updatedClassAttribute = targetAnchor.getAttribute( 'class' ) || ''; + const modified = previousClassAttribute !== updatedClassAttribute; + + if ( modified ) { + dispatch( 'core/block-editor' ).updateBlockAttributes( + reactionContext.clientId, + { + [ reactionContext.attributeKey ]: + reactionContext.container.innerHTML, + } + ); + } +} + +/** + * Create the reaction dropdown + * + * @param {string} targetKey Unique key for selected anchor context. + * @param {string} currentReaction Current reaction class. + * @return {HTMLElement} Dropdown container element. + */ +function createReactionDropdown( targetKey, currentReaction ) { + const container = document.createElement( 'div' ); + container.className = + 'block-editor-link-control__setting webmention-reaction-setting'; + container.dataset.target = targetKey; + + const label = document.createElement( 'label' ); + label.className = 'webmention-reaction-setting__label'; + label.setAttribute( 'for', 'webmention-reaction-select' ); + label.textContent = __( 'Reaction', 'webmention' ); + container.appendChild( label ); + + const selectEl = document.createElement( 'select' ); + selectEl.id = 'webmention-reaction-select'; + selectEl.className = + 'webmention-reaction-setting__select components-select-control__input'; + + REACTION_TYPES.forEach( ( type ) => { + const option = document.createElement( 'option' ); + option.value = type.value; + option.textContent = type.label; + option.selected = type.value === currentReaction; + selectEl.appendChild( option ); + } ); + + selectEl.addEventListener( 'change', ( e ) => { + applyReaction( e.target.value ); + } ); + + container.appendChild( selectEl ); + return container; +} + +/** + * Inject the reaction dropdown into the link popover + */ +function injectReactionDropdown() { + const settingsDrawer = document.querySelector( + '.block-editor-link-control__settings' + ); + + if ( ! settingsDrawer ) { + return; + } + + const reactionContext = getReactionContext(); + const existingDropdown = settingsDrawer.querySelector( + '.webmention-reaction-setting' + ); + if ( ! reactionContext ) { + if ( existingDropdown ) { + existingDropdown.remove(); + } + return; + } + + const targetKey = `${ reactionContext.clientId }:${ reactionContext.attributeKey }:${ reactionContext.targetAnchorIndex }`; + const currentReaction = getCurrentReactionFromBlock( reactionContext ); + + if ( existingDropdown ) { + // Recreate the dropdown if another link or attribute is selected. + if ( existingDropdown.dataset.target !== targetKey ) { + existingDropdown.remove(); + } else { + // Selection is the same, just update the selected value if needed. + const selectEl = existingDropdown.querySelector( 'select' ); + if ( selectEl && selectEl.value !== currentReaction ) { + selectEl.value = currentReaction; + } + return; + } + } + + const dropdown = createReactionDropdown( targetKey, currentReaction ); + settingsDrawer.appendChild( dropdown ); +} + +/** + * Debounce helper + * + * @param {Function} func Function to debounce. + * @param {number} wait Debounce delay in milliseconds. + * @return {Function} Debounced function. + */ +function debounce( func, wait ) { + let timeout; + return function ( ...args ) { + clearTimeout( timeout ); + timeout = setTimeout( () => func.apply( this, args ), wait ); + }; +} + +/** + * Initialize + */ +domReady( () => { + const debouncedInject = debounce( injectReactionDropdown, DEBOUNCE_DELAY ); + const debouncedSelectionCache = debounce( + getReactionContext, + DEBOUNCE_DELAY + ); + + const observer = new window.MutationObserver( () => { + debouncedInject(); + } ); + + document.addEventListener( 'selectionchange', debouncedSelectionCache ); + + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + + // Cleanup on page unload + window.addEventListener( 'beforeunload', () => { + observer.disconnect(); + document.removeEventListener( + 'selectionchange', + debouncedSelectionCache + ); + } ); +} ); diff --git a/src/rsvp/block.json b/src/rsvp/block.json new file mode 100644 index 00000000..761a2792 --- /dev/null +++ b/src/rsvp/block.json @@ -0,0 +1,9 @@ +{ + "name": "webmention/rsvp", + "title": "RSVP: Microformats2 RSVP format for event responses", + "category": "widgets", + "icon": "calendar-alt", + "keywords": [ "rsvp", "microformats", "indieweb", "webmention", "event" ], + "editorScript": "file:./index.js", + "editorStyle": "file:./index.css" +} diff --git a/src/rsvp/editor.scss b/src/rsvp/editor.scss new file mode 100644 index 00000000..85930815 --- /dev/null +++ b/src/rsvp/editor.scss @@ -0,0 +1,18 @@ +/** + * RSVP Format - Editor Styles + */ + +.webmention-rsvp-popover { + .webmention-rsvp-popover__buttons { + display: flex; + padding: 8px; + gap: 4px; + } +} + +/* Style RSVP text in the editor */ +data.p-rsvp { + text-decoration: underline dotted; + text-decoration-thickness: 2px; + text-underline-offset: 2px; +} diff --git a/src/rsvp/index.js b/src/rsvp/index.js new file mode 100644 index 00000000..b422cd79 --- /dev/null +++ b/src/rsvp/index.js @@ -0,0 +1,131 @@ +/** + * RSVP Rich Text Format + * + * Adds text markup + * for microformats2 RSVP responses to events. + */ +import { registerFormatType, applyFormat, removeFormat, getActiveFormat } from '@wordpress/rich-text'; +import { RichTextToolbarButton } from '@wordpress/block-editor'; +import { useState } from '@wordpress/element'; +import { Popover, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { check, close, help, starEmpty, cancelCircleFilled } from '@wordpress/icons'; + +import './editor.scss'; + +const FORMAT_NAME = 'webmention/rsvp'; + +const RSVP_VALUES = [ + { + value: 'yes', + label: __( 'Yes', 'webmention' ), + icon: check, + }, + { + value: 'no', + label: __( 'No', 'webmention' ), + icon: close, + }, + { + value: 'maybe', + label: __( 'Maybe', 'webmention' ), + icon: help, + }, + { + value: 'interested', + label: __( 'Interested', 'webmention' ), + icon: starEmpty, + }, +]; + +/** + * Get current RSVP value from format + */ +function getCurrentRsvpValue( value ) { + const format = getActiveFormat( value, FORMAT_NAME ); + if ( format?.attributes?.value ) { + return format.attributes.value; + } + if ( format?.unregisteredAttributes?.value ) { + return format.unregisteredAttributes.value; + } + return ''; +} + +/** + * RSVP Format Edit Component + */ +const RsvpFormatEdit = ( { isActive, value, onChange } ) => { + const [ isOpen, setIsOpen ] = useState( false ); + const currentValue = getCurrentRsvpValue( value ); + + const currentItem = RSVP_VALUES.find( ( v ) => v.value === currentValue ); + const buttonTitle = currentItem + ? `RSVP: ${ currentItem.label }` + : 'RSVP'; + + return ( + <> + setIsOpen( ! isOpen ) } + isActive={ isActive } + /> + { isOpen && ( + setIsOpen( false ) } + > +
+ { RSVP_VALUES.map( ( rsvp ) => ( +
+
+ ) } + + ); +}; + +/** + * Register the RSVP format type + */ +registerFormatType( FORMAT_NAME, { + title: 'RSVP', + tagName: 'data', + className: 'p-rsvp', + attributes: { + value: 'value', + }, + edit: RsvpFormatEdit, +} );