diff --git a/.eslintrc.js b/.eslintrc.js index 4f4bfa86ffb2ba..45fc4c94140f1c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -165,6 +165,10 @@ module.exports = { selector: 'CallExpression[callee.object.name="Math"][callee.property.name="random"]', message: 'Do not use Math.random() to generate unique IDs; use withInstanceId instead. (If you’re not generating unique IDs: ignore this message.)', }, + { + selector: 'CallExpression[callee.name="withDispatch"] > :function > BlockStatement > :not(VariableDeclaration,ReturnStatement)', + message: 'withDispatch must return an object with consistent keys. Avoid performing logic in `mapDispatchToProps`.', + }, ], 'react/forbid-elements': [ 'error', { forbid: [ diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index e3dd47a328047e..3a130f10d4ddf2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -115,3 +115,7 @@ This list is manually curated to include valuable contributions by volunteers th | @LukePettway | @luke_pettway | | @pratikthink | @pratikthink | | @amdrew | @sumobi | +| @MaedahBatool | @MaedahBatool | +| @luehrsen | @luehrsen | +| @getsource | @mikeschroder | +| @greatislander | @greatislander | diff --git a/README.md b/README.md index 2f662221785b79..1f8ae7ce5b83f3 100644 --- a/README.md +++ b/README.md @@ -6,27 +6,33 @@ Printing since 1440. ![Gutenberg editing](https://cldup.com/H0oKBfpidk.png) -This is the development hub for the editor focus in core. Gutenberg is the project name. If you want to use the latest release with your WordPress, download Gutenberg from the WordPress.org plugins repository. Conversations and discussions take place in #core-editor on the core WordPress Slack. +This repo is the development hub for the editor focus in WordPress Core. `Gutenberg` is the project name. -Discover more about the project here. +## Getting started +- **Download:** If you want to use the latest release with your WordPress site, download the latest release from the WordPress.org plugins repository. +- **Discuss:** Conversations and discussions take place in `#core-editor` channel on the Making WordPress Slack. +- **Contribute:** Development of Gutenberg happens in this GitHub repo. Get started by reading the contributing guidelines. +- **Learn:** Discover more about the project on WordPress.org. -Gutenberg is more than an editor. While the editor is the focus right now, the project will ultimately impact the entire publishing experience including customization (the next focus area). +**Gutenberg is more than an editor.** While the project is currently focused on building the new editor for WordPress, it doesn't end there. This lays the groundwork for a new model for WordPress Core that will ultimately impact the entire publishing experience of the platform. ## Editing focus -> The editor will create a new page- and post-building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery. — Matt Mullenweg +> *The editor will create a new page- and post-building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery.* +> +> — Matt Mullenweg -One thing that sets WordPress apart from other systems is that it allows you to create as rich a post layout as you can imagine -- but only if you know HTML and CSS and build your own custom theme. By thinking of the editor as a tool to let you write rich posts and create beautiful layouts, we can transform WordPress into something users _love_, as opposed something they pick it because it's what everyone else uses. +One thing that sets WordPress apart is that it allows you to create a post layout that's as rich as you can imagine—but only if you can build your own custom theme with HTML and CSS. By thinking of the editor as a tool that allows you to write rich posts **and** create beautiful layouts, we can transform WordPress into something users _love_, as opposed to something they choose because it happens to be what everyone else uses. -Gutenberg looks at the editor as more than a content field, revisiting a layout that has been largely unchanged for almost a decade. This allows us to holistically design a modern editing experience and build a foundation for things to come. +**Gutenberg is a new way forward.** It looks at the editor as more than a content field, revisiting a layout that has been largely unchanged for almost a decade. This project allows The WordPress Project to holistically design a modern editing experience and build a foundation for things to come. Here's why we're looking at the whole editing screen, as opposed to just the content field: -1. The block unifies multiple interfaces. If we add that on top of the existing interface, it would _add_ complexity, as opposed to removing it. -2. By revisiting the interface, we can modernize the writing, editing, and publishing experience, with usability and simplicity in mind, benefitting both new and casual users. -3. When singular block interface takes center stage, it demonstrates a clear path forward for developers to create premium blocks, superior to both shortcodes and widgets. -4. Considering the whole interface lays a solid foundation for the next focus, full site customization. -5. Looking at the full editor screen also gives us the opportunity to drastically modernize the foundation, and take steps towards a more fluid and JavaScript-powered future that fully leverages the WordPress REST API. +1. **The block unifies multiple interfaces.** If Gutenberg added blocks on top of the existing interface, it would _add_ complexity, as opposed to removing it. +2. **Simplified (and enhanced) editing.** By revisiting the interface, Gutenberg can modernize the writing, editing, and publishing experience, with usability and simplicity in mind, benefitting both new and casual users. +3. **Better interface usability.** When singular block interface takes center stage, it demonstrates a clear path forward for developers to create premium blocks, superior to both shortcodes and widgets. +4. **A fresh look at content creation.** Considering the whole interface lays a solid foundation for the next focus: full site customization. +5. **Modern tooling.** Looking at the full editor screen also gives WordPress the opportunity to drastically modernize the foundation, and take steps towards a more fluid and JavaScript-powered future that fully leverages the WordPress REST API. ![Writing in Gutenberg 1.6](https://make.wordpress.org/core/files/2017/10/gutenberg-typing-1_6.gif) @@ -34,7 +40,7 @@ Here's why we're looking at the whole editing screen, as opposed to just the con Blocks are the unifying evolution of what is now covered, in different ways, by shortcodes, embeds, widgets, post formats, custom post types, theme options, meta-boxes, and other formatting elements. They embrace the breadth of functionality WordPress is capable of, with the clarity of a consistent user experience. -Imagine a custom “employee” block that a client can drag to an About page to automatically display a picture, name, and bio. A whole universe of plugins that all extend WordPress in the same way. Simplified menus and widgets. Users who can instantly understand and use WordPress -- and 90% of plugins. This will allow you to easily compose beautiful posts like this example. +Imagine a custom `employee` block that a client can drag onto an `About` page to automatically display a picture, name, and bio of all the employees. Imagine a whole universe of plugins just as flexible, all extending WordPress in the same way. Imagine simplified menus and widgets. Users who can instantly understand and use WordPress—and 90% of plugins. This will allow you to easily compose beautiful posts like this example. Check out the FAQ for answers to the most common questions about the project. @@ -44,11 +50,12 @@ Posts are backwards compatible, and shortcodes will still work. We are continuou ## The stages of Gutenberg -Gutenberg has three planned stages. The first, aimed for inclusion in WordPress 5.0, focuses on the post editing experience and the implementation of blocks. This initial phase focuses on a content-first approach. The use of blocks, as detailed above, allows you to focus on how your content will look without the distraction of other configuration options. This ultimately will help all users present their content in a way that is engaging, direct, and visual. +Gutenberg has three planned stages. +1) **The first, aimed for inclusion in WordPress 5.0, focuses on the post editing experience** and the implementation of blocks. This initial phase focuses on a content-first approach. The use of blocks, as detailed above, allows you to focus on how your content will look without the distraction of other configuration options. This ultimately will help all users present their content in a way that is engaging, direct, and visual. These foundational elements will pave the way forward. +2) Planned for 2019, **The second stage focuses on overhauling The Customizer** and page templates. +3) Ultimately, **full site customization** will be possible. -These foundational elements will pave the way for stages two and three, planned for the next year, to go beyond the post into page templates and ultimately, full site customization. - -Gutenberg is a big change, and there will be ways to ensure that existing functionality (like shortcodes and meta-boxes) continue to work while allowing developers the time and paths to transition effectively. Ultimately, it will open new opportunities for plugin and theme developers to better serve users through a more engaging and visual experience that takes advantage of a toolset supported by core. +**Gutenberg is a big change.** There will be ways to ensure that existing functionality (like shortcodes and meta-boxes) continue to work while allowing developers the time and paths to transition effectively. Ultimately, it will open new opportunities for plugin and theme developers to better serve users through a more engaging and visual experience that takes advantage of a toolset supported by core. ## Get involved diff --git a/assets/stylesheets/_animations.scss b/assets/stylesheets/_animations.scss index 7b14a7036e6d25..755161bc852ff5 100644 --- a/assets/stylesheets/_animations.scss +++ b/assets/stylesheets/_animations.scss @@ -1,78 +1,8 @@ -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } +@mixin edit-post__loading-fade-animation { + animation: edit-post__loading-fade-animation 1.6s ease-in-out infinite; } -@mixin animate_fade { - animation: animate_fade 0.1s ease-out; - animation-fill-mode: forwards; -} - -@mixin move_background { - background-size: 28px 28px; - animation: move_background 0.5s linear infinite; -} - -@mixin loading_fade { - animation: loading_fade 1.6s ease-in-out infinite; -} - -@mixin slide_in_right { - transform: translateX(+100%); - animation: slide_in_right 0.1s forwards; -} - -@mixin slide_in_top { - transform: translateY(-100%); - animation: slide_in_top 0.1s forwards; -} - -@mixin fade_in($speed: 0.2s, $delay: 0s) { - animation: fade-in $speed ease-out $delay; - animation-fill-mode: forwards; -} - -@keyframes editor_region_focus { - from { - box-shadow: inset 0 0 0 0 $blue-medium-400; - } - to { - box-shadow: inset 0 0 0 4px $blue-medium-400; - } -} - -@mixin region_focus($speed: 0.2s) { - animation: editor_region_focus $speed ease-out; - animation-fill-mode: forwards; -} - -@keyframes rotation { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@mixin animate_rotation($speed: 1s) { - animation: rotation $speed infinite linear; -} - -@keyframes modal-appear { - from { - margin-top: $grid-size * 4; - } - to { - margin-top: 0; - } -} - -@mixin modal_appear() { - animation: modal-appear 0.1s ease-out; +@mixin edit-post__fade-in-animation($speed: 0.2s, $delay: 0s) { + animation: edit-post__fade-in-animation $speed ease-out $delay; animation-fill-mode: forwards; } diff --git a/assets/stylesheets/_z-index.scss b/assets/stylesheets/_z-index.scss index 39e1bb461d46b1..85b0099665715d 100644 --- a/assets/stylesheets/_z-index.scss +++ b/assets/stylesheets/_z-index.scss @@ -85,6 +85,7 @@ $z-layers: ( // Shows above edit post sidebar; Specificity needs to be higher than 3 classes. ".block-editor__container .components-popover.components-color-palette__picker.is-bottom": 100001, + ".edit-post-post-visibility__dialog.components-popover.is-bottom": 100001, ".components-autocomplete__results": 1000000, diff --git a/docs/reference/coding-guidelines.md b/docs/contributors/coding-guidelines.md similarity index 94% rename from docs/reference/coding-guidelines.md rename to docs/contributors/coding-guidelines.md index dcc943b3288ae6..8264e765231048 100644 --- a/docs/reference/coding-guidelines.md +++ b/docs/contributors/coding-guidelines.md @@ -93,9 +93,13 @@ Exposed APIs that are still being tested, discussed and are subject to change sh Example: ```js -export { - internalApi as __experimentalExposedApi -} from './internalApi.js'; +export { __experimentalDoAction } from './api'; +``` + +If an API must be exposed but is clearly not intended to be supported into the future, you may also use `__unstable` as a prefix to differentiate it from an experimental API. Unstable APIs should serve an immediate and temporary purpose. They should _never_ be used by plugin developers as they can be removed at any point without notice, and thus should be omitted from public-facing documentation. The inline code documentation should clearly caution their use. + +```js +export { __unstableDoAction } from './api'; ``` ### Variable Naming diff --git a/docs/reference/copy-guide.md b/docs/contributors/copy-guide.md similarity index 66% rename from docs/reference/copy-guide.md rename to docs/contributors/copy-guide.md index 6c0217472f66f3..5e0066a689604c 100644 --- a/docs/reference/copy-guide.md +++ b/docs/contributors/copy-guide.md @@ -33,17 +33,17 @@ Features don’t allow anyone to do anything; they’re just tools that do speci > Preformatted text preserves your tabs and line breaks. -The more direct sentences are almost always clearer. Scan your copy for the words “can,” “be,” “might,” “allows you to,” and “helps” -- they’re the most common culprits, and looking for those words specifically is a way to locate phrasing you can tighten up. +The more direct sentences are almost always clearer. Scan your copy for the words “can,” “be,” “might,” “allows you to,” and “helps”—they’re the most common culprits, and looking for those words specifically is a way to locate phrasing you can tighten up. #### THREE: Beware of “simple,” “easy,” and “just.” -It is not for us to decide what is simple: it’s for the user to decide. If we say something is easy and the user doesn’t have an easy experience, it undermines their trust in us and what we’re building. Same goes for “just” -- many of us know to avoid “simple,” but still use “just” all the time. “Just click here.” “Just enter your username.” It’s the same thing: it implies that something will be no big deal, but we can’t know what the user will find to be a big deal. +It is not for us to decide what is simple: it’s for the user to decide. If we say something is easy and the user doesn’t have an easy experience, it undermines their trust in us and what we’re building. Same goes for “just”—many of us know to avoid “simple,” but still use “just” all the time. “Just click here.” “Just enter your username.” It’s the same thing: it implies that something will be no big deal, but we can’t know what the user will find to be a big deal. It’s also safer and more helpful to be specific. “Easy” and “simple” are shorthand for explanations that we haven’t written; whenever you see them, take a minute to think about what they’re standing in for. Maybe “It’s easy to add a block by hitting ‘enter’” really means “You can add more content to the page without taking your hands off the keyboard.” Great! Say the specific thing instead of relying on “easy.” -This isn’t to say that you should banish these words from your vocabulary. You might want to write a tooltip describing how the cover image block now requires less configuration, or an email about how we’re building a tool for quick creation of custom blocks, and you could legitimately say that the cover image block has been simplified or that we’re working to make custom block creation easier -- there, the terms are descriptive and relative. But be on the lookout for ways you might be using (or overusing) them to make absolute claims that something is easy or simple, and use those as opportunities to be more specific and clear. +This isn’t to say that you should banish these words from your vocabulary. You might want to write a tooltip describing how the cover image block now requires less configuration, or an email about how we’re building a tool for quick creation of custom blocks, and you could legitimately say that the cover image block has been simplified or that we’re working to make custom block creation easier—there, the terms are descriptive and relative. But be on the lookout for ways you might be using (or overusing) them to make absolute claims that something is easy or simple, and use those as opportunities to be more specific and clear. #### FOUR: Look out for “we.” -Any time text or instructions uses “we” a lot, it means the focus of the text is on the people behind the software and not the people using the software. Sometimes that’s what you actually want -- but it’s usually not. The focus should typically be on the user, what they need, and how they benefit rather than “what we did” or “what we want.” +Any time text or instructions uses “we” a lot, it means the focus of the text is on the people behind the software and not the people using the software. Sometimes that’s what you actually want—but it’s usually not. The focus should typically be on the user, what they need, and how they benefit rather than “what we did” or “what we want.” We’re the only ones that care about what we did or want; the user just wants software that works. If you see a lot of “we”s, think about whether you should reframe what you’re writing to focus on the benefits to and successes of the user. @@ -51,25 +51,25 @@ We’re the only ones that care about what we did or want; the user just wants s Guidelines for (duh) writing bulleted lists. #### ONE: Keep sentence structures parallel across all bullets. -Parallel structure makes lists easier to read quickly -- their predictability takes some cognitive load off the reader. +Parallel structure makes lists easier to read quickly—their predictability takes some cognitive load off the reader. GOOD: -What can you do with this block? Lots of things! -* Add a quote. -* Highlight a link. -* Display multiple images. -* Create a bulleted list. +> What can you do with this block? Lots of things! +> * Add a quote. +> * Highlight a link. +> * Display multiple images. +> * Create a bulleted list. -Every bullet is a full sentence, and ends with a period. (If your list is a bunch of one- or two-word items, those can often just turn into a single regular sentence -- easier to read, and space-saving.) Every line begins with a verb that tells the user what the block can do. The subject of the sentence is always the user. +Every bullet is a full sentence, and ends with a period. (If your list is a bunch of one- or two-word items, those can often just turn into a single regular sentence—easier to read, and space-saving.) Every line begins with a verb that tells the user what the block can do. The subject of the sentence is always the user. A user can absorb this list quickly because once they read the first item, they understand how to read the rest and know what information they’ll find. LESS GOOD: -What can you do with this block? Lots of things! -* You can add a quote. -* Highlighting a link you love. -* It displays multiple images. Nice for galleries! -* Bulleted lists +> What can you do with this block? Lots of things! +> * You can add a quote. +> * Highlighting a link you love. +> * It displays multiple images. Nice for galleries! +> * Bulleted lists Here, every line has different phrasing (some start with a verb, some with a noun) and the subject of the sentence changes (sometimes it’s you, sometimes it’s the block). Some lines have added description, some don't. There’s an incomplete sentence, and punctuation is inconsistent. @@ -77,10 +77,10 @@ Reading this list takes more work because the reader has to parse each bullet an Note: this doesn't mean every bullet has to be super short and start with an action verb! “Predicable” doesn’t have to mean “simple.” It just means that each bullet should have the same sentence structure. This list would also be fine: -What can you do with this block? Lots of things! -* Try adding a quote. Sometimes someone else said things best! -* Use it to highlight a link you love -- sharing links is the currency of the internet. -* Create a gallery that displays multiple images, and show off your best photos. +> What can you do with this block? Lots of things! +> * Try adding a quote. Sometimes someone else said things best! +> * Use it to highlight a link you love—sharing links is the currency of the internet. +> * Create a gallery that displays multiple images, and show off your best photos. Here, each bullet starts with a more user-focused verb and includes a piece of supplemental information for more interest. The punctuation varies a bit, which keeps the lines from feeling too formulaic, but since the basic structure of each is the same, they remain easy to read. @@ -89,33 +89,33 @@ Do you have to start with a verb? No. But if you’re at a loss, you usually can In a simple list that’s meant to be purely instructional (e.g., in UI copy where you just need the user to make a decision), it might be fine to start every bullet with the same verb: -To continue, choose an action: -Add a simple text block. -Add a pullquote block. -Add an image block. +> To continue, choose an action: +> * Add a simple text block. +> * Add a pullquote block. +> * Add an image block. If your list is more persuasive (e.g., trying to convince someone to use a feature by listing its benefits) or includes multi-step instructions, you’ll want to vary your verbs to keep the reader engaged with more interesting language, as in the example above: -What can you do with this block? Lots of things! -* Try adding a quote. Sometimes someone else said things best! -* Use it to highlight a link you love -- sharing links is the currency of the internet. -* Create a gallery that displays multiple images, and show off your best photos. +>What can you do with this block? Lots of things! +>* Try adding a quote. Sometimes someone else said things best! +>* Use it to highlight a link you love—sharing links is the currency of the internet. +>* Create a gallery that displays multiple images, and show off your best photos. -These aren’t hard-and-fast rules -- you might choose the use the same verb in a persuasive list to be more focused and powerful, for example. But they’re good starting places for solid lists. +These aren’t hard-and-fast rules—you might choose the use the same verb in a persuasive list to be more focused and powerful, for example. But they’re good starting places for solid lists. #### THREE: When something's clearly a list, you don't have to tell us it's a list. GOOD: -What can you do with this block? Lots of things! -* Add a quote. -* Highlight a link you love. -* Display multiple images. +> What can you do with this block? Lots of things! +> * Add a quote. +> * Highlight a link you love. +> * Display multiple images. LESS GOOD: -What can you do with this block? Lots of things! Here are some examples of ways you can use it. -* You can add a quote. -* Highlighting a link you love. -* It displays multiple images. Nice for galleries! +> What can you do with this block? Lots of things! Here are some examples of ways you can use it. +> * You can add a quote. +> * Highlighting a link you love. +> * It displays multiple images. Nice for galleries! Find the balance between being as clear as possible and trusting a user. On one hand, we know that people don’t always read instructions; on the other, redundancy can make the user feel like we think they’re stupid. @@ -124,24 +124,24 @@ Use it to focus readers on the key information in a bulleted list. This is espec “Key information” is, well, key: bold draws the eye, so stick to the most vital piece of information in a given bullet: -What can you do with this block? Lots of things! -* Try adding a quote. Sometimes someone else said things best! -* Use it to highlight a link you love -- sharing links is the currency of the internet. -* Create a gallery that displays multiple images, and show off your best photos. +>What can you do with this block? Lots of things! +> * Try adding a **quote**. Sometimes someone else said things best! +> * Use it to highlight a **link** you love—sharing links is the currency of the internet. +> * Create a **gallery** that displays multiple images, and show off your best photos. On the flipside, bolding too many things creates visual confusion: -What can you do with this block? Lots of things! -* Try adding a quote. Sometimes someone else said things best! -* Use it to highlight a link you love -- sharing links is the currency of the internet. -* Create a gallery that displays multiple images, and show off your best photos. +> **What can you do with this block?** Lots of things! +> * Try adding a **quote**. Sometimes someone else said things best! +> * Use it to highlight a **link** you love—sharing **links** is the currency of the internet. +> * Create a **gallery** that displays **multiple images**, and show off your best **photos**. -When lists are short and basic, don't bother -- bolding just adds busy-ness. +When lists are short and basic, don't bother—bolding just adds busy-ness. -What can you do with this block? Lots of things! -* Add a quote. -* Highlight a link. -* Display multiple images. +> What can you do with this block? Lots of things! +> * Add a **quote**. +> * Highlight a **link**. +> * Display multiple **images**. The lack of words creates its own focus; you don't have to add any more. @@ -149,20 +149,26 @@ The lack of words creates its own focus; you don't have to add any more. Guidelines for writing one-line feature descriptions, or short descriptions to clarify options. #### ONE: Clarity above all! -If the user doesn't understand what using a particular option will result in, it doesn't matter how clever your pun is. Wordplay and idioms are frequently unclear, and easily misunderstood. If you use them at all, they should be as supplemental information -- never to explain the main idea -- and they should be something you’re fairly certain will be understandable to a pretty wide range of people. +If the user doesn't understand what using a particular option will result in, it doesn't matter how clever your pun is. Wordplay and idioms are frequently unclear, and easily misunderstood. If you use them at all, they should be as supplemental information— never to explain the main idea—and they should be something you’re fairly certain will be understandable to a pretty wide range of people. #### TWO: Refer back to section one, and look out for those bulk-adding phrases. -Active voice is typically the better way to go, and cutting out the bulky phrasing is particularly important when you’ve got limited space and you need people to be able to make decisions and act. +Active voice is typically the better way to go, and cutting out the bulky phrasing is particularly important when you’ve got limited space and you need people to be able to make decisions and act. Often you can shorten a UI instruction phrase to be both shorter and clearer: -Another, UI instruction-particular phrase you can often tighten: +> When you click X, Y happens. -“When you click X, Y happens.” > “Click X do to Y.” -“When you click the “settings” button, the pop-up will display the advanced settings that are available.” VS. -“Click “settings” to access the advanced settings.” +vs. + +> Click X to do Y. + +While it can feel like adding the extra words helps walk a user through the product, the extra words just serve to obscure the point being communicated: -We sometimes think that adding the extra words helps the user feel like they’re being walked through the product, but most of the time, they really are just extra words. +> When you click the “settings” button, the pop-up will display the advanced settings that are available. + +vs. -Similar are “Once you do X…” or “If you want to do X.” Sometimes there are decision points where “If you want to do X” is entirely appropriate and needed because there are different paths the user can take based on their goal, but we often use it to mean “Here is a thing you can do,” which you can express more simply with something like “To do X…” +> Click “settings” to access the advanced settings. + +Similar phrases are “Once you do X…” or “If you want to do X…” Sometimes there are decision points where “If you want to do X…” is entirely appropriate because there are different paths the user can take based on their goal. But, we often use it to mean “Here is a thing you can do,” which you can express more simply as: “To do X…” #### THREE: Be specific. When an action depends on the user having completed some prior action, be specific about what’s required and what happens next. We often default to “when you’re ready.” @@ -170,50 +176,50 @@ Ready for what? Be specific about whatever the prerequisites are. “When you’re ready” can mean: -“When you want to add another block” -“When you’re satisfied with your post” -“After you’ve finished proofreading your post” -“When you’d like to add a featured image” -“After you’ve configured all the settings” +* When you want to add another block” +* When you’re satisfied with your post” +* After you’ve finished proofreading your post” +* When you’d like to add a featured image” +* After you’ve configured all the settings” And when something means everything, it actually means nothing. The more specific instructions are, the more useful they are, and the more trust the person following them will have in the product. #### FOUR: This is still writing. It should have personality and interest. -Clarity above all, yes, and space is often limited here -- but UI text can still be interesting to read. +Clarity above all, yes, and space is often limited here—but UI text can still be interesting to read. Single lines of description can still be complete sentences. -List. Numbered or bulleted. +> List. Numbered or bulleted. vs. -Add a list, either numbered or bulleted. +> Add a list, either numbered or bulleted. You can still use contractions. -Add a list. We will provide formatting options. +> Add a list. We will provide formatting options. vs. -Add a bulleted list -- we’ll give you some formatting options. +> Add a bulleted list—we’ll give you some formatting options. -You can still use punctuation -- em dashes, colons, semicolons -- to control the flow of your words, link ideas, and create pauses. +You can still use punctuation—em dashes, colons, semicolons—to control the flow of your words, link ideas, and create pauses. -List. Numbered or bulleted. +> List. Numbered or bulleted. vs. -Add a list -- numbered or bulleted. Your choice! +> Add a list—numbered or bulleted. Your choice! You can still try to avoid jargon in favor of plain language. -Add unordered or ordered list. +> Add unordered or ordered list. vs. -Add a list, either numbered or bulleted. +> Add a list, either numbered or bulleted. -(And because it bears repeating: no wordplay, please! “Personality” can -- and in UI instructions, should -- be subtle. We’re talking about text that sounds like it was said by a human being, not forced attempts at whimsy.) +(And because it bears repeating: no wordplay, please! “Personality” can—and in UI instructions, should—be subtle. We’re talking about text that sounds like it was said by a human being, not forced attempts at whimsy.) #### FIVE: Pay attention to capitalization. When it comes to headlines and subheads, there are two ways to capitalize: @@ -224,24 +230,27 @@ In sentence case, only the first letter of the line is capitalized Feature names and dashboard sections typically use title case (think “Site Stats” or “Recently Published”), whereas feature labels typically use sentence case (like “Show buttons on” or “Comment Likes are,” where “Likes” is capitalized because it’s the feature name, but the overall label is using sentence case). -When you’re looking at a full page of UI copy, make sure you’re being consistent across all of it, and that all similar kinds of copy -- headlines, tooltips, buttons, etc. -- are using the same case. +When you’re looking at a full page of UI copy, make sure you’re being consistent across all of it, and that all similar kinds of copy—headlines, tooltips, buttons, etc.—are using the same case. ## Error Messaging Guidelines for writing error messages that are understandable and useful. -#### ONE: Don’t ignore voice/tone in error messaging -- they communicate a lot. +#### ONE: Don’t ignore voice/tone in error messaging—they communicate a lot. Voice and tone can say as much as the individual words themselves. Error messages have to convey a significant amount of information and usually need to be fairly short, but try not to sacrifice tone, or to go too far in either a negative or positive direction. -Let’s say someone’s trying to publish a post, but their user role doesn’t allow them to do that. Here are some ways we could -- but should not -- communicate that: +Let’s say someone’s trying to publish a post, but their user role doesn’t allow them to do that. Here are some ways we could—but should not—communicate that: > Your user role is incorrect. -> Here, we sound distant and uncaring. + +Here, we sound distant and uncaring. > Stop! You do not have permission to do this. -> Here, we sound unnecessarily alarmist and stern. + +Here, we sound unnecessarily alarmist and stern. > Oopsie, we can’t let you do that! -> Here, we sound too cute. + +Here, we sound too cute. We can stay direct, positive, and friendly, even in error messages. How? With tips two through four! diff --git a/docs/design.md b/docs/contributors/design.md similarity index 100% rename from docs/design.md rename to docs/contributors/design.md diff --git a/docs/grammar.md b/docs/contributors/grammar.md similarity index 100% rename from docs/grammar.md rename to docs/contributors/grammar.md diff --git a/docs/reference/history.md b/docs/contributors/history.md similarity index 100% rename from docs/reference/history.md rename to docs/contributors/history.md diff --git a/docs/outreach.md b/docs/contributors/outreach.md similarity index 100% rename from docs/outreach.md rename to docs/contributors/outreach.md diff --git a/docs/outreach/articles.md b/docs/contributors/outreach/articles.md similarity index 100% rename from docs/outreach/articles.md rename to docs/contributors/outreach/articles.md diff --git a/docs/outreach/meetups.md b/docs/contributors/outreach/meetups.md similarity index 100% rename from docs/outreach/meetups.md rename to docs/contributors/outreach/meetups.md diff --git a/docs/outreach/resources.md b/docs/contributors/outreach/resources.md similarity index 100% rename from docs/outreach/resources.md rename to docs/contributors/outreach/resources.md diff --git a/docs/outreach/talks.md b/docs/contributors/outreach/talks.md similarity index 100% rename from docs/outreach/talks.md rename to docs/contributors/outreach/talks.md diff --git a/docs/principles.md b/docs/contributors/principles.md similarity index 100% rename from docs/principles.md rename to docs/contributors/principles.md diff --git a/docs/principles/the-block.md b/docs/contributors/principles/the-block.md similarity index 100% rename from docs/principles/the-block.md rename to docs/contributors/principles/the-block.md diff --git a/docs/contributors/readme.md b/docs/contributors/readme.md new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/docs/reference.md b/docs/contributors/reference.md similarity index 67% rename from docs/reference.md rename to docs/contributors/reference.md index 78ba03435973b2..2a4714d5e75a23 100644 --- a/docs/reference.md +++ b/docs/contributors/reference.md @@ -1,9 +1,9 @@ # Reference -- [Glossary](../docs/reference/glossary.md) -- [Coding Guidelines](../docs/reference/coding-guidelines.md) -- [Testing Overview](../docs/reference/testing-overview.md) -- [Frequently Asked Questions](../docs/reference/faq.md) +- [Glossary](../../docs/designers-developers/glossary.md) +- [Coding Guidelines](../../docs/contributors/coding-guidelines.md) +- [Testing Overview](../../docs/contributors/testing-overview.md) +- [Frequently Asked Questions](../../docs/designers-developers/faq.md) ## Logo Gutenberg Logo diff --git a/docs/reference/release-screenshot.png b/docs/contributors/release-screenshot.png similarity index 100% rename from docs/reference/release-screenshot.png rename to docs/contributors/release-screenshot.png diff --git a/docs/reference/release.md b/docs/contributors/release.md similarity index 100% rename from docs/reference/release.md rename to docs/contributors/release.md diff --git a/docs/reference/repository-management.md b/docs/contributors/repository-management.md similarity index 100% rename from docs/reference/repository-management.md rename to docs/contributors/repository-management.md diff --git a/docs/reference/scripts.md b/docs/contributors/scripts.md similarity index 100% rename from docs/reference/scripts.md rename to docs/contributors/scripts.md diff --git a/docs/reference/testing-overview.md b/docs/contributors/testing-overview.md similarity index 100% rename from docs/reference/testing-overview.md rename to docs/contributors/testing-overview.md diff --git a/docs/data/README.md b/docs/data/README.md deleted file mode 100644 index ac44230651976e..00000000000000 --- a/docs/data/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Data Module Reference - - - [**core**: WordPress Core Data](../../docs/data/data-core.md) - - [**core/annotations**: Annotations](../../docs/data/data-core-annotations.md) - - [**core/blocks**: Block Types Data](../../docs/data/data-core-blocks.md) - - [**core/editor**: The Editor’s Data](../../docs/data/data-core-editor.md) - - [**core/edit-post**: The Editor’s UI Data](../../docs/data/data-core-edit-post.md) - - [**core/notices**: Notices Data](../../docs/data/data-core-notices.md) - - [**core/nux**: The NUX (New User Experience) Data](../../docs/data/data-core-nux.md) - - [**core/viewport**: The Viewport Data](../../docs/data/data-core-viewport.md) \ No newline at end of file diff --git a/docs/designers-developers/designers/README.md b/docs/designers-developers/designers/README.md new file mode 100644 index 00000000000000..362cf794885f20 --- /dev/null +++ b/docs/designers-developers/designers/README.md @@ -0,0 +1,3 @@ +# Designer Documentation + +For those designing blocks and other Block Editor integrations, this documentation will provide resources for creating beautiful and intuitive layouts. diff --git a/docs/design/advanced-settings-do.png b/docs/designers-developers/designers/assets/advanced-settings-do.png similarity index 100% rename from docs/design/advanced-settings-do.png rename to docs/designers-developers/designers/assets/advanced-settings-do.png diff --git a/docs/design/block-controls-do.png b/docs/designers-developers/designers/assets/block-controls-do.png similarity index 100% rename from docs/design/block-controls-do.png rename to docs/designers-developers/designers/assets/block-controls-do.png diff --git a/docs/design/block-controls-dont.png b/docs/designers-developers/designers/assets/block-controls-dont.png similarity index 100% rename from docs/design/block-controls-dont.png rename to docs/designers-developers/designers/assets/block-controls-dont.png diff --git a/docs/design/block-descriptions-do.png b/docs/designers-developers/designers/assets/block-descriptions-do.png similarity index 100% rename from docs/design/block-descriptions-do.png rename to docs/designers-developers/designers/assets/block-descriptions-do.png diff --git a/docs/design/block-descriptions-dont.png b/docs/designers-developers/designers/assets/block-descriptions-dont.png similarity index 100% rename from docs/design/block-descriptions-dont.png rename to docs/designers-developers/designers/assets/block-descriptions-dont.png diff --git a/docs/design/blocks-do.png b/docs/designers-developers/designers/assets/blocks-do.png similarity index 100% rename from docs/design/blocks-do.png rename to docs/designers-developers/designers/assets/blocks-do.png diff --git a/docs/design/blocks-dont.png b/docs/designers-developers/designers/assets/blocks-dont.png similarity index 100% rename from docs/design/blocks-dont.png rename to docs/designers-developers/designers/assets/blocks-dont.png diff --git a/docs/design/placeholder-do.png b/docs/designers-developers/designers/assets/placeholder-do.png similarity index 100% rename from docs/design/placeholder-do.png rename to docs/designers-developers/designers/assets/placeholder-do.png diff --git a/docs/design/placeholder-dont.png b/docs/designers-developers/designers/assets/placeholder-dont.png similarity index 100% rename from docs/design/placeholder-dont.png rename to docs/designers-developers/designers/assets/placeholder-dont.png diff --git a/docs/design/block-design.md b/docs/designers-developers/designers/block-design.md similarity index 91% rename from docs/design/block-design.md rename to docs/designers-developers/designers/block-design.md index 7849cf693b9303..ae6abaf4c8a37f 100644 --- a/docs/design/block-design.md +++ b/docs/designers-developers/designers/block-design.md @@ -27,11 +27,11 @@ A block should have a straightforward, short name so users can easily find it in Blocks should have an identifying icon, ideally using a single color. Try to avoid using the same icon used by an existing block. The core block icons are based on [Material Design Icons](https://material.io/tools/icons/). Look to that icon set, or to [Dashicons](https://developer.wordpress.org/resource/dashicons/) for style inspiration. -![A screenshot of the Block Library with concise block names](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/blocks-do.png) +![A screenshot of the Block Library with concise block names](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/blocks-do.png) **Do:** -Use conise block names. +Use concise block names. -![A screenshot of the Block Library with long, multi-line block names](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/blocks-dont.png) +![A screenshot of the Block Library with long, multi-line block names](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/blocks-dont.png) **Don't:** Avoid long, multi-line block names. @@ -39,11 +39,11 @@ Avoid long, multi-line block names. Every block should include a description in the “Block” tab of the Settings sidebar. This description should explain your block's function clearly. Keep it to a single sentence. -![A screenshot of a short block description](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/block-descriptions-do.png) +![A screenshot of a short block description](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-descriptions-do.png) **Do:** Use a short, simple, block description. -![A screenshot of a long block description that includes branding](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/block-descriptions-dont.png) +![A screenshot of a long block description that includes branding](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-descriptions-dont.png) **Don't:** Avoid long descriptions and branding. @@ -51,11 +51,11 @@ Avoid long descriptions and branding. If your block requires a user to configure some options before you can display it, you should provide an instructive placeholder state. -![A screenshot of the Gallery block's placeholder](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/placeholder-do.png) +![A screenshot of the Gallery block's placeholder](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/placeholder-do.png) **Do:** Provide an instructive placeholder state. -![An example Gallery block placeholder but with intense, distracting colors and no instructions](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/placeholder-dont.png) +![An example Gallery block placeholder but with intense, distracting colors and no instructions](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/placeholder-dont.png) **Don't:** Avoid branding and relying on the title alone to convey instructions. @@ -65,11 +65,11 @@ When unselected, your block should preview its content as closely to the front-e When selected, your block may surface additional options like input fields or buttons to configure the block directly, especially when they are necessary for basic operation. -![A Google Maps block with inline, always-accessible controls required for the block to function](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/block-controls-do.png) +![A Google Maps block with inline, always-accessible controls required for the block to function](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-controls-do.png) **Do:** For controls that are essential the the operation of the block, provide them directly in inside the block edit view. -![A Google Maps block with essential controls moved to the sidebar where they can be contextually hidden](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/block-controls-dont.png) +![A Google Maps block with essential controls moved to the sidebar where they can be contextually hidden](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/block-controls-dont.png) **Don't:** Do not put controls that are essential to the block in the sidebar, or the block will appear non-functional to mobile users, or desktop users who have dismissed the sidebar. @@ -77,7 +77,7 @@ Do not put controls that are essential to the block in the sidebar, or the block The “Block” tab of the Settings Sidebar can contain additional block options and configuration. Keep in mind that a user can dismiss the sidebar and never use it. You should not put critical options in the Sidebar. -![A screenshot of the paragraph block's advanced settings in the sidebar](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/advanced-settings-do.png) +![A screenshot of the paragraph block's advanced settings in the sidebar](https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/assets/advanced-settings-do.png) **Do:** Because the Drop Cap feature is not necessary for the basic operation of the block, you can put it ub the Block tab as optional configuration. diff --git a/docs/design/design-patterns.md b/docs/designers-developers/designers/design-patterns.md similarity index 100% rename from docs/design/design-patterns.md rename to docs/designers-developers/designers/design-patterns.md diff --git a/docs/design/design-resources.md b/docs/designers-developers/designers/design-resources.md similarity index 100% rename from docs/design/design-resources.md rename to docs/designers-developers/designers/design-resources.md diff --git a/docs/designers-developers/developers/README.md b/docs/designers-developers/developers/README.md new file mode 100644 index 00000000000000..e88dd2cd6b2ea1 --- /dev/null +++ b/docs/designers-developers/developers/README.md @@ -0,0 +1,45 @@ +# Developer Documentation + +Gutenberg is highly flexible, like most of WordPress. You can build custom blocks, modify the editor's appearance, add special plugins, and much more. + +## Creating Blocks + +Gutenberg is about blocks, and the main extensibility API of Gutenberg is the Block API. It allows you to create your own static blocks, dynamic blocks rendered on the server and also blocks capable of saving data to Post Meta for more structured content. + +If you want to learn more about block creation, the [Blocks Tutorial](../../../docs/designers-developers/developers/tutorials/block-tutorial/intro.md) is the best place to start. + +## Extending Blocks + +It is also possible to modify the behavior of existing blocks or even remove them completely using filters. + +Learn more in the [Block Filters](../../../docs/designers-developers/developers/reference/hooks/block-filters.md) section. + +## Extending the Editor UI + +Extending the editor UI can be accomplished with the `registerPlugin` API, allowing you to define all your plugin's UI elements in one place. + +Refer to the [Plugins](https://github.com/WordPress/gutenberg/blob/master/packages/plugins/README.md) and [Edit Post](https://github.com/WordPress/gutenberg/blob/master/packages/edit-post/README.md) section for more information. + +You can also filter certain aspects of the editor; this is documented on the [Editor Filters](../../../docs/designers-developers/developers/reference/hooks/editor-filters.md) page. + +## Meta Boxes + +**Porting PHP meta boxes to blocks and Gutenberg plugins is highly encouraged!** + +Discover how [Meta Box](../../../docs/designers-developers/developers/backwards-compatibility/meta-box.md) support works in Gutenberg. + +## Theme Support + +By default, blocks provide their styles to enable basic support for blocks in themes without any change. Themes can add/override these styles, or rely on defaults. + +There are some advanced block features which require opt-in support in the theme. See [theme support](../../../docs/designers-developers/developers/themes/theme-support.md). + +## Autocomplete + +Autocompleters within blocks may be extended and overridden. Learn more about the [autocomplete](../../../docs/designers-developers/developers/filters/autocomplete-filters.md) filters. + +## Block Parsing and Serialization + +Posts in the editor move through a couple of different stages between being stored in `post_content` and appearing in the editor. Since the blocks themselves are data structures that live in memory it takes a parsing and serialization step to transform out from and into the stored format in the database. + +Customizing the parser is an advanced topic that you can learn more about in the [Extending the Parser](../../../docs/designers-developers/developers/filters/parser-filters.md) section. diff --git a/docs/designers-developers/developers/backwards-compatibility/README.md b/docs/designers-developers/developers/backwards-compatibility/README.md new file mode 100644 index 00000000000000..bd453cba0e56a4 --- /dev/null +++ b/docs/designers-developers/developers/backwards-compatibility/README.md @@ -0,0 +1 @@ +# Backwards Compatibility diff --git a/docs/reference/deprecated.md b/docs/designers-developers/developers/backwards-compatibility/deprecations.md similarity index 99% rename from docs/reference/deprecated.md rename to docs/designers-developers/developers/backwards-compatibility/deprecations.md index 13efe9f8940bdf..804f55c0a2c19a 100644 --- a/docs/reference/deprecated.md +++ b/docs/designers-developers/developers/backwards-compatibility/deprecations.md @@ -1,3 +1,5 @@ +# Deprecations + Gutenberg's deprecation policy is intended to support backwards-compatibility for releases, when possible. The current deprecations are listed below and are grouped by _the version at which they will be removed completely_. If your plugin depends on these behaviors, you must update to the recommended alternative before the noted version. ## 4.5.0 diff --git a/docs/extensibility/meta-box.md b/docs/designers-developers/developers/backwards-compatibility/meta-box.md similarity index 100% rename from docs/extensibility/meta-box.md rename to docs/designers-developers/developers/backwards-compatibility/meta-box.md diff --git a/docs/designers-developers/developers/block-api/README.md b/docs/designers-developers/developers/block-api/README.md new file mode 100644 index 00000000000000..1301449ecdf2c1 --- /dev/null +++ b/docs/designers-developers/developers/block-api/README.md @@ -0,0 +1,11 @@ +# Block API Reference + +Blocks are the fundamental element of the Gutenberg editor. They are the primary way in which plugins and themes can register their own functionality and extend the capabilities of the editor. + +## Registering a block + +All blocks must be registered before they can be used in the editor. You can learn about block registration, and the available options, in the [block registration](block-api/block-registration.md) documentation. + +## Block `edit` and `save` + +The `edit` and `save` functions define the editor interface with which a user would interact, and the markup to be serialized back when a post is saved. They are the heart of how a block operates, so they are [covered separately](block-api/block-edit-save.md). diff --git a/docs/extensibility/annotations.md b/docs/designers-developers/developers/block-api/block-annotations.md similarity index 100% rename from docs/extensibility/annotations.md rename to docs/designers-developers/developers/block-api/block-annotations.md diff --git a/docs/block-api/attributes.md b/docs/designers-developers/developers/block-api/block-attributes.md similarity index 100% rename from docs/block-api/attributes.md rename to docs/designers-developers/developers/block-api/block-attributes.md diff --git a/docs/block-api/deprecated-blocks.md b/docs/designers-developers/developers/block-api/block-deprecation.md similarity index 92% rename from docs/block-api/deprecated-blocks.md rename to docs/designers-developers/developers/block-api/block-deprecation.md index 9f1e368aed6826..3fd500c1645417 100644 --- a/docs/block-api/deprecated-blocks.md +++ b/docs/designers-developers/developers/block-api/block-deprecation.md @@ -9,9 +9,9 @@ A block can have several deprecated versions. A deprecation will be tried if a p Deprecations are defined on a block type as its `deprecated` property, an array of deprecation objects where each object takes the form: -- `attributes` (Object): The [attributes definition](../docs/block-api/attributes.md) of the deprecated form of the block. -- `support` (Object): The [supports definition](../docs/block-api.md) of the deprecated form of the block. -- `save` (Function): The [save implementation](../docs/block-api/block-edit-save.md) of the deprecated form of the block. +- `attributes` (Object): The [attributes definition](../../../../docs/designers-developers/developers/block-api/block-attributes.md) of the deprecated form of the block. +- `support` (Object): The [supports definition](../../../../docs/designers-developers/developers/block-api/block-registration.md) of the deprecated form of the block. +- `save` (Function): The [save implementation](../../../../docs/designers-developers/developers/block-api/block-edit-save.md) of the deprecated form of the block. - `migrate` (Function, Optional): A function which, given the attributes and inner blocks of the parsed block, is expected to return either the attributes compatible with the deprecated block, or a tuple array of `[ attributes, innerBlocks ]`. - `isEligible` (Function, Optional): A function which, given the attributes and inner blocks of the parsed block, returns true if the deprecation can handle the block migration. This is particularly useful in cases where a block is technically valid even once deprecated, and requires updates to its attributes or inner blocks. diff --git a/docs/block-api/block-edit-save.md b/docs/designers-developers/developers/block-api/block-edit-save.md similarity index 87% rename from docs/block-api/block-edit-save.md rename to docs/designers-developers/developers/block-api/block-edit-save.md index 4d4ee948e3ba4e..c6f4d603cc3085 100644 --- a/docs/block-api/block-edit-save.md +++ b/docs/designers-developers/developers/block-api/block-edit-save.md @@ -103,7 +103,7 @@ For most blocks, the return value of `save` should be an [instance of WordPress _Note:_ While it is possible to return a string value from `save`, it _will be escaped_. If the string includes HTML markup, the markup will be shown on the front of the site verbatim, not as the equivalent HTML node content. If you must return raw HTML from `save`, use `wp.element.RawHTML`. As the name implies, this is prone to [cross-site scripting](https://en.wikipedia.org/wiki/Cross-site_scripting) and therefore is discouraged in favor of a WordPress Element hierarchy whenever possible. -For [dynamic blocks](../../docs/blocks/creating-dynamic-blocks.md), the return value of `save` could either represent a cached copy of the block's content to be shown only in case the plugin implementing the block is ever disabled. Alternatively, return a `null` (empty) value to save no markup in post content for the dynamic block, instead deferring this to always be calculated when the block is shown on the front of the site. +For [dynamic blocks](../../../../docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md), the return value of `save` could either represent a cached copy of the block's content to be shown only in case the plugin implementing the block is ever disabled. Alternatively, return a `null` (empty) value to save no markup in post content for the dynamic block, instead deferring this to always be calculated when the block is shown on the front of the site. ### attributes @@ -153,10 +153,10 @@ The two most common sources of block invalidations are: Before starting to debug, be sure to familiarize yourself with the validation step described above documenting the process for detecting whether a block is invalid. A block is invalid if its regenerated markup does not match what is saved in post content, so often this can be caused by the attributes of a block being parsed incorrectly from the saved content. -If you're using [attribute sources](../../docs/block-api/attributes.md), be sure that attributes sourced from markup are saved exactly as you expect, and in the correct type (usually a `'string'` or `'number'`). +If you're using [attribute sources](../../../../docs/designers-developers/developers/block-api/block-attributes.md), be sure that attributes sourced from markup are saved exactly as you expect, and in the correct type (usually a `'string'` or `'number'`). When a block is detected as invalid, a warning will be logged into your browser's developer tools console. The warning will include specific details about the exact point at which a difference in markup occurred. Be sure to look closely at any differences in the expected and actual markups to see where problems are occurring. **I've changed my block's `save` behavior and old content now includes invalid blocks. How can I fix this?** -Refer to the guide on [Deprecated Blocks](../../docs/block-api/deprecated-blocks.md) to learn more about how to accommodate legacy content in intentional markup changes. +Refer to the guide on [Deprecated Blocks](../../../../docs/designers-developers/developers/block-api/block-deprecations.md) to learn more about how to accommodate legacy content in intentional markup changes. diff --git a/docs/block-api.md b/docs/designers-developers/developers/block-api/block-registration.md similarity index 94% rename from docs/block-api.md rename to docs/designers-developers/developers/block-api/block-registration.md index 01b2295b46de0b..9738ac482353b7 100644 --- a/docs/block-api.md +++ b/docs/designers-developers/developers/block-api/block-registration.md @@ -1,8 +1,6 @@ -# Block API +# Block Registration -Blocks are the fundamental element of the Gutenberg editor. They are the primary way in which plugins and themes can register their own functionality and extend the capabilities of the editor. This document covers the main properties of block registration. - -## Register Block Type +## `register_block_type` * **Type:** `Function` @@ -124,18 +122,18 @@ Block styles can be used to provide alternative styles to block. It works by add // Register block styles. styles: [ // Mark style as default. - { - name: 'default', - label: __( 'Rounded' ), - isDefault: true + { + name: 'default', + label: __( 'Rounded' ), + isDefault: true }, - { - name: 'outline', - label: __( 'Outline' ) + { + name: 'outline', + label: __( 'Outline' ) }, - { - name: 'squared', - label: __( 'Squared' ) + { + name: 'squared', + label: __( 'Squared' ) }, ], ``` @@ -413,6 +411,43 @@ transforms: { ``` {% end %} +A prefix transform is a transform that will be applied if the user prefixes some text in e.g. the paragraph block with a given pattern and a trailing space. + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'prefix', + prefix: '?', + transform: function( content ) { + return createBlock( 'my-plugin/question', { + content, + } ); + }, + }, + ] +} +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'prefix', + prefix: '?', + transform( content ) { + return createBlock( 'my-plugin/question', { + content, + } ); + }, + }, + ] +} +``` +{% end %} + #### parent (optional) @@ -429,6 +464,8 @@ parent: [ 'core/columns' ], #### supports (optional) +*Some [block supports](#supports-optional) — for example, `anchor` or `className` — apply their attributes by adding additional props on the element returned by `save`. This will work automatically for default HTML tag elements (`div`, etc). However, if the return value of your `save` is a custom component element, you will need to ensure that your custom component handles these props in order for the attributes to be persisted.* + * **Type:** `Object` Optional block extended support features. The following options are supported: @@ -511,8 +548,4 @@ By default all blocks can be converted to a reusable block. If supports reusable // Don't allow the block to be converted into a reusable block. reusable: false, ``` -## Edit and Save -The `edit` and `save` functions define the editor interface with which a user would interact, and the markup to be serialized back when a post is saved. They are the heart of how a block operates, so they are [covered separately](../docs/block-api/block-edit-save.md). - -*Some [block supports](#supports-optional) — for example, `anchor` or `className` — apply their attributes by adding additional props on the element returned by `save`. This will work automatically for default HTML tag elements (`div`, etc). However, if the return value of your `save` is a custom component element, you will need to ensure that your custom component handles these props in order for the attributes to be persisted.* diff --git a/docs/templates.md b/docs/designers-developers/developers/block-api/block-templates.md similarity index 100% rename from docs/templates.md rename to docs/designers-developers/developers/block-api/block-templates.md diff --git a/docs/designers-developers/developers/data/README.md b/docs/designers-developers/developers/data/README.md new file mode 100644 index 00000000000000..5233753c1445e5 --- /dev/null +++ b/docs/designers-developers/developers/data/README.md @@ -0,0 +1,10 @@ +# Data Module Reference + + - [**core**: WordPress Core Data](../../docs/designers-developers/developers/data/data-core.md) + - [**core/annotations**: Annotations](../../docs/designers-developers/developers/data/data-core-annotations.md) + - [**core/blocks**: Block Types Data](../../docs/designers-developers/developers/data/data-core-blocks.md) + - [**core/editor**: The Editor’s Data](../../docs/designers-developers/developers/data/data-core-editor.md) + - [**core/edit-post**: The Editor’s UI Data](../../docs/designers-developers/developers/data/data-core-edit-post.md) + - [**core/notices**: Notices Data](../../docs/designers-developers/developers/data/data-core-notices.md) + - [**core/nux**: The NUX (New User Experience) Data](../../docs/designers-developers/developers/data/data-core-nux.md) + - [**core/viewport**: The Viewport Data](../../docs/designers-developers/developers/data/data-core-viewport.md) \ No newline at end of file diff --git a/docs/data/data-core-annotations.md b/docs/designers-developers/developers/data/data-core-annotations.md similarity index 91% rename from docs/data/data-core-annotations.md rename to docs/designers-developers/developers/data/data-core-annotations.md index f4f7d8cb5ff072..6600938219e0e9 100644 --- a/docs/data/data-core-annotations.md +++ b/docs/designers-developers/developers/data/data-core-annotations.md @@ -75,6 +75,16 @@ Removes an annotation with a specific ID. * annotationId: The annotation to remove. +### __experimentalUpdateAnnotationRange + +Updates the range of an annotation. + +*Parameters* + + * annotationId: ID of the annotation to update. + * start: The start of the new range. + * end: The end of the new range. + ### __experimentalRemoveAnnotationsBySource Removes all annotations of a specific source. diff --git a/docs/data/data-core-blocks.md b/docs/designers-developers/developers/data/data-core-blocks.md similarity index 100% rename from docs/data/data-core-blocks.md rename to docs/designers-developers/developers/data/data-core-blocks.md diff --git a/docs/data/data-core-edit-post.md b/docs/designers-developers/developers/data/data-core-edit-post.md similarity index 92% rename from docs/data/data-core-edit-post.md rename to docs/designers-developers/developers/data/data-core-edit-post.md index 93e658e91d4798..c12cf62a83d633 100644 --- a/docs/data/data-core-edit-post.md +++ b/docs/designers-developers/developers/data/data-core-edit-post.md @@ -89,6 +89,20 @@ Returns true if the publish sidebar is opened. Whether the publish sidebar is open. +### isEditorPanelRemoved + +Returns true if the given panel was programmatically removed, or false otherwise. +All panels are not removed by default. + +*Parameters* + + * state: Global application state. + * panelName: A string that identifies the panel. + +*Returns* + +Whether or not the panel is removed. + ### isEditorPanelEnabled Returns true if the given panel is enabled, or false otherwise. Panels are @@ -301,6 +315,14 @@ Returns an action object used to open or close a panel in the editor. * panelName: A string that identifies the panel to open or close. +### removeEditorPanel + +Returns an action object used to remove a panel from the editor. + +*Parameters* + + * panelName: A string that identifies the panel to remove. + ### toggleFeature Returns an action object used to toggle a feature flag. diff --git a/docs/data/data-core-editor.md b/docs/designers-developers/developers/data/data-core-editor.md similarity index 97% rename from docs/data/data-core-editor.md rename to docs/designers-developers/developers/data/data-core-editor.md index 1517f5b5317cc0..25ea6598990d83 100644 --- a/docs/data/data-core-editor.md +++ b/docs/designers-developers/developers/data/data-core-editor.md @@ -398,6 +398,19 @@ the client ID. Block name. +### isBlockValid + +Returns whether a block is valid or not. + +*Parameters* + + * state: Editor state. + * clientId: Block client ID. + +*Returns* + +Is Valid. + ### getBlock Returns a block given its client ID. This is a parsed copy of the block, @@ -1058,6 +1071,30 @@ Returns true if the post is autosaving, or false otherwise. Whether the post is autosaving. +### isPreviewingPost + +Returns true if the post is being previewed, or false otherwise. + +*Parameters* + + * state: Global application state. + +*Returns* + +Whether the post is being previewed. + +### getEditedPostPreviewLink + +Returns the post preview link + +*Parameters* + + * state: Global application state. + +*Returns* + +Preview Link. + ### getSuggestedPostFormat Returns a suggested post format for the current post, inferred only if there @@ -1142,6 +1179,19 @@ Items are returned ordered descendingly by their 'utility' and 'frecency'. Items that appear in inserter. +### hasInserterItems + +Determines whether there are items to show in the inserter. + +*Parameters* + + * state: Editor state. + * rootClientId: Optional root client ID of block list. + +*Returns* + +Items that appear in inserter. + ### __experimentalGetReusableBlock Returns the reusable block with the given ID. @@ -1602,7 +1652,7 @@ Returns an action object to save the post. *Parameters* * options: Options for the save. - * options.autosave: Perform an autosave if true. + * options.isAutosave: Perform an autosave if true. ### mergeBlocks @@ -1617,6 +1667,10 @@ Returns an action object used in signalling that two blocks should be merged Returns an action object used in signalling that the post should autosave. +*Parameters* + + * options: Extra flags to identify the autosave. + ### redo Returns an action object used in signalling that undo history should @@ -1802,8 +1856,4 @@ Returns an action object used to signal that post saving is unlocked. *Parameters* - * lockName: The lock name. - -### createNotice - -### fetchReusableBlocks \ No newline at end of file + * lockName: The lock name. \ No newline at end of file diff --git a/docs/data/data-core-notices.md b/docs/designers-developers/developers/data/data-core-notices.md similarity index 88% rename from docs/data/data-core-notices.md rename to docs/designers-developers/developers/data/data-core-notices.md index b7ea283f02687f..5ae7f1fb000679 100644 --- a/docs/data/data-core-notices.md +++ b/docs/designers-developers/developers/data/data-core-notices.md @@ -32,6 +32,11 @@ Yields action objects used in signalling that a notice is to be created. * options.isDismissible: Whether the notice can be dismissed by user. Defaults to `true`. + * options.speak: Whether the notice + content should be + announced to screen + readers. Defaults to + `true`. * options.actions: User actions to be presented with notice. diff --git a/docs/data/data-core-nux.md b/docs/designers-developers/developers/data/data-core-nux.md similarity index 100% rename from docs/data/data-core-nux.md rename to docs/designers-developers/developers/data/data-core-nux.md diff --git a/docs/data/data-core-viewport.md b/docs/designers-developers/developers/data/data-core-viewport.md similarity index 100% rename from docs/data/data-core-viewport.md rename to docs/designers-developers/developers/data/data-core-viewport.md diff --git a/docs/data/data-core.md b/docs/designers-developers/developers/data/data-core.md similarity index 89% rename from docs/data/data-core.md rename to docs/designers-developers/developers/data/data-core.md index 5310fe2489c81e..2a98c3bb5d3974 100644 --- a/docs/data/data-core.md +++ b/docs/designers-developers/developers/data/data-core.md @@ -136,6 +136,18 @@ get back from the oEmbed preview API. Is the preview for the URL an oEmbed link fallback. +### hasUploadPermissions + +Return Upload Permissions. + +*Parameters* + + * state: State tree. + +*Returns* + +Upload Permissions. + ## Actions ### receiveUserQuery @@ -193,4 +205,12 @@ Action triggered to save an entity record. * kind: Kind of the received entity. * name: Name of the received entity. - * record: Record to be saved. \ No newline at end of file + * record: Record to be saved. + +### receiveUploadPermissions + +Returns an action object used in signalling that Upload permissions have been received. + +*Parameters* + + * hasUploadPermissions: Does the user have permission to upload files? \ No newline at end of file diff --git a/docs/designers-developers/developers/filters/README.md b/docs/designers-developers/developers/filters/README.md new file mode 100644 index 00000000000000..9715e15885935c --- /dev/null +++ b/docs/designers-developers/developers/filters/README.md @@ -0,0 +1,7 @@ +# Filter Reference + +[Hooks](https://developer.wordpress.org/plugins/hooks/) are a way for one piece of code to interact/modify another piece of code. They provide one way for plugins and themes interact with Gutenberg, but they’re also used extensively by WordPress Core itself. + +There are two types of hooks: [Actions](https://developer.wordpress.org/plugins/hooks/actions/) and [Filters](https://developer.wordpress.org/plugins/hooks/filters/). In addition to PHP actions and filters, Gutenberg also provides a mechanism for registering and executing hooks in JavaScript. This functionality is also available on npm as the [@wordpress/hooks](https://www.npmjs.com/package/@wordpress/hooks) package, for general purpose use. + +You can also learn more about both APIs: [PHP](https://codex.wordpress.org/Plugin_API/) and [JavaScript](https://github.com/WordPress/packages/tree/master/packages/hooks). diff --git a/docs/extensibility/autocomplete.md b/docs/designers-developers/developers/filters/autocomplete-filters.md similarity index 93% rename from docs/extensibility/autocomplete.md rename to docs/designers-developers/developers/filters/autocomplete-filters.md index ee1f2bc945852e..2b9d60476de80a 100644 --- a/docs/extensibility/autocomplete.md +++ b/docs/designers-developers/developers/filters/autocomplete-filters.md @@ -1,7 +1,6 @@ -Autocomplete -============ +# Autocomplete -Gutenberg provides a `editor.Autocomplete.completers` filter for extending and overriding the list of autocompleters used by blocks. +Gutenberg provides an `editor.Autocomplete.completers` filter for extending and overriding the list of autocompleters used by blocks. The `Autocomplete` component found in `@wordpress/editor` applies this filter. The `@wordpress/components` package provides the foundational `Autocomplete` component that does not apply such a filter, but blocks should generally use the component provided by `@wordpress/editor`. diff --git a/docs/extensibility/extending-blocks.md b/docs/designers-developers/developers/filters/block-filters.md similarity index 88% rename from docs/extensibility/extending-blocks.md rename to docs/designers-developers/developers/filters/block-filters.md index d00282e6b4665a..378241356578a0 100644 --- a/docs/extensibility/extending-blocks.md +++ b/docs/designers-developers/developers/filters/block-filters.md @@ -1,8 +1,4 @@ -# Extending Blocks (Experimental) - -[Hooks](https://developer.wordpress.org/plugins/hooks/) are a way for one piece of code to interact/modify another piece of code. They make up the foundation for how plugins and themes interact with Gutenberg, but they’re also used extensively by WordPress Core itself. There are two types of hooks: [Actions](https://developer.wordpress.org/plugins/hooks/actions/) and [Filters](https://developer.wordpress.org/plugins/hooks/filters/). They were initially implemented in PHP, but for the purpose of Gutenberg they were ported to JavaScript and published to npm as [@wordpress/hooks](https://www.npmjs.com/package/@wordpress/hooks) package for general purpose use. You can also learn more about both APIs: [PHP](https://codex.wordpress.org/Plugin_API/) and [JavaScript](https://github.com/WordPress/packages/tree/master/packages/hooks). - -## Modifying Blocks +# Block Filters To modify the behavior of existing blocks, Gutenberg exposes several APIs: @@ -93,7 +89,7 @@ wp.hooks.addFilter( ); ``` -_Note:_ This filter must always be run on every page load, and not in your browser's developer tools console. Otherwise, a [block validation](../../docs/block-api/block-edit-save.md#validation) error will occur the next time the post is edited. This is due to the fact that block validation occurs by verifying that the saved output matches what is stored in the post's content during editor initialization. So, if this filter does not exist when the editor loads, the block will be marked as invalid. +_Note:_ This filter must always be run on every page load, and not in your browser's developer tools console. Otherwise, a [block validation](../../../../docs/designers-developers/developers/block-api/block-edit-save.md#validation) error will occur the next time the post is edited. This is due to the fact that block validation occurs by verifying that the saved output matches what is stored in the post's content during editor initialization. So, if this filter does not exist when the editor loads, the block will be marked as invalid. #### `blocks.getBlockDefaultClassName` @@ -342,4 +338,3 @@ add_filter( 'block_categories', 'my_plugin_block_categories', 10, 2 ); ``` You can also display an icon with your block category by setting an `icon` attribute. The value can be the slug of a [WordPress Dashicon](https://developer.wordpress.org/resource/dashicons/), or a custom `svg` element. - diff --git a/docs/designers-developers/developers/filters/editor-filters.md b/docs/designers-developers/developers/filters/editor-filters.md new file mode 100644 index 00000000000000..1289b8241936ba --- /dev/null +++ b/docs/designers-developers/developers/filters/editor-filters.md @@ -0,0 +1,18 @@ +# Editor Filters (Experimental) + +To modify the behavior of the editor experience, Gutenberg exposes the following Filters: + +### `editor.PostFeaturedImage.imageSize` + +Used to modify the image size displayed in the Post Featured Image component. It defaults to `'post-thumbnail'`, and will fail back to the `full` image size when the specified image size doesn't exist in the media object. It's modeled after the `admin_post_thumbnail_size` filter in the Classic Editor. + +_Example:_ + +```js +var withImageSize = function( size, mediaId, postId ) { + return 'large'; +}; + +wp.hooks.addFilter( 'editor.PostFeaturedImage.imageSize', 'my-plugin/with-image-size', withImageSize ); +``` + diff --git a/docs/extensibility/parser.md b/docs/designers-developers/developers/filters/parser-filters.md similarity index 98% rename from docs/extensibility/parser.md rename to docs/designers-developers/developers/filters/parser-filters.md index 7c1e5bc1be7c21..3acfb2489182db 100644 --- a/docs/extensibility/parser.md +++ b/docs/designers-developers/developers/filters/parser-filters.md @@ -1,4 +1,4 @@ -# Extending the Parser +# Parser Filters When the editor is interacting with blocks, these are stored in memory as data structures comprising a few basic properties and attributes. Upon saving a working post we serialize these data structures into a specific HTML structure and save the resultant string into the `post_content` property of the post in the WordPress database. When we load that post back into the editor we have to make the reverse transformation to build those data structures from the serialized format in HTML. diff --git a/docs/designers-developers/developers/internationalization.md b/docs/designers-developers/developers/internationalization.md new file mode 100644 index 00000000000000..602b7494880855 --- /dev/null +++ b/docs/designers-developers/developers/internationalization.md @@ -0,0 +1,33 @@ +# Internationalization + +## PHP + +WordPress has long offered a number of ways to create translatable strings in PHP. + +### Common methods + +- `__( $string_to_translate, $text_domain )` - translatable string wrapper, denoting translation namespace +- `_e( $string_to_translate, $text_domain )` - transltable string wrapper, with echo to print. +- `esc_html__( $string_to_translate, $text_domain )` - escapes and returns translation +- `esc_html_e( $string_to_translate, $text_domain )` - escapes, translates, and prints +- `_n( $singular, $plural, $number, $text_domain )` - Translatable singular/plural string, using %d to inject changing number digit. + +### More Resources + +- i18n for Developers - Covers numbers in translatable strings, best practices. +- WP-CLI can be used to generate translation files. + +## JavaScript + +Historically, `wp_localize_script()` has been used to put server-side PHP data into a properly-escaped native JavaScript object. + +The new editor introduces a new approach to translating strings for the editor through a new package called `@wordpress/i18n` and a build tool for Babel called `@wordpress/babel-plugin-makepot` to create the necessary translation file (requires use of babel to compile code to extract the i18n methods). + +The new script package is registered with WordPress as `wp-i18n` and should be declared as a dependency during `wp_register_script()` and imported as a global off the Window object as `wp.i18n`. + +### Common methods in wp.i18n (may look similar) + +- `setLocaleData( data: Object, domain: string )` - Create new Jed instance providing translation data for a domain (probably writing this to the DOM in escaped in PHP function). +- `__( stringToTranslate, textDomain )` - translatable string wrapper, denoting translation namespace +- `_n( singular, plural, number, textDomain )` - Translatable singular/plural string, using %d to inject changing number digit. +- `_x( singular, plural, number, textDomain )` - gettext equivalent for translation diff --git a/docs/packages.md b/docs/designers-developers/developers/packages.md similarity index 73% rename from docs/packages.md rename to docs/designers-developers/developers/packages.md index ac767e389d18c9..cde5bf98553aeb 100644 --- a/docs/packages.md +++ b/docs/designers-developers/developers/packages.md @@ -1,4 +1,4 @@ -# packages +# Packages Gutenberg exposes a list of JavaScript packages and tools for WordPress development. @@ -7,5 +7,3 @@ Gutenberg exposes a list of JavaScript packages and tools for WordPress developm JavaScript packages are available as a registered script in WordPress and can be accessed using the `wp` global variable. All the packages are also available on [npm](https://www.npmjs.com/org/wordpress) if you want to bundle them in your code. - -

Code is Poetry.

\ No newline at end of file diff --git a/docs/designers-developers/developers/themes/README.md b/docs/designers-developers/developers/themes/README.md new file mode 100644 index 00000000000000..e2bc67c0599eaa --- /dev/null +++ b/docs/designers-developers/developers/themes/README.md @@ -0,0 +1,5 @@ +# Theming for Gutenberg + +The new editor provides a number of options for theme designers and developers, including theme-defined color settings, font size control, and much more. + +In this section, you'll learn about the ways that themes can customise the editor. diff --git a/docs/extensibility/theme-support.md b/docs/designers-developers/developers/themes/theme-support.md similarity index 88% rename from docs/extensibility/theme-support.md rename to docs/designers-developers/developers/themes/theme-support.md index db0b129608eca2..9c9a5cc63539c5 100644 --- a/docs/extensibility/theme-support.md +++ b/docs/designers-developers/developers/themes/theme-support.md @@ -1,5 +1,15 @@ # Theme Support +The new Blocks include baseline support in all themes, enhancements to opt-in to and the ability to extend and customize. + +There are a few new concepts to consider when building themes: + +- **Editor Color Palette** - A default set of colors is provided, but themes and register their own and optionally lock users into picking from the defined palette. +- **Editor Text Size Palette** - A default set of sizes is provided, but themes and register their own and optionally lock users into picking from preselected sizes. +- **Responsive Embeds** - Themes must opt-in to responsive embeds. +- **Frontend & Editor Styles** - To get the most out of blocks, theme authors will want to make sure Core styles look good and opt-in, or write their own styles to best fit their theme. +- **Dark Mode** - If a Theme is a Dark Theme with a dark background containing light text, the theme author can opt-in to the Dark Mode. + By default, blocks provide their styles to enable basic support for blocks in themes without any change. Themes can add/override these styles, or they can provide no styles at all, and rely fully on what the blocks provide. Some advanced block features require opt-in support in the theme itself as it's difficult for the block to provide these styles, they may require some architecting of the theme itself, in order to work well. @@ -57,7 +67,7 @@ Here's the markup for an `Image` with a caption: ```html
- +
Short image caption.
``` @@ -67,7 +77,7 @@ Here's the markup for a left-floated image: ```html
- +
Short image caption.
@@ -118,13 +128,12 @@ Themes are responsible for creating the classes that apply the colors in differe } ``` -The class name is built appending 'has-', followed by the class name *using* kebab case and ending with the context name. +The class name is built appending 'has-', followed by the class name _using_ kebab case and ending with the context name. ### Block Font Sizes: Blocks may allow the user to configure the font sizes they use, e.g., the paragraph block. Gutenberg provides a default set of font sizes, but a theme can overwrite it and provide its own: - ```php add_theme_support( 'editor-font-sizes', array( array( @@ -157,13 +166,13 @@ add_theme_support( 'editor-font-sizes', array( The font sizes are rendered on the font size picker in the order themes provide them. Themes are responsible for creating the classes that apply the correct font size styles. -The class name is built appending 'has-', followed by the font size name *using* kebab case and ending with `-font-size`. +The class name is built appending 'has-', followed by the font size name _using_ kebab case and ending with `-font-size`. As an example for the regular font size, a theme may provide the following class. ```css .has-regular-font-size { - font-size: 16px; + font-size: 16px; } ``` @@ -262,9 +271,7 @@ add_theme_support( 'wp-block-styles' ); The embed blocks automatically apply styles to embedded content to reflect the aspect ratio of content that is embedded in an iFrame. A block styled with the aspect ratio responsive styles would look like: ```html -
- ... -
+
...
``` To make the content resize and keep its aspect ratio, the `` element needs the `wp-embed-responsive` class. This is not set by default, and requires the theme to opt in to the `responsive-embeds` feature: diff --git a/docs/blocks/applying-styles-with-stylesheets.md b/docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md similarity index 100% rename from docs/blocks/applying-styles-with-stylesheets.md rename to docs/designers-developers/developers/tutorials/block-tutorial/applying-styles-with-stylesheets.md diff --git a/docs/blocks/block-controls-toolbars-and-inspector.md b/docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md similarity index 100% rename from docs/blocks/block-controls-toolbars-and-inspector.md rename to docs/designers-developers/developers/tutorials/block-tutorial/block-controls-toolbars-and-inspector.md diff --git a/docs/blocks/creating-dynamic-blocks.md b/docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md similarity index 100% rename from docs/blocks/creating-dynamic-blocks.md rename to docs/designers-developers/developers/tutorials/block-tutorial/creating-dynamic-blocks.md diff --git a/docs/blocks/generate-blocks-with-wp-cli.md b/docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md similarity index 100% rename from docs/blocks/generate-blocks-with-wp-cli.md rename to docs/designers-developers/developers/tutorials/block-tutorial/generate-blocks-with-wp-cli.md diff --git a/docs/blocks/inspector.png b/docs/designers-developers/developers/tutorials/block-tutorial/inspector.png similarity index 100% rename from docs/blocks/inspector.png rename to docs/designers-developers/developers/tutorials/block-tutorial/inspector.png diff --git a/docs/blocks.md b/docs/designers-developers/developers/tutorials/block-tutorial/intro.md similarity index 100% rename from docs/blocks.md rename to docs/designers-developers/developers/tutorials/block-tutorial/intro.md diff --git a/docs/blocks/introducing-attributes-and-editable-fields.md b/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md similarity index 96% rename from docs/blocks/introducing-attributes-and-editable-fields.md rename to docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md index 7f96bd78986798..3a993ce1c2b71a 100644 --- a/docs/blocks/introducing-attributes-and-editable-fields.md +++ b/docs/designers-developers/developers/tutorials/block-tutorial/introducing-attributes-and-editable-fields.md @@ -110,7 +110,7 @@ registerBlockType( 'gutenberg-boilerplate-esnext/hello-world-step-03', { ``` {% end %} -When registering a new block type, the `attributes` property describes the shape of the attributes object you'd like to receive in the `edit` and `save` functions. Each value is a [source function](../../docs/block-api/attributes.md) to find the desired value from the markup of the block. +When registering a new block type, the `attributes` property describes the shape of the attributes object you'd like to receive in the `edit` and `save` functions. Each value is a [source function](../../../../../docs/designers-developers/developers/block-api/block-attributes.md) to find the desired value from the markup of the block. In the code snippet above, when loading the editor, we will extract the `content` value as the HTML of the paragraph element in the saved post's markup. diff --git a/docs/blocks/writing-your-first-block-type.md b/docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md similarity index 100% rename from docs/blocks/writing-your-first-block-type.md rename to docs/designers-developers/developers/tutorials/block-tutorial/writing-your-first-block-type.md diff --git a/docs/reference/faq.md b/docs/designers-developers/faq.md similarity index 97% rename from docs/reference/faq.md rename to docs/designers-developers/faq.md index 740c641f7914e5..547395acb1eb23 100644 --- a/docs/reference/faq.md +++ b/docs/designers-developers/faq.md @@ -251,7 +251,7 @@ Our [list of supported browsers can be found in the Make WordPress handbook](htt ## How do I make my own block? -The API for creating blocks is a crucial aspect of the project. We are working on improved documentation and tutorials. Check out the [Creating Block Types](../../docs/blocks.md) section to get started. +The API for creating blocks is a crucial aspect of the project. We are working on improved documentation and tutorials. Check out the [Creating Block Types](../../docs/designers-developers/developers/tutorials/block-tutorial/intro.md) section to get started. ## Does Gutenberg involve editing posts/pages in the front-end? @@ -297,7 +297,7 @@ Blocks will be able to provide base structural CSS styles, and themes can add st Other features, like the new _wide_ and _full-wide_ alignment options, will simply be CSS classes applied to blocks that offer this alignment. We are looking at how a theme can opt in to this feature, for example using `add_theme_support`. -*See:* [Theme Support](../../docs/extensibility/theme-support.md) +*See:* [Theme Support](../../docs/designers-developers/developers/themes/theme-support.md) ## How will editor styles work? @@ -310,7 +310,7 @@ function gutenbergtheme_editor_styles() { add_action( 'enqueue_block_editor_assets', 'gutenbergtheme_editor_styles' ); ``` -*Details:* [Editor Styles](../../docs/extensibility/theme-support.md#editor-styles) +*See:* [Editor Styles](../../docs/designers-developers/developers/themes/theme-support.md#editor-styles) ## Should I be concerned that Gutenberg will make my plugin obsolete? @@ -333,11 +333,13 @@ Custom TinyMCE buttons will still work in the “Classic” block, which is a bl (Gutenberg comes with a new universal inserter tool, which gives you access to every block available, searchable, sorted by recency and categories. This inserter tool levels the playing field for every plugin that adds content to the editor, and provides a single interface to learn how to use.) ## How will shortcodes work in Gutenberg? + Shortcodes will continue to work as they do now. However we see the block as an evolution of the `[shortcode]`. Instead of having to type out code, you can use the universal inserter tray to pick a block and get a richer interface for both configuring the block and previewing it. We would recommend people eventually upgrade their shortcodes to be blocks. ## Should I move shortcodes to content blocks? + We think so. Blocks are designed to be visually representative of the final look, and they will likely become the expected way in which users will discover and insert content in WordPress. ## Will Gutenberg be made properly accessible? @@ -346,19 +348,13 @@ Accessibility is not an afterthought. Not every aspect of Gutenberg is accessibl If you would like to contribute to the accessibility of Gutenberg, we can always use more people to test and contribute. -## Are there any design resources for Gutenberg? - -Yes, primarily in [design principles](../../docs/reference/design-principles.md) - -We are still adding more documentation. - ## How is data stored? I've seen HTML comments, what is their purpose? Our approach—as outlined in [the technical overview introduction](https://make.wordpress.org/core/2017/01/17/editor-technical-overview/)—is to augment the existing data format in a way that doesn’t break the decade-and-a-half-fabric of content WordPress provides. In other terms, this optimizes for a format that prioritizes human readability (the HTML document of the web) and easy-to-render-anywhere over a machine convenient file (JSON in post-meta) that benefits the editing context primarily. This also [gives us the flexibility](https://github.com/WordPress/gutenberg/issues/1516) to store those blocks that are inherently separate from the content stream (reusable pieces like widgets or small post type elements) elsewhere, and just keep token references for their placement. -We suggest you look at the [language of Gutenberg](../../docs/language.md) to learn more about how this aspect of the project works. +We suggest you look at the [Gutenberg key concepts](../../docs/designers-developers/key-concepts.md) to learn more about how this aspect of the project works. ## How can I parse the post content back out into blocks in PHP or JS? In JS: diff --git a/docs/reference/glossary.md b/docs/designers-developers/glossary.md similarity index 65% rename from docs/reference/glossary.md rename to docs/designers-developers/glossary.md index f7818d9a785c23..afa574179db276 100644 --- a/docs/reference/glossary.md +++ b/docs/designers-developers/glossary.md @@ -1,15 +1,21 @@ # Glossary -- __Attribute sources__: An object describing the attributes shape of a block. The keys can be named as most appropriate to describe the state of a block type. The value for each key is a function which describes the strategy by which the attribute value should be extracted from the content of a saved post's content. When processed, a new object is created, taking the form of the keys defined in the attribute sources, where each value is the result of the attribute source function. -- __Attributes__: The object representation of the current state of a block in post content. When loading a saved post, this is determined by the attribute sources for the block type. These values can change over time during an editing session when the user modifies a block, and are used when determining how to serialize the block. -- __Block__: The abstract term used to describe units of markup that, composed together, form the content or layout of a webpage. The idea combines concepts of what in WordPress today we achieve with shortcodes, custom HTML, and embed discovery into a single consistent API and user experience. -- __Block name__: A unique identifier for a block type, consisting of a plugin-specific namespace and a short label describing the block's intent. e.g. `core/image` -- __Block type__: In contrast with the blocks composing a particular post, a block type describes the blueprint by which any block of that type should behave. So while there may be many images within a post, each behaves consistent with a unified image block type definition. -- __Dynamic block__: A type of block where the content of which may change and cannot be determined at the time of saving a post, instead calculated any time the post is shown on the front of a site. These blocks may save fallback content or no content at all in their JavaScript implementation, instead deferring to a PHP block implementation for runtime rendering. -- __RichText__: A common component enabling rich content editing including bold, italics, hyperlinks, etc. It is not too much unlike the single editor region of the legacy post editor, and is in fact powered by the same TinyMCE library. -- __Inspector__: A block settings region shown in place of the post settings when a block is selected. Fields may be shown here to allow the user to customize the selected block. -- __Post settings__: A sidebar region containing metadata fields for the post, including scheduling, visibility, terms, and featured image. -- __Serialization__: The process of converting a block's attributes object into HTML markup, typically occurring when saving the post. -- __Static block__: A type of block where the content of which is known at the time of saving a post. A static block will be saved with HTML markup directly in post content. -- __TinyMCE__: [TinyMCE](https://www.tinymce.com/) is a web-based JavaScript WYSIWYG (What You See Is What You Get) editor. -- __Toolbar__: A set of button controls. In the context of a block, usually referring to the toolbar of block controls shown above the selected block. +- **Attribute sources**: An object describing the attributes shape of a block. The keys can be named as most appropriate to describe the state of a block type. The value for each key is a function which describes the strategy by which the attribute value should be extracted from the content of a saved post's content. When processed, a new object is created, taking the form of the keys defined in the attribute sources, where each value is the result of the attribute source function. +- **Attributes**: The object representation of the current state of a block in post content. When loading a saved post, this is determined by the attribute sources for the block type. These values can change over time during an editing session when the user modifies a block, and are used when determining how to serialize the block. +- **Block**: The abstract term used to describe units of markup that, composed together, form the content or layout of a webpage. The idea combines concepts of what in WordPress today we achieve with shortcodes, custom HTML, and embed discovery into a single consistent API and user experience. +- **Block Categories**: These are not a WordPress taxonomy, but instead used internally to sort blocks in the Block Inserter. +- **Block Inserter**: Primary interface for selecting from the available blocks, triggered by plus icon buttons on Blocks or in the top-left of the editor interface. +- **Block name**: A unique identifier for a block type, consisting of a plugin-specific namespace and a short label describing the block's intent. e.g. `core/image` +- **Block type**: In contrast with the blocks composing a particular post, a block type describes the blueprint by which any block of that type should behave. So while there may be many images within a post, each behaves consistent with a unified image block type definition. +- **Classic block**: +- **Dynamic block**: A type of block where the content of which may change and cannot be determined at the time of saving a post, instead calculated any time the post is shown on the front of a site. These blocks may save fallback content or no content at all in their JavaScript implementation, instead deferring to a PHP block implementation for runtime rendering. +- **RichText**: A common component enabling rich content editing including bold, italics, hyperlinks, etc. It is not too much unlike the single editor region of the legacy post editor, and is in fact powered by the same TinyMCE library. +- **Inspector**: A block settings region shown in place of the post settings when a block is selected. Fields may be shown here to allow the user to customize the selected block. +- **Post settings**: A sidebar region containing metadata fields for the post, including scheduling, visibility, terms, and featured image. +- **Reusable block**: +- **Sidebar**: +- **Serialization**: The process of converting a block's attributes object into HTML markup, typically occurring when saving the post. +- **Static block**: A type of block where the content of which is known at the time of saving a post. A static block will be saved with HTML markup directly in post content. +- **TinyMCE**: [TinyMCE](https://www.tinymce.com/) is a web-based JavaScript WYSIWYG (What You See Is What You Get) editor. +- **Toolbar**: A set of button controls. In the context of a block, usually referring to the toolbar of block controls shown above the selected block. +- **Template**: diff --git a/docs/language.md b/docs/designers-developers/key-concepts.md similarity index 75% rename from docs/language.md rename to docs/designers-developers/key-concepts.md index 8a71e707619fbc..ce270c0d2ee816 100644 --- a/docs/language.md +++ b/docs/designers-developers/key-concepts.md @@ -1,4 +1,52 @@ -# The Language of Gutenberg +# Key Concepts + +## Blocks + +Blocks are an abstract unit for organizing and composing content, strung together to create content for a webpage. + +Blocks are hiearchical, in that a block can be a child or parent to another block. One example is a two-column Columns block can be the parent block to multiple child blocks in each column. + +If it helps, you can think of blocks as a more graceful shortcode, with rich formatting tools for users to compose content. To this point, there is a new Block Grammar. Distilled, the block grammar is an HTML comment, either a self-closing tag or with a begining tag and ending tag. In the main tag, depending on the block type and user customizations, there can be a JSON object. This raw form of the block is referred to as serialized. + +```html + +

Welcome to the world of blocks.

+ +``` + +Blocks can be static or dynamic. Static blocks contain rendered content and an object of Attributes used to re-render based on changes. Dynamic blocks require server-side data and rendering while the post content is being generated (rendering). + +Each block contains Attributes or configuration settings, which can be sourced from raw HTML in the content, via meta or other customizible origins. + +The Paragraph is the default Block. Instead of a new line upon typing return on a keyboard, try to think of it as an empty paragraph block (type / to trigger an autocompleting Slash Inserter -- /image will pull up Images as well as Instagram embeds). + +Users insert new blocks by clicking the plus button for the Block Inserter, typing / for the Slash Inserter or typing return for a blank Paragraph block. + +Blocks can be duplicated within content using the menu from the block's toolbar or via keyboard shortcut. + +Blocks can be made repeatable, shared across posts and post types and used multiple times in the same post. Changes in one place reflect everywhere that reusable block is used. + +Blocks can be limited or locked-in-place by Templates and custom code. + +#### More on Blocks + +- **Block API** +- **Block Styles** +- **Tutorial: Building A Custom Block** + +## Block Categories + +In the Block Inserter, the accordion-sorted, popup modal that shows a site's available blocks to users, each accordion title is a Block Category, which are either the defaults or customized by developers through Plugins or code. + +## Reusable Blocks + +Reusable Blocks are a way to share a block (or multiple blocks) as a reusable, repeatable piece of content. + +Any edits to a reusable block are made to every usage of a repeatable block. + +Reusable Blocks are stored as a hidden post type, and are dynamic blocks that "ref" or reference the post_id and return the post_content for that wp_block. + +## Templates At the core of Gutenberg lies the concept of the block. From a technical point of view, blocks both raise the level of abstraction from a single document to a collection of meaningful elements, and they replace ambiguity—inherent in HTML—with explicit structure. A post in Gutenberg is then a _collection of blocks_. @@ -8,7 +56,7 @@ This is true for content blocks. They are the way in which the user creates thei Content in WordPress is stored as HTML-like text in `post_content`. HTML is a robust document markup format and has been used to describe content as simple as unformatted paragraphs of text and as complex as entire application interfaces. Understanding HTML is not trivial; a significant number of existing documents and tools deal with technically invalid or ambiguous code. This code, even when valid, can be incredibly tricky and complicated to parse – and to understand. -The main point is to let the machines work at what they are good at, and optimize for the user and the document. The analogy with the printing press can be taken further in that what matters is the printed page, not the arrangement of metal type that originated it. As a matter of fact, the arrangement of type is a pretty inconvenient storage mechanism. The page is both the result _and_ the proper way to store the data. The metal type is just an instrument for publication and editing, but more ephemeral in nature. Exactly as our use of an object tree (e.g. JSON) in the editor. We have the ability to rebuild this structure from the printed page, as if we printed notations in the margins that allows a machine to know which [sorts](https://en.wikipedia.org/wiki/Sort_(typesetting)) (metal type) to assemble to recreate the page. +The main point is to let the machines work at what they are good at, and optimize for the user and the document. The analogy with the printing press can be taken further in that what matters is the printed page, not the arrangement of metal type that originated it. As a matter of fact, the arrangement of type is a pretty inconvenient storage mechanism. The page is both the result _and_ the proper way to store the data. The metal type is just an instrument for publication and editing, but more ephemeral in nature. Exactly as our use of an object tree (e.g. JSON) in the editor. We have the ability to rebuild this structure from the printed page, as if we printed notations in the margins that allows a machine to know which [sorts]() (metal type) to assemble to recreate the page. ## Blocks are higher-level than HTML @@ -20,7 +68,7 @@ Additionally, how do we even know this came from our editor? Maybe someone snuck ## The post dichotomy -A Gutenberg post is the proper block-aware representation of a post, a collection of semantically consistent descriptions of what each block is and what its essential data is. This representation only ever exists in memory. It is the [chase](https://en.wikipedia.org/wiki/Chase_(printing)) in the typesetter's workshop, ever-shifting as sorts are attached and repositioned. +A Gutenberg post is the proper block-aware representation of a post, a collection of semantically consistent descriptions of what each block is and what its essential data is. This representation only ever exists in memory. It is the [chase]() in the typesetter's workshop, ever-shifting as sorts are attached and repositioned. A Gutenberg post is not the artifact it produces, namely the `post_content`. The latter is the printed page, optimized for the reader, but retaining its invisible markings for later editing. @@ -34,25 +82,21 @@ The tree of objects describes the list of blocks that compose a post. ```js [ - { - type: 'core/cover-image', - attributes: { - url: 'my-hero.jpg', - align: 'full', - hasParallax: false, - hasBackgroundDim: true - }, - children: [ - "Gutenberg posts aren't HTML" - ] - }, - { - type: 'core/paragraph', - children: [ - "Lately I've been hearing plen…" - ] - } -] + { + type: "core/cover-image", + attributes: { + url: "my-hero.jpg", + align: "full", + hasParallax: false, + hasBackgroundDim: true + }, + children: ["Gutenberg posts aren't HTML"] + }, + { + type: "core/paragraph", + children: ["Lately I've been hearing plen…"] + } +]; ``` ## Serialization and the Purpose of HTML Comments @@ -79,7 +123,7 @@ After running this through the parser we're left with a simple object we can man This has dramatic implications for how simple and performant we can make our parser. These explicit boundaries also protect damage in a single block from bleeding into other blocks or tarnishing the entire document. It also allows the system to identify unrecognized blocks before rendering them. -_N.B.:_ The defining aspect of blocks are their semantics and the isolation mechanism they provide; in other words, their identity. On the other hand, where their data is stored is a more liberal aspect. Blocks support more than just static local data (via JSON literals inside the HTML comment or within the block's HTML), and more mechanisms (_e.g._, global blocks or otherwise resorting to storage in complementary `WP_Post` objects) are expected. See [attributes](../docs/block-api/attributes.md) for details. +_N.B.:_ The defining aspect of blocks are their semantics and the isolation mechanism they provide; in other words, their identity. On the other hand, where their data is stored is a more liberal aspect. Blocks support more than just static local data (via JSON literals inside the HTML comment or within the block's HTML), and more mechanisms (_e.g._, global blocks or otherwise resorting to storage in complementary `WP_Post` objects) are expected. See [attributes](../../docs/designers-developers/developers/block-api/block-attributes.md) for details. ## The Anatomy of a Serialized Block @@ -87,9 +131,7 @@ When blocks are saved to the content, after the editing session, its attributes ```html -
- -
+
``` diff --git a/docs/designers-developers/readme.md b/docs/designers-developers/readme.md new file mode 100644 index 00000000000000..5e54798a3c7873 --- /dev/null +++ b/docs/designers-developers/readme.md @@ -0,0 +1,11 @@ +# Designer & Developer Handbook + +Gutenberg is a transformation of the WordPress editor for working with content. + +![Gutenberg Demo](https://cldup.com/kZXGDcGPMU.gif) + +Using a system of Blocks to compose and format content, the new block-based editor is designed to create rich, flexible layouts for websites and digital products. Content is created in the unit of blocks instead of freeform text with inserted media, embeds and Shortcodes (there's a Shortcode block though). + +Blocks treat Paragraphs, Headings, Media, Embeds all as components that strung together make up the content stored in the WordPress database, replacing the traditional concept of freeform text with embeded media and shortcodes. The new editor is designed with progressive enhancement, meaning it is back-compatible with all legacy content, offers a process to try to convert and split a Classic block into block equivalents using client-side parsing and finally the blocks offer enhanced editing and format controls. + +The Editor offers rich new value to users with visual, drag-and-drop creation tools and powerful developer enhancements with modern vendor packages, reusable components, rich APIs and hooks to modify and extend the editor through Custom Blocks, Custom Block Styles and Plugins. diff --git a/docs/extensibility.md b/docs/extensibility.md deleted file mode 100644 index ab6f8c8db6361b..00000000000000 --- a/docs/extensibility.md +++ /dev/null @@ -1,82 +0,0 @@ -# Extensibility - -Extensibility is key for WordPress and, like the rest of WordPress components, Gutenberg is highly extensible. - - -## Creating Blocks - -Gutenberg is about blocks, and the main extensibility API of Gutenberg is the Block API. It allows you to create your own static blocks, dynamic blocks rendered on the server and also blocks capable of saving data to Post Meta for more structured content. - -Here is a small example of a static custom block type (you can try it in your browser's console): - -{% codetabs %} -{% ES5 %} -```js -var el = wp.element.createElement; - -wp.blocks.registerBlockType( 'mytheme/red-block', { - title: 'Red Block', - icon: 'universal-access-alt', - category: 'layout', - edit: function() { - return el( 'div', { style: { backgroundColor: '#900', color: '#fff', padding: '20px' } }, 'I am a red block.' ); - }, - save: function() { - return el( 'div', { style: { backgroundColor: '#900', color: '#fff', padding: '20px' } }, 'I am a red block.' ); - } -} ); -``` -{% ESNext %} -```js -const { registerBlockType } = wp.blocks; -const blockStyle = { backgroundColor: '#900', color: '#fff', padding: '20px' }; - -registerBlockType( 'mytheme/red-block', { - title: 'Red Block', - icon: 'universal-access-alt', - category: 'layout', - edit: function() { - return
I am a red block
- }, - save: function() { - return
I am a red block
- } -} ); -``` -{% end %} - -If you want to learn more about block creation, the [Blocks Tutorial](../docs/blocks.md) is the best place to start. - -## Extending Blocks - -It is also possible to modify the behavior of existing blocks or even remove them completely using filters. - -Learn more in the [Extending Blocks](../docs/extensibility/extending-blocks.md) section. - -## Extending the Editor UI - -Extending the editor UI can be accomplished with the `registerPlugin` API, allowing you to define all your plugin's UI elements in one place. - -Refer to the [Plugins](https://github.com/WordPress/gutenberg/blob/master/packages/plugins/README.md) and [Edit Post](https://github.com/WordPress/gutenberg/blob/master/packages/edit-post/README.md) section for more information. - -## Meta Boxes - -**Porting PHP meta boxes to blocks is highly encouraged!** - -Discover how [Meta Box](../docs/extensibility/meta-box.md) support works in Gutenberg. - -## Theme Support - -By default, blocks provide their styles to enable basic support for blocks in themes without any change. Themes can add/override these styles, or rely on defaults. - -There are some advanced block features which require opt-in support in the theme. See [theme support](../docs/extensibility/theme-support.md). - -## Autocomplete - -Autocompleters within blocks may be extended and overridden. See [autocomplete](../docs/extensibility/autocomplete.md). - -## Block Parsing and Serialization - -Posts in the editor move through a couple of different stages between being stored in `post_content` and appearing in the editor. Since the blocks themselves are data structures that live in memory it takes a parsing and serialization step to transform out from and into the stored format in the database. - -Customizing the parser is an advanced topic that you can learn more about in the [Extending the Parser](../docs/extensibility/parser.md) section. diff --git a/docs/extensibility/extending-editor.md b/docs/extensibility/extending-editor.md deleted file mode 100644 index ddb874682ab168..00000000000000 --- a/docs/extensibility/extending-editor.md +++ /dev/null @@ -1,21 +0,0 @@ -# Extending Editor (Experimental) - -[Hooks](https://developer.wordpress.org/plugins/hooks/) are a way for one piece of code to interact/modify another piece of code. They make up the foundation for how plugins and themes interact with Gutenberg, but they’re also used extensively by WordPress Core itself. There are two types of hooks: [Actions](https://developer.wordpress.org/plugins/hooks/actions/) and [Filters](https://developer.wordpress.org/plugins/hooks/filters/). They were initially implemented in PHP, but for the purpose of Gutenberg they were ported to JavaScript and published to npm as [@wordpress/hooks](https://www.npmjs.com/package/@wordpress/hooks) package for general purpose use. You can also learn more about both APIs: [PHP](https://codex.wordpress.org/Plugin_API/) and [JavaScript](https://github.com/WordPress/packages/tree/master/packages/hooks). - -## Modifying Editor - -To modify the behavior of the editor experience, Gutenberg exposes the following Filters: - -### `editor.PostFeaturedImage.imageSize` - -Used to modify the image size displayed in the Post Featured Image component. It defaults to `'post-thumbnail'`, and will fail back to the `full` image size when the specified image size doesn't exist in the media object. It's modeled after the `admin_post_thumbnail_size` filter in the Classic Editor. - -_Example:_ - -```js -var withImageSize = function( size, mediaId, postId ) { - return 'large'; -}; - -wp.hooks.addFilter( 'editor.PostFeaturedImage.imageSize', 'my-plugin/with-image-size', withImageSize ); -``` diff --git a/docs/manifest.json b/docs/manifest.json index 075d922f2cad93..c461c5d3b9a361 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1,260 +1,182 @@ [ { - "title": "Introduction", - "slug": "handbook", + "title": "Gutenberg Handbook", + "slug": "readme", "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/readme.md", "parent": null }, { - "title": "The Language of Gutenberg", - "slug": "language", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/language.md", + "title": "Designer & Developer Handbook", + "slug": "designers-developers", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/README.md", "parent": null }, { - "title": "The Gutenberg block grammar", - "slug": "grammar", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/grammar.md", - "parent": "language" + "title": "Key Concepts", + "slug": "key-concepts", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/key-concepts.md", + "parent": "designers-developers" }, { - "title": "Block API", + "title": "Developer Documentation", + "slug": "developers", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/README.md", + "parent": "designers-developers" + }, + { + "title": "Block API Reference", "slug": "block-api", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/block-api.md", - "parent": null + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/README.md", + "parent": "designers-developers/developers" }, { - "title": "Attributes", - "slug": "attributes", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/block-api/attributes.md", - "parent": "block-api" + "title": "Block Registration ", + "slug": "block-registration", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-registration.md", + "parent": "designers-developers/developers/block-api" }, { "title": "Edit and Save", "slug": "block-edit-save", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/block-api/block-edit-save.md", - "parent": "block-api" + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-edit-save.md", + "parent": "designers-developers/developers/block-api" }, { - "title": "RichText API", - "slug": "rich-text-api", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/editor/src/components/rich-text/README.md", - "parent": "block-api" + "title": "Attributes", + "slug": "block-attributes", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-attributes.md", + "parent": "designers-developers/developers/block-api" }, { "title": "Deprecated Blocks", - "slug": "deprecated-blocks", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/block-api/deprecated-blocks.md", - "parent": "block-api" + "slug": "block-deprecation", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-deprecation.md", + "parent": "designers-developers/developers/block-api" }, { "title": "Templates", - "slug": "templates", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/templates.md", - "parent": null + "slug": "block-templates", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-templates.md", + "parent": "designers-developers/developers/block-api" }, { - "title": "Extensibility", - "slug": "extensibility", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility.md", - "parent": null + "title": "Annotations", + "slug": "block-annotations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/block-api/block-annotations.md", + "parent": "designers-developers/developers/block-api" }, { - "title": "Extending Blocks", - "slug": "extending-blocks", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/extending-blocks.md", - "parent": "extensibility" + "title": "Filter Reference", + "slug": "filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/README.md", + "parent": "designers-developers/developers" }, { - "title": "Extending Editor", - "slug": "extending-editor", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/extending-editor.md", - "parent": "extensibility" + "title": "Block Filters", + "slug": "block-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/block-filters.md", + "parent": "designers-developers/developers/filters" }, { - "title": "Meta Boxes", - "slug": "meta-box", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/meta-box.md", - "parent": "extensibility" + "title": "Editor Filters (Experimental)", + "slug": "editor-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/editor-filters.md", + "parent": "designers-developers/developers/filters" }, { - "title": "Theme Support", - "slug": "theme-support", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/theme-support.md", - "parent": "extensibility" + "title": "Parser Filters", + "slug": "parser-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/parser-filters.md", + "parent": "designers-developers/developers/filters" }, { "title": "Autocomplete", - "slug": "autocomplete", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/autocomplete.md", - "parent": "extensibility" + "slug": "autocomplete-filters", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/filters/autocomplete-filters.md", + "parent": "designers-developers/developers/filters" }, { - "title": "Annotations", - "slug": "annotations", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/extensibility/annotations.md", - "parent": "extensibility" - }, - { - "title": "Design", - "slug": "design", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design.md", - "parent": null - }, - { - "title": "Patterns", - "slug": "design-patterns", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/design-patterns.md", - "parent": "design" + "title": "Data Module Reference", + "slug": "data", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/README.md", + "parent": "designers-developers/developers" }, { - "title": "Block Design", - "slug": "block-design", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/block-design.md", - "parent": "design" + "title": "Packages", + "slug": "packages", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/packages.md", + "parent": "designers-developers/developers" }, { - "title": "Resources", - "slug": "design-resources", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/design/design-resources.md", - "parent": "design" + "title": "Theming for Gutenberg", + "slug": "themes", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/themes/README.md", + "parent": "designers-developers/developers" }, { - "title": "Creating Block Types", - "slug": "blocks", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks.md", - "parent": null + "title": "Theme Support", + "slug": "theme-support", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/themes/theme-support.md", + "parent": "designers-developers/developers/themes" }, { - "title": "Writing Your First Block Type", - "slug": "writing-your-first-block-type", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks/writing-your-first-block-type.md", - "parent": "blocks" + "title": "Backwards Compatibility", + "slug": "backwards-compatibility", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/backwards-compatibility/README.md", + "parent": "designers-developers/developers" }, { - "title": "Applying Styles With Stylesheets", - "slug": "applying-styles-with-stylesheets", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks/applying-styles-with-stylesheets.md", - "parent": "blocks" + "title": "Deprecations", + "slug": "deprecations", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/backwards-compatibility/deprecations.md", + "parent": "designers-developers/developers/backwards-compatibility" }, { - "title": "Introducing Attributes and Editable Fields", - "slug": "introducing-attributes-and-editable-fields", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks/introducing-attributes-and-editable-fields.md", - "parent": "blocks" + "title": "Meta Boxes", + "slug": "meta-box", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/backwards-compatibility/meta-box.md", + "parent": "designers-developers/developers/backwards-compatibility" }, { - "title": "Block Controls: Toolbars and Inspector", - "slug": "block-controls-toolbars-and-inspector", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks/block-controls-toolbars-and-inspector.md", - "parent": "blocks" + "title": "Designer Documentation", + "slug": "designers", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/README.md", + "parent": "designers-developers" }, { - "title": "Creating dynamic blocks", - "slug": "creating-dynamic-blocks", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks/creating-dynamic-blocks.md", - "parent": "blocks" + "title": "Block Design", + "slug": "block-design", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/block-design.md", + "parent": "designers-developers/designers" }, { - "title": "Generate Blocks with WP-CLI", - "slug": "generate-blocks-with-wp-cli", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/blocks/generate-blocks-with-wp-cli.md", - "parent": "blocks" + "title": "Patterns", + "slug": "design-patterns", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/design-patterns.md", + "parent": "designers-developers/designers" }, { - "title": "Reference", - "slug": "reference", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference.md", - "parent": null + "title": "Resources", + "slug": "design-resources", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/designers/design-resources.md", + "parent": "designers-developers/designers" }, { "title": "Glossary", "slug": "glossary", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/glossary.md", - "parent": "reference" - }, - { - "title": "History", - "slug": "history", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/history.md", - "parent": "reference" - }, - { - "title": "Coding Guidelines", - "slug": "coding-guidelines", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/coding-guidelines.md", - "parent": "reference" - }, - { - "title": "Testing Overview", - "slug": "testing-overview", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/testing-overview.md", - "parent": "reference" + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/glossary.md", + "parent": "designers-developers" }, { "title": "Frequently Asked Questions", "slug": "faq", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/faq.md", - "parent": "reference" - }, - { - "title": "Release Process", - "slug": "release", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/release.md", - "parent": "reference" - }, - { - "title": "Scripts", - "slug": "scripts", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/scripts.md", - "parent": "reference" - }, - { - "title": "Deprecated Features", - "slug": "deprecated", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/deprecated.md", - "parent": "reference" - }, - { - "title": "Repository Management", - "slug": "repository-management", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/reference/repository-management.md", - "parent": "reference" - }, - { - "title": "Outreach", - "slug": "outreach", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/outreach.md", - "parent": null - }, - { - "title": "Articles", - "slug": "articles", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/outreach/docs/articles.md", - "parent": "outreach" - }, - { - "title": "Meetups", - "slug": "meetups", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/outreach/meetups.md", - "parent": "outreach" - }, - { - "title": "Talks", - "slug": "talks", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/outreach/talks.md", - "parent": "outreach" - }, - { - "title": "Resources", - "slug": "resources", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/outreach/resources.md", - "parent": "outreach" + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/faq.md", + "parent": "designers-developers" }, { "title": "Packages", "slug": "packages", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/packages.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/packages.md", "parent": null }, { @@ -755,6 +677,12 @@ "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/icon/README.md", "parent": "components" }, + { + "title": "IsolatedEventContainer", + "slug": "isolated-event-container", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/packages/components/src/isolated-event-container/README.md", + "parent": "components" + }, { "title": "KeyboardShortcuts", "slug": "keyboard-shortcuts", @@ -932,55 +860,55 @@ { "title": "Data Package Reference", "slug": "data", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/README.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/README.md", "parent": null }, { "title": "WordPress Core Data", "slug": "data-core", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core.md", "parent": "data" }, { "title": "Annotations", "slug": "data-core-annotations", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-annotations.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-annotations.md", "parent": "data" }, { "title": "Block Types Data", "slug": "data-core-blocks", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-blocks.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-blocks.md", "parent": "data" }, { "title": "The Editor’s Data", "slug": "data-core-editor", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-editor.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-editor.md", "parent": "data" }, { "title": "The Editor’s UI Data", "slug": "data-core-edit-post", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-edit-post.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-edit-post.md", "parent": "data" }, { "title": "Notices Data", "slug": "data-core-notices", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-notices.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-notices.md", "parent": "data" }, { "title": "The NUX (New User Experience) Data", "slug": "data-core-nux", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-nux.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-nux.md", "parent": "data" }, { "title": "The Viewport Data", "slug": "data-core-viewport", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/data/data-core-viewport.md", + "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/designers-developers/developers/data/data-core-viewport.md", "parent": "data" } ] \ No newline at end of file diff --git a/docs/readme.md b/docs/readme.md index 4d1c74172ec236..93b58f0b17cda7 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -1,11 +1,21 @@ -# Introduction +# Gutenberg Handbook -Gutenberg began as a transformation of the WordPress editor — a new interface for adding, editing, and manipulating content. It seeks to make it easy for anyone to create rich, flexible content layouts with a block-based UI. All types of page components are represented as modular blocks, which means they can be accessed from a unified block menu, dropped anywhere on a page, and directly edited to create the custom presentation the user wants. +The Gutenberg project provides three sources of documentation: -It is a fundamental modernization and transformation of how the WordPress experience works, creating new opportunities for both users and developers. Gutenberg introduces new frameworks, interaction patterns, functionality, and user experiences for WordPress. And similar to a new macOS version, we will talk about “Gutenberg”, and all the new possibilities it enables, until eventually the idea of Gutenberg as a separate entity will fade and it will simply be WordPress. +## Designer & Developer Handbook -![Gutenberg Demo](https://cldup.com/kZXGDcGPMU.gif) +Learn how to build blocks and extend the editor, best practices for designing block interfaces, and how to create themes that make the most of the new features Gutenberg provides. -Gutenberg brings many changes to WordPress, but the biggest impact comes from the way it can enable a much clearer product architecture — one which enables modularity, consistency, and interoperability — and the positive impact that can have on the end user experience of WordPress. This handbook will describe the scope of those architectural and user experience (UX) changes, including the central “block as the interface” principle — the most crucial conceptual change to understand about Gutenberg. +[Visit the Designer & Developer Handbook](../docs/designers-developers/readme.md) -Here you can also find design guidelines, API documentation, and tutorials about getting started with Gutenberg development. +## User Handbook + +Discover the new features Gutenberg offers, learn how your site will be affected by the new editor and how to keep using the old interface, and tips for creating beautiful posts and pages. + +[Visit the User Handbook](../docs/users/readme.md) + +## Contributor Handbook + +Help make Gutenberg better by contributing ideas, code, testing, and more. + +[Visit the Contributor Handbook](../docs/contributors/readme.md) diff --git a/docs/root-manifest.json b/docs/root-manifest.json index 1202ab14eacb4a..784008dd17bb50 100644 --- a/docs/root-manifest.json +++ b/docs/root-manifest.json @@ -1,254 +1,176 @@ [ - { - "title": "Introduction", - "slug": "handbook", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/readme.md", - "parent": null - }, - { - "title": "The Language of Gutenberg", - "slug": "language", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/language.md", - "parent": null - }, - { - "title": "The Gutenberg block grammar", - "slug": "grammar", - "markdown_source": "https://raw.githubusercontent.com/WordPress/gutenberg/master/docs/grammar.md", - "parent": "language" - }, - { - "title": "Block API", - "slug": "block-api", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/block-api.md", - "parent": null - }, - { - "title": "Attributes", - "slug": "attributes", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/block-api\/attributes.md", - "parent": "block-api" - }, - { - "title": "Edit and Save", - "slug": "block-edit-save", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/block-api\/block-edit-save.md", - "parent": "block-api" - }, - { - "title": "RichText API", - "slug": "rich-text-api", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/packages\/editor\/src\/components\/rich-text\/README.md", - "parent": "block-api" - }, - { - "title": "Deprecated Blocks", - "slug": "deprecated-blocks", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/block-api\/deprecated-blocks.md", - "parent": "block-api" - }, - { - "title": "Templates", - "slug": "templates", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/templates.md", - "parent": null - }, - { - "title": "Extensibility", - "slug": "extensibility", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility.md", - "parent": null - }, - { - "title": "Extending Blocks", - "slug": "extending-blocks", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/extending-blocks.md", - "parent": "extensibility" - }, - { - "title": "Extending Editor", - "slug": "extending-editor", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/extending-editor.md", - "parent": "extensibility" - }, - { - "title": "Meta Boxes", - "slug": "meta-box", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/meta-box.md", - "parent": "extensibility" - }, - { - "title": "Theme Support", - "slug": "theme-support", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/theme-support.md", - "parent": "extensibility" - }, - { - "title": "Autocomplete", - "slug": "autocomplete", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/autocomplete.md", - "parent": "extensibility" - }, - { - "title": "Annotations", - "slug": "annotations", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/extensibility/annotations.md", - "parent": "extensibility" - }, - { - "title": "Design", - "slug": "design", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/design.md", - "parent": null - }, - { - "title": "Patterns", - "slug": "design-patterns", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/design\/design-patterns.md", - "parent": "design" - }, - { - "title": "Block Design", - "slug": "block-design", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/design\/block-design.md", - "parent": "design" - }, - { - "title": "Resources", - "slug": "design-resources", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/design\/design-resources.md", - "parent": "design" - }, - { - "title": "Creating Block Types", - "slug": "blocks", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks.md", - "parent": null - }, - { - "title": "Writing Your First Block Type", - "slug": "writing-your-first-block-type", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks\/writing-your-first-block-type.md", - "parent": "blocks" - }, - { - "title": "Applying Styles With Stylesheets", - "slug": "applying-styles-with-stylesheets", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks\/applying-styles-with-stylesheets.md", - "parent": "blocks" - }, - { - "title": "Introducing Attributes and Editable Fields", - "slug": "introducing-attributes-and-editable-fields", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks\/introducing-attributes-and-editable-fields.md", - "parent": "blocks" - }, - { - "title": "Block Controls: Toolbars and Inspector", - "slug": "block-controls-toolbars-and-inspector", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks\/block-controls-toolbars-and-inspector.md", - "parent": "blocks" - }, - { - "title": "Creating dynamic blocks", - "slug": "creating-dynamic-blocks", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks\/creating-dynamic-blocks.md", - "parent": "blocks" - }, - { - "title": "Generate Blocks with WP-CLI", - "slug": "generate-blocks-with-wp-cli", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/blocks\/generate-blocks-with-wp-cli.md", - "parent": "blocks" - }, - { - "title": "Reference", - "slug": "reference", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference.md", - "parent": null - }, - { - "title": "Glossary", - "slug": "glossary", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/glossary.md", - "parent": "reference" - }, - { - "title": "History", - "slug": "history", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/history.md", - "parent": "reference" - }, - { - "title": "Coding Guidelines", - "slug": "coding-guidelines", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/coding-guidelines.md", - "parent": "reference" - }, - { - "title": "Testing Overview", - "slug": "testing-overview", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/testing-overview.md", - "parent": "reference" - }, - { - "title": "Frequently Asked Questions", - "slug": "faq", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/faq.md", - "parent": "reference" - }, - { - "title": "Release Process", - "slug": "release", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/release.md", - "parent": "reference" - }, - { - "title": "Scripts", - "slug": "scripts", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/scripts.md", - "parent": "reference" - }, - { - "title": "Deprecated Features", - "slug": "deprecated", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/deprecated.md", - "parent": "reference" - }, - { - "title": "Repository Management", - "slug": "repository-management", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/reference\/repository-management.md", - "parent": "reference" - }, - { - "title": "Outreach", - "slug": "outreach", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/outreach.md", - "parent": null - }, - { - "title": "Articles", - "slug": "articles", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/outreach\/docs\/articles.md", - "parent": "outreach" - }, - { - "title": "Meetups", - "slug": "meetups", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/outreach\/meetups.md", - "parent": "outreach" - }, - { - "title": "Talks", - "slug": "talks", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/outreach\/talks.md", - "parent": "outreach" - }, - { - "title": "Resources", - "slug": "resources", - "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/outreach\/resources.md", - "parent": "outreach" - } -] + { + "title": "Gutenberg Handbook", + "slug": "readme", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/readme.md", + "parent": null + }, + { + "title": "Designer & Developer Handbook", + "slug": "designers-developers", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/README.md", + "parent": null + }, + { + "title": "Key Concepts", + "slug": "key-concepts", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/key-concepts.md", + "parent": "designers-developers" + }, + { + "title": "Developer Documentation", + "slug": "developers", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/README.md", + "parent": "designers-developers" + }, + { + "title": "Block API Reference", + "slug": "block-api", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/README.md", + "parent": "designers-developers\/developers" + }, + { + "title": "Block Registration ", + "slug": "block-registration", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/block-registration.md", + "parent": "designers-developers\/developers\/block-api" + }, + { + "title": "Edit and Save", + "slug": "block-edit-save", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/block-edit-save.md", + "parent": "designers-developers\/developers\/block-api" + }, + { + "title": "Attributes", + "slug": "block-attributes", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/block-attributes.md", + "parent": "designers-developers\/developers\/block-api" + }, + { + "title": "Deprecated Blocks", + "slug": "block-deprecation", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/block-deprecation.md", + "parent": "designers-developers\/developers\/block-api" + }, + { + "title": "Templates", + "slug": "block-templates", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/block-templates.md", + "parent": "designers-developers\/developers\/block-api" + }, + { + "title": "Annotations", + "slug": "block-annotations", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/block-api\/block-annotations.md", + "parent": "designers-developers\/developers\/block-api" + }, + { + "title": "Filter Reference", + "slug": "filters", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/filters\/README.md", + "parent": "designers-developers\/developers" + }, + { + "title": "Block Filters", + "slug": "block-filters", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/filters\/block-filters.md", + "parent": "designers-developers\/developers\/filters" + }, + { + "title": "Editor Filters (Experimental)", + "slug": "editor-filters", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/filters\/editor-filters.md", + "parent": "designers-developers\/developers\/filters" + }, + { + "title": "Parser Filters", + "slug": "parser-filters", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/filters\/parser-filters.md", + "parent": "designers-developers\/developers\/filters" + }, + { + "title": "Autocomplete", + "slug": "autocomplete-filters", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/filters\/autocomplete-filters.md", + "parent": "designers-developers\/developers\/filters" + }, + { + "title": "Data Module Reference", + "slug": "data", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/data\/README.md", + "parent": "designers-developers\/developers" + }, + { + "title": "Packages", + "slug": "packages", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/packages.md", + "parent": "designers-developers\/developers" + }, + { + "title": "Theming for Gutenberg", + "slug": "themes", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/themes\/README.md", + "parent": "designers-developers\/developers" + }, + { + "title": "Theme Support", + "slug": "theme-support", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/themes\/theme-support.md", + "parent": "designers-developers\/developers\/themes" + }, + { + "title": "Backwards Compatibility", + "slug": "backwards-compatibility", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/backwards-compatibility\/README.md", + "parent": "designers-developers\/developers" + }, + { + "title": "Deprecations", + "slug": "deprecations", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/backwards-compatibility\/deprecations.md", + "parent": "designers-developers\/developers\/backwards-compatibility" + }, + { + "title": "Meta Boxes", + "slug": "meta-box", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/developers\/backwards-compatibility\/meta-box.md", + "parent": "designers-developers\/developers\/backwards-compatibility" + }, + { + "title": "Designer Documentation", + "slug": "designers", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/designers\/README.md", + "parent": "designers-developers" + }, + { + "title": "Block Design", + "slug": "block-design", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/designers\/block-design.md", + "parent": "designers-developers\/designers" + }, + { + "title": "Patterns", + "slug": "design-patterns", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/designers\/design-patterns.md", + "parent": "designers-developers\/designers" + }, + { + "title": "Resources", + "slug": "design-resources", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/designers\/design-resources.md", + "parent": "designers-developers\/designers" + }, + { + "title": "Glossary", + "slug": "glossary", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/glossary.md", + "parent": "designers-developers" + }, + { + "title": "Frequently Asked Questions", + "slug": "faq", + "markdown_source": "https:\/\/raw.githubusercontent.com\/WordPress\/gutenberg\/master\/docs\/designers-developers\/faq.md", + "parent": "designers-developers" + } +] \ No newline at end of file diff --git a/docs/toc.json b/docs/toc.json new file mode 100644 index 00000000000000..d519837b89a8a8 --- /dev/null +++ b/docs/toc.json @@ -0,0 +1,38 @@ +[ + {"docs/readme.md": []}, + {"docs/designers-developers/README.md": [ + {"docs/designers-developers/key-concepts.md": []}, + {"docs/designers-developers/developers/README.md": [ + {"docs/designers-developers/developers/block-api/README.md": [ + {"docs/designers-developers/developers/block-api/block-registration.md": []}, + {"docs/designers-developers/developers/block-api/block-edit-save.md": []}, + {"docs/designers-developers/developers/block-api/block-attributes.md": []}, + {"docs/designers-developers/developers/block-api/block-deprecation.md": []}, + {"docs/designers-developers/developers/block-api/block-templates.md": []}, + {"docs/designers-developers/developers/block-api/block-annotations.md": []} + ]}, + {"docs/designers-developers/developers/filters/README.md": [ + {"docs/designers-developers/developers/filters/block-filters.md": []}, + {"docs/designers-developers/developers/filters/editor-filters.md": []}, + {"docs/designers-developers/developers/filters/parser-filters.md": []}, + {"docs/designers-developers/developers/filters/autocomplete-filters.md": []} + ]}, + {"docs/designers-developers/developers/data/README.md": "{{data}}"}, + {"docs/designers-developers/developers/packages.md": "{{packages}}"}, + {"docs/designers-developers/developers/themes/README.md": [ + {"docs/designers-developers/developers/themes/theme-support.md": []} + ]}, + {"docs/designers-developers/developers/backwards-compatibility/README.md": [ + {"docs/designers-developers/developers/backwards-compatibility/deprecations.md": []}, + {"docs/designers-developers/developers/backwards-compatibility/meta-box.md": []} + ]} + ]}, + {"docs/designers-developers/designers/README.md": [ + {"docs/designers-developers/designers/block-design.md": []}, + {"docs/designers-developers/designers/design-patterns.md": []}, + {"docs/designers-developers/designers/design-resources.md": []} + ]}, + {"docs/designers-developers/glossary.md": []}, + {"docs/designers-developers/faq.md": []} + ]} +] diff --git a/docs/tool/config.js b/docs/tool/config.js index 6758ac592a72f7..1a3e69450c9a8e 100644 --- a/docs/tool/config.js +++ b/docs/tool/config.js @@ -51,7 +51,7 @@ module.exports = { actions: [ path.resolve( root, 'packages/viewport/src/store/actions.js' ) ], }, }, - dataDocsOutput: path.resolve( __dirname, '../data' ), + dataDocsOutput: path.resolve( __dirname, '../designers-developers/developers/data' ), packageFileNames: glob( 'packages/*/package.json' ) .map( ( fileName ) => fileName.split( '/' )[ 1 ] ), diff --git a/docs/tool/generator.js b/docs/tool/generator.js index 401ea43f33fa0f..1f7a0744dbf3e3 100644 --- a/docs/tool/generator.js +++ b/docs/tool/generator.js @@ -17,7 +17,7 @@ function generateTableOfContent( parsedNamespaces ) { '# Data Module Reference', '', Object.values( parsedNamespaces ).map( ( parsedNamespace ) => { - return ` - [**${ parsedNamespace.name }**: ${ parsedNamespace.title }](../../docs/data/data-${ kebabCase( parsedNamespace.name ) }.md)`; + return ` - [**${ parsedNamespace.name }**: ${ parsedNamespace.title }](../../docs/designers-developers/developers/data/data-${ kebabCase( parsedNamespace.name ) }.md)`; } ).join( '\n' ), ].join( '\n' ); } diff --git a/docs/tool/manifest.js b/docs/tool/manifest.js index 5b5a4bd5ea4021..22d98734278161 100644 --- a/docs/tool/manifest.js +++ b/docs/tool/manifest.js @@ -17,7 +17,7 @@ function getPackageManifest( packageFolderNames ) { { title: 'Packages', slug: 'packages', - markdown_source: `${ baseRepoUrl }/docs/packages.md`, + markdown_source: `${ baseRepoUrl }/docs/designers-developers/developers/packages.md`, parent: null, }, ].concat( @@ -72,7 +72,7 @@ function getDataManifest( parsedNamespaces ) { return [ { title: 'Data Package Reference', slug: 'data', - markdown_source: `${ baseRepoUrl }/docs/data/README.md`, + markdown_source: `${ baseRepoUrl }/docs/designers-developers/developers/data/README.md`, parent: null, } ].concat( Object.values( parsedNamespaces ).map( ( parsedNamespace ) => { @@ -80,7 +80,7 @@ function getDataManifest( parsedNamespaces ) { return { title: parsedNamespace.title, slug, - markdown_source: `${ baseRepoUrl }/docs/data/${ slug }.md`, + markdown_source: `${ baseRepoUrl }/docs/designers-developers/developers/data/${ slug }.md`, parent: 'data', }; } ) diff --git a/docs/users/readme.md b/docs/users/readme.md new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/gutenberg.php b/gutenberg.php index 7e130a7034bc28..4c99e466cc6b6a 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -3,7 +3,7 @@ * Plugin Name: Gutenberg * Plugin URI: https://github.com/WordPress/gutenberg * Description: Printing since 1440. This is the development plugin for the new block editor in core. - * Version: 4.3.0-rc.1 + * Version: 4.4.0 * Author: Gutenberg Team * * @package gutenberg @@ -28,6 +28,27 @@ function the_gutenberg_project() { global $post_type_object; ?> +

labels->edit_item ); ?>

@@ -101,6 +122,14 @@ function is_gutenberg_page() { return false; } + /* + * There have been reports of specialized loading scenarios where `get_current_screen` + * does not exist. In these cases, it is safe to say we are not loading Gutenberg. + */ + if ( ! function_exists( 'get_current_screen' ) ) { + return false; + } + if ( get_current_screen()->base !== 'post' ) { return false; } @@ -123,7 +152,7 @@ function is_gutenberg_page() { */ function gutenberg_wordpress_version_notice() { echo '

'; - echo __( 'Gutenberg requires WordPress 4.9.8 or later to function properly. Please upgrade WordPress before activating Gutenberg.', 'gutenberg' ); + _e( 'Gutenberg requires WordPress 4.9.8 or later to function properly. Please upgrade WordPress before activating Gutenberg.', 'gutenberg' ); echo '

'; deactivate_plugins( array( 'gutenberg/gutenberg.php' ) ); @@ -136,7 +165,7 @@ function gutenberg_wordpress_version_notice() { */ function gutenberg_build_files_notice() { echo '

'; - echo __( 'Gutenberg development mode requires files to be built. Run npm install to install dependencies, npm run build to build the files or npm run dev to build the files and watch for changes. Read the contributing file for more information.', 'gutenberg' ); + _e( 'Gutenberg development mode requires files to be built. Run npm install to install dependencies, npm run build to build the files or npm run dev to build the files and watch for changes. Read the contributing file for more information.', 'gutenberg' ); echo '

'; } @@ -171,6 +200,13 @@ function gutenberg_pre_init() { add_filter( 'replace_editor', 'gutenberg_init', 10, 2 ); } +/** + * Enable Gutenberg based on user_can_richedit setting. + * Set gutenberg_can_edit_post based on user setting for disable visual editor. + */ +add_filter( 'gutenberg_can_edit_post_type', 'user_can_richedit', 5 ); +add_filter( 'gutenberg_can_edit_post', 'user_can_richedit', 5 ); + /** * Initialize Gutenberg. * diff --git a/lib/blocks.php b/lib/blocks.php index 6ef34c4176eefb..a8083466a04053 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -58,21 +58,6 @@ function unregister_block_type( $name ) { * @return array Array of parsed block objects. */ function gutenberg_parse_blocks( $content ) { - /* - * If there are no blocks in the content, return a single block, rather - * than wasting time trying to parse the string. - */ - if ( ! has_blocks( $content ) ) { - return array( - array( - 'blockName' => null, - 'attrs' => array(), - 'innerBlocks' => array(), - 'innerHTML' => $content, - ), - ); - } - /** * Filter to allow plugins to replace the server-side block parser * @@ -148,27 +133,34 @@ function get_dynamic_blocks_regex() { * Renders a single block into a HTML string. * * @since 1.9.0 + * @since 4.4.0 renders full nested tree of blocks before reassembling into HTML string + * @global WP_Post $post The post to edit. * * @param array $block A single parsed block object. * @return string String of rendered HTML. */ function gutenberg_render_block( $block ) { - $block_name = isset( $block['blockName'] ) ? $block['blockName'] : null; - $attributes = is_array( $block['attrs'] ) ? $block['attrs'] : array(); - $raw_content = isset( $block['innerHTML'] ) ? $block['innerHTML'] : null; + global $post; - if ( $block_name ) { - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); - if ( null !== $block_type && $block_type->is_dynamic() ) { - return $block_type->render( $attributes ); - } + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + $is_dynamic = $block['blockName'] && null !== $block_type && $block_type->is_dynamic(); + $inner_content = ''; + $index = 0; + + foreach ( $block['innerContent'] as $chunk ) { + $inner_content .= is_string( $chunk ) ? $chunk : gutenberg_render_block( $block['innerBlocks'][ $index++ ] ); } - if ( $raw_content ) { - return $raw_content; + if ( $is_dynamic ) { + $attributes = is_array( $block['attrs'] ) ? (array) $block['attrs'] : array(); + $global_post = $post; + $output = $block_type->render( $attributes, $inner_content ); + $post = $global_post; + + return $output; } - return ''; + return $inner_content; } if ( ! function_exists( 'do_blocks' ) ) { @@ -176,91 +168,20 @@ function gutenberg_render_block( $block ) { * Parses dynamic blocks out of `post_content` and re-renders them. * * @since 0.1.0 - * @global WP_Post $post The post to edit. + * @since 4.4.0 performs full parse on input post content * * @param string $content Post content. * @return string Updated post content. */ function do_blocks( $content ) { - global $post; - - $rendered_content = ''; - $dynamic_block_pattern = get_dynamic_blocks_regex(); - - /* - * Back up global post, to restore after render callback. - * Allows callbacks to run new WP_Query instances without breaking the global post. - */ - $global_post = $post; - - while ( preg_match( $dynamic_block_pattern, $content, $block_match, PREG_OFFSET_CAPTURE ) ) { - $opening_tag = $block_match[0][0]; - $offset = $block_match[0][1]; - $block_name = $block_match[1][0]; - $is_self_closing = isset( $block_match[4] ); - - // Reset attributes JSON to prevent scope bleed from last iteration. - $block_attributes_json = null; - if ( isset( $block_match[3] ) ) { - $block_attributes_json = $block_match[3][0]; - } + $blocks = gutenberg_parse_blocks( $content ); + $output = ''; - // Since content is a working copy since the last match, append to - // rendered content up to the matched offset... - $rendered_content .= substr( $content, 0, $offset ); - - // ...then update the working copy of content. - $content = substr( $content, $offset + strlen( $opening_tag ) ); - - // Make implicit core namespace explicit. - $is_implicit_core_namespace = ( false === strpos( $block_name, '/' ) ); - $normalized_block_name = $is_implicit_core_namespace ? 'core/' . $block_name : $block_name; - - // Find registered block type. We can assume it exists since we use the - // `get_dynamic_block_names` function as a source for pattern matching. - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $normalized_block_name ); - - // Attempt to parse attributes JSON, if available. - $attributes = array(); - if ( ! empty( $block_attributes_json ) ) { - $decoded_attributes = json_decode( $block_attributes_json, true ); - if ( ! is_null( $decoded_attributes ) ) { - $attributes = $decoded_attributes; - } - } - - $inner_content = ''; - - if ( ! $is_self_closing ) { - $end_tag_pattern = '//'; - if ( ! preg_match( $end_tag_pattern, $content, $block_match_end, PREG_OFFSET_CAPTURE ) ) { - // If no closing tag is found, abort all matching, and continue - // to append remainder of content to rendered output. - break; - } - - // Update content to omit text up to and including closing tag. - $end_tag = $block_match_end[0][0]; - $end_offset = $block_match_end[0][1]; - - $inner_content = substr( $content, 0, $end_offset ); - $content = substr( $content, $end_offset + strlen( $end_tag ) ); - } - - // Replace dynamic block with server-rendered output. - $rendered_content .= $block_type->render( $attributes, $inner_content ); - - // Restore global $post. - $post = $global_post; + foreach ( $blocks as $block ) { + $output .= gutenberg_render_block( $block ); } - // Append remaining unmatched content. - $rendered_content .= $content; - - // Strip remaining block comment demarcations. - $rendered_content = preg_replace( '/\r?\n?/m', '', $rendered_content ); - - return $rendered_content; + return $output; } add_filter( 'the_content', 'do_blocks', 7 ); // BEFORE do_shortcode() and oembed. diff --git a/lib/class-wp-block-type.php b/lib/class-wp-block-type.php index 0c18bb6efa4682..c186eec88a0a3b 100644 --- a/lib/class-wp-block-type.php +++ b/lib/class-wp-block-type.php @@ -120,36 +120,47 @@ public function is_dynamic() { /** * Validates attributes against the current block schema, populating - * defaulted and missing values, and omitting unknown attributes. + * defaulted and missing values. * * @param array $attributes Original block attributes. * @return array Prepared block attributes. */ public function prepare_attributes_for_render( $attributes ) { + // If there are no attribute definitions for the block type, skip + // processing and return vebatim. if ( ! isset( $this->attributes ) ) { return $attributes; } - $prepared_attributes = array(); + foreach ( $attributes as $attribute_name => $value ) { + // If the attribute is not defined by the block type, it cannot be + // validated. + if ( ! isset( $this->attributes[ $attribute_name ] ) ) { + continue; + } - foreach ( $this->attributes as $attribute_name => $schema ) { - $value = null; + $schema = $this->attributes[ $attribute_name ]; - if ( isset( $attributes[ $attribute_name ] ) ) { - $is_valid = rest_validate_value_from_schema( $attributes[ $attribute_name ], $schema ); - if ( ! is_wp_error( $is_valid ) ) { - $value = rest_sanitize_value_from_schema( $attributes[ $attribute_name ], $schema ); - } + // Validate value by JSON schema. An invalid value should revert to + // its default, if one exists. This occurs by virtue of the missing + // attributes loop immediately following. If there is not a default + // assigned, the attribute value should remain unset. + $is_valid = rest_validate_value_from_schema( $value, $schema ); + if ( is_wp_error( $is_valid ) ) { + unset( $attributes[ $attribute_name ] ); } + } - if ( is_null( $value ) && isset( $schema['default'] ) ) { - $value = $schema['default']; + // Populate values of any missing attributes for which the block type + // defines a default. + $missing_schema_attributes = array_diff_key( $this->attributes, $attributes ); + foreach ( $missing_schema_attributes as $attribute_name => $schema ) { + if ( isset( $schema['default'] ) ) { + $attributes[ $attribute_name ] = $schema['default']; } - - $prepared_attributes[ $attribute_name ] = $value; } - return $prepared_attributes; + return $attributes; } /** diff --git a/lib/class-wp-rest-autosaves-controller.php b/lib/class-wp-rest-autosaves-controller.php index fe15b3f3f3fac8..6f6496c35790e3 100644 --- a/lib/class-wp-rest-autosaves-controller.php +++ b/lib/class-wp-rest-autosaves-controller.php @@ -79,7 +79,7 @@ public function __construct( $parent_post_type ) { public function register_routes() { register_rest_route( $this->rest_namespace, - '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, + '/' . $this->parent_base . '/(?P[\d]+)/' . $this->rest_base, array( 'args' => array( 'parent' => array( @@ -90,14 +90,14 @@ public function register_routes() { array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this->revisions_controller, 'get_items_permissions_check' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), 'args' => $this->get_collection_params(), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => array( $this, 'create_item_permissions_check' ), - 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + 'args' => $this->parent_controller->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ), ), 'schema' => array( $this, 'get_public_item_schema' ), ) @@ -143,6 +143,28 @@ protected function get_parent( $parent_id ) { return $this->revisions_controller->get_parent( $parent_id ); } + /** + * Checks if a given request has access to get autosaves. + * + * @since 5.0.0 + * + * @param WP_REST_Request $request Full data about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + $parent = $this->get_parent( $request['id'] ); + if ( is_wp_error( $parent ) ) { + return $parent; + } + + $parent_post_type_obj = get_post_type_object( $parent->post_type ); + if ( ! current_user_can( $parent_post_type_obj->cap->edit_post, $parent->ID ) ) { + return new WP_Error( 'rest_cannot_read', __( 'Sorry, you are not allowed to view revisions of this post.', 'gutenberg' ), array( 'status' => rest_authorization_required_code() ) ); + } + + return true; + } + /** * Checks if a given request has access to create an autosave revision. * @@ -177,7 +199,7 @@ public function create_item( $request ) { define( 'DOING_AUTOSAVE', true ); } - $post = get_post( $request->get_param( 'id' ) ); + $post = get_post( $request['id'] ); if ( is_wp_error( $post ) ) { return $post; @@ -245,7 +267,7 @@ public function get_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $parent = $this->get_parent( $request->get_param( 'parent' ) ); + $parent = $this->get_parent( $request['id'] ); if ( is_wp_error( $parent ) ) { return $parent; } @@ -388,4 +410,17 @@ public function prepare_item_for_response( $post, $request ) { */ return apply_filters( 'rest_prepare_autosave', $response, $post, $request ); } + + /** + * Retrieves the query params for the autosaves collection. + * + * @since 5.0.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + return array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ); + } } diff --git a/lib/class-wp-rest-block-renderer-controller.php b/lib/class-wp-rest-block-renderer-controller.php index d3fcd221e3db92..b9839f080376a2 100644 --- a/lib/class-wp-rest-block-renderer-controller.php +++ b/lib/class-wp-rest-block-renderer-controller.php @@ -59,6 +59,7 @@ public function register_routes() { 'type' => 'object', 'additionalProperties' => false, 'properties' => $block_type->get_attributes(), + 'default' => array(), ), 'post_id' => array( 'description' => __( 'ID of the post context.', 'gutenberg' ), @@ -91,7 +92,7 @@ public function get_item_permissions_check( $request ) { if ( ! $post || ! current_user_can( 'edit_post', $post->ID ) ) { return new WP_Error( 'gutenberg_block_cannot_read', - __( 'Sorry, you are not allowed to read Gutenberg blocks of this post', 'gutenberg' ), + __( 'Sorry, you are not allowed to read Gutenberg blocks of this post.', 'gutenberg' ), array( 'status' => rest_authorization_required_code(), ) diff --git a/lib/class-wp-rest-blocks-controller.php b/lib/class-wp-rest-blocks-controller.php index 9689820c7494be..47882fafbff042 100644 --- a/lib/class-wp-rest-blocks-controller.php +++ b/lib/class-wp-rest-blocks-controller.php @@ -33,4 +33,57 @@ public function check_read_permission( $post ) { return parent::check_read_permission( $post ); } + + /** + * Filters a response based on the context defined in the schema. + * + * @since 4.4.0 + * + * @param array $data Response data to fiter. + * @param string $context Context defined in the schema. + * @return array Filtered response. + */ + public function filter_response_by_context( $data, $context ) { + $data = parent::filter_response_by_context( $data, $context ); + + /* + * Remove `title.rendered` and `content.rendered` from the response. It + * doesn't make sense for a reusable block to have rendered content on its + * own, since rendering a block requires it to be inside a post or a page. + */ + unset( $data['title']['rendered'] ); + unset( $data['content']['rendered'] ); + + return $data; + } + + /** + * Retrieves the block's schema, conforming to JSON Schema. + * + * @since 4.4.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + + /* + * Allow all contexts to access `title.raw` and `content.raw`. Clients always + * need the raw markup of a reusable block to do anything useful, e.g. parse + * it or display it in an editor. + */ + $schema['properties']['title']['properties']['raw']['context'] = array( 'view', 'edit' ); + $schema['properties']['content']['properties']['raw']['context'] = array( 'view', 'edit' ); + + /* + * Remove `title.rendered` and `content.rendered` from the schema. It doesn’t + * make sense for a reusable block to have rendered content on its own, since + * rendering a block requires it to be inside a post or a page. + */ + unset( $schema['properties']['title']['properties']['rendered'] ); + unset( $schema['properties']['content']['properties']['rendered'] ); + + return $schema; + } + } diff --git a/lib/client-assets.php b/lib/client-assets.php index 71fb20403897e3..fd1553c3cfd87b 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -133,6 +133,29 @@ function gutenberg_override_style( $handle, $src, $deps = array(), $ver = false, wp_register_style( $handle, $src, $deps, $ver, $media ); } +/** + * Registers all the WordPress packages scripts that are in the standardized + * `build/` location. + * + * @since 4.5.0 + */ +function gutenberg_register_packages_scripts() { + $packages_dependencies = include dirname( __FILE__ ) . '/packages-dependencies.php'; + + foreach ( $packages_dependencies as $handle => $dependencies ) { + // Remove `wp-` prefix from the handle to get the package's name. + $package_name = strpos( $handle, 'wp-' ) === 0 ? substr( $handle, 3 ) : $handle; + $path = "build/$package_name/index.js"; + gutenberg_override_script( + $handle, + gutenberg_url( $path ), + array_merge( $dependencies, array( 'wp-polyfill' ) ), + filemtime( gutenberg_dir_path() . $path ), + true + ); + } +} + /** * Registers common scripts and styles to be used as dependencies of the editor * and plugins. @@ -144,90 +167,22 @@ function gutenberg_register_scripts_and_styles() { register_tinymce_scripts(); - wp_script_add_data( + wp_add_inline_script( 'wp-polyfill', - 'data', gutenberg_get_script_polyfill( array( '\'fetch\' in window' => 'wp-polyfill-fetch', 'document.contains' => 'wp-polyfill-node-contains', 'window.FormData && window.FormData.prototype.keys' => 'wp-polyfill-formdata', 'Element.prototype.matches && Element.prototype.closest' => 'wp-polyfill-element-closest', - ) + ), + 'after' ) ); - gutenberg_override_script( - 'wp-url', - gutenberg_url( 'build/url/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/url/index.js' ), - true - ); - gutenberg_override_script( - 'wp-autop', - gutenberg_url( 'build/autop/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/autop/index.js' ), - true - ); - gutenberg_override_script( - 'wp-wordcount', - gutenberg_url( 'build/wordcount/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/wordcount/index.js' ), - true - ); - gutenberg_override_script( - 'wp-dom-ready', - gutenberg_url( 'build/dom-ready/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/dom-ready/index.js' ), - true - ); - gutenberg_override_script( - 'wp-a11y', - gutenberg_url( 'build/a11y/index.js' ), - array( 'wp-dom-ready', 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/a11y/index.js' ), - true - ); - gutenberg_override_script( - 'wp-hooks', - gutenberg_url( 'build/hooks/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/hooks/index.js' ), - true - ); - gutenberg_override_script( - 'wp-i18n', - gutenberg_url( 'build/i18n/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/i18n/index.js' ), - true - ); - gutenberg_override_script( - 'wp-is-shallow-equal', - gutenberg_url( 'build/is-shallow-equal/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/is-shallow-equal/index.js' ), - true - ); - gutenberg_override_script( - 'wp-token-list', - gutenberg_url( 'build/token-list/index.js' ), - array( 'lodash', 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/token-list/index.js' ), - true - ); - // Editor Scripts. - gutenberg_override_script( - 'wp-api-fetch', - gutenberg_url( 'build/api-fetch/index.js' ), - array( 'wp-polyfill', 'wp-hooks', 'wp-i18n', 'wp-url' ), - filemtime( gutenberg_dir_path() . 'build/api-fetch/index.js' ), - true - ); + gutenberg_register_packages_scripts(); + + // Inline scripts. wp_add_inline_script( 'wp-api-fetch', sprintf( @@ -244,63 +199,6 @@ function gutenberg_register_scripts_and_styles() { ), 'after' ); - - gutenberg_override_script( - 'wp-deprecated', - gutenberg_url( 'build/deprecated/index.js' ), - array( 'wp-polyfill', 'wp-hooks' ), - filemtime( gutenberg_dir_path() . 'build/deprecated/index.js' ), - true - ); - gutenberg_override_script( - 'wp-blob', - gutenberg_url( 'build/blob/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/blob/index.js' ), - true - ); - gutenberg_override_script( - 'wp-compose', - gutenberg_url( 'build/compose/index.js' ), - array( - 'lodash', - 'wp-deprecated', - 'wp-element', - 'wp-is-shallow-equal', - 'wp-polyfill', - ), - filemtime( gutenberg_dir_path() . 'build/compose/index.js' ), - true - ); - gutenberg_override_script( - 'wp-keycodes', - gutenberg_url( 'build/keycodes/index.js' ), - array( 'lodash', 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/keycodes/index.js' ), - true - ); - gutenberg_override_script( - 'wp-html-entities', - gutenberg_url( 'build/html-entities/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/html-entities/index.js' ), - true - ); - gutenberg_override_script( - 'wp-data', - gutenberg_url( 'build/data/index.js' ), - array( - 'lodash', - 'wp-compose', - 'wp-deprecated', - 'wp-element', - 'wp-is-shallow-equal', - 'wp-polyfill', - 'wp-redux-routine', - ), - filemtime( gutenberg_dir_path() . 'build/data/index.js' ), - true - ); wp_add_inline_script( 'wp-data', implode( @@ -316,62 +214,6 @@ function gutenberg_register_scripts_and_styles() { ) ) ); - gutenberg_override_script( - 'wp-annotations', - gutenberg_url( 'build/annotations/index.js' ), - array( 'wp-polyfill', 'wp-data', 'wp-rich-text', 'wp-hooks', 'wp-i18n' ), - filemtime( gutenberg_dir_path() . 'build/annotations/index.js' ), - true - ); - gutenberg_override_script( - 'wp-core-data', - gutenberg_url( 'build/core-data/index.js' ), - array( 'wp-data', 'wp-api-fetch', 'wp-polyfill', 'wp-url', 'lodash' ), - filemtime( gutenberg_dir_path() . 'build/core-data/index.js' ), - true - ); - gutenberg_override_script( - 'wp-dom', - gutenberg_url( 'build/dom/index.js' ), - array( 'lodash', 'wp-polyfill', 'wp-tinymce' ), - filemtime( gutenberg_dir_path() . 'build/dom/index.js' ), - true - ); - gutenberg_override_script( - 'wp-block-serialization-default-parser', - gutenberg_url( 'build/block-serialization-default-parser/index.js' ), - array(), - filemtime( gutenberg_dir_path() . 'build/block-serialization-default-parser/index.js' ), - true - ); - gutenberg_override_script( - 'wp-block-serialization-spec-parser', - gutenberg_url( 'build/block-serialization-spec-parser/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/block-serialization-spec-parser/index.js' ), - true - ); - gutenberg_override_script( - 'wp-shortcode', - gutenberg_url( 'build/shortcode/index.js' ), - array( 'wp-polyfill', 'lodash' ), - filemtime( gutenberg_dir_path() . 'build/shortcode/index.js' ), - true - ); - gutenberg_override_script( - 'wp-redux-routine', - gutenberg_url( 'build/redux-routine/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/redux-routine/index.js' ), - true - ); - gutenberg_override_script( - 'wp-date', - gutenberg_url( 'build/date/index.js' ), - array( 'moment', 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/date/index.js' ), - true - ); global $wp_locale; wp_add_inline_script( 'wp-date', @@ -408,163 +250,32 @@ function gutenberg_register_scripts_and_styles() { ), 'after' ); - gutenberg_override_script( - 'wp-element', - gutenberg_url( 'build/element/index.js' ), - array( 'wp-polyfill', 'react', 'react-dom', 'lodash', 'wp-escape-html' ), - filemtime( gutenberg_dir_path() . 'build/element/index.js' ), - true - ); - gutenberg_override_script( - 'wp-escape-html', - gutenberg_url( 'build/escape-html/index.js' ), - array( 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/element/index.js' ), - true - ); - gutenberg_override_script( - 'wp-rich-text', - gutenberg_url( 'build/rich-text/index.js' ), - array( - 'lodash', - 'wp-polyfill', - 'wp-data', - 'wp-deprecated', - 'wp-escape-html', - ), - filemtime( gutenberg_dir_path() . 'build/rich-text/index.js' ), - true - ); - gutenberg_override_script( - 'wp-components', - gutenberg_url( 'build/components/index.js' ), - array( - 'lodash', - 'moment', - 'wp-a11y', - 'wp-api-fetch', - 'wp-compose', - 'wp-deprecated', - 'wp-dom', - 'wp-element', - 'wp-hooks', - 'wp-html-entities', - 'wp-i18n', - 'wp-is-shallow-equal', - 'wp-keycodes', - 'wp-polyfill', - 'wp-rich-text', - 'wp-url', - ), - filemtime( gutenberg_dir_path() . 'build/components/index.js' ), - true - ); - gutenberg_override_script( - 'wp-blocks', - gutenberg_url( 'build/blocks/index.js' ), - array( - 'wp-autop', - 'wp-blob', - 'wp-block-serialization-default-parser', - 'wp-data', - 'wp-dom', - 'wp-element', - 'wp-hooks', - 'wp-i18n', - 'wp-is-shallow-equal', - 'wp-polyfill', - 'wp-shortcode', - 'lodash', - ), - filemtime( gutenberg_dir_path() . 'build/blocks/index.js' ), - true - ); - gutenberg_override_script( - 'wp-notices', - gutenberg_url( 'build/notices/index.js' ), - array( - 'lodash', - 'wp-a11y', - 'wp-data', - 'wp-polyfill', - ), - filemtime( gutenberg_dir_path() . 'build/notices/index.js' ), - true - ); - gutenberg_override_script( - 'wp-viewport', - gutenberg_url( 'build/viewport/index.js' ), - array( 'wp-polyfill', 'wp-element', 'wp-data', 'wp-compose', 'lodash' ), - filemtime( gutenberg_dir_path() . 'build/viewport/index.js' ), - true - ); - gutenberg_override_script( - 'wp-block-library', - gutenberg_url( 'build/block-library/index.js' ), - array( - 'editor', - 'lodash', - 'moment', - 'wp-api-fetch', - 'wp-autop', - 'wp-blob', - 'wp-blocks', - 'wp-components', - 'wp-compose', - 'wp-core-data', - 'wp-data', - 'wp-date', - 'wp-editor', - 'wp-element', - 'wp-html-entities', - 'wp-i18n', - 'wp-keycodes', - 'wp-polyfill', - 'wp-url', - 'wp-viewport', - 'wp-rich-text', - ), - filemtime( gutenberg_dir_path() . 'build/block-library/index.js' ), - true - ); - gutenberg_override_script( - 'wp-format-library', - gutenberg_url( 'build/format-library/index.js' ), - array( - 'wp-components', - 'wp-dom', - 'wp-editor', - 'wp-element', - 'wp-i18n', - 'wp-keycodes', - 'wp-polyfill', - 'wp-rich-text', - 'wp-url', - ), - filemtime( gutenberg_dir_path() . 'build/format-library/index.js' ), - true - ); - gutenberg_override_script( - 'wp-nux', - gutenberg_url( 'build/nux/index.js' ), - array( - 'wp-element', - 'wp-components', - 'wp-compose', - 'wp-data', - 'wp-deprecated', - 'wp-i18n', - 'wp-polyfill', - 'lodash', + wp_add_inline_script( + 'moment', + sprintf( + "moment.locale( '%s', %s );", + get_user_locale(), + wp_json_encode( + array( + 'months' => array_values( $wp_locale->month ), + 'monthsShort' => array_values( $wp_locale->month_abbrev ), + 'weekdays' => array_values( $wp_locale->weekday ), + 'weekdaysShort' => array_values( $wp_locale->weekday_abbrev ), + 'week' => array( + 'dow' => (int) get_option( 'start_of_week', 0 ), + ), + 'longDateFormat' => array( + 'LT' => get_option( 'time_format', __( 'g:i a', 'default' ) ), + 'LTS' => null, + 'L' => null, + 'LL' => get_option( 'date_format', __( 'F j, Y', 'default' ) ), + 'LLL' => __( 'F j, Y g:i a', 'default' ), + 'LLLL' => null, + ), + ) + ) ), - filemtime( gutenberg_dir_path() . 'build/nux/index.js' ), - true - ); - gutenberg_override_script( - 'wp-plugins', - gutenberg_url( 'build/plugins/index.js' ), - array( 'lodash', 'wp-compose', 'wp-element', 'wp-hooks', 'wp-polyfill' ), - filemtime( gutenberg_dir_path() . 'build/plugins/index.js' ) + 'after' ); // Loading the old editor and its config to ensure the classic block works as expected. wp_add_inline_script( @@ -572,205 +283,127 @@ function gutenberg_register_scripts_and_styles() { 'window.wp.oldEditor = window.wp.editor;', 'after' ); - $tinymce_settings = apply_filters( - 'tiny_mce_before_init', - array( - 'plugins' => implode( - ',', - array_unique( - apply_filters( - 'tiny_mce_plugins', - array( - 'charmap', - 'colorpicker', - 'hr', - 'lists', - 'media', - 'paste', - 'tabfocus', - 'textcolor', - 'fullscreen', - 'wordpress', - 'wpautoresize', - 'wpeditimage', - 'wpemoji', - 'wpgallery', - 'wplink', - 'wpdialogs', - 'wptextpattern', - 'wpview', - ) - ) - ) - ), - 'toolbar1' => implode( - ',', - apply_filters( - 'mce_buttons', - array( - 'formatselect', - 'bold', - 'italic', - 'bullist', - 'numlist', - 'blockquote', - 'alignleft', - 'aligncenter', - 'alignright', - 'link', - 'unlink', - 'wp_more', - 'spellchecker', - 'wp_add_media', - 'kitchensink', - ), - 'editor' - ) - ), - 'toolbar2' => implode( - ',', - apply_filters( - 'mce_buttons_2', - array( - 'strikethrough', - 'hr', - 'forecolor', - 'pastetext', - 'removeformat', - 'charmap', - 'outdent', - 'indent', - 'undo', - 'redo', - 'wp_help', - ), - 'editor' - ) - ), - 'toolbar3' => implode( ',', apply_filters( 'mce_buttons_3', array(), 'editor' ) ), - 'toolbar4' => implode( ',', apply_filters( 'mce_buttons_4', array(), 'editor' ) ), - 'external_plugins' => apply_filters( 'mce_external_plugins', array() ), - ), - 'editor' - ); - if ( isset( $tinymce_settings['style_formats'] ) && is_string( $tinymce_settings['style_formats'] ) ) { - // Decode the options as we used to recommende json_encoding the TinyMCE settings. - $tinymce_settings['style_formats'] = json_decode( $tinymce_settings['style_formats'] ); + + $tinymce_plugins = array( + 'charmap', + 'colorpicker', + 'hr', + 'lists', + 'media', + 'paste', + 'tabfocus', + 'textcolor', + 'fullscreen', + 'wordpress', + 'wpautoresize', + 'wpeditimage', + 'wpemoji', + 'wpgallery', + 'wplink', + 'wpdialogs', + 'wptextpattern', + 'wpview', + ); + $tinymce_plugins = apply_filters( 'tiny_mce_plugins', $tinymce_plugins, 'classic-block' ); + $tinymce_plugins = array_unique( $tinymce_plugins ); + + $toolbar1 = array( + 'formatselect', + 'bold', + 'italic', + 'bullist', + 'numlist', + 'blockquote', + 'alignleft', + 'aligncenter', + 'alignright', + 'link', + 'unlink', + 'wp_more', + 'spellchecker', + 'wp_add_media', + 'kitchensink', + ); + $toolbar1 = apply_filters( 'mce_buttons', $toolbar1, 'classic-block' ); + + $toolbar2 = array( + 'strikethrough', + 'hr', + 'forecolor', + 'pastetext', + 'removeformat', + 'charmap', + 'outdent', + 'indent', + 'undo', + 'redo', + 'wp_help', + ); + $toolbar2 = apply_filters( 'mce_buttons_2', $toolbar2, 'classic-block' ); + + $toolbar3 = apply_filters( 'mce_buttons_3', array(), 'classic-block' ); + $toolbar4 = apply_filters( 'mce_buttons_4', array(), 'classic-block' ); + + $external_plugins = apply_filters( 'mce_external_plugins', array(), 'classic-block' ); + + $tinymce_settings = array( + 'plugins' => implode( ',', $tinymce_plugins ), + 'toolbar1' => implode( ',', $toolbar1 ), + 'toolbar2' => implode( ',', $toolbar2 ), + 'toolbar3' => implode( ',', $toolbar3 ), + 'toolbar4' => implode( ',', $toolbar4 ), + 'external_plugins' => wp_json_encode( $external_plugins ), + 'classic_block_editor' => true, + ); + $tinymce_settings = apply_filters( 'tiny_mce_before_init', $tinymce_settings, 'classic-block' ); + + // Do "by hand" translation from PHP array to js object. + // Prevents breakage in some custom settings. + $init_obj = ''; + foreach ( $tinymce_settings as $key => $value ) { + if ( is_bool( $value ) ) { + $val = $value ? 'true' : 'false'; + $init_obj .= $key . ':' . $val . ','; + continue; + } elseif ( ! empty( $value ) && is_string( $value ) && ( + ( '{' == $value{0} && '}' == $value{strlen( $value ) - 1} ) || + ( '[' == $value{0} && ']' == $value{strlen( $value ) - 1} ) || + preg_match( '/^\(?function ?\(/', $value ) ) ) { + + $init_obj .= $key . ':' . $value . ','; + continue; + } + $init_obj .= $key . ':"' . $value . '",'; } - wp_localize_script( - 'wp-block-library', - 'wpEditorL10n', - array( - 'tinymce' => array( - 'baseURL' => includes_url( 'js/tinymce' ), - 'suffix' => SCRIPT_DEBUG ? '' : '.min', - 'settings' => $tinymce_settings, - ), - ) - ); - gutenberg_override_script( - 'wp-editor', - gutenberg_url( 'build/editor/index.js' ), - array( - 'jquery', - 'lodash', - 'tinymce-latest-lists', - 'wp-a11y', - 'wp-api-fetch', - 'wp-blob', - 'wp-blocks', - 'wp-components', - 'wp-compose', - 'wp-core-data', - 'wp-data', - 'wp-date', - 'wp-deprecated', - 'wp-dom', - 'wp-element', - 'wp-hooks', - 'wp-html-entities', - 'wp-i18n', - 'wp-is-shallow-equal', - 'wp-keycodes', - 'wp-notices', - 'wp-nux', - 'wp-polyfill', - 'wp-tinymce', - 'wp-token-list', - 'wp-url', - 'wp-viewport', - 'wp-wordcount', - 'wp-rich-text', - ), - filemtime( gutenberg_dir_path() . 'build/editor/index.js' ) - ); + $init_obj = '{' . trim( $init_obj, ' ,' ) . '}'; - gutenberg_override_script( - 'wp-edit-post', - gutenberg_url( 'build/edit-post/index.js' ), - array( - 'jquery', - 'lodash', - 'postbox', - 'media-models', - 'media-views', - 'wp-a11y', - 'wp-api-fetch', - 'wp-block-library', - 'wp-blocks', - 'wp-components', - 'wp-compose', - 'wp-core-data', - 'wp-data', - 'wp-dom-ready', - 'wp-editor', - 'wp-element', - 'wp-embed', - 'wp-i18n', - 'wp-keycodes', - 'wp-nux', - 'wp-plugins', - 'wp-polyfill', - 'wp-url', - 'wp-viewport', - ), - filemtime( gutenberg_dir_path() . 'build/edit-post/index.js' ), - true - ); + $script = 'window.wpEditorL10n = { + tinymce: { + baseURL: ' . wp_json_encode( includes_url( 'js/tinymce' ) ) . ', + suffix: ' . ( SCRIPT_DEBUG ? '""' : '".min"' ) . ', + settings: ' . $init_obj . ', + } + }'; - gutenberg_override_script( - 'wp-list-reusable-blocks', - gutenberg_url( 'build/list-reusable-blocks/index.js' ), - array( - 'lodash', - 'wp-api-fetch', - 'wp-components', - 'wp-compose', - 'wp-element', - 'wp-i18n', - 'wp-polyfill', - ), - filemtime( gutenberg_dir_path() . 'build/list-reusable-blocks/index.js' ), - true - ); + wp_add_inline_script( 'wp-block-library', $script, 'before' ); // Editor Styles. // This empty stylesheet is defined to ensure backwards compatibility. gutenberg_override_style( 'wp-blocks', false ); - $fonts_url = ''; /* - * Translators: If there are characters in your language that are not supported - * by Noto Serif, translate this to 'off'. Do not translate into your own language. + * Translators: Use this to specify the proper Google Font name and variants + * to load that is supported by your language. Do not translate. + * Set to 'off' to disable loading. */ - if ( 'off' !== _x( 'on', 'Noto Serif font: on or off', 'gutenberg' ) ) { + $font_family = _x( 'Noto Serif:400,400i,700,700i', 'Google Font Name and Variants', 'gutenberg' ); + if ( 'off' !== $font_family ) { $query_args = array( - 'family' => urlencode( 'Noto Serif:400,400i,700,700i' ), + 'family' => urlencode( $font_family ), ); - - $fonts_url = esc_url_raw( add_query_arg( $query_args, 'https://fonts.googleapis.com/css' ) ); + $fonts_url = esc_url_raw( add_query_arg( $query_args, 'https://fonts.googleapis.com/css' ) ); } gutenberg_override_style( @@ -908,12 +541,22 @@ function gutenberg_preload_api_request( $memo, $path ) { return $memo; } + $method = 'GET'; + if ( is_array( $path ) && 2 === count( $path ) ) { + $method = end( $path ); + $path = reset( $path ); + + if ( ! in_array( $method, array( 'GET', 'OPTIONS' ), true ) ) { + $method = 'GET'; + } + } + $path_parts = parse_url( $path ); if ( false === $path_parts ) { return $memo; } - $request = new WP_REST_Request( 'GET', $path_parts['path'] ); + $request = new WP_REST_Request( $method, $path_parts['path'] ); if ( ! empty( $path_parts['query'] ) ) { parse_str( $path_parts['query'], $query_params ); $request->set_query_params( $query_params ); @@ -928,10 +571,19 @@ function gutenberg_preload_api_request( $memo, $path ) { $data['_links'] = $links; } - $memo[ $path ] = array( - 'body' => $data, - 'headers' => $response->headers, - ); + if ( 'OPTIONS' === $method ) { + $response = rest_send_allow_header( $response, $server, $request ); + + $memo[ $method ][ $path ] = array( + 'body' => $data, + 'headers' => $response->headers, + ); + } else { + $memo[ $path ] = array( + 'body' => $data, + 'headers' => $response->headers, + ); + } } return $memo; @@ -954,11 +606,12 @@ function gutenberg_register_vendor_scripts() { gutenberg_register_vendor_script( 'react', - 'https://unpkg.com/react@16.4.1/umd/react' . $react_suffix . '.js' + 'https://unpkg.com/react@16.6.3/umd/react' . $react_suffix . '.js', + array( 'wp-polyfill' ) ); gutenberg_register_vendor_script( 'react-dom', - 'https://unpkg.com/react-dom@16.4.1/umd/react-dom' . $react_suffix . '.js', + 'https://unpkg.com/react-dom@16.6.3/umd/react-dom' . $react_suffix . '.js', array( 'react' ) ); $moment_script = SCRIPT_DEBUG ? 'moment.js' : 'min/moment.min.js'; @@ -1402,12 +1055,6 @@ function gutenberg_editor_scripts_and_styles( $hook ) { 'after' ); - // Ignore Classic Editor's `rich_editing` user option, aka "Disable visual - // editor". Forcing this to be true guarantees that TinyMCE and its plugins - // are available in Gutenberg. Fixes - // https://github.com/WordPress/gutenberg/issues/5667. - add_filter( 'user_can_richedit', '__return_true' ); - wp_enqueue_script( 'wp-edit-post' ); wp_enqueue_script( 'wp-format-library' ); wp_enqueue_style( 'wp-format-library' ); @@ -1431,6 +1078,7 @@ function gutenberg_editor_scripts_and_styles( $hook ) { sprintf( '/wp/v2/%s/%s?context=edit', $rest_base, $post->ID ), sprintf( '/wp/v2/types/%s?context=edit', $post_type ), sprintf( '/wp/v2/users/me?post_type=%s&context=edit', $post_type ), + array( '/wp/v2/media', 'OPTIONS' ), ); /** @@ -1559,6 +1207,16 @@ function gutenberg_editor_scripts_and_styles( $hook ) { ), ), ); + + /* + * Set a locale specific default font. + * Translators: Use this to specify the CSS font family for the default font + */ + $locale_font_family = esc_html_x( 'Noto Serif', 'CSS Font Family for Editor Font', 'gutenberg' ); + $styles[] = array( + 'css' => "body { font-family: '$locale_font_family' }", + ); + if ( $editor_styles && current_theme_supports( 'editor-styles' ) ) { foreach ( $editor_styles as $style ) { if ( filter_var( $style, FILTER_VALIDATE_URL ) ) { diff --git a/lib/compat.php b/lib/compat.php index ff0938c496828a..88b417491a89e9 100644 --- a/lib/compat.php +++ b/lib/compat.php @@ -116,7 +116,6 @@ function gutenberg_wpautop( $content ) { remove_filter( 'the_content', 'wpautop' ); add_filter( 'the_content', 'gutenberg_wpautop', 6 ); - /** * Check if we need to load the block warning in the Classic Editor. * @@ -304,3 +303,22 @@ function gutenberg_warn_classic_about_blocks() { array( + 'wp-dom-ready', + ), + 'wp-annotations' => array( + 'wp-data', + 'wp-hooks', + 'wp-i18n', + 'wp-rich-text', + ), + 'wp-api-fetch' => array( + 'wp-hooks', + 'wp-i18n', + 'wp-url', + ), + 'wp-autop' => array(), + 'wp-blob' => array(), + 'wp-block-library' => array( + 'editor', + 'lodash', + 'moment', + 'wp-api-fetch', + 'wp-autop', + 'wp-blob', + 'wp-blocks', + 'wp-components', + 'wp-compose', + 'wp-core-data', + 'wp-data', + 'wp-date', + 'wp-editor', + 'wp-element', + 'wp-html-entities', + 'wp-i18n', + 'wp-keycodes', + 'wp-rich-text', + 'wp-url', + 'wp-viewport', + ), + 'wp-block-serialization-default-parser' => array(), + 'wp-block-serialization-spec-parser' => array(), + 'wp-blocks' => array( + 'lodash', + 'wp-autop', + 'wp-blob', + 'wp-block-serialization-default-parser', + 'wp-data', + 'wp-dom', + 'wp-element', + 'wp-hooks', + 'wp-html-entities', + 'wp-i18n', + 'wp-is-shallow-equal', + 'wp-shortcode', + ), + 'wp-components' => array( + 'lodash', + 'moment', + 'wp-a11y', + 'wp-api-fetch', + 'wp-compose', + 'wp-dom', + 'wp-element', + 'wp-hooks', + 'wp-html-entities', + 'wp-i18n', + 'wp-is-shallow-equal', + 'wp-keycodes', + 'wp-rich-text', + 'wp-url', + ), + 'wp-compose' => array( + 'lodash', + 'wp-element', + 'wp-is-shallow-equal', + ), + 'wp-core-data' => array( + 'lodash', + 'wp-api-fetch', + 'wp-data', + 'wp-url', + ), + 'wp-data' => array( + 'lodash', + 'wp-compose', + 'wp-element', + 'wp-is-shallow-equal', + 'wp-redux-routine', + ), + 'wp-date' => array( + 'moment', + ), + 'wp-deprecated' => array( + 'wp-hooks', + ), + 'wp-dom' => array( + 'lodash', + 'wp-tinymce', + ), + 'wp-dom-ready' => array(), + 'wp-edit-post' => array( + 'jquery', + 'lodash', + 'postbox', + 'media-models', + 'media-views', + 'wp-a11y', + 'wp-api-fetch', + 'wp-block-library', + 'wp-blocks', + 'wp-components', + 'wp-compose', + 'wp-core-data', + 'wp-data', + 'wp-dom-ready', + 'wp-editor', + 'wp-element', + 'wp-embed', + 'wp-i18n', + 'wp-keycodes', + 'wp-notices', + 'wp-nux', + 'wp-plugins', + 'wp-url', + 'wp-viewport', + ), + 'wp-editor' => array( + 'jquery', + 'lodash', + 'tinymce-latest-lists', + 'wp-a11y', + 'wp-api-fetch', + 'wp-blob', + 'wp-blocks', + 'wp-components', + 'wp-compose', + 'wp-core-data', + 'wp-data', + 'wp-date', + 'wp-deprecated', + 'wp-dom', + 'wp-element', + 'wp-hooks', + 'wp-html-entities', + 'wp-i18n', + 'wp-is-shallow-equal', + 'wp-keycodes', + 'wp-notices', + 'wp-nux', + 'wp-rich-text', + 'wp-tinymce', + 'wp-token-list', + 'wp-url', + 'wp-viewport', + 'wp-wordcount', + ), + 'wp-element' => array( + 'lodash', + 'react', + 'react-dom', + 'wp-escape-html', + ), + 'wp-escape-html' => array(), + 'wp-format-library' => array( + 'wp-components', + 'wp-dom', + 'wp-editor', + 'wp-element', + 'wp-i18n', + 'wp-keycodes', + 'wp-rich-text', + 'wp-url', + ), + 'wp-hooks' => array(), + 'wp-html-entities' => array(), + 'wp-i18n' => array(), + 'wp-is-shallow-equal' => array(), + 'wp-keycodes' => array( + 'lodash', + ), + 'wp-list-reusable-blocks' => array( + 'lodash', + 'wp-api-fetch', + 'wp-components', + 'wp-compose', + 'wp-element', + 'wp-i18n', + ), + 'wp-notices' => array( + 'lodash', + 'wp-a11y', + 'wp-data', + ), + 'wp-nux' => array( + 'lodash', + 'wp-components', + 'wp-compose', + 'wp-data', + 'wp-element', + 'wp-i18n', + ), + 'wp-plugins' => array( + 'lodash', + 'wp-compose', + 'wp-element', + 'wp-hooks', + ), + 'wp-redux-routine' => array(), + 'wp-rich-text' => array( + 'lodash', + 'wp-data', + 'wp-escape-html', + ), + 'wp-shortcode' => array( + 'lodash', + ), + 'wp-token-list' => array( + 'lodash', + ), + 'wp-url' => array(), + 'wp-viewport' => array( + 'lodash', + 'wp-compose', + 'wp-data', + 'wp-element', + ), + 'wp-wordcount' => array(), +); diff --git a/lib/parser.php b/lib/parser.php index 85ca8213c3e60b..8fbcaa19bb458f 100644 --- a/lib/parser.php +++ b/lib/parser.php @@ -260,7 +260,7 @@ private function peg_f2($blockName, $a) { return $a; } private function peg_f3($blockName, $attrs) { return array( 'blockName' => $blockName, - 'attrs' => isset( $attrs ) ? $attrs : array(), + 'attrs' => empty( $attrs ) ? peg_empty_attrs() : $attrs, 'innerBlocks' => array(), 'innerHTML' => '', 'innerContent' => array(), @@ -271,7 +271,7 @@ private function peg_f4($s, $children, $e) { return array( 'blockName' => $s['blockName'], - 'attrs' => $s['attrs'], + 'attrs' => empty( $s['attrs'] ) ? peg_empty_attrs() : $s['attrs'], 'innerBlocks' => $innerBlocks, 'innerHTML' => $innerHTML, 'innerContent' => $innerContent, @@ -1582,6 +1582,18 @@ public function parse($input) { // The `maybeJSON` function is not needed in PHP because its return semantics // are the same as `json_decode` + if ( ! function_exists( 'peg_empty_attrs' ) ) { + function peg_empty_attrs() { + static $empty_attrs = null; + + if ( null === $empty_attrs ) { + $empty_attrs = json_decode( '{}', true ); + } + + return $empty_attrs; + } + } + // array arguments are backwards because of PHP if ( ! function_exists( 'peg_process_inner_content' ) ) { function peg_process_inner_content( $array ) { @@ -1610,7 +1622,7 @@ function peg_join_blocks( $pre, $tokens, $post ) { if ( ! empty( $pre ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $pre, 'innerContent' => array( $pre ), @@ -1625,7 +1637,7 @@ function peg_join_blocks( $pre, $tokens, $post ) { if ( ! empty( $html ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $html, 'innerContent' => array( $html ), @@ -1636,7 +1648,7 @@ function peg_join_blocks( $pre, $tokens, $post ) { if ( ! empty( $post ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $post, 'innerContent' => array( $post ), diff --git a/package-lock.json b/package-lock.json index 9355492ccc3407..b98a21aac33c39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "4.3.0-rc.1", + "version": "4.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2076,6 +2076,33 @@ "integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==", "dev": true }, + "@tannin/compile": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tannin/compile/-/compile-1.0.1.tgz", + "integrity": "sha512-ymd9icvnkQin8UG4eRU3+xBc7gqTn/Kv5+EMY3ALWVwIl6j/7McWbCkxB8MgU40UaHJk8kLCk06wiKszXLdXWQ==", + "requires": { + "@tannin/evaluate": "^1.0.0", + "@tannin/postfix": "^1.0.0" + } + }, + "@tannin/evaluate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@tannin/evaluate/-/evaluate-1.0.0.tgz", + "integrity": "sha512-gO7YbJsD8sj5/nqUbFZv71Meu2++D9n4DZov/cWwp3YJbBwKShPlWwwlXr/0vz4vuxm/gys+3NiGbZkmhlXf0Q==" + }, + "@tannin/plural-forms": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tannin/plural-forms/-/plural-forms-1.0.1.tgz", + "integrity": "sha512-SXutT+XLbMOECvmWDBSqIOHhS5hzWG9875HCFGKYgp8ghGPrJ4HZ325Xc0hsRThdjgrWMEQixlbpWl4SXOQTig==", + "requires": { + "@tannin/compile": "^1.0.0" + } + }, + "@tannin/postfix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.0.0.tgz", + "integrity": "sha512-59/mWwU7sXHfoU2kI3RcWRki2Jjbz5nEVJNBN4MUyIhPjXTebAcZqgsQACvlk+sjKVOTMEMHcrFrKQbaxz/1Dw==" + }, "@types/node": { "version": "10.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.2.tgz", @@ -2258,12 +2285,6 @@ "lodash": "^4.17.10", "rememo": "^3.0.0", "uuid": "^3.3.2" - }, - "dependencies": { - "uuid": { - "version": "3.3.2", - "bundled": true - } } }, "@wordpress/api-fetch": { @@ -2365,6 +2386,7 @@ "@wordpress/dom": "file:packages/dom", "@wordpress/element": "file:packages/element", "@wordpress/hooks": "file:packages/hooks", + "@wordpress/html-entities": "file:packages/html-entities", "@wordpress/i18n": "file:packages/i18n", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/shortcode": "file:packages/shortcode", @@ -2388,13 +2410,13 @@ "@wordpress/a11y": "file:packages/a11y", "@wordpress/api-fetch": "file:packages/api-fetch", "@wordpress/compose": "file:packages/compose", - "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/dom": "file:packages/dom", "@wordpress/element": "file:packages/element", "@wordpress/hooks": "file:packages/hooks", "@wordpress/i18n": "file:packages/i18n", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/url": "file:packages/url", "classnames": "^2.2.5", "clipboard": "^2.0.1", @@ -2416,7 +2438,6 @@ "version": "file:packages/compose", "requires": { "@babel/runtime": "^7.0.0", - "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "lodash": "^4.17.10" @@ -2447,7 +2468,6 @@ "requires": { "@babel/runtime": "^7.0.0", "@wordpress/compose": "file:packages/compose", - "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/redux-routine": "file:packages/redux-routine", @@ -2562,8 +2582,8 @@ "@babel/runtime": "^7.0.0", "@wordpress/escape-html": "file:packages/escape-html", "lodash": "^4.17.10", - "react": "^16.4.1", - "react-dom": "^16.4.1" + "react": "^16.6.3", + "react-dom": "^16.6.3" } }, "@wordpress/escape-html": { @@ -2603,9 +2623,10 @@ "requires": { "@babel/runtime": "^7.0.0", "gettext-parser": "^1.3.1", - "jed": "^1.1.1", "lodash": "^4.17.10", - "memize": "^1.0.5" + "memize": "^1.0.5", + "sprintf-js": "^1.1.1", + "tannin": "^1.0.1" } }, "@wordpress/is-shallow-equal": { @@ -2619,7 +2640,7 @@ "dev": true, "requires": { "@babel/runtime": "^7.0.0", - "jest-matcher-utils": "^22.4.3", + "jest-matcher-utils": "^23.6.0", "lodash": "^4.17.10" } }, @@ -2628,7 +2649,7 @@ "dev": true, "requires": { "@wordpress/jest-console": "file:packages/jest-console", - "babel-jest": "^23.4.2", + "babel-jest": "^23.6.0", "enzyme": "^3.7.0", "enzyme-adapter-react-16": "^1.6.0", "jest-enzyme": "^6.0.2" @@ -2650,6 +2671,18 @@ "webpack-sources": "^1.1.0" } }, + "@wordpress/list-reusable-blocks": { + "version": "file:packages/list-reusable-blocks", + "requires": { + "@babel/runtime": "^7.0.0", + "@wordpress/api-fetch": "file:packages/api-fetch", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "lodash": "^4.17.10" + } + }, "@wordpress/notices": { "version": "file:packages/notices", "requires": { @@ -2670,7 +2703,6 @@ "@wordpress/components": "file:packages/components", "@wordpress/compose": "file:packages/compose", "@wordpress/data": "file:packages/data", - "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/element": "file:packages/element", "@wordpress/i18n": "file:packages/i18n", "lodash": "^4.17.10", @@ -2707,8 +2739,8 @@ "version": "file:packages/rich-text", "requires": { "@babel/runtime": "^7.0.0", + "@wordpress/compose": "file:packages/compose", "@wordpress/data": "file:packages/data", - "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/escape-html": "file:packages/escape-html", "lodash": "^4.17.10", "rememo": "^3.0.0" @@ -2725,7 +2757,7 @@ "chalk": "^2.4.1", "cross-spawn": "^5.1.0", "eslint": "^4.19.1", - "jest": "^23.4.2", + "jest": "^23.6.0", "npm-package-json-lint": "^3.3.1", "read-pkg-up": "^1.0.1", "resolve-bin": "^0.4.0" @@ -2983,12 +3015,12 @@ "dev": true }, "append-transform": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", - "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-0.4.0.tgz", + "integrity": "sha1-126/jKlNJ24keja61EpLdKthGZE=", "dev": true, "requires": { - "default-require-extensions": "^2.0.0" + "default-require-extensions": "^1.0.0" } }, "aproba": { @@ -3654,9 +3686,9 @@ } }, "babel-jest": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.4.2.tgz", - "integrity": "sha512-wg1LJ2tzsafXqPFVgAsYsMCVD5U7kwJZAvbZIxVm27iOewsQw1BR7VZifDlMTEWVo3wasoPPyMdKXWCsfFPr3Q==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.6.0.tgz", + "integrity": "sha512-lqKGG6LYXYu+DQh/slrQ8nxXQkEkhugdXsU6St7GmhVS7Ilc/22ArwqXNJrf0QaOBjZB0360qZMwXqDYQHXaew==", "dev": true, "requires": { "babel-plugin-istanbul": "^4.1.6", @@ -5676,12 +5708,6 @@ } } }, - "compare-versions": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.3.0.tgz", - "integrity": "sha512-MAAAIOdi2s4Gl6rZ76PNcUa9IOYB+5ICdT41o5uMRf09aEu/F9RK+qhe8RjXNPwcTjGV7KU7h2P/fljThFVqyQ==", - "dev": true - }, "component-emitter": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", @@ -6833,20 +6859,12 @@ "integrity": "sha512-95k0GDqvBjZavkuvzx/YqVLv/6YYa17fz6ILMSf7neqQITCPbnfEnQvEgMPNjH4kgobe7+WIL0yJEHku+H3qtQ==" }, "default-require-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", - "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz", + "integrity": "sha1-836hXT4T/9m0N9M+GnW1+5eHTLg=", "dev": true, "requires": { - "strip-bom": "^3.0.0" - }, - "dependencies": { - "strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", - "dev": true - } + "strip-bom": "^2.0.0" } }, "defaults": { @@ -7916,15 +7934,15 @@ } }, "expect": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-23.4.0.tgz", - "integrity": "sha1-baTsyZwUcSU+cogziYOtHrrbYMM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-23.6.0.tgz", + "integrity": "sha512-dgSoOHgmtn/aDGRVFWclQyPDKl2CQRq0hmIEoUAuQs/2rn2NcvCWcSCovm6BLeuB/7EZuLGu2QfnR+qRt5OM4w==", "dev": true, "requires": { "ansi-styles": "^3.2.0", - "jest-diff": "^23.2.0", + "jest-diff": "^23.6.0", "jest-get-type": "^22.1.0", - "jest-matcher-utils": "^23.2.0", + "jest-matcher-utils": "^23.6.0", "jest-message-util": "^23.4.0", "jest-regex-util": "^23.3.0" }, @@ -7974,14 +7992,14 @@ } }, "jest-matcher-utils": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.2.0.tgz", - "integrity": "sha1-TUmB8jIT6Tnjzt8j3DTHR7WuGRM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", "dev": true, "requires": { "chalk": "^2.0.1", "jest-get-type": "^22.1.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" } }, "jest-message-util": { @@ -8028,9 +8046,9 @@ } }, "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -11033,23 +11051,45 @@ "dev": true }, "istanbul-api": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-1.3.1.tgz", - "integrity": "sha512-duj6AlLcsWNwUpfyfHt0nWIeRiZpuShnP40YTxOGQgtaN8fd6JYSxsvxUphTDy8V5MfDXo4s/xVCIIvVCO808g==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/istanbul-api/-/istanbul-api-1.3.7.tgz", + "integrity": "sha512-4/ApBnMVeEPG3EkSzcw25wDe4N66wxwn+KKn6b47vyek8Xb3NBAcg4xfuQbS7BqcZuTX4wxfD5lVagdggR3gyA==", "dev": true, "requires": { "async": "^2.1.4", - "compare-versions": "^3.1.0", "fileset": "^2.0.2", - "istanbul-lib-coverage": "^1.2.0", - "istanbul-lib-hook": "^1.2.0", - "istanbul-lib-instrument": "^1.10.1", - "istanbul-lib-report": "^1.1.4", - "istanbul-lib-source-maps": "^1.2.4", - "istanbul-reports": "^1.3.0", + "istanbul-lib-coverage": "^1.2.1", + "istanbul-lib-hook": "^1.2.2", + "istanbul-lib-instrument": "^1.10.2", + "istanbul-lib-report": "^1.1.5", + "istanbul-lib-source-maps": "^1.2.6", + "istanbul-reports": "^1.5.1", "js-yaml": "^3.7.0", "mkdirp": "^0.5.1", "once": "^1.4.0" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.2.tgz", + "integrity": "sha512-aWHxfxDqvh/ZlxR8BBaEPVSWDPUkGD63VjGQn3jcw8jCp7sHEMKcrj4xfJn/ABzdMEHiQNyvDQhqm5o8+SQg7A==", + "dev": true, + "requires": { + "babel-generator": "^6.18.0", + "babel-template": "^6.16.0", + "babel-traverse": "^6.18.0", + "babel-types": "^6.18.0", + "babylon": "^6.18.0", + "istanbul-lib-coverage": "^1.2.1", + "semver": "^5.3.0" + } + } } }, "istanbul-lib-coverage": { @@ -11059,12 +11099,12 @@ "dev": true }, "istanbul-lib-hook": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.2.1.tgz", - "integrity": "sha512-eLAMkPG9FU0v5L02lIkcj/2/Zlz9OuluaXikdr5iStk8FDbSwAixTK9TkYxbF0eNnzAJTwM2fkV2A1tpsIp4Jg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-1.2.2.tgz", + "integrity": "sha512-/Jmq7Y1VeHnZEQ3TL10VHyb564mn6VrQXHchON9Jf/AEcmQ3ZIiyD1BVzNOKTZf/G3gE+kiGK6SmpF9y3qGPLw==", "dev": true, "requires": { - "append-transform": "^1.0.0" + "append-transform": "^0.4.0" } }, "istanbul-lib-instrument": { @@ -11083,12 +11123,12 @@ } }, "istanbul-lib-report": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-1.1.4.tgz", - "integrity": "sha512-Azqvq5tT0U09nrncK3q82e/Zjkxa4tkFZv7E6VcqP0QCPn6oNljDPfrZEC/umNXds2t7b8sRJfs6Kmpzt8m2kA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-1.1.5.tgz", + "integrity": "sha512-UsYfRMoi6QO/doUshYNqcKJqVmFe9w51GZz8BS3WB0lYxAllQYklka2wP9+dGZeHYaWIdcXUx8JGdbqaoXRXzw==", "dev": true, "requires": { - "istanbul-lib-coverage": "^1.2.0", + "istanbul-lib-coverage": "^1.2.1", "mkdirp": "^0.5.1", "path-parse": "^1.0.5", "supports-color": "^3.1.2" @@ -11100,6 +11140,12 @@ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", "dev": true }, + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + }, "supports-color": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", @@ -11112,22 +11158,30 @@ } }, "istanbul-lib-source-maps": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.5.tgz", - "integrity": "sha512-8O2T/3VhrQHn0XcJbP1/GN7kXMiRAlPi+fj3uEHrjBD8Oz7Py0prSC25C09NuAZS6bgW1NNKAvCSHZXB0irSGA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-1.2.6.tgz", + "integrity": "sha512-TtbsY5GIHgbMsMiRw35YBHGpZ1DVFEO19vxxeiDMYaeOFOCzfnYVxvl6pOUIZR4dtPhAGpSMup8OyF8ubsaqEg==", "dev": true, "requires": { "debug": "^3.1.0", - "istanbul-lib-coverage": "^1.2.0", + "istanbul-lib-coverage": "^1.2.1", "mkdirp": "^0.5.1", "rimraf": "^2.6.1", "source-map": "^0.5.3" + }, + "dependencies": { + "istanbul-lib-coverage": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.1.tgz", + "integrity": "sha512-PzITeunAgyGbtY1ibVIUiV679EFChHjoMNRibEIobvmrCRaIgwLxNucOSimtNWUhEib/oO7QY2imD75JVgCJWQ==", + "dev": true + } } }, "istanbul-reports": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-1.3.0.tgz", - "integrity": "sha512-y2Z2IMqE1gefWUaVjrBm0mSKvUkaBy9Vqz8iwr/r40Y9hBbIteH5wqHG/9DLTfJ9xUnUT2j7A3+VVJ6EaYBllA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-1.5.1.tgz", + "integrity": "sha512-+cfoZ0UXzWjhAdzosCPP3AN8vvef8XDkWtTfgaN+7L3YTpNYITnCaEkceo5SEYy644VkHka/P1FvkWvrG/rrJw==", "dev": true, "requires": { "handlebars": "^4.0.3" @@ -11154,19 +11208,14 @@ "is-object": "^1.0.1" } }, - "jed": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/jed/-/jed-1.1.1.tgz", - "integrity": "sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ=" - }, "jest": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-23.4.2.tgz", - "integrity": "sha512-w10HGpVFWT1oN8B2coxeiMEsZoggkDaw3i26xHGLU+rsR+LYkBk8qpZCgi+1cD1S6ttPjZDL8E8M99lmNhgTeA==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-23.6.0.tgz", + "integrity": "sha512-lWzcd+HSiqeuxyhG+EnZds6iO3Y3ZEnMrfZq/OTGvF/C+Z4fPMCdhWTGSAiO2Oym9rbEXfwddHhh6jqrTF3+Lw==", "dev": true, "requires": { "import-local": "^1.0.0", - "jest-cli": "^23.4.2" + "jest-cli": "^23.6.0" }, "dependencies": { "arr-diff": { @@ -11214,9 +11263,9 @@ } }, "jest-cli": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-23.4.2.tgz", - "integrity": "sha512-vaDzy0wRWrgSfz4ZImCqD2gtZqCSoEWp60y3USvGDxA2b4K0rGj2voru6a5scJFjDx5GCiNWKpz2E8IdWDVjdw==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-23.6.0.tgz", + "integrity": "sha512-hgeD1zRUp1E1zsiyOXjEn4LzRLWdJBV//ukAHGlx6s5mfCNJTbhbHjgxnDUXA8fsKWN/HqFFF6X5XcCwC/IvYQ==", "dev": true, "requires": { "ansi-escapes": "^3.0.0", @@ -11231,18 +11280,18 @@ "istanbul-lib-instrument": "^1.10.1", "istanbul-lib-source-maps": "^1.2.4", "jest-changed-files": "^23.4.2", - "jest-config": "^23.4.2", + "jest-config": "^23.6.0", "jest-environment-jsdom": "^23.4.0", "jest-get-type": "^22.1.0", - "jest-haste-map": "^23.4.1", + "jest-haste-map": "^23.6.0", "jest-message-util": "^23.4.0", "jest-regex-util": "^23.3.0", - "jest-resolve-dependencies": "^23.4.2", - "jest-runner": "^23.4.2", - "jest-runtime": "^23.4.2", - "jest-snapshot": "^23.4.2", + "jest-resolve-dependencies": "^23.6.0", + "jest-runner": "^23.6.0", + "jest-runtime": "^23.6.0", + "jest-snapshot": "^23.6.0", "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", + "jest-validate": "^23.6.0", "jest-watcher": "^23.4.0", "jest-worker": "^23.2.0", "micromatch": "^2.3.11", @@ -11303,6 +11352,18 @@ "source-map": "^0.6.0" } }, + "jest-validate": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.6.0.tgz", + "integrity": "sha512-OFKapYxe72yz7agrDAWi8v2WL8GIfVqcbKRCLbRG9PAxtzF9b1SEDdTpytNDN12z2fJynoBwpMpvj2R39plI2A==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.1.0", + "leven": "^2.1.0", + "pretty-format": "^23.6.0" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -11380,24 +11441,25 @@ } }, "jest-config": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.4.2.tgz", - "integrity": "sha512-CDJGO4H+7P+T6khaSHEjTxqVaIlmQMEFAyJFOVrAQeM+Xn12iZ+YY8Pluk1RDxi8Jqj9DoE09PHQzASCGePGtg==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-23.6.0.tgz", + "integrity": "sha512-i8V7z9BeDXab1+VNo78WM0AtWpBRXJLnkT+lyT+Slx/cbP5sZJ0+NDuLcmBE5hXAoK0aUp7vI+MOxR+R4d8SRQ==", "dev": true, "requires": { "babel-core": "^6.0.0", - "babel-jest": "^23.4.2", + "babel-jest": "^23.6.0", "chalk": "^2.0.1", "glob": "^7.1.1", "jest-environment-jsdom": "^23.4.0", "jest-environment-node": "^23.4.0", "jest-get-type": "^22.1.0", - "jest-jasmine2": "^23.4.2", + "jest-jasmine2": "^23.6.0", "jest-regex-util": "^23.3.0", - "jest-resolve": "^23.4.1", + "jest-resolve": "^23.6.0", "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", - "pretty-format": "^23.2.0" + "jest-validate": "^23.6.0", + "micromatch": "^2.3.11", + "pretty-format": "^23.6.0" }, "dependencies": { "arr-diff": { @@ -11442,6 +11504,16 @@ "source-map": "^0.5.7" } }, + "babel-jest": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-23.6.0.tgz", + "integrity": "sha512-lqKGG6LYXYu+DQh/slrQ8nxXQkEkhugdXsU6St7GmhVS7Ilc/22ArwqXNJrf0QaOBjZB0360qZMwXqDYQHXaew==", + "dev": true, + "requires": { + "babel-plugin-istanbul": "^4.1.6", + "babel-preset-jest": "^23.2.0" + } + }, "braces": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", @@ -11534,6 +11606,18 @@ } } }, + "jest-validate": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.6.0.tgz", + "integrity": "sha512-OFKapYxe72yz7agrDAWi8v2WL8GIfVqcbKRCLbRG9PAxtzF9b1SEDdTpytNDN12z2fJynoBwpMpvj2R39plI2A==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.1.0", + "leven": "^2.1.0", + "pretty-format": "^23.6.0" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -11565,9 +11649,9 @@ } }, "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -11641,21 +11725,21 @@ } }, "jest-diff": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-23.2.0.tgz", - "integrity": "sha1-nyz0tR4Sx5FVAgCrwWtHEwrxBio=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-23.6.0.tgz", + "integrity": "sha512-Gz9l5Ov+X3aL5L37IT+8hoCUsof1CVYBb2QEkOupK64XyRR3h+uRpYIm97K7sY8diFxowR8pIGEdyfMKTixo3g==", "dev": true, "requires": { "chalk": "^2.0.1", "diff": "^3.2.0", "jest-get-type": "^22.1.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" }, "dependencies": { "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -11674,19 +11758,19 @@ } }, "jest-each": { - "version": "23.4.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-23.4.0.tgz", - "integrity": "sha1-L6nt2J2qGk7cn/m/YGKja3E0UUM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-23.6.0.tgz", + "integrity": "sha512-x7V6M/WGJo6/kLoissORuvLIeAoyo2YqLOoCDkohgJ4XOXSqOtyvr8FbInlAWS77ojBsZrafbozWoKVRdtxFCg==", "dev": true, "requires": { "chalk": "^2.0.1", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" }, "dependencies": { "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -11874,13 +11958,14 @@ "dev": true }, "jest-haste-map": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-23.4.1.tgz", - "integrity": "sha512-PGQxOEGAfRbTyJkmZeOKkVSs+KVeWgG625p89KUuq+sIIchY5P8iPIIc+Hw2tJJPBzahU3qopw1kF/qyhDdNBw==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-23.6.0.tgz", + "integrity": "sha512-uyNhMyl6dr6HaXGHp8VF7cK6KpC6G9z9LiMNsst+rJIZ8l7wY0tk8qwjPmEghczojZ2/ZhtEdIabZ0OQRJSGGg==", "dev": true, "requires": { "fb-watchman": "^2.0.0", "graceful-fs": "^4.1.11", + "invariant": "^2.2.4", "jest-docblock": "^23.2.0", "jest-serializer": "^23.0.1", "jest-worker": "^23.2.0", @@ -11965,23 +12050,23 @@ } }, "jest-jasmine2": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.4.2.tgz", - "integrity": "sha512-MUoqn41XHMQe5u8QvRTH2HahpBNzImnnjS3pV/T7LvrCM6f2zfGdi1Pm+bRbFMLJROqR8VlK8HmsenL2WjrUIQ==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-23.6.0.tgz", + "integrity": "sha512-pe2Ytgs1nyCs8IvsEJRiRTPC0eVYd8L/dXJGU08GFuBwZ4sYH/lmFDdOL3ZmvJR8QKqV9MFuwlsAi/EWkFUbsQ==", "dev": true, "requires": { "babel-traverse": "^6.0.0", "chalk": "^2.0.1", "co": "^4.6.0", - "expect": "^23.4.0", + "expect": "^23.6.0", "is-generator-fn": "^1.0.0", - "jest-diff": "^23.2.0", - "jest-each": "^23.4.0", - "jest-matcher-utils": "^23.2.0", + "jest-diff": "^23.6.0", + "jest-each": "^23.6.0", + "jest-matcher-utils": "^23.6.0", "jest-message-util": "^23.4.0", - "jest-snapshot": "^23.4.2", + "jest-snapshot": "^23.6.0", "jest-util": "^23.4.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" }, "dependencies": { "arr-diff": { @@ -12029,14 +12114,14 @@ } }, "jest-matcher-utils": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.2.0.tgz", - "integrity": "sha1-TUmB8jIT6Tnjzt8j3DTHR7WuGRM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", "dev": true, "requires": { "chalk": "^2.0.1", "jest-get-type": "^22.1.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" } }, "jest-message-util": { @@ -12099,9 +12184,9 @@ } }, "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -12117,18 +12202,18 @@ } }, "jest-leak-detector": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-23.2.0.tgz", - "integrity": "sha1-wonZYdxjjxQ1fU75bgQx7MGqN30=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-23.6.0.tgz", + "integrity": "sha512-f/8zA04rsl1Nzj10HIyEsXvYlMpMPcy0QkQilVZDFOaPbv2ur71X5u2+C4ZQJGyV/xvVXtCCZ3wQ99IgQxftCg==", "dev": true, "requires": { - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" }, "dependencies": { "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -12138,14 +12223,14 @@ } }, "jest-matcher-utils": { - "version": "22.4.3", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-22.4.3.tgz", - "integrity": "sha512-lsEHVaTnKzdAPR5t4B6OcxXo9Vy4K+kRRbG5gtddY8lBEC+Mlpvm1CJcsMESRjzUhzkz568exMV1hTB76nAKbA==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", "dev": true, "requires": { "chalk": "^2.0.1", - "jest-get-type": "^22.4.3", - "pretty-format": "^22.4.3" + "jest-get-type": "^22.1.0", + "pretty-format": "^23.6.0" } }, "jest-message-util": { @@ -12260,9 +12345,9 @@ "dev": true }, "jest-resolve": { - "version": "23.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.4.1.tgz", - "integrity": "sha512-VNk4YRNR5gsHhNS0Lp46/DzTT11e+ecbUC61ikE593cKbtdrhrMO+zXkOJaE8YDD5sHxH9W6OfssNn4FkZBzZQ==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-23.6.0.tgz", + "integrity": "sha512-XyoRxNtO7YGpQDmtQCmZjum1MljDqUCob7XlZ6jy9gsMugHdN2hY4+Acz9Qvjz2mSsOnPSH7skBmDYCHXVZqkA==", "dev": true, "requires": { "browser-resolve": "^1.11.3", @@ -12271,30 +12356,30 @@ } }, "jest-resolve-dependencies": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-23.4.2.tgz", - "integrity": "sha512-JUrU1/1mQAf0eKwKT4+RRnaqcw0UcRzRE38vyO+YnqoXUVidf646iuaKE+NG7E6Gb0+EVPOJ6TgqkaTPdQz78A==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-23.6.0.tgz", + "integrity": "sha512-EkQWkFWjGKwRtRyIwRwI6rtPAEyPWlUC2MpzHissYnzJeHcyCn1Hc8j7Nn1xUVrS5C6W5+ZL37XTem4D4pLZdA==", "dev": true, "requires": { "jest-regex-util": "^23.3.0", - "jest-snapshot": "^23.4.2" + "jest-snapshot": "^23.6.0" } }, "jest-runner": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-23.4.2.tgz", - "integrity": "sha512-o+aEdDE7/Gyp8fLYEEf5B8aOUguz76AYcAMl5pueucey2Q50O8uUIXJ7zvt8O6OEJDztR3Kb+osMoh8MVIqgTw==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-23.6.0.tgz", + "integrity": "sha512-kw0+uj710dzSJKU6ygri851CObtCD9cN8aNkg8jWJf4ewFyEa6kwmiH/r/M1Ec5IL/6VFa0wnAk6w+gzUtjJzA==", "dev": true, "requires": { "exit": "^0.1.2", "graceful-fs": "^4.1.11", - "jest-config": "^23.4.2", + "jest-config": "^23.6.0", "jest-docblock": "^23.2.0", - "jest-haste-map": "^23.4.1", - "jest-jasmine2": "^23.4.2", - "jest-leak-detector": "^23.2.0", + "jest-haste-map": "^23.6.0", + "jest-jasmine2": "^23.6.0", + "jest-leak-detector": "^23.6.0", "jest-message-util": "^23.4.0", - "jest-runtime": "^23.4.2", + "jest-runtime": "^23.6.0", "jest-util": "^23.4.0", "jest-worker": "^23.2.0", "source-map-support": "^0.5.6", @@ -12411,9 +12496,9 @@ "dev": true }, "source-map-support": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz", - "integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==", + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.9.tgz", + "integrity": "sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA==", "dev": true, "requires": { "buffer-from": "^1.0.0", @@ -12423,9 +12508,9 @@ } }, "jest-runtime": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-23.4.2.tgz", - "integrity": "sha512-qaUDOi7tcdDe3MH5g5ycEslTpx0voPZvzIYbKjvWxCzCL2JEemLM+7IEe0BeLi2v5wvb/uh3dkb2wQI67uPtCw==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-23.6.0.tgz", + "integrity": "sha512-ycnLTNPT2Gv+TRhnAYAQ0B3SryEXhhRj1kA6hBPSeZaNQkJ7GbZsxOLUkwg6YmvWGdX3BB3PYKFLDQCAE1zNOw==", "dev": true, "requires": { "babel-core": "^6.0.0", @@ -12435,14 +12520,14 @@ "exit": "^0.1.2", "fast-json-stable-stringify": "^2.0.0", "graceful-fs": "^4.1.11", - "jest-config": "^23.4.2", - "jest-haste-map": "^23.4.1", + "jest-config": "^23.6.0", + "jest-haste-map": "^23.6.0", "jest-message-util": "^23.4.0", "jest-regex-util": "^23.3.0", - "jest-resolve": "^23.4.1", - "jest-snapshot": "^23.4.2", + "jest-resolve": "^23.6.0", + "jest-snapshot": "^23.6.0", "jest-util": "^23.4.0", - "jest-validate": "^23.4.0", + "jest-validate": "^23.6.0", "micromatch": "^2.3.11", "realpath-native": "^1.0.0", "slash": "^1.0.0", @@ -12568,6 +12653,18 @@ } } }, + "jest-validate": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-23.6.0.tgz", + "integrity": "sha512-OFKapYxe72yz7agrDAWi8v2WL8GIfVqcbKRCLbRG9PAxtzF9b1SEDdTpytNDN12z2fJynoBwpMpvj2R39plI2A==", + "dev": true, + "requires": { + "chalk": "^2.0.1", + "jest-get-type": "^22.1.0", + "leven": "^2.1.0", + "pretty-format": "^23.6.0" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -12598,6 +12695,16 @@ "regex-cache": "^0.4.2" } }, + "pretty-format": { + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0", + "ansi-styles": "^3.2.0" + } + }, "strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -12642,20 +12749,20 @@ "dev": true }, "jest-snapshot": { - "version": "23.4.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.4.2.tgz", - "integrity": "sha512-rCBxIURDlVEW1gJgJSpo8l2F2gFwp9U7Yb3CmcABUpmQ8NASpb6LJkEvtcQifAYSi22OL44TSuanq1G6x1GJwg==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-23.6.0.tgz", + "integrity": "sha512-tM7/Bprftun6Cvj2Awh/ikS7zV3pVwjRYU2qNYS51VZHgaAMBs5l4o/69AiDHhQrj5+LA2Lq4VIvK7zYk/bswg==", "dev": true, "requires": { "babel-types": "^6.0.0", "chalk": "^2.0.1", - "jest-diff": "^23.2.0", - "jest-matcher-utils": "^23.2.0", + "jest-diff": "^23.6.0", + "jest-matcher-utils": "^23.6.0", "jest-message-util": "^23.4.0", - "jest-resolve": "^23.4.1", + "jest-resolve": "^23.6.0", "mkdirp": "^0.5.1", "natural-compare": "^1.4.0", - "pretty-format": "^23.2.0", + "pretty-format": "^23.6.0", "semver": "^5.5.0" }, "dependencies": { @@ -12704,14 +12811,14 @@ } }, "jest-matcher-utils": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.2.0.tgz", - "integrity": "sha1-TUmB8jIT6Tnjzt8j3DTHR7WuGRM=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.6.0.tgz", + "integrity": "sha512-rosyCHQfBcol4NsckTn01cdelzWLU9Cq7aaigDf8VwwpIRvWE/9zLgX2bON+FkEW69/0UuYslUe22SOdEf2nog==", "dev": true, "requires": { "chalk": "^2.0.1", "jest-get-type": "^22.1.0", - "pretty-format": "^23.2.0" + "pretty-format": "^23.6.0" } }, "jest-message-util": { @@ -12758,9 +12865,9 @@ } }, "pretty-format": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.2.0.tgz", - "integrity": "sha1-OwqqY8AYpTWDNzwcs6XZbMXoMBc=", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -13213,9 +13320,9 @@ "dev": true }, "kleur": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-2.0.1.tgz", - "integrity": "sha512-Zq/jyANIJ2uX8UZjWlqLwbyhcxSXJtT/Y89lClyeZd3l++3ztL1I5SSCYrbcbwSunTjC88N3WuMk0kRDQD6gzA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-2.0.2.tgz", + "integrity": "sha512-77XF9iTllATmG9lSlIv0qdQ2BQ/h9t0bJllHlbvsQ0zUWfU7Yi0S8L5JXzPZgkefIiajLmBJJ4BsMJmqcf7oxQ==", "dev": true }, "known-css-properties": { @@ -14501,9 +14608,9 @@ } }, "merge": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.0.tgz", - "integrity": "sha1-dTHjnUlJwoGma4xabgJl6LBYlNo=", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz", + "integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==", "dev": true }, "merge-descriptors": { @@ -14956,13 +15063,13 @@ } }, "node-notifier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.2.1.tgz", - "integrity": "sha512-MIBs+AAd6dJ2SklbbE8RUDRlIVhU8MaNLh1A9SUZDUHPiZkWLFde6UNwG41yQHZEToHgJMXqyVZ9UcS/ReOVTg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-5.3.0.tgz", + "integrity": "sha512-AhENzCSGZnZJgBARsUjnQ7DnZbzyP+HxlVXuD0xqAnvL8q+OqtSX7lGg9e8nHzwXkMMXNdVeqq4E2M3EUAqX6Q==", "dev": true, "requires": { "growly": "^1.3.0", - "semver": "^5.4.1", + "semver": "^5.5.0", "shellwords": "^0.1.1", "which": "^1.3.0" } @@ -17379,9 +17486,9 @@ "dev": true }, "pretty-format": { - "version": "22.4.3", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-22.4.3.tgz", - "integrity": "sha512-S4oT9/sT6MN7/3COoOy+ZJeA92VmOnveLHgrwBE3Z1W5N9S2A1QGNYiE1z75DAENbJrXXUb+OWXhpJcg05QKQQ==", + "version": "23.6.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz", + "integrity": "sha512-zf9NV1NSlDLDjycnwm6hpFATCGl/K1lt0R/GdkAK2O5LN/rwJoB+Mh93gGJjut4YbmecbfgLWVGSTCr0Ewvvbw==", "dev": true, "requires": { "ansi-regex": "^3.0.0", @@ -17735,14 +17842,14 @@ "integrity": "sha512-pLJkPbZCe+3ml+9Q15z+R69qYZDsluj0KwrdFb8kSNaqDzYAveDUblf7voHH9hNTdKIiIvP8iIdGFFKSgffVaQ==" }, "react": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/react/-/react-16.4.1.tgz", - "integrity": "sha512-3GEs0giKp6E0Oh/Y9ZC60CmYgUPnp7voH9fbjWsvXtYFb4EWtgQub0ADSq0sJR0BbHc4FThLLtzlcFaFXIorwg==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react/-/react-16.6.3.tgz", + "integrity": "sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.0" + "prop-types": "^15.6.2", + "scheduler": "^0.11.2" }, "dependencies": { "prop-types": { @@ -17822,14 +17929,14 @@ } }, "react-dom": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.4.1.tgz", - "integrity": "sha512-1Gin+wghF/7gl4Cqcvr1DxFX2Osz7ugxSwl6gBqCMpdrxHjIFUS7GYxrFftZ9Ln44FHw0JxCFD9YtZsrbR5/4A==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.6.3.tgz", + "integrity": "sha512-8ugJWRCWLGXy+7PmNh8WJz3g1TaTUt1XyoIcFN+x0Zbkoz+KKdUyx1AQLYJdbFXjuF41Nmjn5+j//rxvhFjgSQ==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.0" + "prop-types": "^15.6.2", + "scheduler": "^0.11.2" }, "dependencies": { "prop-types": { @@ -17844,9 +17951,9 @@ } }, "react-is": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.4.1.tgz", - "integrity": "sha512-xpb0PpALlFWNw/q13A+1aHeyJyLYCg0/cCHPUA43zYluZuIPHaHL3k8OBsTgQtxqW0FhyDEMvi8fZ/+7+r4OSQ==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", + "integrity": "sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA==", "dev": true }, "react-moment-proptypes": { @@ -17888,15 +17995,15 @@ } }, "react-test-renderer": { - "version": "16.4.1", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.4.1.tgz", - "integrity": "sha512-wyyiPxRZOTpKnNIgUBOB6xPLTpIzwcQMIURhZvzUqZzezvHjaGNsDPBhMac5fIY3Jf5NuKxoGvV64zDSOECPPQ==", + "version": "16.6.3", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.6.3.tgz", + "integrity": "sha512-B5bCer+qymrQz/wN03lT0LppbZUDRq6AMfzMKrovzkGzfO81a9T+PWQW6MzkWknbwODQH/qpJno/yFQLX5IWrQ==", "dev": true, "requires": { - "fbjs": "^0.8.16", "object-assign": "^4.1.1", - "prop-types": "^15.6.0", - "react-is": "^16.4.1" + "prop-types": "^15.6.2", + "react-is": "^16.6.3", + "scheduler": "^0.11.2" }, "dependencies": { "prop-types": { @@ -18135,9 +18242,9 @@ } }, "realpath-native": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.0.1.tgz", - "integrity": "sha512-W14EcXuqUvKP8dkWkD7B95iMy77lpMnlFXbbk409bQtNCbeu0kvRE5reo+yIZ3JXxg6frbGsz2DLQ39lrCB40g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/realpath-native/-/realpath-native-1.0.2.tgz", + "integrity": "sha512-+S3zTvVt9yTntFrBpm7TQmQ3tzpCrnA1a/y+3cUHAc9ZR6aIjG0WNLR+Rj79QpJktY+VeW/TQtFlQ1bzsehI8g==", "dev": true, "requires": { "util.promisify": "^1.0.0" @@ -18933,6 +19040,15 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true }, + "scheduler": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.11.2.tgz", + "integrity": "sha512-+WCP3s3wOaW4S7C1tl3TEXp4l9lJn0ZK8G3W3WKRWmw77Z2cIFUW2MiNTMHn5sCjxN+t7N43HAOOgMjyAg5hlg==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "schema-utils": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.5.tgz", @@ -19543,8 +19659,7 @@ "sprintf-js": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.1.tgz", - "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=", - "dev": true + "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=" }, "sshpk": { "version": "1.14.2", @@ -20346,6 +20461,14 @@ } } }, + "tannin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tannin/-/tannin-1.0.1.tgz", + "integrity": "sha512-dDtnwHQ63bS/Gz0ZLY+E+JCdRoTZkmoKDoC64y3hzAD2X2qrp8jSuWNUjtiYHA48mtj4Ens9xl4knAOm1t+rfQ==", + "requires": { + "@tannin/plural-forms": "^1.0.0" + } + }, "tapable": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.0.0.tgz", diff --git a/package.json b/package.json index 395b975363abf6..19548299b7e193 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "4.3.0-rc.1", + "version": "4.4.0", "private": true, "description": "A new WordPress editor experience", "repository": "git+https://github.com/WordPress/gutenberg.git", @@ -42,6 +42,7 @@ "@wordpress/i18n": "file:packages/i18n", "@wordpress/is-shallow-equal": "file:packages/is-shallow-equal", "@wordpress/keycodes": "file:packages/keycodes", + "@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks", "@wordpress/notices": "file:packages/notices", "@wordpress/nux": "file:packages/nux", "@wordpress/plugins": "file:packages/plugins", @@ -98,7 +99,7 @@ "phpegjs": "1.0.0-beta7", "postcss-color-function": "4.0.1", "puppeteer": "1.6.1", - "react-test-renderer": "16.4.1", + "react-test-renderer": "16.6.3", "rimraf": "2.6.2", "rtlcss": "2.4.0", "sass-loader": "6.0.7", diff --git a/packages/annotations/CHANGELOG.md b/packages/annotations/CHANGELOG.md index 95745945dd46e2..f8bd3d08f2c7bc 100644 --- a/packages/annotations/CHANGELOG.md +++ b/packages/annotations/CHANGELOG.md @@ -1,4 +1,6 @@ -## 1.0.0 (unreleased) +## 1.0.1 (2018-11-15) + +## 1.0.0 (2018-11-12) ### New Features diff --git a/packages/annotations/package.json b/packages/annotations/package.json index b68ce8f7efa25e..89eb55c53d0a67 100644 --- a/packages/annotations/package.json +++ b/packages/annotations/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/annotations", - "version": "1.0.0-beta1", + "version": "1.0.1", "description": "Annotate content in the Gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/annotations/src/format/annotation.js b/packages/annotations/src/format/annotation.js index b052de27f335fd..a2f6f2973c7268 100644 --- a/packages/annotations/src/format/annotation.js +++ b/packages/annotations/src/format/annotation.js @@ -2,13 +2,12 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { applyFormat, removeFormat } from '@wordpress/rich-text'; -const name = 'core/annotation'; +const FORMAT_NAME = 'core/annotation'; -/** - * WordPress dependencies - */ -import { applyFormat, removeFormat } from '@wordpress/rich-text'; +const ANNOTATION_ATTRIBUTE_PREFIX = 'annotation-text-'; +const STORE_KEY = 'core/annotations'; /** * Applies given annotations to the given record. @@ -29,11 +28,17 @@ export function applyAnnotations( record, annotations = [] ) { end = record.text.length; } - const className = 'annotation-text-' + annotation.source; + const className = ANNOTATION_ATTRIBUTE_PREFIX + annotation.source; + const id = ANNOTATION_ATTRIBUTE_PREFIX + annotation.id; record = applyFormat( record, - { type: 'core/annotation', attributes: { className } }, + { + type: FORMAT_NAME, attributes: { + className, + id, + }, + }, start, end ); @@ -52,31 +57,104 @@ export function removeAnnotations( record ) { return removeFormat( record, 'core/annotation', 0, record.text.length ); } +/** + * Retrieves the positions of annotations inside an array of formats. + * + * @param {Array} formats Formats with annotations in there. + * @return {Object} ID keyed positions of annotations. + */ +function retrieveAnnotationPositions( formats ) { + const positions = {}; + + formats.forEach( ( characterFormats, i ) => { + characterFormats = characterFormats || []; + characterFormats = characterFormats.filter( ( format ) => format.type === FORMAT_NAME ); + characterFormats.forEach( ( format ) => { + let { id } = format.attributes; + id = id.replace( ANNOTATION_ATTRIBUTE_PREFIX, '' ); + + if ( ! positions.hasOwnProperty( id ) ) { + positions[ id ] = { + start: i, + }; + } + + // Annotations refer to positions between characters. + // Formats refer to the character themselves. + // So we need to adjust for that here. + positions[ id ].end = i + 1; + } ); + } ); + + return positions; +} + +/** + * Updates annotations in the state based on positions retrieved from RichText. + * + * @param {Array} annotations The annotations that are currently applied. + * @param {Array} positions The current positions of the given annotations. + * @param {Function} removeAnnotation Function to remove an annotation from the state. + * @param {Function} updateAnnotationRange Function to update an annotation range in the state. + */ +function updateAnnotationsWithPositions( annotations, positions, { removeAnnotation, updateAnnotationRange } ) { + annotations.forEach( ( currentAnnotation ) => { + const position = positions[ currentAnnotation.id ]; + // If we cannot find an annotation, delete it. + if ( ! position ) { + // Apparently the annotation has been removed, so remove it from the state: + // Remove... + removeAnnotation( currentAnnotation.id ); + return; + } + + const { start, end } = currentAnnotation; + if ( start !== position.start || end !== position.end ) { + updateAnnotationRange( currentAnnotation.id, position.start, position.end ); + } + } ); +} + export const annotation = { - name, + name: FORMAT_NAME, title: __( 'Annotation' ), tagName: 'mark', className: 'annotation-text', attributes: { className: 'class', + id: 'id', }, edit() { return null; }, __experimentalGetPropsForEditableTreePreparation( select, { richTextIdentifier, blockClientId } ) { return { - annotations: select( 'core/annotations' ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ), + annotations: select( STORE_KEY ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ), }; }, - __experimentalCreatePrepareEditableTree( props ) { + __experimentalCreatePrepareEditableTree( { annotations } ) { return ( formats, text ) => { - if ( props.annotations.length === 0 ) { + if ( annotations.length === 0 ) { return formats; } let record = { formats, text }; - record = applyAnnotations( record, props.annotations ); + record = applyAnnotations( record, annotations ); return record.formats; }; }, + __experimentalGetPropsForEditableTreeChangeHandler( dispatch ) { + return { + removeAnnotation: dispatch( STORE_KEY ).__experimentalRemoveAnnotation, + updateAnnotationRange: dispatch( STORE_KEY ).__experimentalUpdateAnnotationRange, + }; + }, + __experimentalCreateOnChangeEditableValue( props ) { + return ( formats ) => { + const positions = retrieveAnnotationPositions( formats ); + const { removeAnnotation, updateAnnotationRange, annotations } = props; + + updateAnnotationsWithPositions( annotations, positions, { removeAnnotation, updateAnnotationRange } ); + }; + }, }; diff --git a/packages/annotations/src/store/actions.js b/packages/annotations/src/store/actions.js index 73f8c9e1fe381c..96598022d5a7c1 100644 --- a/packages/annotations/src/store/actions.js +++ b/packages/annotations/src/store/actions.js @@ -57,6 +57,24 @@ export function __experimentalRemoveAnnotation( annotationId ) { }; } +/** + * Updates the range of an annotation. + * + * @param {string} annotationId ID of the annotation to update. + * @param {number} start The start of the new range. + * @param {number} end The end of the new range. + * + * @return {Object} Action object. + */ +export function __experimentalUpdateAnnotationRange( annotationId, start, end ) { + return { + type: 'ANNOTATION_UPDATE_RANGE', + annotationId, + start, + end, + }; +} + /** * Removes all annotations of a specific source. * diff --git a/packages/annotations/src/store/reducer.js b/packages/annotations/src/store/reducer.js index cb14165a5d6bdd..1f768a78ad2157 100644 --- a/packages/annotations/src/store/reducer.js +++ b/packages/annotations/src/store/reducer.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { isNumber, mapValues } from 'lodash'; +import { get, isNumber, mapValues } from 'lodash'; /** * Filters an array based on the predicate, but keeps the reference the same if @@ -38,7 +38,7 @@ function isValidAnnotationRange( annotation ) { * * @return {Array} Updated state. */ -export function annotations( state = { all: [], byBlockClientId: {} }, action ) { +export function annotations( state = {}, action ) { switch ( action.type ) { case 'ANNOTATION_ADD': const blockClientId = action.blockClientId; @@ -55,52 +55,48 @@ export function annotations( state = { all: [], byBlockClientId: {} }, action ) return state; } - const previousAnnotationsForBlock = state.byBlockClientId[ blockClientId ] || []; + const previousAnnotationsForBlock = get( state, blockClientId, [] ); return { - all: [ - ...state.all, - newAnnotation, - ], - byBlockClientId: { - ...state.byBlockClientId, - [ blockClientId ]: [ ...previousAnnotationsForBlock, action.id ], - }, + ...state, + [ blockClientId ]: [ ...previousAnnotationsForBlock, newAnnotation ], }; case 'ANNOTATION_REMOVE': - return { - all: state.all.filter( ( annotation ) => annotation.id !== action.annotationId ), + return mapValues( state, ( annotationsForBlock ) => { + return filterWithReference( annotationsForBlock, ( annotation ) => { + return annotation.id !== action.annotationId; + } ); + } ); - // We use filterWithReference to not refresh the reference if a block still has - // the same annotations. - byBlockClientId: mapValues( state.byBlockClientId, ( annotationForBlock ) => { - return filterWithReference( annotationForBlock, ( annotationId ) => { - return annotationId !== action.annotationId; - } ); - } ), - }; + case 'ANNOTATION_UPDATE_RANGE': + return mapValues( state, ( annotationsForBlock ) => { + let hasChangedRange = false; - case 'ANNOTATION_REMOVE_SOURCE': - const idsToRemove = []; + const newAnnotations = annotationsForBlock.map( ( annotation ) => { + if ( annotation.id === action.annotationId ) { + hasChangedRange = true; + return { + ...annotation, + range: { + start: action.start, + end: action.end, + }, + }; + } - const allAnnotations = state.all.filter( ( annotation ) => { - if ( annotation.source === action.source ) { - idsToRemove.push( annotation.id ); - return false; - } + return annotation; + } ); - return true; + return hasChangedRange ? newAnnotations : annotationsForBlock; } ); - return { - all: allAnnotations, - byBlockClientId: mapValues( state.byBlockClientId, ( annotationForBlock ) => { - return filterWithReference( annotationForBlock, ( annotationId ) => { - return ! idsToRemove.includes( annotationId ); - } ); - } ), - }; + case 'ANNOTATION_REMOVE_SOURCE': + return mapValues( state, ( annotationsForBlock ) => { + return filterWithReference( annotationsForBlock, ( annotation ) => { + return annotation.source !== action.source; + } ); + } ); } return state; diff --git a/packages/annotations/src/store/selectors.js b/packages/annotations/src/store/selectors.js index 659b83e83e30d1..ca6fcb64796d5f 100644 --- a/packages/annotations/src/store/selectors.js +++ b/packages/annotations/src/store/selectors.js @@ -2,6 +2,7 @@ * External dependencies */ import createSelector from 'rememo'; +import { get, flatMap } from 'lodash'; /** * Returns the annotations for a specific client ID. @@ -13,15 +14,19 @@ import createSelector from 'rememo'; */ export const __experimentalGetAnnotationsForBlock = createSelector( ( state, blockClientId ) => { - return state.all.filter( ( annotation ) => { - return annotation.selector === 'block' && annotation.blockClientId === blockClientId; + return get( state, blockClientId, [] ).filter( ( annotation ) => { + return annotation.selector === 'block'; } ); }, ( state, blockClientId ) => [ - state.byBlockClientId[ blockClientId ], + get( state, blockClientId, [] ), ] ); +export const __experimentalGetAllAnnotationsForBlock = function( state, blockClientId ) { + return get( state, blockClientId, [] ); +}; + /** * Returns the annotations that apply to the given RichText instance. * @@ -36,9 +41,8 @@ export const __experimentalGetAnnotationsForBlock = createSelector( */ export const __experimentalGetAnnotationsForRichText = createSelector( ( state, blockClientId, richTextIdentifier ) => { - return state.all.filter( ( annotation ) => { + return get( state, blockClientId, [] ).filter( ( annotation ) => { return annotation.selector === 'range' && - annotation.blockClientId === blockClientId && richTextIdentifier === annotation.richTextIdentifier; } ).map( ( annotation ) => { const { range, ...other } = annotation; @@ -50,7 +54,7 @@ export const __experimentalGetAnnotationsForRichText = createSelector( } ); }, ( state, blockClientId ) => [ - state.byBlockClientId[ blockClientId ], + get( state, blockClientId, [] ), ] ); @@ -61,5 +65,7 @@ export const __experimentalGetAnnotationsForRichText = createSelector( * @return {Array} All annotations currently applied. */ export function __experimentalGetAnnotations( state ) { - return state.all; + return flatMap( state, ( annotations ) => { + return annotations; + } ); } diff --git a/packages/annotations/src/store/test/reducer.js b/packages/annotations/src/store/test/reducer.js index a1dba8db8c8ac4..190795bd8a98cf 100644 --- a/packages/annotations/src/store/test/reducer.js +++ b/packages/annotations/src/store/test/reducer.js @@ -4,12 +4,12 @@ import { annotations } from '../reducer'; describe( 'annotations', () => { - const initialState = { all: [], byBlockClientId: {} }; + const initialState = {}; it( 'returns all annotations and annotation IDs per block', () => { const state = annotations( undefined, {} ); - expect( state ).toEqual( { all: [], byBlockClientId: {} } ); + expect( state ).toEqual( initialState ); } ); it( 'returns a state with an annotation that has been added', () => { @@ -23,24 +23,19 @@ describe( 'annotations', () => { } ); expect( state ).toEqual( { - all: [ - { - id: 'annotationId', - blockClientId: 'blockClientId', - richTextIdentifier: 'identifier', - source: 'default', - selector: 'block', - }, - ], - byBlockClientId: { - blockClientId: [ 'annotationId' ], - }, + blockClientId: [ { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'block', + } ], } ); } ); it( 'allows an annotation to be removed', () => { const state = annotations( { - all: [ + blockClientId: [ { id: 'annotationId', blockClientId: 'blockClientId', @@ -49,15 +44,12 @@ describe( 'annotations', () => { selector: 'block', }, ], - byBlockClientId: { - blockClientId: [ 'annotationId' ], - }, }, { type: 'ANNOTATION_REMOVE', annotationId: 'annotationId', } ); - expect( state ).toEqual( { all: [], byBlockClientId: { blockClientId: [] } } ); + expect( state ).toEqual( { blockClientId: [] } ); } ); it( 'allows an annotation to be removed by its source', () => { @@ -76,25 +68,20 @@ describe( 'annotations', () => { selector: 'block', }; const state = annotations( { - all: [ + blockClientId: [ annotation1, + ], + blockClientId2: [ annotation2, ], - byBlockClientId: { - blockClientId: [ 'annotationId' ], - blockClientId2: [ 'annotationId2' ], - }, }, { type: 'ANNOTATION_REMOVE_SOURCE', source: 'default', } ); expect( state ).toEqual( { - all: [ annotation2 ], - byBlockClientId: { - blockClientId: [], - blockClientId2: [ 'annotationId2' ], - }, + blockClientId: [], + blockClientId2: [ annotation2 ], } ); } ); @@ -113,7 +100,7 @@ describe( 'annotations', () => { } ); expect( state ).toEqual( { - all: [ + blockClientId: [ { id: 'annotationId', blockClientId: 'blockClientId', @@ -126,9 +113,45 @@ describe( 'annotations', () => { }, }, ], - byBlockClientId: { - blockClientId: [ 'annotationId' ], - }, + } ); + } ); + + it( 'moves annotations when said action is dispatched', () => { + const state = annotations( { + blockClientId: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 0, + end: 100, + }, + }, + ], + }, { + type: 'ANNOTATION_UPDATE_RANGE', + annotationId: 'annotationId', + start: 50, + end: 75, + } ); + + expect( state ).toEqual( { + blockClientId: [ + { + id: 'annotationId', + blockClientId: 'blockClientId', + richTextIdentifier: 'identifier', + source: 'default', + selector: 'range', + range: { + start: 50, + end: 75, + }, + }, + ], } ); } ); diff --git a/packages/api-fetch/CHANGELOG.md b/packages/api-fetch/CHANGELOG.md index 451bdd2aaf686f..733d979bc8ce89 100644 --- a/packages/api-fetch/CHANGELOG.md +++ b/packages/api-fetch/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.2.4 (2018-11-15) + +## 2.2.3 (2018-11-12) + ## 2.2.2 (2018-11-03) ## 2.2.1 (2018-10-30) diff --git a/packages/api-fetch/README.md b/packages/api-fetch/README.md index 00ba94755ade3c..85bd65e1be6329 100644 --- a/packages/api-fetch/README.md +++ b/packages/api-fetch/README.md @@ -17,7 +17,7 @@ _This package assumes that your code will run in an **ES2015+** environment. If ```js import apiFetch from '@wordpress/api-fetch'; -apiFetch( { path: '/wp-json/wp/v2/posts' } ).then( posts => { +apiFetch( { path: '/wp/v2/posts' } ).then( posts => { console.log( posts ); } ); ``` diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index b34a81862930fe..fc9d2ac3295902 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/api-fetch", - "version": "2.2.2", + "version": "2.2.4", "description": "Utility to make WordPress REST API requests.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/api-fetch/src/middlewares/preloading.js b/packages/api-fetch/src/middlewares/preloading.js index 78f431a984d989..020cb913fced15 100644 --- a/packages/api-fetch/src/middlewares/preloading.js +++ b/packages/api-fetch/src/middlewares/preloading.js @@ -28,12 +28,14 @@ const createPreloadingMiddleware = ( preloadedData ) => ( options, next ) => { } const { parse = true } = options; - if ( typeof options.path === 'string' && parse ) { + if ( typeof options.path === 'string' ) { const method = options.method || 'GET'; const path = getStablePath( options.path ); - if ( 'GET' === method && preloadedData[ path ] ) { + if ( parse && 'GET' === method && preloadedData[ path ] ) { return Promise.resolve( preloadedData[ path ].body ); + } else if ( 'OPTIONS' === method && preloadedData[ method ][ path ] ) { + return Promise.resolve( preloadedData[ method ][ path ] ); } } diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index 29738639f8fc56..9dc4bfe833a91b 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -1,3 +1,17 @@ +## 2.2.4 (2018-11-15) + +## 2.2.3 (2018-11-12) + +### Bug Fixes + +- Add a minimum width for the audio block to fixed floated audio blocks. + +## 2.2.2 (2018-11-12) + +### Polish + +- Columns Block: Improve usability while editing columns. + ## 2.2.1 (2018-11-09) ## 2.2.0 (2018-11-09) @@ -44,7 +58,7 @@ ### Breaking Change -- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. ### Deprecations diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 06e8c00e05185b..b2d15a2e64cef5 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-library", - "version": "2.2.1", + "version": "2.2.4", "description": "Block library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -46,7 +46,7 @@ "devDependencies": { "deep-freeze": "^0.0.1", "enzyme": "^3.7.0", - "react-test-renderer": "^16.4.1" + "react-test-renderer": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/packages/block-library/src/archives/index.php b/packages/block-library/src/archives/index.php index 97c8849ffc8188..85186ce6123a3f 100644 --- a/packages/block-library/src/archives/index.php +++ b/packages/block-library/src/archives/index.php @@ -32,7 +32,7 @@ function render_block_core_archives( $attributes ) { $class .= ' wp-block-archives-dropdown'; $dropdown_id = esc_attr( uniqid( 'wp-block-archives-' ) ); - $title = __( 'Archives', 'gutenberg' ); + $title = __( 'Archives', 'default' ); /** This filter is documented in wp-includes/widgets/class-wp-widget-archives.php */ $dropdown_args = apply_filters( @@ -50,19 +50,19 @@ function render_block_core_archives( $attributes ) { switch ( $dropdown_args['type'] ) { case 'yearly': - $label = __( 'Select Year', 'gutenberg' ); + $label = __( 'Select Year', 'default' ); break; case 'monthly': - $label = __( 'Select Month', 'gutenberg' ); + $label = __( 'Select Month', 'default' ); break; case 'daily': - $label = __( 'Select Day', 'gutenberg' ); + $label = __( 'Select Day', 'default' ); break; case 'weekly': - $label = __( 'Select Week', 'gutenberg' ); + $label = __( 'Select Week', 'default' ); break; default: - $label = __( 'Select Post', 'gutenberg' ); + $label = __( 'Select Post', 'default' ); break; } @@ -101,7 +101,7 @@ function render_block_core_archives( $attributes ) { $block_content = sprintf( '
%2$s
', $classnames, - __( 'No archives to show.', 'gutenberg' ) + __( 'No archives to show.', 'default' ) ); } else { diff --git a/packages/block-library/src/audio/edit.js b/packages/block-library/src/audio/edit.js index ed5c7d5aee2d94..ee2966208d015b 100644 --- a/packages/block-library/src/audio/edit.js +++ b/packages/block-library/src/audio/edit.js @@ -21,6 +21,11 @@ import { } from '@wordpress/editor'; import { getBlobByURL, isBlobURL } from '@wordpress/blob'; +/** + * Internal dependencies + */ +import { createUpgradedEmbedBlock } from '../embed/util'; + const ALLOWED_MEDIA_TYPES = [ 'audio' ]; class AudioEdit extends Component { @@ -73,6 +78,14 @@ class AudioEdit extends Component { // Set the block's src from the edit component's state, and switch off // the editing UI. if ( newSrc !== src ) { + // Check if there's an embed block that handles this URL. + const embedBlock = createUpgradedEmbedBlock( + { attributes: { url: newSrc } } + ); + if ( undefined !== embedBlock ) { + this.props.onReplace( embedBlock ); + return; + } setAttributes( { src: newSrc, id: undefined } ); } diff --git a/packages/block-library/src/audio/editor.scss b/packages/block-library/src/audio/editor.scss index fa16dddc224716..e09d789e3c9ea5 100644 --- a/packages/block-library/src/audio/editor.scss +++ b/packages/block-library/src/audio/editor.scss @@ -1,7 +1,3 @@ .wp-block-audio { margin: 0; } - -.wp-block-audio audio { - width: 100%; -} diff --git a/packages/block-library/src/audio/style.scss b/packages/block-library/src/audio/style.scss index f8a99467a694df..64d79cafd85dc9 100644 --- a/packages/block-library/src/audio/style.scss +++ b/packages/block-library/src/audio/style.scss @@ -1,6 +1,17 @@ -.wp-block-audio figcaption { - margin-top: 0.5em; - color: $dark-gray-300; - text-align: center; - font-size: $default-font-size; +.wp-block-audio { + // Supply caption styles to audio blocks, even if the theme hasn't opted in. + // Reason being: the new markup, , are not likely to be styled in the majority of existing themes, + // so we supply the styles so as to not appear broken or unstyled in those themes. + figcaption { + @include caption-style(); + } + + // Show full-width when not aligned. + audio { + width: 100%; + + // The browser natively applies a 300px width to the audio block. + // We restore this as a min-width instead, for alignments. + min-width: 300px; + } } diff --git a/packages/block-library/src/block/index.js b/packages/block-library/src/block/index.js index 799522b0bdbc50..2a3b451f8f2a98 100644 --- a/packages/block-library/src/block/index.js +++ b/packages/block-library/src/block/index.js @@ -15,7 +15,7 @@ export const settings = { category: 'reusable', - description: __( 'Create content, and save it to reuse across your site. Update the block, and the changes apply everywhere it’s used.' ), + description: __( 'Create content, and save it for you and other contributors to reuse across your site. Update the block, and the changes apply everywhere it’s used.' ), attributes: { ref: { diff --git a/packages/block-library/src/button/editor.scss b/packages/block-library/src/button/editor.scss index bf75ef8bb69520..f20d565964dbcd 100644 --- a/packages/block-library/src/button/editor.scss +++ b/packages/block-library/src/button/editor.scss @@ -31,6 +31,11 @@ $blocks-button__line-height: $big-font-size + 6px; opacity: 0.8; } + // Polish the empty placeholder text for the button in variation previews. + .editor-rich-text__tinymce[data-is-placeholder-visible="true"] { + height: auto; + } + // Don't let the placeholder text wrap in the variation preview. .editor-block-preview__content & { max-width: 100%; @@ -53,8 +58,8 @@ $blocks-button__line-height: $big-font-size + 6px; font-size: $default-font-size; line-height: $default-line-height; - // the width of input box plus two icon buttons plus a padding - $blocks-button__link-input-width: 300px + 2 * $icon-button-size + 8px; + // the width of input box plus padding plus two icon buttons. + $blocks-button__link-input-width: 300px + 2px + 2 * $icon-button-size; width: $blocks-button__link-input-width; .editor-url-input { diff --git a/packages/block-library/src/button/style.scss b/packages/block-library/src/button/style.scss index 49ae444c831495..fa72a8bc7835d5 100644 --- a/packages/block-library/src/button/style.scss +++ b/packages/block-library/src/button/style.scss @@ -2,28 +2,9 @@ $blocks-button__height: 46px; $blocks-button__line-height: $big-font-size + 6px; .wp-block-button { + color: $white; margin-bottom: 1.5em; - & .wp-block-button__link { - border: none; - border-radius: $blocks-button__height / 2; - box-shadow: none; - cursor: pointer; - display: inline-block; - font-size: $big-font-size; - line-height: $blocks-button__line-height; - margin: 0; - padding: ($blocks-button__height - $blocks-button__line-height) / 2 24px; - text-align: center; - text-decoration: none; - white-space: normal; - word-break: break-all; - } - - &.is-style-squared .wp-block-button__link { - border-radius: 0; - } - &.aligncenter { text-align: center; } @@ -34,45 +15,39 @@ $blocks-button__line-height: $big-font-size + 6px; } } -.wp-block-button__link:not(.has-background) { +.wp-block-button__link { background-color: $dark-gray-700; + border: none; + border-radius: $blocks-button__height / 2; + box-shadow: none; + color: inherit; + cursor: pointer; + display: inline-block; + font-size: $big-font-size; + line-height: $blocks-button__line-height; + margin: 0; + padding: ($blocks-button__height - $blocks-button__line-height) / 2 24px; + text-align: center; + text-decoration: none; + white-space: normal; + word-break: break-all; &:hover, &:focus, &:active { - background-color: $dark-gray-700; + color: inherit; } } -.wp-block-button.is-style-outline { - .wp-block-button__link { - background: transparent; - border: 2px solid currentcolor; - - &:hover, - &:focus, - &:active { - border-color: currentcolor; - } - } - - .wp-block-button__link:not(.has-text-color) { - color: $dark-gray-700; - - &:hover, - &:focus, - &:active { - color: $dark-gray-700; - } - } +.is-style-squared .wp-block-button__link { + border-radius: 0; } -.wp-block-button__link:not(.has-text-color) { - color: $white; +.is-style-outline { + color: $dark-gray-700; - &:hover, - &:focus, - &:active { - color: $white; + .wp-block-button__link { + background: transparent; + border: 2px solid currentcolor; } } diff --git a/packages/block-library/src/categories/index.php b/packages/block-library/src/categories/index.php index 83bf1c9db26397..478d4579207eac 100644 --- a/packages/block-library/src/categories/index.php +++ b/packages/block-library/src/categories/index.php @@ -27,7 +27,7 @@ function render_block_core_categories( $attributes ) { if ( ! empty( $attributes['displayAsDropdown'] ) ) { $id = 'wp-block-categories-' . $block_id; $args['id'] = $id; - $args['show_option_none'] = __( 'Select Category', 'gutenberg' ); + $args['show_option_none'] = __( 'Select Category', 'default' ); $wrapper_markup = '
%2$s
'; $items_markup = wp_dropdown_categories( $args ); $type = 'dropdown'; diff --git a/packages/block-library/src/classic/edit.js b/packages/block-library/src/classic/edit.js index 97307e91195533..db1467b6301399 100644 --- a/packages/block-library/src/classic/edit.js +++ b/packages/block-library/src/classic/edit.js @@ -79,13 +79,6 @@ export default class ClassicEdit extends Component { this.editor = editor; - // Disable TinyMCE's keyboard shortcut help. - editor.on( 'BeforeExecCommand', ( event ) => { - if ( event.command === 'WP_Help' ) { - event.preventDefault(); - } - } ); - if ( content ) { editor.on( 'loadContent', () => editor.setContent( content ) ); } diff --git a/packages/block-library/src/code/edit.native.js b/packages/block-library/src/code/edit.native.js index b6f4a8e541b37b..40f1225b2ae755 100644 --- a/packages/block-library/src/code/edit.native.js +++ b/packages/block-library/src/code/edit.native.js @@ -20,7 +20,9 @@ import styles from './theme.scss'; // Note: styling is applied directly to the (nested) PlainText component. Web-side components // apply it to the container 'div' but we don't have a proper proposal for cascading styling yet. -export default function CodeEdit( { attributes, setAttributes, style } ) { +export default function CodeEdit( props ) { + const { attributes, setAttributes, style } = props; + return ( setAttributes( { content } ) } placeholder={ __( 'Write code…' ) } aria-label={ __( 'Code' ) } + isSelected={ props.isSelected } /> </View> ); diff --git a/packages/block-library/src/code/index.js b/packages/block-library/src/code/index.js index cb304079e1e2c7..ef893570bac1b8 100644 --- a/packages/block-library/src/code/index.js +++ b/packages/block-library/src/code/index.js @@ -39,8 +39,7 @@ export const settings = { transforms: { from: [ { - type: 'pattern', - trigger: 'enter', + type: 'enter', regExp: /^```$/, transform: () => createBlock( 'core/code' ), }, diff --git a/packages/block-library/src/columns/column.js b/packages/block-library/src/columns/column.js index a8f37dd7df7ece..802e2c15082b4c 100644 --- a/packages/block-library/src/columns/column.js +++ b/packages/block-library/src/columns/column.js @@ -21,6 +21,7 @@ export const settings = { supports: { inserter: false, reusable: false, + html: false, }, edit() { diff --git a/packages/block-library/src/columns/editor.scss b/packages/block-library/src/columns/editor.scss index 7b269d0dd9a0ad..db8b558c4a85a7 100644 --- a/packages/block-library/src/columns/editor.scss +++ b/packages/block-library/src/columns/editor.scss @@ -12,16 +12,17 @@ } } -// Fullwide: show margin left/right to ensure there's room for the side UI -// This is not a 1:1 preview with the front-end where these margins would presumably be zero -// @todo this could be revisited, by for example showing this margin only when the parent block was selected first -// Then at least an unselected columns block would be an accurate preview -.editor-block-list__block[data-align="full"] .wp-block-columns .editor-block-list__layout { - &:first-child { - margin-left: $block-side-ui-width + $block-side-ui-clearance; - } - &:last-child { - margin-right: $block-side-ui-width + $block-side-ui-clearance; +// Fullwide: show margin left/right to ensure there's room for the side UI. +// This is not a 1:1 preview with the front-end where these margins would presumably be zero. +// @todo This could be revisited, by for example showing this margin only when the parent block was selected first. +// Then at least an unselected columns block would be an accurate preview. +.editor-block-list__block[data-align="full"] .wp-block-columns > .editor-inner-blocks { + padding-left: $block-padding; + padding-right: $block-padding; + + @include break-small() { + padding-left: $block-padding + $block-padding + $block-side-ui-width + $block-side-ui-clearance + $block-side-ui-clearance; + padding-right: $block-padding + $block-padding + $block-side-ui-width + $block-side-ui-clearance + $block-side-ui-clearance; } } @@ -34,10 +35,11 @@ // Responsiveness: Allow wrapping on mobile. flex-wrap: wrap; - @include break-medium() { + @include break-small() { flex-wrap: nowrap; } + // Adjust the individual column block. > [data-type="core/column"] { display: flex; flex-direction: column; @@ -46,47 +48,41 @@ // The Column block is a child of the Columns block and is mostly a passthrough container. // Therefore it shouldn't add additional paddings and margins, so we reset these, and compensate for margins. // @todo In the future, if a passthrough feature lands, it would be good to apply these rules to such an element in a more generic way. - margin-top: -$block-padding - $block-padding; - margin-bottom: -$block-padding - $block-padding; + > .editor-block-list__block-edit > div > .editor-inner-blocks { + margin-top: -$block-padding - $block-padding; + margin-bottom: -$block-padding - $block-padding; + } > .editor-block-list__block-edit { margin-top: 0; margin-bottom: 0; } + // Extend the passthrough concept to the block paddings, which we zero out. + > .editor-block-list__block-edit::before { + left: 0; + right: 0; + } + > .editor-block-list__block-edit > .editor-block-contextual-toolbar { + margin-left: -$border-width; + } + // On mobile, only a single column is shown, so match adjacent block paddings. padding-left: 0; padding-right: 0; margin-left: -$block-padding; margin-right: -$block-padding; - @include break-small () { - padding-left: $block-padding; - padding-right: $block-padding; - margin-right: inherit; - // Every .editor-block-list__block has auto-left/right margins, which centers columns. - // Since they aren't centered on the front-end, we explicitly set a zero left margin here. - margin-left: 0; - } - @include break-small() { - > .editor-block-list__block-edit > .editor-block-contextual-toolbar { - top: $block-toolbar-height - $border-width; - transform: translateY(-$block-toolbar-height - $border-width); - margin-left: -$grid-size-large - $border-width; - } + margin-left: $block-padding; + margin-right: $block-padding; + } - > .editor-block-list__block-edit::before { - top: 0; - right: 0; - bottom: 0; - left: 0; - } + // Prevent the columns from growing wider than their distributed sizes. + min-width: 0; - > .editor-block-list__block-edit > .editor-block-list__breadcrumb { - top: 0; - right: 0; - } - } + // Prevent long unbroken words from overflowing. + word-break: break-word; // For back-compat. + overflow-wrap: break-word; // New standard. // Responsiveness: Show at most one columns on mobile. flex-basis: 100%; @@ -101,27 +97,21 @@ // This has to match the same padding applied in style.scss. // Only apply this beyond the mobile breakpoint, as there's only a single column on mobile. @include break-small() { - > .editor-block-list__block-edit { - padding-left: $grid-size-large; - padding-right: $grid-size-large; + &:nth-child(odd) { + margin-right: $grid-size-large * 2; } - - &:nth-child(odd) > .editor-block-list__block-edit { - padding-left: 0; - } - - &:nth-child(even) > .editor-block-list__block-edit { - padding-right: 0; + &:nth-child(even) { + margin-left: $grid-size-large * 2; } } - @include break-medium() { - &:not(:first-child) > .editor-block-list__block-edit { - padding-left: $grid-size-large; + @include break-small() { + &:not(:first-child) { + margin-left: $grid-size-large * 2; } - &:not(:last-child) > .editor-block-list__block-edit { - padding-right: $grid-size-large; + &:not(:last-child) { + margin-right: $grid-size-large * 2; } } } @@ -147,6 +137,7 @@ } } -:not(.components-disabled) > .wp-block-columns > .editor-inner-blocks > .editor-block-list__layout > [data-type="core/column"] > .editor-block-list__block-edit .editor-inner-blocks { +// This selector re-enables clicking on any child of a column block. +:not(.components-disabled) > .wp-block-columns > .editor-inner-blocks > .editor-block-list__layout > [data-type="core/column"] > .editor-block-list__block-edit .editor-block-list__layout > * { pointer-events: all; } diff --git a/packages/block-library/src/columns/index.js b/packages/block-library/src/columns/index.js index 3b9d5b76c4f0a2..2c0e55b2d10354 100644 --- a/packages/block-library/src/columns/index.js +++ b/packages/block-library/src/columns/index.js @@ -85,6 +85,7 @@ export const settings = { supports: { align: [ 'wide', 'full' ], + html: false, }, deprecated: [ diff --git a/packages/block-library/src/columns/style.scss b/packages/block-library/src/columns/style.scss index 6a7aed4971d8bc..90c61ea5acb0ff 100644 --- a/packages/block-library/src/columns/style.scss +++ b/packages/block-library/src/columns/style.scss @@ -22,28 +22,29 @@ flex-grow: 0; } + // Prevent the columns from growing wider than their distributed sizes. + min-width: 0; + + // Prevent long unbroken words from overflowing. + word-break: break-word; // For back-compat. + overflow-wrap: break-word; // New standard. + // Add space between columns. Themes can customize this if they wish to work differently. // Only apply this beyond the mobile breakpoint, as there's only a single column on mobile. @include break-small() { - padding-left: $grid-size-large; - padding-right: $grid-size-large; - &:nth-child(odd) { - padding-left: 0; + margin-right: $grid-size-large * 2; } - &:nth-child(even) { - padding-right: 0; + margin-left: $grid-size-large * 2; } - } - @include break-medium() { &:not(:first-child) { - padding-left: $grid-size-large; + margin-left: $grid-size-large * 2; } &:not(:last-child) { - padding-right: $grid-size-large; + margin-right: $grid-size-large * 2; } } } diff --git a/packages/block-library/src/cover/index.js b/packages/block-library/src/cover/index.js index 881755f1dd1f53..8dff936dc440d1 100644 --- a/packages/block-library/src/cover/index.js +++ b/packages/block-library/src/cover/index.js @@ -26,6 +26,7 @@ import { BlockAlignmentToolbar, MediaPlaceholder, MediaUpload, + MediaUploadCheck, AlignmentToolbar, PanelColorSettings, RichText, @@ -214,6 +215,7 @@ export const settings = { } mediaType = media.type; } + setAttributes( { url: media.url, id: media.id, @@ -258,21 +260,23 @@ export const settings = { setAttributes( { contentAlign: nextAlign } ); } } /> - <Toolbar> - <MediaUpload - onSelect={ onSelectMedia } - allowedTypes={ ALLOWED_MEDIA_TYPES } - value={ id } - render={ ( { open } ) => ( - <IconButton - className="components-toolbar__control" - label={ __( 'Edit media' ) } - icon="edit" - onClick={ open } - /> - ) } - /> - </Toolbar> + <MediaUploadCheck> + <Toolbar> + <MediaUpload + onSelect={ onSelectMedia } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ id } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit media' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + </Toolbar> + </MediaUploadCheck> </Fragment> ) } </BlockControls> diff --git a/packages/block-library/src/cover/style.scss b/packages/block-library/src/cover/style.scss index 294118094ed84b..806ed02fb2dc9b 100644 --- a/packages/block-library/src/cover/style.scss +++ b/packages/block-library/src/cover/style.scss @@ -10,6 +10,7 @@ display: flex; justify-content: center; align-items: center; + overflow: hidden; &.has-left-content { justify-content: flex-start; @@ -94,6 +95,13 @@ max-width: $content-width / 2; width: 100%; } + + // Aligned cover blocks should not use our global alignment rules + &.aligncenter, + &.alignleft, + &.alignright { + display: flex; + } } .wp-block-cover__video-background { @@ -104,5 +112,5 @@ width: 100%; height: 100%; z-index: z-index(".wp-block-cover__video-background"); - object-fit: fill; + object-fit: cover; } diff --git a/packages/block-library/src/embed/editor.scss b/packages/block-library/src/embed/editor.scss index f56a9f6b35a306..0b79d5b0414beb 100644 --- a/packages/block-library/src/embed/editor.scss +++ b/packages/block-library/src/embed/editor.scss @@ -7,7 +7,9 @@ // Apply a min-width, or the embed can collapse when floated. // Instagram widgets have a min-width of 326px, so go a bit beyond that. - min-width: 360px; + @include break-small() { + min-width: 360px; + } &.is-loading { display: flex; diff --git a/packages/block-library/src/embed/test/__snapshots__/index.js.snap b/packages/block-library/src/embed/test/__snapshots__/index.js.snap index b1f124507649b0..47bbb2dd8c934d 100644 --- a/packages/block-library/src/embed/test/__snapshots__/index.js.snap +++ b/packages/block-library/src/embed/test/__snapshots__/index.js.snap @@ -7,7 +7,7 @@ exports[`core/embed block edit matches snapshot 1`] = ` <div class="components-placeholder__label" > - <div + <span class="editor-block-icon has-colors" > <svg @@ -24,7 +24,7 @@ exports[`core/embed block edit matches snapshot 1`] = ` d="M17 4H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-3 6.5L12.5 12l1.5 1.5V15l-3-3 3-3v1.5zm1 4.5v-1.5l1.5-1.5-1.5-1.5V9l3 3-3 3z" /> </svg> - </div> + </span> Embed URL </div> <div diff --git a/packages/block-library/src/file/edit.js b/packages/block-library/src/file/edit.js index 5d1dc30cf9e365..eb311fb48a73a9 100644 --- a/packages/block-library/src/file/edit.js +++ b/packages/block-library/src/file/edit.js @@ -19,6 +19,7 @@ import { Component, Fragment } from '@wordpress/element'; import { MediaUpload, MediaPlaceholder, + MediaUploadCheck, BlockControls, RichText, mediaUpload, @@ -165,20 +166,22 @@ class FileEdit extends Component { } } /> <BlockControls> - <Toolbar> - <MediaUpload - onSelect={ this.onSelectFile } - value={ id } - render={ ( { open } ) => ( - <IconButton - className="components-toolbar__control" - label={ __( 'Edit file' ) } - onClick={ open } - icon="edit" - /> - ) } - /> - </Toolbar> + <MediaUploadCheck> + <Toolbar> + <MediaUpload + onSelect={ this.onSelectFile } + value={ id } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit file' ) } + onClick={ open } + icon="edit" + /> + ) } + /> + </Toolbar> + </MediaUploadCheck> </BlockControls> <div className={ classes }> <div className={ `${ className }__content-wrapper` }> diff --git a/packages/block-library/src/file/editor.scss b/packages/block-library/src/file/editor.scss index 194fe3c62cd6e0..25e78effbd528a 100644 --- a/packages/block-library/src/file/editor.scss +++ b/packages/block-library/src/file/editor.scss @@ -5,7 +5,7 @@ margin-bottom: 0; &.is-transient { - @include loading_fade; + @include edit-post__loading-fade-animation; } .wp-block-file__content-wrapper { diff --git a/packages/block-library/src/gallery/edit.js b/packages/block-library/src/gallery/edit.js index 3ba6c57bf85b31..003570d3bdf7d1 100644 --- a/packages/block-library/src/gallery/edit.js +++ b/packages/block-library/src/gallery/edit.js @@ -1,7 +1,7 @@ /** * External Dependencies */ -import { filter, pick } from 'lodash'; +import { filter, pick, map, get } from 'lodash'; /** * WordPress dependencies @@ -45,7 +45,9 @@ export function defaultColumnsNumber( attributes ) { } export const pickRelevantMediaFiles = ( image ) => { - return pick( image, [ 'alt', 'id', 'link', 'url', 'caption' ] ); + const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] ); + imageProps.url = get( image, [ 'sizes', 'large', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || image.url; + return imageProps; }; class GalleryEdit extends Component { @@ -61,12 +63,28 @@ class GalleryEdit extends Component { this.setImageAttributes = this.setImageAttributes.bind( this ); this.addFiles = this.addFiles.bind( this ); this.uploadFromFiles = this.uploadFromFiles.bind( this ); + this.setAttributes = this.setAttributes.bind( this ); this.state = { selectedImage: null, }; } + setAttributes( attributes ) { + if ( attributes.ids ) { + throw new Error( 'The "ids" attribute should not be changed directly. It is managed automatically when "images" attribute changes' ); + } + + if ( attributes.images ) { + attributes = { + ...attributes, + ids: map( attributes.images, 'id' ), + }; + } + + this.props.setAttributes( attributes ); + } + onSelectImage( index ) { return () => { if ( this.state.selectedImage !== index ) { @@ -82,7 +100,7 @@ class GalleryEdit extends Component { const images = filter( this.props.attributes.images, ( img, i ) => index !== i ); const { columns } = this.props.attributes; this.setState( { selectedImage: null } ); - this.props.setAttributes( { + this.setAttributes( { images, columns: columns ? Math.min( images.length, columns ) : columns, } ); @@ -90,21 +108,21 @@ class GalleryEdit extends Component { } onSelectImages( images ) { - this.props.setAttributes( { + this.setAttributes( { images: images.map( ( image ) => pickRelevantMediaFiles( image ) ), } ); } setLinkTo( value ) { - this.props.setAttributes( { linkTo: value } ); + this.setAttributes( { linkTo: value } ); } setColumnsNumber( value ) { - this.props.setAttributes( { columns: value } ); + this.setAttributes( { columns: value } ); } toggleImageCrop() { - this.props.setAttributes( { imageCrop: ! this.props.attributes.imageCrop } ); + this.setAttributes( { imageCrop: ! this.props.attributes.imageCrop } ); } getImageCropHelp( checked ) { @@ -112,7 +130,8 @@ class GalleryEdit extends Component { } setImageAttributes( index, attributes ) { - const { attributes: { images }, setAttributes } = this.props; + const { attributes: { images } } = this.props; + const { setAttributes } = this; if ( ! images[ index ] ) { return; } @@ -134,7 +153,8 @@ class GalleryEdit extends Component { addFiles( files ) { const currentImages = this.props.attributes.images || []; - const { noticeOperations, setAttributes } = this.props; + const { noticeOperations } = this.props; + const { setAttributes } = this; mediaUpload( { allowedTypes: ALLOWED_MEDIA_TYPES, filesList: files, diff --git a/packages/block-library/src/gallery/editor.scss b/packages/block-library/src/gallery/editor.scss index 26ea875564805b..9140d86007d077 100644 --- a/packages/block-library/src/gallery/editor.scss +++ b/packages/block-library/src/gallery/editor.scss @@ -17,8 +17,8 @@ ul.wp-block-gallery li { outline: 4px solid theme(primary); } - &.is-transient img { - @include loading_fade; + .is-transient img { + opacity: 0.3; } .editor-rich-text { @@ -119,5 +119,17 @@ ul.wp-block-gallery li { position: absolute; top: 50%; left: 50%; - transform: translate(-50%, -50%); + margin-top: -9px; + margin-left: -9px; +} + +// Last item always needs margins reset. +// When block is selected, only reset the right margin of the 2nd to last item. +.wp-block-gallery { + .is-selected & .blocks-gallery-image:nth-last-child(2), + .is-selected & .blocks-gallery-item:nth-last-child(2), + .is-typing & .blocks-gallery-image:nth-last-child(2), + .is-typing & .blocks-gallery-item:nth-last-child(2) { + margin-right: 0; + } } diff --git a/packages/block-library/src/gallery/gallery-image.js b/packages/block-library/src/gallery/gallery-image.js index 6db6f856b2ea6d..bbdd1db6a222cf 100644 --- a/packages/block-library/src/gallery/gallery-image.js +++ b/packages/block-library/src/gallery/gallery-image.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress Dependencies */ -import { Component } from '@wordpress/element'; +import { Component, Fragment } from '@wordpress/element'; import { IconButton, Spinner } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { BACKSPACE, DELETE } from '@wordpress/keycodes'; @@ -99,10 +99,24 @@ class GalleryImage extends Component { break; } - // Disable reason: Image itself is not meant to be - // interactive, but should direct image selection and unfocus caption fields - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - const img = url ? <img src={ url } alt={ alt } data-id={ id } onClick={ this.onImageClick } tabIndex="0" onKeyDown={ this.onImageClick } aria-label={ ariaLabel } /> : <Spinner />; + const img = ( + // Disable reason: Image itself is not meant to be interactive, but should + // direct image selection and unfocus caption fields. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + <Fragment> + <img + src={ url } + alt={ alt } + data-id={ id } + onClick={ this.onImageClick } + tabIndex="0" + onKeyDown={ this.onImageClick } + aria-label={ ariaLabel } + /> + { isBlobURL( url ) && <Spinner /> } + </Fragment> + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ); const className = classnames( { 'is-selected': isSelected, diff --git a/packages/block-library/src/gallery/index.js b/packages/block-library/src/gallery/index.js index e347c806d2af37..080da725b8d92e 100644 --- a/packages/block-library/src/gallery/index.js +++ b/packages/block-library/src/gallery/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { filter, every } from 'lodash'; +import { filter, every, map, some } from 'lodash'; /** * WordPress dependencies @@ -52,6 +52,10 @@ const blockAttributes = { }, }, }, + ids: { + type: 'array', + default: [], + }, columns: { type: 'number', }, @@ -67,6 +71,16 @@ const blockAttributes = { export const name = 'core/gallery'; +const parseShortcodeIds = ( ids ) => { + if ( ! ids ) { + return []; + } + + return ids.split( ',' ).map( ( id ) => ( + parseInt( id, 10 ) + ) ); +}; + export const settings = { title: __( 'Gallery' ), description: __( 'Display multiple images in a rich gallery.' ), @@ -89,6 +103,7 @@ export const settings = { if ( validImages.length > 0 ) { return createBlock( 'core/gallery', { images: validImages.map( ( { id, url, alt, caption } ) => ( { id, url, alt, caption } ) ), + ids: validImages.map( ( { id } ) => id ), } ); } return createBlock( 'core/gallery' ); @@ -101,15 +116,17 @@ export const settings = { images: { type: 'array', shortcode: ( { named: { ids } } ) => { - if ( ! ids ) { - return []; - } - - return ids.split( ',' ).map( ( id ) => ( { - id: parseInt( id, 10 ), + return parseShortcodeIds( ids ).map( ( id ) => ( { + id, } ) ); }, }, + ids: { + type: 'array', + shortcode: ( { named: { ids } } ) => { + return parseShortcodeIds( ids ); + }, + }, columns: { type: 'number', shortcode: ( { named: { columns = '3' } } ) => { @@ -139,8 +156,12 @@ export const settings = { mediaUpload( { filesList: files, onFileChange: ( images ) => { + const imagesAttr = images.map( + pickRelevantMediaFiles + ); onChange( block.clientId, { - images: images.map( ( image ) => pickRelevantMediaFiles( image ) ), + ids: map( imagesAttr, 'id' ), + images: imagesAttr, } ); }, allowedTypes: [ 'image' ], @@ -199,6 +220,66 @@ export const settings = { }, deprecated: [ + { + attributes: blockAttributes, + isEligible( { images, ids } ) { + return images && + images.length > 0 && + ( + ( ! ids && images ) || + ( ids && images && ids.length !== images.length ) || + some( images, ( id, index ) => { + if ( ! id && ids[ index ] !== null ) { + return true; + } + return parseInt( id, 10 ) !== ids[ index ]; + } ) + ); + }, + migrate( attributes ) { + return { + ...attributes, + ids: map( attributes.images, ( { id } ) => { + if ( ! id ) { + return null; + } + return parseInt( id, 10 ); + } ), + }; + }, + save( { attributes } ) { + const { images, columns = defaultColumnsNumber( attributes ), imageCrop, linkTo } = attributes; + return ( + <ul className={ `columns-${ columns } ${ imageCrop ? 'is-cropped' : '' }` } > + { images.map( ( image ) => { + let href; + + switch ( linkTo ) { + case 'media': + href = image.url; + break; + case 'attachment': + href = image.link; + break; + } + + const img = <img src={ image.url } alt={ image.alt } data-id={ image.id } data-link={ image.link } className={ image.id ? `wp-image-${ image.id }` : null } />; + + return ( + <li key={ image.id || image.url } className="blocks-gallery-item"> + <figure> + { href ? <a href={ href }>{ img }</a> : img } + { image.caption && image.caption.length > 0 && ( + <RichText.Content tagName="figcaption" value={ image.caption } /> + ) } + </figure> + </li> + ); + } ) } + </ul> + ); + }, + }, { attributes: blockAttributes, save( { attributes } ) { diff --git a/packages/block-library/src/gallery/style.scss b/packages/block-library/src/gallery/style.scss index a43d08046bb5e4..26053298a3b3b7 100644 --- a/packages/block-library/src/gallery/style.scss +++ b/packages/block-library/src/gallery/style.scss @@ -114,13 +114,8 @@ } // Last item always needs margins reset. - // When block is selected, only reset the right margin of the 2nd to last item. .blocks-gallery-image:last-child, - .blocks-gallery-item:last-child, - .is-selected & .blocks-gallery-image:nth-last-child(2), - .is-selected & .blocks-gallery-item:nth-last-child(2), - .is-typing & .blocks-gallery-image:nth-last-child(2), - .is-typing & .blocks-gallery-item:nth-last-child(2) { + .blocks-gallery-item:last-child { margin-right: 0; } diff --git a/packages/block-library/src/heading/edit.js b/packages/block-library/src/heading/edit.js index 711f0c100e4317..95b810c90c5294 100644 --- a/packages/block-library/src/heading/edit.js +++ b/packages/block-library/src/heading/edit.js @@ -48,7 +48,7 @@ export default function HeadingEdit( { value={ content } onChange={ ( value ) => setAttributes( { content: value } ) } onMerge={ mergeBlocks } - onSplit={ + unstableOnSplit={ insertBlocksAfter ? ( before, after, ...blocks ) => { setAttributes( { content: before } ); diff --git a/packages/block-library/src/heading/edit.native.js b/packages/block-library/src/heading/edit.native.js index 077d2701c84d39..6d2ed5bf27e7d2 100644 --- a/packages/block-library/src/heading/edit.native.js +++ b/packages/block-library/src/heading/edit.native.js @@ -39,7 +39,6 @@ class HeadingEdit extends Component { } = attributes; const tagName = 'h' + level; - return ( <View> <BlockControls> @@ -48,6 +47,7 @@ class HeadingEdit extends Component { <RichText tagName={ tagName } value={ content } + isSelected={ this.props.isSelected } style={ { minHeight: Math.max( minHeight, typeof attributes.aztecHeight === 'undefined' ? 0 : attributes.aztecHeight ), } } diff --git a/packages/block-library/src/heading/index.js b/packages/block-library/src/heading/index.js index 148d0629340744..288a72d35bc1be 100644 --- a/packages/block-library/src/heading/index.js +++ b/packages/block-library/src/heading/index.js @@ -107,18 +107,16 @@ export const settings = { } ); }, }, - { - type: 'pattern', - regExp: /^(#{2,6})\s/, - transform: ( { content, match } ) => { - const level = match[ 1 ].length; - + ...[ 2, 3, 4, 5, 6 ].map( ( level ) => ( { + type: 'prefix', + prefix: Array( level + 1 ).join( '#' ), + transform( content ) { return createBlock( 'core/heading', { level, content, } ); }, - }, + } ) ), ], to: [ { diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index 3d0c3706d14e40..7daffa06203b08 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -6,6 +6,7 @@ import { get, isEmpty, map, + last, pick, compact, } from 'lodash'; @@ -13,7 +14,8 @@ import { /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { getPath } from '@wordpress/url'; +import { __, sprintf } from '@wordpress/i18n'; import { Component, Fragment } from '@wordpress/element'; import { getBlobByURL, revokeBlobURL, isBlobURL } from '@wordpress/blob'; import { @@ -23,6 +25,7 @@ import { PanelBody, ResizableBox, SelectControl, + Spinner, TextControl, TextareaControl, Toolbar, @@ -36,6 +39,7 @@ import { InspectorControls, MediaPlaceholder, MediaUpload, + MediaUploadCheck, BlockAlignmentToolbar, mediaUpload, } from '@wordpress/editor'; @@ -45,6 +49,7 @@ import { compose } from '@wordpress/compose'; /** * Internal dependencies */ +import { createUpgradedEmbedBlock } from '../embed/util'; import ImageSize from './image-size'; /** @@ -55,10 +60,13 @@ const LINK_DESTINATION_NONE = 'none'; const LINK_DESTINATION_MEDIA = 'media'; const LINK_DESTINATION_ATTACHMENT = 'attachment'; const LINK_DESTINATION_CUSTOM = 'custom'; +const NEW_TAB_REL = 'noreferrer noopener'; const ALLOWED_MEDIA_TYPES = [ 'image' ]; export const pickRelevantMediaFiles = ( image ) => { - return pick( image, [ 'alt', 'id', 'link', 'url', 'caption' ] ); + const imageProps = pick( image, [ 'alt', 'id', 'link', 'caption' ] ); + imageProps.url = get( image, [ 'sizes', 'large', 'url' ] ) || get( image, [ 'media_details', 'sizes', 'large', 'source_url' ] ) || image.url; + return imageProps; }; /** @@ -97,9 +105,14 @@ class ImageEdit extends Component { this.updateHeight = this.updateHeight.bind( this ); this.updateDimensions = this.updateDimensions.bind( this ); this.onSetCustomHref = this.onSetCustomHref.bind( this ); + this.onSetLinkClass = this.onSetLinkClass.bind( this ); + this.onSetLinkRel = this.onSetLinkRel.bind( this ); this.onSetLinkDestination = this.onSetLinkDestination.bind( this ); + this.onSetNewTab = this.onSetNewTab.bind( this ); + this.getFilename = this.getFilename.bind( this ); this.toggleIsEditing = this.toggleIsEditing.bind( this ); this.onUploadError = this.onUploadError.bind( this ); + this.onImageError = this.onImageError.bind( this ); this.state = { captionFocused: false, @@ -205,10 +218,45 @@ class ImageEdit extends Component { } ); } + onImageError( url ) { + // Check if there's an embed block that handles this URL. + const embedBlock = createUpgradedEmbedBlock( + { attributes: { url } } + ); + if ( undefined !== embedBlock ) { + this.props.onReplace( embedBlock ); + } + } + onSetCustomHref( value ) { this.props.setAttributes( { href: value } ); } + onSetLinkClass( value ) { + this.props.setAttributes( { linkClass: value } ); + } + + onSetLinkRel( value ) { + this.props.setAttributes( { rel: value } ); + } + + onSetNewTab( value ) { + const { rel } = this.props.attributes; + const linkTarget = value ? '_blank' : undefined; + + let updatedRel = rel; + if ( linkTarget && ! rel ) { + updatedRel = NEW_TAB_REL; + } else if ( ! linkTarget && rel === NEW_TAB_REL ) { + updatedRel = undefined; + } + + this.props.setAttributes( { + linkTarget, + rel: updatedRel, + } ); + } + onFocusCaption() { if ( ! this.state.captionFocused ) { this.setState( { @@ -254,6 +302,13 @@ class ImageEdit extends Component { }; } + getFilename( url ) { + const path = getPath( url ); + if ( path ) { + return last( path.split( '/' ) ); + } + } + getLinkDestinationOptions() { return [ { value: LINK_DESTINATION_NONE, label: __( 'None' ) }, @@ -296,7 +351,20 @@ class ImageEdit extends Component { toggleSelection, isRTL, } = this.props; - const { url, alt, caption, align, id, href, linkDestination, width, height, linkTarget } = attributes; + const { + url, + alt, + caption, + align, + id, + href, + rel, + linkClass, + linkDestination, + width, + height, + linkTarget, + } = attributes; const isExternal = isExternalImage( id, url ); const imageSizeOptions = this.getImageSizeOptions(); @@ -315,21 +383,23 @@ class ImageEdit extends Component { ); } else { toolbarEditButton = ( - <Toolbar> - <MediaUpload - onSelect={ this.onSelectImage } - allowedTypes={ ALLOWED_MEDIA_TYPES } - value={ id } - render={ ( { open } ) => ( - <IconButton - className="components-toolbar__control" - label={ __( 'Edit image' ) } - icon="edit" - onClick={ open } - /> - ) } - /> - </Toolbar> + <MediaUploadCheck> + <Toolbar> + <MediaUpload + onSelect={ this.onSelectImage } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ id } + render={ ( { open } ) => ( + <IconButton + className="components-toolbar__control" + label={ __( 'Edit image' ) } + icon="edit" + onClick={ open } + /> + ) } + /> + </Toolbar> + </MediaUploadCheck> ); } } @@ -464,8 +534,18 @@ class ImageEdit extends Component { /> <ToggleControl label={ __( 'Open in New Tab' ) } - onChange={ () => setAttributes( { linkTarget: ! linkTarget ? '_blank' : undefined } ) } + onChange={ this.onSetNewTab } checked={ linkTarget === '_blank' } /> + <TextControl + label={ __( 'Link CSS Class' ) } + value={ linkClass || '' } + onChange={ this.onSetLinkClass } + /> + <TextControl + label={ __( 'Link Rel' ) } + value={ rel || '' } + onChange={ this.onSetLinkRel } + /> </Fragment> ) } </PanelBody> @@ -487,10 +567,26 @@ class ImageEdit extends Component { imageHeight, } = sizes; - // Disable reason: Image itself is not meant to be - // interactive, but should direct focus to block - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions - const img = <img src={ url } alt={ alt } onClick={ this.onImageClick } />; + const filename = this.getFilename( url ); + let defaultedAlt; + if ( alt ) { + defaultedAlt = alt; + } else if ( filename ) { + defaultedAlt = sprintf( __( 'This image has an empty alt attribute; its file name is %s' ), filename ); + } else { + defaultedAlt = __( 'This image has an empty alt attribute' ); + } + + const img = ( + // Disable reason: Image itself is not meant to be interactive, but + // should direct focus to block. + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ + <Fragment> + <img src={ url } alt={ defaultedAlt } onClick={ this.onImageClick } onError={ () => this.onImageError( url ) } /> + { isBlobURL( url ) && <Spinner /> } + </Fragment> + /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ + ); if ( ! isResizable || ! imageWidthWithinContainer ) { return ( @@ -510,6 +606,13 @@ class ImageEdit extends Component { const minWidth = imageWidth < imageHeight ? MIN_SIZE : MIN_SIZE * ratio; const minHeight = imageHeight < imageWidth ? MIN_SIZE : MIN_SIZE / ratio; + // With the current implementation of ResizableBox, an image needs an explicit pixel value for the max-width. + // In absence of being able to set the content-width, this max-width is currently dictated by the vanilla editor style. + // The following variable adds a buffer to this vanilla style, so 3rd party themes have some wiggleroom. + // This does, in most cases, allow you to scale the image beyond the width of the main column, though not infinitely. + // @todo It would be good to revisit this once a content-width variable becomes available. + const maxWidthBuffer = maxWidth * 2.5; + let showRightHandle = false; let showLeftHandle = false; @@ -550,9 +653,9 @@ class ImageEdit extends Component { } : undefined } minWidth={ minWidth } - maxWidth={ maxWidth } + maxWidth={ maxWidthBuffer } minHeight={ minHeight } - maxHeight={ maxWidth / ratio } + maxHeight={ maxWidthBuffer / ratio } lockAspectRatio enable={ { top: false, diff --git a/packages/block-library/src/image/edit.native.js b/packages/block-library/src/image/edit.native.js index ab7af710e4e6dc..fde82c5a717770 100644 --- a/packages/block-library/src/image/edit.native.js +++ b/packages/block-library/src/image/edit.native.js @@ -7,7 +7,9 @@ import RNReactNativeGutenbergBridge from 'react-native-gutenberg-bridge'; /** * Internal dependencies */ -import { MediaPlaceholder, RichText } from '@wordpress/editor'; +import { MediaPlaceholder, RichText, BlockControls } from '@wordpress/editor'; +import { Toolbar, IconButton } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; export default function ImageEdit( props ) { const { attributes, isSelected, setAttributes } = props; @@ -20,8 +22,6 @@ export default function ImageEdit( props ) { }; const onMediaLibraryPress = () => { - // Call onMediaLibraryPress from the Native<->RN bridge. It should trigger an image picker from - // the WordPress media library and call the provided callback to set the image URL. RNReactNativeGutenbergBridge.onMediaLibraryPress( ( mediaUrl ) => { if ( mediaUrl ) { setAttributes( { url: mediaUrl } ); @@ -38,8 +38,22 @@ export default function ImageEdit( props ) { ); } + const toolbarEditButton = ( + <Toolbar> + <IconButton + className="components-toolbar__control" + label={ __( 'Edit image' ) } + icon="edit" + onClick={ onMediaLibraryPress } + /> + </Toolbar> + ); + return ( <View style={ { flex: 1 } }> + <BlockControls> + { toolbarEditButton } + </BlockControls> <Image style={ { width: '100%', height: 200 } } resizeMethod="scale" diff --git a/packages/block-library/src/image/editor.scss b/packages/block-library/src/image/editor.scss index 5e0135fe3ebd60..1c4d985707c3b5 100644 --- a/packages/block-library/src/image/editor.scss +++ b/packages/block-library/src/image/editor.scss @@ -2,12 +2,21 @@ position: relative; &.is-transient img { - @include loading_fade; + opacity: 0.3; } figcaption img { display: inline; } + + // Shown while image is being uploaded + .components-spinner { + position: absolute; + top: 50%; + left: 50%; + margin-top: -9px; + margin-left: -9px; + } } // This is necessary for the editor resize handles to accurately work on a non-floated, non-resized, small image. diff --git a/packages/block-library/src/image/index.js b/packages/block-library/src/image/index.js index 4f6619a6c6e527..0a5ca7618c4aa9 100644 --- a/packages/block-library/src/image/index.js +++ b/packages/block-library/src/image/index.js @@ -52,6 +52,18 @@ const blockAttributes = { selector: 'figure > a', attribute: 'href', }, + rel: { + type: 'string', + source: 'attribute', + selector: 'figure > a', + attribute: 'rel', + }, + linkClass: { + type: 'string', + source: 'attribute', + selector: 'figure > a', + attribute: 'class', + }, id: { type: 'number', }, @@ -89,7 +101,7 @@ const schema = { children: { ...imageSchema, a: { - attributes: [ 'href', 'target' ], + attributes: [ 'href', 'rel', 'target' ], children: imageSchema, }, figcaption: { @@ -132,7 +144,9 @@ export const settings = { const anchorElement = node.querySelector( 'a' ); const linkDestination = anchorElement && anchorElement.href ? 'custom' : undefined; const href = anchorElement && anchorElement.href ? anchorElement.href : undefined; - const attributes = getBlockAttributes( 'core/image', node.outerHTML, { align, id, linkDestination, href } ); + const rel = anchorElement && anchorElement.rel ? anchorElement.rel : undefined; + const linkClass = anchorElement && anchorElement.className ? anchorElement.className : undefined; + const attributes = getBlockAttributes( 'core/image', node.outerHTML, { align, id, linkDestination, href, rel, linkClass } ); return createBlock( 'core/image', attributes ); }, }, @@ -181,6 +195,18 @@ export const settings = { attribute: 'href', selector: 'a', }, + rel: { + type: 'string', + source: 'attribute', + attribute: 'rel', + selector: 'a', + }, + linkClass: { + type: 'string', + source: 'attribute', + attribute: 'class', + selector: 'a', + }, id: { type: 'number', shortcode: ( { named: { id } } ) => { @@ -212,7 +238,19 @@ export const settings = { edit, save( { attributes } ) { - const { url, alt, caption, align, href, width, height, id, linkTarget } = attributes; + const { + url, + alt, + caption, + align, + href, + rel, + linkClass, + width, + height, + id, + linkTarget, + } = attributes; const classes = classnames( { [ `align${ align }` ]: align, @@ -231,7 +269,16 @@ export const settings = { const figure = ( <Fragment> - { href ? <a href={ href } target={ linkTarget } rel={ linkTarget === '_blank' ? 'noreferrer noopener' : undefined }>{ image }</a> : image } + { href ? ( + <a + className={ linkClass } + href={ href } + target={ linkTarget } + rel={ rel } + > + { image } + </a> + ) : image } { ! RichText.isEmpty( caption ) && <RichText.Content tagName="figcaption" value={ caption } /> } </Fragment> ); diff --git a/packages/block-library/src/index.native.js b/packages/block-library/src/index.native.js index 3da15242c7cfbd..a1bd0b0d2f4f8c 100644 --- a/packages/block-library/src/index.native.js +++ b/packages/block-library/src/index.native.js @@ -3,6 +3,7 @@ */ import { registerBlockType, + setDefaultBlockName, } from '@wordpress/blocks'; /** @@ -27,3 +28,5 @@ export const registerCoreBlocks = () => { registerBlockType( name, settings ); } ); }; + +setDefaultBlockName( paragraph.name ); diff --git a/packages/block-library/src/latest-comments/index.php b/packages/block-library/src/latest-comments/index.php index 199a426a93ef64..927157eb43481d 100644 --- a/packages/block-library/src/latest-comments/index.php +++ b/packages/block-library/src/latest-comments/index.php @@ -29,7 +29,7 @@ function gutenberg_draft_or_post_title( $post = 0 ) { $title = get_the_title( $post ); if ( empty( $title ) ) { - $title = __( '(no title)', 'gutenberg' ); + $title = __( '(no title)', 'default' ); } return esc_html( $title ); } @@ -98,7 +98,7 @@ function gutenberg_render_block_core_latest_comments( $attributes = array() ) { $list_items_markup .= sprintf( /* translators: 1: author name (inside <a> or <span> tag, based on if they have a URL), 2: post title related to this comment */ - __( '%1$s on %2$s', 'gutenberg' ), + __( '%1$s on %2$s', 'default' ), $author_markup, $post_title ); @@ -143,7 +143,7 @@ function gutenberg_render_block_core_latest_comments( $attributes = array() ) { ) : sprintf( '<div class="%1$s">%2$s</div>', $classnames, - __( 'No comments to show.', 'gutenberg' ) + __( 'No comments to show.', 'default' ) ); return $block_content; diff --git a/packages/block-library/src/latest-posts/edit.js b/packages/block-library/src/latest-posts/edit.js index a9e66200ff5bbd..50310e883ab3c1 100644 --- a/packages/block-library/src/latest-posts/edit.js +++ b/packages/block-library/src/latest-posts/edit.js @@ -33,7 +33,7 @@ import { withSelect } from '@wordpress/data'; * Module Constants */ const CATEGORIES_LIST_QUERY = { - per_page: 100, + per_page: -1, }; const MAX_POSTS_COLUMNS = 6; diff --git a/packages/block-library/src/latest-posts/index.php b/packages/block-library/src/latest-posts/index.php index b395d8a2bacfcc..850192a5492c3e 100644 --- a/packages/block-library/src/latest-posts/index.php +++ b/packages/block-library/src/latest-posts/index.php @@ -30,7 +30,7 @@ function render_block_core_latest_posts( $attributes ) { $title = get_the_title( $post_id ); if ( ! $title ) { - $title = __( '(Untitled)', 'gutenberg' ); + $title = __( '(Untitled)', 'default' ); } $list_items_markup .= sprintf( '<li><a href="%1$s">%2$s</a>', diff --git a/packages/block-library/src/list/index.js b/packages/block-library/src/list/index.js index 9fe52078e89937..e7a6174ef25084 100644 --- a/packages/block-library/src/list/index.js +++ b/packages/block-library/src/list/index.js @@ -1,22 +1,18 @@ /** * External dependencies */ -import { find, omit } from 'lodash'; +import { omit } from 'lodash'; /** * WordPress dependencies */ -import { Component, Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { createBlock, getPhrasingContentSchema, getBlockAttributes, } from '@wordpress/blocks'; -import { - BlockControls, - RichText, -} from '@wordpress/editor'; +import { RichText } from '@wordpress/editor'; import { replace, join, split, create, toHTMLString, LINE_SEPARATOR } from '@wordpress/rich-text'; import { G, Path, SVG } from '@wordpress/components'; @@ -114,25 +110,25 @@ export const settings = { } ); }, }, - { - type: 'pattern', - regExp: /^[*-]\s/, - transform: ( { content } ) => { + ...[ '*', '-' ].map( ( prefix ) => ( { + type: 'prefix', + prefix, + transform( content ) { return createBlock( 'core/list', { values: `<li>${ content }</li>`, } ); }, - }, - { - type: 'pattern', - regExp: /^1[.)]\s/, - transform: ( { content } ) => { + } ) ), + ...[ '1.', '1)' ].map( ( prefix ) => ( { + type: 'prefix', + prefix, + transform( content ) { return createBlock( 'core/list', { ordered: true, values: `<li>${ content }</li>`, } ); }, - }, + } ) ), ], to: [ { @@ -216,158 +212,50 @@ export const settings = { }; }, - edit: class extends Component { - constructor() { - super( ...arguments ); - - this.setupEditor = this.setupEditor.bind( this ); - this.getEditorSettings = this.getEditorSettings.bind( this ); - this.setNextValues = this.setNextValues.bind( this ); - - this.state = { - internalListType: null, - }; - } - - findInternalListType( { parents } ) { - const list = find( parents, ( node ) => node.nodeName === 'UL' || node.nodeName === 'OL' ); - return list ? list.nodeName : null; - } - - setupEditor( editor ) { - editor.on( 'nodeChange', ( nodeInfo ) => { - this.setState( { - internalListType: this.findInternalListType( nodeInfo ), - } ); - } ); - - // Check for languages that do not have square brackets on their keyboards. - const lang = window.navigator.browserLanguage || window.navigator.language; - const keyboardHasSquareBracket = ! /^(?:fr|nl|sv|ru|de|es|it)/.test( lang ); - - if ( keyboardHasSquareBracket ) { - // `[` is keycode 219; `]` is keycode 221. - editor.shortcuts.add( 'meta+219', 'Decrease indent', 'Outdent' ); - editor.shortcuts.add( 'meta+221', 'Increase indent', 'Indent' ); - } else { - editor.shortcuts.add( 'meta+shift+m', 'Decrease indent', 'Outdent' ); - editor.shortcuts.add( 'meta+m', 'Increase indent', 'Indent' ); - } - - this.editor = editor; - } - - createSetListType( type, command ) { - return () => { - const { setAttributes } = this.props; - const { internalListType } = this.state; - if ( internalListType ) { - // Only change list types, don't toggle off internal lists. - if ( internalListType !== type && this.editor ) { - this.editor.execCommand( command ); - } - } else { - setAttributes( { ordered: type === 'OL' } ); - } - }; - } + edit( { + attributes, + insertBlocksAfter, + setAttributes, + mergeBlocks, + onReplace, + className, + } ) { + const { ordered, values } = attributes; - createExecCommand( command ) { - return () => { - if ( this.editor ) { - this.editor.execCommand( command ); + return ( + <RichText + identifier="values" + multiline="li" + tagName={ ordered ? 'ol' : 'ul' } + onChange={ ( nextValues ) => setAttributes( { values: nextValues } ) } + value={ values } + wrapperClassName="block-library-list" + className={ className } + placeholder={ __( 'Write list…' ) } + onMerge={ mergeBlocks } + unstableOnSplit={ + insertBlocksAfter ? + ( before, after, ...blocks ) => { + if ( ! blocks.length ) { + blocks.push( createBlock( 'core/paragraph' ) ); + } + + if ( after !== '<li></li>' ) { + blocks.push( createBlock( 'core/list', { + ordered, + values: after, + } ) ); + } + + setAttributes( { values: before } ); + insertBlocksAfter( blocks ); + } : + undefined } - }; - } - - getEditorSettings( editorSettings ) { - return { - ...editorSettings, - plugins: ( editorSettings.plugins || [] ).concat( 'lists' ), - lists_indent_on_tab: false, - }; - } - - setNextValues( nextValues ) { - this.props.setAttributes( { values: nextValues } ); - } - - render() { - const { - attributes, - insertBlocksAfter, - setAttributes, - mergeBlocks, - onReplace, - className, - } = this.props; - const { ordered, values } = attributes; - const tagName = ordered ? 'ol' : 'ul'; - - return ( - <Fragment> - <BlockControls - controls={ [ - { - icon: 'editor-ul', - title: __( 'Convert to unordered list' ), - isActive: ! ordered, - onClick: this.createSetListType( 'UL', 'InsertUnorderedList' ), - }, - { - icon: 'editor-ol', - title: __( 'Convert to ordered list' ), - isActive: ordered, - onClick: this.createSetListType( 'OL', 'InsertOrderedList' ), - }, - { - icon: 'editor-outdent', - title: __( 'Outdent list item' ), - onClick: this.createExecCommand( 'Outdent' ), - }, - { - icon: 'editor-indent', - title: __( 'Indent list item' ), - onClick: this.createExecCommand( 'Indent' ), - }, - ] } - /> - <RichText - identifier="values" - multiline="li" - tagName={ tagName } - unstableGetSettings={ this.getEditorSettings } - unstableOnSetup={ this.setupEditor } - onChange={ this.setNextValues } - value={ values } - wrapperClassName="block-library-list" - className={ className } - placeholder={ __( 'Write list…' ) } - onMerge={ mergeBlocks } - onSplit={ - insertBlocksAfter ? - ( before, after, ...blocks ) => { - if ( ! blocks.length ) { - blocks.push( createBlock( 'core/paragraph' ) ); - } - - if ( after !== '<li></li>' ) { - blocks.push( createBlock( 'core/list', { - ordered, - values: after, - } ) ); - } - - setAttributes( { values: before } ); - insertBlocksAfter( blocks ); - } : - undefined - } - onRemove={ () => onReplace( [] ) } - /> - </Fragment> - ); - } + onRemove={ () => onReplace( [] ) } + onTagNameChange={ ( tag ) => setAttributes( { ordered: tag === 'ol' } ) } + /> + ); }, save( { attributes } ) { diff --git a/packages/block-library/src/media-text/edit.js b/packages/block-library/src/media-text/edit.js index ca932f9b8115dd..c1cabf248e57ca 100644 --- a/packages/block-library/src/media-text/edit.js +++ b/packages/block-library/src/media-text/edit.js @@ -2,6 +2,7 @@ * External dependencies */ import classnames from 'classnames'; +import { get } from 'lodash'; /** * WordPress dependencies @@ -50,6 +51,7 @@ class MediaTextEdit extends Component { const { setAttributes } = this.props; let mediaType; + let src; // for media selections originated from a file upload. if ( media.media_type ) { if ( media.media_type === 'image' ) { @@ -63,11 +65,16 @@ class MediaTextEdit extends Component { mediaType = media.type; } + if ( mediaType === 'image' ) { + // Try the "large" size URL, falling back to the "full" size URL below. + src = get( media, [ 'sizes', 'large', 'url' ] ) || get( media, [ 'media_details', 'sizes', 'large', 'source_url' ] ); + } + setAttributes( { mediaAlt: media.alt, mediaId: media.id, mediaType, - mediaUrl: media.url, + mediaUrl: src || media.url, } ); } @@ -187,6 +194,7 @@ class MediaTextEdit extends Component { <InnerBlocks allowedBlocks={ ALLOWED_BLOCKS } template={ TEMPLATE } + templateInsertUpdatesSelection={ false } /> </div> </Fragment> diff --git a/packages/block-library/src/media-text/index.js b/packages/block-library/src/media-text/index.js index 79e472456daff9..281db991ae268a 100644 --- a/packages/block-library/src/media-text/index.js +++ b/packages/block-library/src/media-text/index.js @@ -24,10 +24,54 @@ const DEFAULT_MEDIA_WIDTH = 50; export const name = 'core/media-text'; +const blockAttributes = { + align: { + type: 'string', + default: 'wide', + }, + backgroundColor: { + type: 'string', + }, + customBackgroundColor: { + type: 'string', + }, + mediaAlt: { + type: 'string', + source: 'attribute', + selector: 'figure img', + attribute: 'alt', + default: '', + }, + mediaPosition: { + type: 'string', + default: 'left', + }, + mediaId: { + type: 'number', + }, + mediaUrl: { + type: 'string', + source: 'attribute', + selector: 'figure video,figure img', + attribute: 'src', + }, + mediaType: { + type: 'string', + }, + mediaWidth: { + type: 'number', + default: 50, + }, + isStackedOnMobile: { + type: 'boolean', + default: false, + }, +}; + export const settings = { title: __( 'Media & Text' ), - description: __( 'Set media and words side-by-side media for a richer layout.' ), + description: __( 'Set media and words side-by-side for a richer layout.' ), icon: <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M13 17h8v-2h-8v2zM3 19h8V5H3v14zM13 9h8V7h-8v2zm0 4h8v-2h-8v2z" /></SVG>, @@ -35,49 +79,7 @@ export const settings = { keywords: [ __( 'image' ), __( 'video' ) ], - attributes: { - align: { - type: 'string', - default: 'wide', - }, - backgroundColor: { - type: 'string', - }, - customBackgroundColor: { - type: 'string', - }, - mediaAlt: { - type: 'string', - source: 'attribute', - selector: 'figure img', - attribute: 'alt', - default: '', - }, - mediaPosition: { - type: 'string', - default: 'left', - }, - mediaId: { - type: 'number', - }, - mediaUrl: { - type: 'string', - source: 'attribute', - selector: 'figure video,figure img', - attribute: 'src', - }, - mediaType: { - type: 'string', - }, - mediaWidth: { - type: 'number', - default: 50, - }, - isStackedOnMobile: { - type: 'boolean', - default: false, - }, - }, + attributes: blockAttributes, supports: { align: [ 'wide', 'full' ], @@ -152,12 +154,12 @@ export const settings = { mediaType, mediaUrl, mediaWidth, + mediaId, } = attributes; const mediaTypeRenders = { - image: () => <img src={ mediaUrl } alt={ mediaAlt } />, + image: () => <img src={ mediaUrl } alt={ mediaAlt } className={ ( mediaId && mediaType === 'image' ) ? `wp-image-${ mediaId }` : null } />, video: () => <video controls src={ mediaUrl } />, }; - const backgroundClass = getColorClassName( 'background-color', backgroundColor ); const className = classnames( { 'has-media-on-the-right': 'right' === mediaPosition, @@ -184,4 +186,51 @@ export const settings = { </div> ); }, + + deprecated: [ + { + attributes: blockAttributes, + save( { attributes } ) { + const { + backgroundColor, + customBackgroundColor, + isStackedOnMobile, + mediaAlt, + mediaPosition, + mediaType, + mediaUrl, + mediaWidth, + } = attributes; + const mediaTypeRenders = { + image: () => <img src={ mediaUrl } alt={ mediaAlt } />, + video: () => <video controls src={ mediaUrl } />, + }; + const backgroundClass = getColorClassName( 'background-color', backgroundColor ); + const className = classnames( { + 'has-media-on-the-right': 'right' === mediaPosition, + [ backgroundClass ]: backgroundClass, + 'is-stacked-on-mobile': isStackedOnMobile, + } ); + + let gridTemplateColumns; + if ( mediaWidth !== DEFAULT_MEDIA_WIDTH ) { + gridTemplateColumns = 'right' === mediaPosition ? `auto ${ mediaWidth }%` : `${ mediaWidth }% auto`; + } + const style = { + backgroundColor: backgroundClass ? undefined : customBackgroundColor, + gridTemplateColumns, + }; + return ( + <div className={ className } style={ style }> + <figure className="wp-block-media-text__media" > + { ( mediaTypeRenders[ mediaType ] || noop )() } + </figure> + <div className="wp-block-media-text__content"> + <InnerBlocks.Content /> + </div> + </div> + ); + }, + }, + ], }; diff --git a/packages/block-library/src/missing/index.js b/packages/block-library/src/missing/index.js index 82de41b1d6eff5..8ec90b2d5cd441 100644 --- a/packages/block-library/src/missing/index.js +++ b/packages/block-library/src/missing/index.js @@ -17,7 +17,7 @@ function MissingBlockWarning( { attributes, convertToHTML } ) { let messageHTML; if ( hasContent && hasHTMLBlock ) { messageHTML = sprintf( - __( 'Your site doesn\'t include support for the "%s" block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely.' ), + __( 'Your site doesn’t include support for the "%s" block. You can leave this block intact, convert its content to a Custom HTML block, or remove it entirely.' ), originalName ); actions.push( @@ -27,7 +27,7 @@ function MissingBlockWarning( { attributes, convertToHTML } ) { ); } else { messageHTML = sprintf( - __( 'Your site doesn\'t include support for the "%s" block. You can leave this block intact or remove it entirely.' ), + __( 'Your site doesn’t include support for the "%s" block. You can leave this block intact or remove it entirely.' ), originalName ); } @@ -59,7 +59,7 @@ export const settings = { name, category: 'common', title: __( 'Unrecognized Block' ), - description: __( 'Your site doesn\'t include support for this block.' ), + description: __( 'Your site doesn’t include support for this block.' ), supports: { className: false, diff --git a/packages/block-library/src/more/edit.native.js b/packages/block-library/src/more/edit.native.js index c2e8d36eae0d36..2c4d8bf89a693a 100644 --- a/packages/block-library/src/more/edit.native.js +++ b/packages/block-library/src/more/edit.native.js @@ -14,7 +14,8 @@ import { __ } from '@wordpress/i18n'; import { PlainText } from '@wordpress/editor'; import styles from './editor.scss'; -export default function MoreEdit( { attributes, setAttributes } ) { +export default function MoreEdit( props ) { + const { attributes, setAttributes } = props; const { customText } = attributes; const defaultText = __( 'Read more' ); const value = customText !== undefined ? customText : defaultText; @@ -30,6 +31,7 @@ export default function MoreEdit( { attributes, setAttributes } ) { underlineColorAndroid="transparent" onChange={ ( newValue ) => setAttributes( { customText: newValue } ) } placeholder={ defaultText } + isSelected={ props.isSelected } /> <Text className={ styles[ 'block-library-more__right-marker' ] }>--&gt;</Text> </View> diff --git a/packages/block-library/src/more/index.js b/packages/block-library/src/more/index.js index 0aab7198df8f02..f51b9ef971e455 100644 --- a/packages/block-library/src/more/index.js +++ b/packages/block-library/src/more/index.js @@ -25,7 +25,7 @@ export const name = 'core/more'; export const settings = { title: _x( 'More', 'block name' ), - description: __( 'Want to show only an excerpt of this post on your homepage? Use this block to define where you want the separation.' ), + description: __( 'Mark the excerpt of this content. Content before this block will be shown in the excerpt on your archives page.' ), icon: <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><Path fill="none" d="M0 0h24v24H0V0z" /><G><Path d="M2 9v2h19V9H2zm0 6h5v-2H2v2zm7 0h5v-2H9v2zm7 0h5v-2h-5v2z" /></G></SVG>, diff --git a/packages/block-library/src/paragraph/edit.js b/packages/block-library/src/paragraph/edit.js index f538ed4938ac9c..4d57376ee10c39 100644 --- a/packages/block-library/src/paragraph/edit.js +++ b/packages/block-library/src/paragraph/edit.js @@ -242,7 +242,7 @@ class ParagraphBlock extends Component { content: nextContent, } ); } } - onSplit={ this.splitBlock } + unstableOnSplit={ this.splitBlock } onMerge={ mergeBlocks } onReplace={ this.onReplace } onRemove={ () => onReplace( [] ) } diff --git a/packages/block-library/src/paragraph/edit.native.js b/packages/block-library/src/paragraph/edit.native.js index c77f28fd556d7c..91cc885170144d 100644 --- a/packages/block-library/src/paragraph/edit.native.js +++ b/packages/block-library/src/paragraph/edit.native.js @@ -87,6 +87,7 @@ class ParagraphEdit extends Component { <RichText tagName="p" value={ content } + isSelected={ this.props.isSelected } style={ { ...style, minHeight: Math.max( minHeight, typeof attributes.aztecHeight === 'undefined' ? 0 : attributes.aztecHeight ), diff --git a/packages/block-library/src/quote/index.js b/packages/block-library/src/quote/index.js index dd60905cded3b0..d3f020a220c3db 100644 --- a/packages/block-library/src/quote/index.js +++ b/packages/block-library/src/quote/index.js @@ -90,9 +90,9 @@ export const settings = { } ), }, { - type: 'pattern', - regExp: /^>\s/, - transform: ( { content } ) => { + type: 'prefix', + prefix: '>', + transform: ( content ) => { return createBlock( 'core/quote', { value: `<p>${ content }</p>`, } ); @@ -100,7 +100,12 @@ export const settings = { }, { type: 'raw', - selector: 'blockquote', + isMatch: ( node ) => ( + node.nodeName === 'BLOCKQUOTE' && + // The quote block can only handle multiline paragraph + // content. + Array.from( node.childNodes ).every( ( child ) => child.nodeName === 'P' ) + ), schema: { blockquote: { children: { diff --git a/packages/block-library/src/separator/index.js b/packages/block-library/src/separator/index.js index f077d6533413de..adcc34297d2ff1 100644 --- a/packages/block-library/src/separator/index.js +++ b/packages/block-library/src/separator/index.js @@ -27,8 +27,7 @@ export const settings = { transforms: { from: [ { - type: 'pattern', - trigger: 'enter', + type: 'enter', regExp: /^-{3,}$/, transform: () => createBlock( 'core/separator' ), }, diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index 45618cc406b11a..9d9b2379696b2f 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -22,91 +22,94 @@ @import "./verse/style.scss"; @import "./video/style.scss"; -.has-pale-pink-background-color { +// Class names are doubled to increase specificity to assure colors take effect +// over another base class color. + +.has-pale-pink-background-color.has-pale-pink-background-color { background-color: #f78da7; } -.has-vivid-red-background-color { +.has-vivid-red-background-color.has-vivid-red-background-color { background-color: #cf2e2e; } -.has-luminous-vivid-orange-background-color { +.has-luminous-vivid-orange-background-color.has-luminous-vivid-orange-background-color { background-color: #ff6900; } -.has-luminous-vivid-amber-background-color { +.has-luminous-vivid-amber-background-color.has-luminous-vivid-amber-background-color { background-color: #fcb900; } -.has-light-green-cyan-background-color { +.has-light-green-cyan-background-color.has-light-green-cyan-background-color { background-color: #7bdcb5; } -.has-vivid-green-cyan-background-color { +.has-vivid-green-cyan-background-color.has-vivid-green-cyan-background-color { background-color: #00d084; } -.has-pale-cyan-blue-background-color { +.has-pale-cyan-blue-background-color.has-pale-cyan-blue-background-color { background-color: #8ed1fc; } -.has-vivid-cyan-blue-background-color { +.has-vivid-cyan-blue-background-color.has-vivid-cyan-blue-background-color { background-color: #0693e3; } -.has-very-light-gray-background-color { +.has-very-light-gray-background-color.has-very-light-gray-background-color { background-color: #eee; } -.has-cyan-bluish-gray-background-color { +.has-cyan-bluish-gray-background-color.has-cyan-bluish-gray-background-color { background-color: #abb8c3; } -.has-very-dark-gray-background-color { +.has-very-dark-gray-background-color.has-very-dark-gray-background-color { background-color: #313131; } -.has-pale-pink-color { +.has-pale-pink-color.has-pale-pink-color { color: #f78da7; } -.has-vivid-red-color { +.has-vivid-red-color.has-vivid-red-color { color: #cf2e2e; } -.has-luminous-vivid-orange-color { +.has-luminous-vivid-orange-color.has-luminous-vivid-orange-color { color: #ff6900; } -.has-luminous-vivid-amber-color { +.has-luminous-vivid-amber-color.has-luminous-vivid-amber-color { color: #fcb900; } -.has-light-green-cyan-color { +.has-light-green-cyan-color.has-light-green-cyan-color { color: #7bdcb5; } -.has-vivid-green-cyan-color { +.has-vivid-green-cyan-color.has-vivid-green-cyan-color { color: #00d084; } -.has-pale-cyan-blue-color { +.has-pale-cyan-blue-color.has-pale-cyan-blue-color { color: #8ed1fc; } -.has-vivid-cyan-blue-color { +.has-vivid-cyan-blue-color.has-vivid-cyan-blue-color { color: #0693e3; } -.has-very-light-gray-color { +.has-very-light-gray-color.has-very-light-gray-color { color: #eee; } -.has-cyan-bluish-gray-color { +.has-cyan-bluish-gray-color.has-cyan-bluish-gray-color { color: #abb8c3; } -.has-very-dark-gray-color { +.has-very-dark-gray-color.has-very-dark-gray-color { color: #313131; } @@ -128,6 +131,6 @@ } .has-larger-font-size, // not used now, kept because of backward compatibility. -.has-huge-font-size, { +.has-huge-font-size { font-size: 42px; } diff --git a/packages/block-library/src/video/edit.js b/packages/block-library/src/video/edit.js index 26039e79c85c3a..118bd117606685 100644 --- a/packages/block-library/src/video/edit.js +++ b/packages/block-library/src/video/edit.js @@ -19,6 +19,7 @@ import { InspectorControls, MediaPlaceholder, MediaUpload, + MediaUploadCheck, RichText, mediaUpload, } from '@wordpress/editor'; @@ -207,30 +208,32 @@ class VideoEdit extends Component { { value: 'none', label: __( 'None' ) }, ] } /> - <BaseControl - className="editor-video-poster-control" - label={ __( 'Poster Image' ) } - > - <MediaUpload - title={ __( 'Select Poster Image' ) } - onSelect={ this.onSelectPoster } - allowedTypes={ VIDEO_POSTER_ALLOWED_MEDIA_TYPES } - render={ ( { open } ) => ( - <Button - isDefault - onClick={ open } - ref={ this.posterImageButton } - > - { ! this.props.attributes.poster ? __( 'Select Poster Image' ) : __( 'Replace image' ) } + <MediaUploadCheck> + <BaseControl + className="editor-video-poster-control" + label={ __( 'Poster Image' ) } + > + <MediaUpload + title={ __( 'Select Poster Image' ) } + onSelect={ this.onSelectPoster } + allowedTypes={ VIDEO_POSTER_ALLOWED_MEDIA_TYPES } + render={ ( { open } ) => ( + <Button + isDefault + onClick={ open } + ref={ this.posterImageButton } + > + { ! this.props.attributes.poster ? __( 'Select Poster Image' ) : __( 'Replace image' ) } + </Button> + ) } + /> + { !! this.props.attributes.poster && + <Button onClick={ this.onRemovePoster } isLink isDestructive> + { __( 'Remove Poster Image' ) } </Button> - ) } - /> - { !! this.props.attributes.poster && - <Button onClick={ this.onRemovePoster } isLink isDestructive> - { __( 'Remove Poster Image' ) } - </Button> - } - </BaseControl> + } + </BaseControl> + </MediaUploadCheck> </PanelBody> </InspectorControls> <figure className={ className }> diff --git a/packages/block-serialization-default-parser/CHANGELOG.md b/packages/block-serialization-default-parser/CHANGELOG.md index d707d54620836d..e651970cc3bdcb 100644 --- a/packages/block-serialization-default-parser/CHANGELOG.md +++ b/packages/block-serialization-default-parser/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.0.0 (2018-11-12) + +### Breaking Changes + +- Inner blocks are now cast as arrays instead of objects. +- JS and PHP parsers now behave consistently when parsing empty attributes. + ## 1.1.1 (2018-11-09) ## 1.1.0 (2018-11-09) diff --git a/packages/block-serialization-default-parser/package.json b/packages/block-serialization-default-parser/package.json index 845f3596d78bb9..aed2a538f5961d 100644 --- a/packages/block-serialization-default-parser/package.json +++ b/packages/block-serialization-default-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-default-parser", - "version": "1.1.1", + "version": "2.0.0", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-default-parser/parser.php b/packages/block-serialization-default-parser/parser.php index 2211cf0c192260..c9fa5db1f32e9f 100644 --- a/packages/block-serialization-default-parser/parser.php +++ b/packages/block-serialization-default-parser/parser.php @@ -173,6 +173,14 @@ class WP_Block_Parser { */ public $stack; + /** + * Empty associative array, here due to PHP quirks + * + * @since 4.4.0 + * @var array empty associative array + */ + public $empty_attrs; + /** * Parses a document and returns a list of block structures * @@ -186,10 +194,11 @@ class WP_Block_Parser { * @return WP_Block_Parser_Block[] */ function parse( $document ) { - $this->document = $document; - $this->offset = 0; - $this->output = array(); - $this->stack = array(); + $this->document = $document; + $this->offset = 0; + $this->output = array(); + $this->stack = array(); + $this->empty_attrs = json_decode( '{}', true ); do { // twiddle our thumbs @@ -364,7 +373,7 @@ function next_token() { * match back in PHP to see which one it was. */ $has_match = preg_match( - '/<!--\s+(?<closer>\/)?wp:(?<namespace>[a-z][a-z0-9_-]*\/)?(?<name>[a-z][a-z0-9_-]*)\s+(?<attrs>{(?:[^}]+|}+(?=})|(?!}\s+-->).)+?}\s+)?(?<void>\/)?-->/s', + '/<!--\s+(?<closer>\/)?wp:(?<namespace>[a-z][a-z0-9_-]*\/)?(?<name>[a-z][a-z0-9_-]*)\s+(?<attrs>{(?:[^}]+|}+(?=})|(?!}\s+-->).)*?}\s+)?(?<void>\/)?-->/s', $this->document, $matches, PREG_OFFSET_CAPTURE, @@ -392,7 +401,7 @@ function next_token() { */ $attrs = $has_attrs ? json_decode( $matches[ 'attrs' ][ 0 ], /* as-associative */ true ) - : json_decode( '{}', /* don't ask why, just verify in PHP */ false ); + : $this->empty_attrs; /* * This state isn't allowed @@ -422,8 +431,8 @@ function next_token() { * @param string $innerHTML HTML content of block * @return WP_Block_Parser_Block freeform block object */ - static function freeform( $innerHTML ) { - return new WP_Block_Parser_Block( null, array(), array(), $innerHTML, array( $innerHTML ) ); + function freeform( $innerHTML ) { + return new WP_Block_Parser_Block( null, $this->empty_attrs, array(), $innerHTML, array( $innerHTML ) ); } /** @@ -457,7 +466,7 @@ function add_freeform( $length = null ) { */ function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) { $parent = $this->stack[ count( $this->stack ) - 1 ]; - $parent->block->innerBlocks[] = $block; + $parent->block->innerBlocks[] = (array) $block; $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset ); if ( ! empty( $html ) ) { diff --git a/packages/block-serialization-default-parser/src/index.js b/packages/block-serialization-default-parser/src/index.js index 577284fc1e539e..936c4a380969cc 100644 --- a/packages/block-serialization-default-parser/src/index.js +++ b/packages/block-serialization-default-parser/src/index.js @@ -2,7 +2,7 @@ let document; let offset; let output; let stack; -const tokenizer = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:[^}]+|}+(?=})|(?!}\s+-->)[^])+?}\s+)?(\/)?-->/g; +const tokenizer = /<!--\s+(\/)?wp:([a-z][a-z0-9_-]*\/)?([a-z][a-z0-9_-]*)\s+({(?:[^}]+|}+(?=})|(?!}\s+-->)[^])*?}\s+)?(\/)?-->/g; function Block( blockName, attrs, innerBlocks, innerHTML, innerContent ) { return { diff --git a/packages/block-serialization-default-parser/test/__snapshots__/index.js.snap b/packages/block-serialization-default-parser/test/__snapshots__/index.js.snap deleted file mode 100644 index 4a6910fa35f61c..00000000000000 --- a/packages/block-serialization-default-parser/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`block-serialization-default-parser-js basic parsing parse() works properly 1`] = ` -Array [ - Object { - "attrs": Object {}, - "blockName": "core/more", - "innerBlocks": Array [], - "innerContent": Array [ - "<!--more-->", - ], - "innerHTML": "<!--more-->", - }, -] -`; - -exports[`block-serialization-default-parser-php basic parsing parse() works properly 1`] = ` -Array [ - Object { - "attrs": Object {}, - "blockName": "core/more", - "innerBlocks": Array [], - "innerContent": Array [ - "<!--more-->", - ], - "innerHTML": "<!--more-->", - }, -] -`; diff --git a/packages/block-serialization-spec-parser/CHANGELOG.md b/packages/block-serialization-spec-parser/CHANGELOG.md index 276abd6e3b3a7f..d31eb025e02759 100644 --- a/packages/block-serialization-spec-parser/CHANGELOG.md +++ b/packages/block-serialization-spec-parser/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.0.0 (2018-11-12) + +### Breaking Change + +- JS and PHP parsers now behave consistently when parsing empty attributes. + ## 1.1.1 (2018-11-09) ## 1.1.0 (2018-11-09) diff --git a/packages/block-serialization-spec-parser/grammar.pegjs b/packages/block-serialization-spec-parser/grammar.pegjs index 4bf80d2f6755ac..19b8dd9e70487b 100644 --- a/packages/block-serialization-spec-parser/grammar.pegjs +++ b/packages/block-serialization-spec-parser/grammar.pegjs @@ -50,6 +50,18 @@ // The `maybeJSON` function is not needed in PHP because its return semantics // are the same as `json_decode` +if ( ! function_exists( 'peg_empty_attrs' ) ) { + function peg_empty_attrs() { + static $empty_attrs = null; + + if ( null === $empty_attrs ) { + $empty_attrs = json_decode( '{}', true ); + } + + return $empty_attrs; + } +} + // array arguments are backwards because of PHP if ( ! function_exists( 'peg_process_inner_content' ) ) { function peg_process_inner_content( $array ) { @@ -78,7 +90,7 @@ if ( ! function_exists( 'peg_join_blocks' ) ) { if ( ! empty( $pre ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $pre, 'innerContent' => array( $pre ), @@ -93,7 +105,7 @@ if ( ! function_exists( 'peg_join_blocks' ) ) { if ( ! empty( $html ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $html, 'innerContent' => array( $html ), @@ -104,7 +116,7 @@ if ( ! function_exists( 'peg_join_blocks' ) ) { if ( ! empty( $post ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $post, 'innerContent' => array( $post ), @@ -212,7 +224,7 @@ Block_Void /** <?php return array( 'blockName' => $blockName, - 'attrs' => isset( $attrs ) ? $attrs : array(), + 'attrs' => empty( $attrs ) ? peg_empty_attrs() : $attrs, 'innerBlocks' => array(), 'innerHTML' => '', 'innerContent' => array(), @@ -236,7 +248,7 @@ Block_Balanced return array( 'blockName' => $s['blockName'], - 'attrs' => $s['attrs'], + 'attrs' => empty( $s['attrs'] ) ? peg_empty_attrs() : $s['attrs'], 'innerBlocks' => $innerBlocks, 'innerHTML' => $innerHTML, 'innerContent' => $innerContent, diff --git a/packages/block-serialization-spec-parser/package.json b/packages/block-serialization-spec-parser/package.json index 46e23fe37ac527..73a8859f5feac5 100644 --- a/packages/block-serialization-spec-parser/package.json +++ b/packages/block-serialization-spec-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-spec-parser", - "version": "1.1.1", + "version": "2.0.0", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-spec-parser/parser.js b/packages/block-serialization-spec-parser/parser.js index 85fdc6ec9b5d65..be39ccaa3046d6 100644 --- a/packages/block-serialization-spec-parser/parser.js +++ b/packages/block-serialization-spec-parser/parser.js @@ -166,7 +166,7 @@ /** <?php return array( 'blockName' => $blockName, - 'attrs' => isset( $attrs ) ? $attrs : array(), + 'attrs' => empty( $attrs ) ? peg_empty_attrs() : $attrs, 'innerBlocks' => array(), 'innerHTML' => '', 'innerContent' => array(), @@ -187,7 +187,7 @@ return array( 'blockName' => $s['blockName'], - 'attrs' => $s['attrs'], + 'attrs' => empty( $s['attrs'] ) ? peg_empty_attrs() : $s['attrs'], 'innerBlocks' => $innerBlocks, 'innerHTML' => $innerHTML, 'innerContent' => $innerContent, @@ -1618,6 +1618,18 @@ // The `maybeJSON` function is not needed in PHP because its return semantics // are the same as `json_decode` + if ( ! function_exists( 'peg_empty_attrs' ) ) { + function peg_empty_attrs() { + static $empty_attrs = null; + + if ( null === $empty_attrs ) { + $empty_attrs = json_decode( '{}', true ); + } + + return $empty_attrs; + } + } + // array arguments are backwards because of PHP if ( ! function_exists( 'peg_process_inner_content' ) ) { function peg_process_inner_content( $array ) { @@ -1646,7 +1658,7 @@ if ( ! empty( $pre ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $pre, 'innerContent' => array( $pre ), @@ -1661,7 +1673,7 @@ if ( ! empty( $html ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $html, 'innerContent' => array( $html ), @@ -1672,7 +1684,7 @@ if ( ! empty( $post ) ) { $blocks[] = array( 'blockName' => null, - 'attrs' => array(), + 'attrs' => peg_empty_attrs(), 'innerBlocks' => array(), 'innerHTML' => $post, 'innerContent' => array( $post ), diff --git a/packages/block-serialization-spec-parser/shared-tests.js b/packages/block-serialization-spec-parser/shared-tests.js index 54137636d3afdf..19f3c9b4d70791 100644 --- a/packages/block-serialization-spec-parser/shared-tests.js +++ b/packages/block-serialization-spec-parser/shared-tests.js @@ -1,7 +1,64 @@ export const jsTester = ( parse ) => () => { - describe( 'basic parsing', () => { - test( 'parse() works properly', () => { - expect( parse( '<!-- wp:core/more --><!--more--><!-- /wp:core/more -->' ) ).toMatchSnapshot(); + describe( 'output structure', () => { + test( 'output is an array', () => { + expect( parse( '' ) ).toEqual( expect.any( Array ) ); + expect( parse( 'test' ) ).toEqual( expect.any( Array ) ); + expect( parse( '<!-- wp:void /-->' ) ).toEqual( expect.any( Array ) ); + expect( parse( '<!-- wp:block --><!-- wp:inner /--><!-- /wp:block -->' ) ).toEqual( expect.any( Array ) ); + expect( parse( '<!-- wp:first /--><!-- wp:second /-->' ) ).toEqual( expect.any( Array ) ); + } ); + + test( 'parses blocks of various types', () => { + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {"value":true} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {"a":{}} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void { "value" : true } /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {\n\t"value" : true\n} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:block --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {"value":true} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {} -->inner<!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + expect( parse( '<!-- wp:block {"value":{"a" : "true"}} -->inner<!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/block' ); + } ); + + test( 'blockName is namespaced string (except freeform)', () => { + expect( parse( 'freeform has null name' )[ 0 ] ).toHaveProperty( 'blockName', null ); + expect( parse( '<!-- wp:more /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/more' ); + expect( parse( '<!-- wp:core/more /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/more' ); + expect( parse( '<!-- wp:my/more /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'my/more' ); + } ); + + test( 'JSON attributes are key/value object', () => { + expect( parse( 'freeform has empty attrs' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:void {} /-->' )[ 0 ] ).toHaveProperty( 'blockName', 'core/void' ); + expect( parse( '<!-- wp:void {} /-->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:void {"key": "value"} /-->' )[ 0 ] ).toHaveProperty( 'attrs', { key: 'value' } ); + expect( parse( '<!-- wp:block --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:block {} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'attrs', {} ); + expect( parse( '<!-- wp:block {"key": "value"} --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'attrs', { key: 'value' } ); + } ); + + test( 'innerBlocks is a list', () => { + expect( parse( 'freeform has empty innerBlocks' )[ 0 ] ).toHaveProperty( 'innerBlocks', [] ); + expect( parse( '<!-- wp:void /-->' )[ 0 ] ).toHaveProperty( 'innerBlocks', [] ); + expect( parse( '<!-- wp:block --><!-- /wp:block -->' )[ 0 ] ).toHaveProperty( 'innerBlocks', [] ); + + const withInner = parse( '<!-- wp:block --><!-- wp:inner /--><!-- /wp:block -->' )[ 0 ]; + expect( withInner ).toHaveProperty( 'innerBlocks', expect.any( Array ) ); + expect( withInner.innerBlocks ).toHaveLength( 1 ); + + const withTwoInner = parse( '<!-- wp:block -->a<!-- wp:first /-->b<!-- wp:second /-->c<!-- /wp:block -->' )[ 0 ]; + expect( withTwoInner ).toHaveProperty( 'innerBlocks', expect.any( Array ) ); + expect( withTwoInner.innerBlocks ).toHaveLength( 2 ); + } ); + + test( 'innerHTML is a string', () => { + expect( parse( 'test' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + expect( parse( '<!-- wp:test /-->' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + expect( parse( '<!-- wp:test --><!-- /wp:test -->' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); + expect( parse( '<!-- wp:test -->test<!-- /wp:test -->' )[ 0 ] ).toHaveProperty( 'innerHTML', expect.any( String ) ); } ); } ); @@ -131,7 +188,13 @@ export const phpTester = ( name, filename ) => makeTest( } try { - return JSON.parse( process.stdout ); + /* + * Due to an issue with PHP's json_encode() serializing an empty associative array + * as an empty list `[]` we're manually replacing the already-encoded bit here. + * + * This is an issue with the test runner, not with the parser. + */ + return JSON.parse( process.stdout.replace( /"attrs":\s*\[\]/g, '"attrs":{}' ) ); } catch ( e ) { throw new Error( 'failed to parse JSON:\n' + process.stdout ); } diff --git a/packages/block-serialization-spec-parser/test/__snapshots__/index.js.snap b/packages/block-serialization-spec-parser/test/__snapshots__/index.js.snap deleted file mode 100644 index 480cde5428505b..00000000000000 --- a/packages/block-serialization-spec-parser/test/__snapshots__/index.js.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`block-serialization-spec-parser-js basic parsing parse() works properly 1`] = ` -Array [ - Object { - "attrs": Object {}, - "blockName": "core/more", - "innerBlocks": Array [], - "innerContent": Array [ - "<!--more-->", - ], - "innerHTML": "<!--more-->", - }, -] -`; - -exports[`block-serialization-spec-parser-php basic parsing parse() works properly 1`] = ` -Array [ - Object { - "attrs": Array [], - "blockName": "core/more", - "innerBlocks": Array [], - "innerContent": Array [ - "<!--more-->", - ], - "innerHTML": "<!--more-->", - }, -] -`; diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md index 26d05ff27a017a..05a69fef416970 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -1,3 +1,15 @@ +## 6.0.0 (2018-11-15) + +### Breaking Changes + +- `isValidBlock` has been removed. Please use `isValidBlockContent` instead but keep in mind that the order of params has changed. + +### Bug Fix + +- The block validator is more lenient toward equivalent encoding forms. + +## 5.3.1 (2018-11-12) + ## 5.3.0 (2018-11-09) ### New feature diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 7159a55ca9d4f6..fe7d7b9dced3ae 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -249,13 +249,12 @@ editor interface where blocks are implemented. An object can also be passed, in this case, icon, as specified above, should be included in the src property. Besides src the object can contain background and foreground colors, this colors will appear with the icon when they are applicable e.g.: in the inserter. -- `attributes: Object | Function` - An object of attribute schemas, where the +- `attributes: Object` - An object of attribute schemas, where the keys of the object define the shape of attributes, and each value an object schema describing the `type`, `default` (optional), and [`source`](https://wordpress.org/gutenberg/handbook/block-api/attributes/) (optional) of the attribute. If `source` is omitted, the attribute is - serialized into the block's comment delimiters. Alternatively, define - `attributes` as a function which returns the attributes object. + serialized into the block's comment delimiters. - `category: string` - Slug of the block's category. The category is used to organize the blocks in the block inserter. - `edit( { attributes: Object, setAttributes: Function } ): WPElement` - diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 593f0c261a188b..176def63903a7c 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blocks", - "version": "5.3.0", + "version": "6.0.0", "description": "Block API for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -29,6 +29,7 @@ "@wordpress/dom": "file:../dom", "@wordpress/element": "file:../element", "@wordpress/hooks": "file:../hooks", + "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/shortcode": "file:../shortcode", diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 0bdf1d01abd6be..ad115edd06095e 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -20,7 +20,7 @@ export { getSaveElement, getSaveContent, } from './serializer'; -export { isValidBlockContent, isValidBlock } from './validation'; +export { isValidBlockContent } from './validation'; export { getCategories, setCategories, diff --git a/packages/blocks/src/api/index.native.js b/packages/blocks/src/api/index.native.js index ac675f1f21870a..aa1056f46631e5 100644 --- a/packages/blocks/src/api/index.native.js +++ b/packages/blocks/src/api/index.native.js @@ -20,6 +20,8 @@ export { getBlockTypes, hasBlockSupport, isReusableBlock, + setDefaultBlockName, + getDefaultBlockName, } from './registration'; export { getPhrasingContentSchema } from './raw-handling'; export { default as children } from './children'; diff --git a/packages/blocks/src/api/raw-handling/index.js b/packages/blocks/src/api/raw-handling/index.js index db51f13e0fc6cf..2de9bff8e20595 100644 --- a/packages/blocks/src/api/raw-handling/index.js +++ b/packages/blocks/src/api/raw-handling/index.js @@ -84,13 +84,14 @@ function htmlToBlocks( { html, rawTransforms } ) { const rawTransform = findTransform( rawTransforms, ( { isMatch } ) => isMatch( node ) ); if ( ! rawTransform ) { - console.warn( - 'A block registered a raw transformation schema for `' + node.nodeName + '` but did not match it. ' + - 'Make sure there is a `selector` or `isMatch` property that can match the schema.\n' + - 'Sanitized HTML: `' + node.outerHTML + '`' + return createBlock( + // Should not be hardcoded. + 'core/html', + getBlockAttributes( + 'core/html', + node.outerHTML + ) ); - - return; } const { transform, blockName } = rawTransform; @@ -269,6 +270,8 @@ export function rawHandler( { HTML = '' } ) { // from raw HTML. These filters move around some content or add // additional tags, they do not remove any content. const filters = [ + // Needed to adjust invalid lists. + listReducer, // Needed to create more and nextpage blocks. specialCommentConverter, // Needed to create media blocks. diff --git a/packages/blocks/src/api/test/validation.js b/packages/blocks/src/api/test/validation.js index 0fd199f43b4776..babcad7081ae0e 100644 --- a/packages/blocks/src/api/test/validation.js +++ b/packages/blocks/src/api/test/validation.js @@ -2,10 +2,11 @@ * Internal dependencies */ import { + IdentityEntityParser, getTextPiecesSplitOnWhitespace, getTextWithCollapsedWhitespace, getMeaningfulAttributePairs, - isEqualTextTokensWithCollapsedWhitespace, + isEquivalentTextTokens, getNormalizedStyleValue, getStyleProperties, isEqualAttributesOfName, @@ -40,6 +41,16 @@ describe( 'validation', () => { } ); } ); + describe( 'IdentityEntityParser', () => { + it( 'can be constructed', () => { + expect( new IdentityEntityParser() instanceof IdentityEntityParser ).toBe( true ); + } ); + + it( 'returns parse as decoded value', () => { + expect( new IdentityEntityParser().parse( 'quot' ) ).toBe( '"' ); + } ); + } ); + describe( 'getTextPiecesSplitOnWhitespace()', () => { it( 'returns text pieces spilt on whitespace', () => { const pieces = getTextPiecesSplitOnWhitespace( ' a \t b \n c' ); @@ -98,9 +109,9 @@ describe( 'validation', () => { } ); } ); - describe( 'isEqualTextTokensWithCollapsedWhitespace()', () => { + describe( 'isEquivalentTextTokens()', () => { it( 'should return false if not equal with collapsed whitespace', () => { - const isEqual = isEqualTextTokensWithCollapsedWhitespace( + const isEqual = isEquivalentTextTokens( { chars: ' a \t b \n c' }, { chars: 'a \n c \t b ' }, ); @@ -110,7 +121,7 @@ describe( 'validation', () => { } ); it( 'should return true if equal with collapsed whitespace', () => { - const isEqual = isEqualTextTokensWithCollapsedWhitespace( + const isEqual = isEquivalentTextTokens( { chars: ' a \t b \n c' }, { chars: 'a \n b \t c ' }, ); @@ -379,8 +390,8 @@ describe( 'validation', () => { it( 'should return true for effectively equivalent html', () => { const isEquivalent = isEquivalentHTML( - '<div>&quot; Hello<span class="b a" id="foo"> World!</ span> "</div>', - '<div >" Hello\n<span id="foo" class="a b">World!</span>"</div>' + '<div>&quot; Hello<span class="b a" id="foo" data-foo="here &mdash; there"> World! &#128517;</ span> "</div>', + '<div >" Hello\n<span id="foo" class="a b" data-foo="here — there">World! 😅</span>"</div>' ); expect( isEquivalent ).toBe( true ); diff --git a/packages/blocks/src/api/validation.js b/packages/blocks/src/api/validation.js index 5685756d7f4dc9..4ccf580b8ffdd9 100644 --- a/packages/blocks/src/api/validation.js +++ b/packages/blocks/src/api/validation.js @@ -1,13 +1,20 @@ /** * External dependencies */ -import { tokenize } from 'simple-html-tokenizer'; -import { xor, fromPairs, isEqual, includes, stubTrue } from 'lodash'; +import Tokenizer from 'simple-html-tokenizer/dist/es6/tokenizer'; +import { + identity, + xor, + fromPairs, + isEqual, + includes, + stubTrue, +} from 'lodash'; /** * WordPress dependencies */ -import deprecated from '@wordpress/deprecated'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -134,6 +141,40 @@ const MEANINGFUL_ATTRIBUTES = [ ...ENUMERATED_ATTRIBUTES, ]; +/** + * Array of functions which receive a text string on which to apply normalizing + * behavior for consideration in text token equivalence, carefully ordered from + * least-to-most expensive operations. + * + * @type {Array} + */ +const TEXT_NORMALIZATIONS = [ + identity, + getTextWithCollapsedWhitespace, +]; + +/** + * Subsitute EntityParser class for `simple-html-tokenizer` which bypasses + * entity substitution in favor of validator's internal normalization. + * + * @see https://github.com/tildeio/simple-html-tokenizer/tree/master/src/entity-parser.ts + */ +export class IdentityEntityParser { + /** + * Returns a substitute string for an entity string sequence between `&` + * and `;`, or undefined if no substitution should occur. + * + * In this implementation, undefined is always returned. + * + * @param {string} entity Entity fragment discovered in HTML. + * + * @return {?string} Entity substitute value. + */ + parse( entity ) { + return decodeEntities( '&' + entity + ';' ); + } +} + /** * Object of logger functions. */ @@ -186,6 +227,10 @@ export function getTextPiecesSplitOnWhitespace( text ) { * @return {string} Trimmed text with consecutive whitespace collapsed. */ export function getTextWithCollapsedWhitespace( text ) { + // This is an overly simplified whitespace comparison. The specification is + // more prescriptive of whitespace behavior in inline and block contexts. + // + // See: https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33 return getTextPiecesSplitOnWhitespace( text ).join( ' ' ); } @@ -220,18 +265,28 @@ export function getMeaningfulAttributePairs( token ) { * * @return {boolean} Whether two text tokens are equivalent. */ -export function isEqualTextTokensWithCollapsedWhitespace( actual, expected ) { - // This is an overly simplified whitespace comparison. The specification is - // more prescriptive of whitespace behavior in inline and block contexts. - // - // See: https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33 - const isEquivalentText = isEqual( ...[ actual.chars, expected.chars ].map( getTextWithCollapsedWhitespace ) ); - - if ( ! isEquivalentText ) { - log.warning( 'Expected text `%s`, saw `%s`.', expected.chars, actual.chars ); +export function isEquivalentTextTokens( actual, expected ) { + // This function is intentionally written as syntactically "ugly" as a hot + // path optimization. Text is progressively normalized in order from least- + // to-most operationally expensive, until the earliest point at which text + // can be confidently inferred as being equal. + let actualChars = actual.chars; + let expectedChars = expected.chars; + + for ( let i = 0; i < TEXT_NORMALIZATIONS.length; i++ ) { + const normalize = TEXT_NORMALIZATIONS[ i ]; + + actualChars = normalize( actualChars ); + expectedChars = normalize( expectedChars ); + + if ( actualChars === expectedChars ) { + return true; + } } - return isEquivalentText; + log.warning( 'Expected text `%s`, saw `%s`.', expected.chars, actual.chars ); + + return false; } /** @@ -359,8 +414,8 @@ export const isEqualTokensOfType = { ...[ actual, expected ].map( getMeaningfulAttributePairs ) ); }, - Chars: isEqualTextTokensWithCollapsedWhitespace, - Comment: isEqualTextTokensWithCollapsedWhitespace, + Chars: isEquivalentTextTokens, + Comment: isEquivalentTextTokens, }; /** @@ -396,7 +451,7 @@ export function getNextNonWhitespaceToken( tokens ) { */ function getHTMLTokens( html ) { try { - return tokenize( html ); + return new Tokenizer( new IdentityEntityParser() ).tokenize( html ); } catch ( e ) { log.warning( 'Malformed HTML detected: %s', html ); } @@ -491,17 +546,6 @@ export function isEquivalentHTML( actual, expected ) { return true; } -export function isValidBlock( innerHTML, blockType, attributes ) { - deprecated( 'isValidBlock', { - plugin: 'Gutenberg', - version: '4.4', - alternative: 'isValidBlockContent', - hint: 'The order of params has changed.', - } ); - - return isValidBlockContent( blockType, attributes, innerHTML ); -} - /** * Returns true if the parsed block is valid given the input content. A block * is considered valid if, when serialized with assumed attributes, the content diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 3d8a39e1782451..81726381c57f13 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,6 +1,24 @@ -## 6.0.0 (Unreleased) +## 7.0.0 (Unreleased) -### Breaking Changes +### Breaking Change + +- `Dropdown.refresh()` has been removed. The contained `Popover` is now automatically refreshed. + +## 6.0.2 (2018-11-15) + +## 6.0.1 (2018-11-12) + +### Bug Fixes + +- Avoid constantly recomputing the popover position. + +### Polish + +- Remove `<DateTimePicker />` obsolete `locale` prop (and pass-through to child components) and obsolete `is12Hour` prop pass through to `<DateTime />` [#11649](https://github.com/WordPress/gutenberg/pull/11649) + +## 6.0.0 (2018-11-12) + +### Breaking Change - The `PanelColor` component has been removed. @@ -82,7 +100,7 @@ - `withAPIData` has been removed. Please use the Core Data module or `@wordpress/api-fetch` directly instead. - `Draggable` as a DOM node drag handler has been deprecated. Please, use `Draggable` as a wrap component for your DOM node drag handler. -- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. - `withContext` has been removed. Please use `wp.element.createContext` instead. See: https://reactjs.org/docs/context.html. ### New Feature diff --git a/packages/components/package.json b/packages/components/package.json index 2d6c70ab312b23..24ad3e68fbd39d 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/components", - "version": "5.1.1", + "version": "6.0.2", "description": "UI components for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -24,13 +24,13 @@ "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/compose": "file:../compose", - "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", "@wordpress/element": "file:../element", "@wordpress/hooks": "file:../hooks", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/keycodes": "file:../keycodes", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/url": "file:../url", "classnames": "^2.2.5", "clipboard": "^2.0.1", @@ -50,7 +50,7 @@ "devDependencies": { "@wordpress/token-list": "file:../token-list", "enzyme": "^3.7.0", - "react-test-renderer": "^16.4.1" + "react-test-renderer": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/packages/components/src/date-time/README.md b/packages/components/src/date-time/README.md index dc23cc9cd097f2..c70c8199952b34 100644 --- a/packages/components/src/date-time/README.md +++ b/packages/components/src/date-time/README.md @@ -29,7 +29,6 @@ const MyDateTimePicker = withState( { <DateTimePicker currentDate={ date } onChange={ ( date ) => setState( { date } ) } - locale={ settings.l10n.locale } is12Hour={ is12HourTime } /> ); @@ -55,13 +54,6 @@ The function called when a new date or time has been selected. It is passed the - Required: No - Default: `noop` -### locale - -The localization for the display of the date and time. - -- Type: `string` -- Required: No - ### is12Hour Whether we use a 12-hour clock. With a 12-hour clock, an AM/PM widget is displayed and the time format is assumed to be MM-DD-YYYY. diff --git a/packages/components/src/date-time/index.js b/packages/components/src/date-time/index.js index 72268765dd4057..e3782393fe2e8f 100644 --- a/packages/components/src/date-time/index.js +++ b/packages/components/src/date-time/index.js @@ -34,7 +34,7 @@ export class DateTimePicker extends Component { } render() { - const { currentDate, is12Hour, locale, onChange } = this.props; + const { currentDate, is12Hour, onChange } = this.props; return ( <div className="components-datetime"> @@ -48,8 +48,6 @@ export class DateTimePicker extends Component { <DatePicker currentDate={ currentDate } onChange={ onChange } - locale={ locale } - is12Hour={ is12Hour } /> </Fragment> ) } diff --git a/packages/components/src/date-time/style.scss b/packages/components/src/date-time/style.scss index bee202f96099b9..8c99f8216e1fd8 100644 --- a/packages/components/src/date-time/style.scss +++ b/packages/components/src/date-time/style.scss @@ -105,7 +105,7 @@ &.am-pm button { font-size: 11px; - font-weight: bold; + font-weight: 600; } select { diff --git a/packages/components/src/dropdown/README.md b/packages/components/src/dropdown/README.md index 3d4c46bc8f396a..552fb2741776ea 100644 --- a/packages/components/src/dropdown/README.md +++ b/packages/components/src/dropdown/README.md @@ -55,7 +55,7 @@ The direction in which the popover should open relative to its parent node. Spec - Required: No - Default: `"top center"` -## renderToggle +### renderToggle A callback invoked to render the Dropdown Toggle Button. @@ -68,14 +68,14 @@ The first argument of the callback is an object containing the following propert - `onToggle`: A function switching the dropdown menu's state from open to closed and vice versa - `onClose`: A function that closes the menu if invoked -## renderContent +### renderContent A callback invoked to render the content of the dropdown menu. Its first argument is the same as the `renderToggle` prop. - Type: `Function` - Required: Yes -## expandOnMobile +### expandOnMobile Opt-in prop to show popovers fullscreen on mobile, pass `false` in this prop to avoid this behavior. @@ -83,7 +83,7 @@ Opt-in prop to show popovers fullscreen on mobile, pass `false` in this prop to - Required: No - Default: `false` - ## headerTitle +### headerTitle Set this to customize the text that is shown in the dropdown's header when it is fullscreen on mobile. diff --git a/packages/components/src/dropdown/index.js b/packages/components/src/dropdown/index.js index 71042b40bd7efe..81ebe65da6534d 100644 --- a/packages/components/src/dropdown/index.js +++ b/packages/components/src/dropdown/index.js @@ -1,7 +1,6 @@ /** * WordPress dependencies */ -import deprecated from '@wordpress/deprecated'; import { Component, createRef } from '@wordpress/element'; /** @@ -16,7 +15,6 @@ class Dropdown extends Component { this.toggle = this.toggle.bind( this ); this.close = this.close.bind( this ); this.closeIfClickOutside = this.closeIfClickOutside.bind( this ); - this.refresh = this.refresh.bind( this ); this.containerRef = createRef(); @@ -41,20 +39,6 @@ class Dropdown extends Component { } } - /** - * When contents change height due to user interaction, - * `refresh` can be called to re-render Popover with correct - * attributes which allow scroll, if need be. - * @deprecated - */ - refresh() { - deprecated( 'Dropdown.refresh()', { - plugin: 'Gutenberg', - version: '4.5', - hint: 'Popover is now automatically re-rendered without needing to execute "refresh"', - } ); - } - toggle() { this.setState( ( state ) => ( { isOpen: ! state.isOpen, diff --git a/packages/components/src/form-toggle/style.scss b/packages/components/src/form-toggle/style.scss index fe66e3455adf0b..0def01e03beaa6 100644 --- a/packages/components/src/form-toggle/style.scss +++ b/packages/components/src/form-toggle/style.scss @@ -63,7 +63,7 @@ $toggle-border-width: 2px; } } - // checked state + // Checked state. &.is-checked .components-form-toggle__track { background-color: theme(toggle); border: $toggle-border-width solid theme(toggle); @@ -86,9 +86,15 @@ $toggle-border-width: 2px; border: $toggle-border-width solid theme(toggle); } } + + // Disabled state: + .components-disabled & { + opacity: 0.3; + } } -.components-form-toggle__input[type="checkbox"] { +// This needs specificity to override inherited checkbox styles. +.components-form-toggle input.components-form-toggle__input[type="checkbox"] { position: absolute; top: 0; left: 0; @@ -98,6 +104,17 @@ $toggle-border-width: 2px; margin: 0; padding: 0; z-index: z-index(".components-form-toggle__input"); + + // This overrides a border style that is inherited from parent checkbox styles. + border: none; + &:checked { + background: none; + } + + // Don't show custom checkbox checkmark. + &::before { + content: ""; + } } // Ensure on indicator works in normal and Windows high contrast mode both. diff --git a/packages/components/src/higher-order/navigate-regions/style.scss b/packages/components/src/higher-order/navigate-regions/style.scss index 21f8f8b5a728b8..3d50aef11067ff 100644 --- a/packages/components/src/higher-order/navigate-regions/style.scss +++ b/packages/components/src/higher-order/navigate-regions/style.scss @@ -8,6 +8,16 @@ right: 0; pointer-events: none; outline: 4px solid transparent; // Shown in Windows High Contrast mode. - @include region_focus(0.1s); + animation: editor-animation__region-focus 0.2s ease-out; + animation-fill-mode: forwards; + } +} + +@keyframes editor-animation__region-focus { + from { + box-shadow: inset 0 0 0 0 $blue-medium-400; + } + to { + box-shadow: inset 0 0 0 4px $blue-medium-400; } } diff --git a/packages/components/src/index.js b/packages/components/src/index.js index c1143ba729cced..415cd5a1be5dc4 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -58,6 +58,7 @@ export { default as Toolbar } from './toolbar'; export { default as ToolbarButton } from './toolbar-button'; export { default as Tooltip } from './tooltip'; export { default as TreeSelect } from './tree-select'; +export { default as IsolatedEventContainer } from './isolated-event-container'; export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot-fill'; // Higher-Order Components diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index 2fd9eaa5e836eb..47799cd872668a 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -3,6 +3,7 @@ export * from './primitives'; export { default as Dashicon } from './dashicon'; export { default as Toolbar } from './toolbar'; export { default as withSpokenMessages } from './higher-order/with-spoken-messages'; +export { default as IconButton } from './icon-button'; export { createSlotFill, Slot, Fill, Provider as SlotFillProvider } from './slot-fill'; // Higher-Order Components diff --git a/packages/components/src/isolated-event-container/README.md b/packages/components/src/isolated-event-container/README.md new file mode 100644 index 00000000000000..90ce7624f41679 --- /dev/null +++ b/packages/components/src/isolated-event-container/README.md @@ -0,0 +1,34 @@ +# Isolated Event Container + +This is a container that prevents certain events from propagating outside of the container. This is used to wrap +UI elements such as modals and popovers where the propagated event can cause problems. The event continues to work +inside the component. + +For example, a `mousedown` event in a modal container can propagate to the surrounding DOM, causing UI outside of the +modal to be interacted with. + +The current isolated events are: +- mousedown - This prevents UI interaction with other `mousedown` event handlers, such as selection + +## Usage + +Creates a custom component that won't propagate `mousedown` events outside of the component. + +```jsx +import { IsolatedEventContainer } from '@wordpress/components'; + +const MyModal = () => { + return ( + <IsolatedEventContainer + className="component-some_component" + onClick={ clickHandler } + > + <p>This is an isolated component</p> + </IsolatedEventContainer> + ); +}; +``` + +## Props + +All props are passed as-is to the `<IsolatedEventContainer />` diff --git a/packages/components/src/isolated-event-container/index.js b/packages/components/src/isolated-event-container/index.js new file mode 100644 index 00000000000000..10f26bceb89ed8 --- /dev/null +++ b/packages/components/src/isolated-event-container/index.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; + +class IsolatedEventContainer extends Component { + constructor( props ) { + super( props ); + + this.stopEventPropagationOutsideContainer = this.stopEventPropagationOutsideContainer.bind( this ); + } + + stopEventPropagationOutsideContainer( event ) { + event.stopPropagation(); + } + + render() { + const { children, ... props } = this.props; + + // Disable reason: this stops certain events from propagating outside of the component. + // - onMouseDown is disabled as this can cause interactions with other DOM elements + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( + <div + { ... props } + onMouseDown={ this.stopEventPropagationOutsideContainer } + > + { children } + </div> + ); + } +} + +export default IsolatedEventContainer; diff --git a/packages/components/src/isolated-event-container/test/index.js b/packages/components/src/isolated-event-container/test/index.js new file mode 100644 index 00000000000000..7d34c53961365f --- /dev/null +++ b/packages/components/src/isolated-event-container/test/index.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import IsolatedEventContainer from '../'; + +describe( 'IsolatedEventContainer', () => { + it( 'should pass props to container', () => { + const isolated = shallow( <IsolatedEventContainer className="test" onClick="click" /> ); + + expect( isolated.hasClass( 'test' ) ).toBe( true ); + expect( isolated.prop( 'onClick' ) ).toBe( 'click' ); + } ); + + it( 'should stop mousedown event propagation', () => { + const isolated = shallow( <IsolatedEventContainer /> ); + const event = { stopPropagation: jest.fn() }; + + isolated.simulate( 'mousedown', event ); + expect( event.stopPropagation ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/components/src/menu-item/style.scss b/packages/components/src/menu-item/style.scss index 55c7137a3c672f..85a0a1b5803568 100644 --- a/packages/components/src/menu-item/style.scss +++ b/packages/components/src/menu-item/style.scss @@ -18,7 +18,12 @@ } &:hover:not(:disabled):not([aria-disabled="true"]) { - @include menu-style__hover; + color: $dark-gray-500; + // Disable hover style on mobile to prevent odd scroll behaviour. + // See: https://github.com/WordPress/gutenberg/pull/10333 + @include break-medium() { + @include menu-style__hover; + } } &:focus:not(:disabled):not([aria-disabled="true"]) { diff --git a/packages/components/src/modal/index.js b/packages/components/src/modal/index.js index 2316331d0ecc6e..d82c6928a1073c 100644 --- a/packages/components/src/modal/index.js +++ b/packages/components/src/modal/index.js @@ -16,6 +16,7 @@ import { withInstanceId } from '@wordpress/compose'; import ModalFrame from './frame'; import ModalHeader from './header'; import * as ariaHelper from './aria-helper'; +import IsolatedEventContainer from '../isolated-event-container'; // Used to count the number of open modals. let parentElement, @@ -26,7 +27,6 @@ class Modal extends Component { super( props ); this.prepareDOM(); - this.stopEventPropagationOutsideModal = this.stopEventPropagationOutsideModal.bind( this ); } /** @@ -102,14 +102,6 @@ class Modal extends Component { ariaHelper.showApp(); } - /** - * Stop all onMouseDown events propagating further - they should only go to the modal - * @param {string} event Event object - */ - stopEventPropagationOutsideModal( event ) { - event.stopPropagation(); - } - /** * Renders the modal. * @@ -136,9 +128,8 @@ class Modal extends Component { // other elements underneath the modal overlay. /* eslint-disable jsx-a11y/no-static-element-interactions */ return createPortal( - <div + <IsolatedEventContainer className={ classnames( 'components-modal__screen-overlay', overlayClassName ) } - onMouseDown={ this.stopEventPropagationOutsideModal } > <ModalFrame className={ classnames( @@ -164,7 +155,7 @@ class Modal extends Component { { children } </div> </ModalFrame> - </div>, + </IsolatedEventContainer>, this.node ); /* eslint-enable jsx-a11y/no-static-element-interactions */ diff --git a/packages/components/src/modal/style.scss b/packages/components/src/modal/style.scss index 4c61481ad4535e..16b5e165ad1d06 100644 --- a/packages/components/src/modal/style.scss +++ b/packages/components/src/modal/style.scss @@ -9,7 +9,7 @@ z-index: z-index(".components-modal__screen-overlay"); // This animates the appearance of the white background. - @include fade-in(); + @include edit-post__fade-in-animation(); } // The modal window element. @@ -40,7 +40,17 @@ transform: translate(-50%, -50%); // Animate the modal frame/contents appearing on the page. - @include modal_appear(); + animation: components-modal__appear-animation 0.1s ease-out; + animation-fill-mode: forwards; + } +} + +@keyframes components-modal__appear-animation { + from { + margin-top: $grid-size * 4; + } + to { + margin-top: 0; } } @@ -65,7 +75,7 @@ .components-modal__header-heading { font-size: 1em; - font-weight: normal; + font-weight: 400; } h1 { diff --git a/packages/components/src/navigable-container/menu.js b/packages/components/src/navigable-container/menu.js index 869bde17064076..e97c2aece7c0a0 100644 --- a/packages/components/src/navigable-container/menu.js +++ b/packages/components/src/navigable-container/menu.js @@ -48,7 +48,7 @@ export function NavigableMenu( { stopNavigationEvents onlyBrowserTabstops={ false } role={ role } - aria-orientation={ orientation } + aria-orientation={ role === 'presentation' ? null : orientation } eventToOffset={ eventToOffset } { ...rest } /> diff --git a/packages/components/src/notice/index.js b/packages/components/src/notice/index.js index 616774ec99915c..90b2d908deee92 100644 --- a/packages/components/src/notice/index.js +++ b/packages/components/src/notice/index.js @@ -8,6 +8,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { RawHTML } from '@wordpress/element'; /** * Internal dependencies @@ -21,11 +22,16 @@ function Notice( { onRemove = noop, isDismissible = true, actions = [], + __unstableHTML, } ) { const classes = classnames( className, 'components-notice', 'is-' + status, { 'is-dismissible': isDismissible, } ); + if ( __unstableHTML ) { + children = <RawHTML>{ children }</RawHTML>; + } + return ( <div className={ classes }> <div className="components-notice__content"> diff --git a/packages/components/src/notice/list.js b/packages/components/src/notice/list.js index a9e72c546deb6e..4ff12e2a451181 100644 --- a/packages/components/src/notice/list.js +++ b/packages/components/src/notice/list.js @@ -25,7 +25,11 @@ function NoticeList( { notices, onRemove = noop, className = 'components-notice- <div className={ className }> { children } { [ ...notices ].reverse().map( ( notice ) => ( - <Notice { ...omit( notice, 'content' ) } key={ notice.id } onRemove={ removeNotice( notice.id ) }> + <Notice + { ...omit( notice, [ 'content' ] ) } + key={ notice.id } + onRemove={ removeNotice( notice.id ) } + > { notice.content } </Notice> ) ) } diff --git a/packages/components/src/panel/README.md b/packages/components/src/panel/README.md index 0b90e01213ba1c..1c5c2bcf95df04 100644 --- a/packages/components/src/panel/README.md +++ b/packages/components/src/panel/README.md @@ -81,7 +81,7 @@ The is a generic container for panel content. Default styles add a top margin an ##### className -The class that will be added with `components-panel__row`. to the classes of the wrapper div. If no `className` is passed only `components-panel__body` is used. +The class that will be added with `components-panel__row`. to the classes of the wrapper div. If no `className` is passed only `components-panel__row` is used. - Type: `String` - Required: No diff --git a/packages/components/src/panel/body.js b/packages/components/src/panel/body.js index c9dab971220e8d..b7b60e976e44e8 100644 --- a/packages/components/src/panel/body.js +++ b/packages/components/src/panel/body.js @@ -51,16 +51,22 @@ export class PanelBody extends Component { onClick={ this.toggle } aria-expanded={ isOpened } > - { isOpened ? - <SVG className="components-panel__arrow" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <G><Path fill="none" d="M0,0h24v24H0V0z" /></G> - <G><Path d="M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z" /></G> - </SVG> : - <SVG className="components-panel__arrow" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <G><Path fill="none" d="M0,0h24v24H0V0z" /></G> - <G><Path d="M7.41,8.59L12,13.17l4.59-4.58L18,10l-6,6l-6-6L7.41,8.59z" /></G> - </SVG> - } + { /* + Firefox + NVDA don't announce aria-expanded because the browser + repaints the whole element, so this wrapping span hides that. + */ } + <span aria-hidden="true"> + { isOpened ? + <SVG className="components-panel__arrow" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <G><Path fill="none" d="M0,0h24v24H0V0z" /></G> + <G><Path d="M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z" /></G> + </SVG> : + <SVG className="components-panel__arrow" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <G><Path fill="none" d="M0,0h24v24H0V0z" /></G> + <G><Path d="M7.41,8.59L12,13.17l4.59-4.58L18,10l-6,6l-6-6L7.41,8.59z" /></G> + </SVG> + } + </span> { title } { icon && <Icon icon={ icon } className="components-panel__icon" size={ 20 } /> } </Button> diff --git a/packages/components/src/panel/test/body.js b/packages/components/src/panel/test/body.js index 2763079dcb385e..cda8c66bbf6d57 100644 --- a/packages/components/src/panel/test/body.js +++ b/packages/components/src/panel/test/body.js @@ -24,7 +24,8 @@ describe( 'PanelBody', () => { expect( panelBody.hasClass( 'is-opened' ) ).toBe( true ); expect( panelBody.state( 'opened' ) ).toBe( true ); expect( button.prop( 'onClick' ) ).toBe( panelBody.instance().toggle ); - expect( button.childAt( 0 ).name() ).toBe( 'SVG' ); + expect( button.childAt( 0 ).name() ).toBe( 'span' ); + expect( button.childAt( 0 ).childAt( 0 ).name() ).toBe( 'SVG' ); expect( button.childAt( 1 ).text() ).toBe( 'Some Text' ); } ); diff --git a/packages/components/src/placeholder/style.scss b/packages/components/src/placeholder/style.scss index 62d23e704bbd65..1d2f079a1dd37b 100644 --- a/packages/components/src/placeholder/style.scss +++ b/packages/components/src/placeholder/style.scss @@ -25,7 +25,8 @@ font-weight: 600; margin-bottom: 1em; - .dashicon { + .dashicon, + .editor-block-icon { margin-right: 1ch; } } diff --git a/packages/components/src/popover/index.js b/packages/components/src/popover/index.js index 9ad4c6143e0508..643d0329841814 100644 --- a/packages/components/src/popover/index.js +++ b/packages/components/src/popover/index.js @@ -2,6 +2,7 @@ * External dependencies */ import classnames from 'classnames'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * WordPress dependencies @@ -19,6 +20,7 @@ import withConstrainedTabbing from '../higher-order/with-constrained-tabbing'; import PopoverDetectOutside from './detect-outside'; import IconButton from '../icon-button'; import ScrollLock from '../scroll-lock'; +import IsolatedEventContainer from '../isolated-event-container'; import { Slot, Fill, Consumer } from '../slot-fill'; const FocusManaged = withConstrainedTabbing( withFocusReturn( ( { children } ) => children ) ); @@ -34,13 +36,12 @@ class Popover extends Component { constructor() { super( ...arguments ); - this.focus = this.focus.bind( this ); - this.refresh = this.refresh.bind( this ); this.getAnchorRect = this.getAnchorRect.bind( this ); - this.updatePopoverSize = this.updatePopoverSize.bind( this ); this.computePopoverPosition = this.computePopoverPosition.bind( this ); this.maybeClose = this.maybeClose.bind( this ); this.throttledRefresh = this.throttledRefresh.bind( this ); + this.refresh = this.refresh.bind( this ); + this.refreshOnAnchorMove = this.refreshOnAnchorMove.bind( this ); this.contentNode = createRef(); this.anchorNode = createRef(); @@ -55,6 +56,10 @@ class Popover extends Component { isMobile: false, popoverSize: null, }; + + // Property used keep track of the previous anchor rect + // used to compute the popover position and size. + this.anchorRect = {}; } componentDidMount() { @@ -74,7 +79,7 @@ class Popover extends Component { componentDidUpdate( prevProps ) { if ( prevProps.position !== this.props.position ) { - this.computePopoverPosition(); + this.computePopoverPosition( this.state.popoverSize, this.anchorRect ); } } @@ -99,7 +104,7 @@ class Popover extends Component { * For these situations, we refresh the popover every 0.5s */ if ( isActive ) { - this.autoRefresh = setInterval( this.throttledRefresh, 500 ); + this.autoRefresh = setInterval( this.refreshOnAnchorMove, 500 ); } else { clearInterval( this.autoRefresh ); } @@ -113,16 +118,42 @@ class Popover extends Component { this.rafHandle = window.requestAnimationFrame( this.refresh ); } + /** + * Calling refreshOnAnchorMove + * will only refresh the popover position if the anchor moves. + */ + refreshOnAnchorMove() { + const { getAnchorRect = this.getAnchorRect } = this.props; + const anchorRect = getAnchorRect( this.anchorNode.current ); + const didAnchorRectChange = ! isShallowEqual( anchorRect, this.anchorRect ); + if ( didAnchorRectChange ) { + this.anchorRect = anchorRect; + this.computePopoverPosition( this.state.popoverSize, anchorRect ); + } + } + /** * Calling `refresh()` will force the Popover to recalculate its size and * position. This is useful when a DOM change causes the anchor node to change * position. - * - * @return {void} */ refresh() { - const popoverSize = this.updatePopoverSize(); - this.computePopoverPosition( popoverSize ); + const { getAnchorRect = this.getAnchorRect } = this.props; + const anchorRect = getAnchorRect( this.anchorNode.current ); + const contentRect = this.contentNode.current.getBoundingClientRect(); + const popoverSize = { + width: contentRect.width, + height: contentRect.height, + }; + const didPopoverSizeChange = ! this.state.popoverSize || ( + popoverSize.width !== this.state.popoverSize.width || + popoverSize.height !== this.state.popoverSize.height + ); + if ( didPopoverSizeChange ) { + this.setState( { popoverSize } ); + } + this.anchorRect = anchorRect; + this.computePopoverPosition( popoverSize, anchorRect ); } focus() { @@ -174,27 +205,11 @@ class Popover extends Component { }; } - updatePopoverSize() { - const popoverSize = { - width: this.contentNode.current.scrollWidth, - height: this.contentNode.current.scrollHeight, - }; - if ( - ! this.state.popoverSize || - popoverSize.width !== this.state.popoverSize.width || - popoverSize.height !== this.state.popoverSize.height - ) { - this.setState( { popoverSize } ); - return popoverSize; - } - return this.state.popoverSize; - } - - computePopoverPosition( popoverSize ) { - const { getAnchorRect = this.getAnchorRect, position = 'top', expandOnMobile } = this.props; + computePopoverPosition( popoverSize, anchorRect ) { + const { position = 'top', expandOnMobile } = this.props; const newPopoverPosition = computePopoverPosition( - getAnchorRect( this.anchorNode.current ), - popoverSize || this.state.popoverSize, + anchorRect, + popoverSize, position, expandOnMobile ); @@ -274,7 +289,7 @@ class Popover extends Component { /* eslint-disable jsx-a11y/no-static-element-interactions */ let content = ( <PopoverDetectOutside onClickOutside={ onClickOutside }> - <div + <IsolatedEventContainer className={ classes } style={ { top: ! isMobile && popoverTop ? popoverTop + 'px' : undefined, @@ -303,7 +318,7 @@ class Popover extends Component { > { children } </div> - </div> + </IsolatedEventContainer> </PopoverDetectOutside> ); /* eslint-enable jsx-a11y/no-static-element-interactions */ diff --git a/packages/components/src/popover/style.scss b/packages/components/src/popover/style.scss index c47cffe2e616f3..6b9ed471028d5d 100644 --- a/packages/components/src/popover/style.scss +++ b/packages/components/src/popover/style.scss @@ -1,5 +1,6 @@ $arrow-size: 8px; +/*!rtl:begin:ignore*/ .components-popover { position: fixed; z-index: z-index(".components-popover"); @@ -75,7 +76,6 @@ $arrow-size: 8px; } &.is-middle.is-left { - /*!rtl:begin:ignore*/ margin-left: -$arrow-size; &::before { @@ -93,11 +93,9 @@ $arrow-size: 8px; border-right: none; border-top-color: transparent; } - /*!rtl:end:ignore*/ } &.is-middle.is-right { - /*!rtl:begin:ignore*/ margin-left: $arrow-size; &::before { @@ -115,7 +113,6 @@ $arrow-size: 8px; border-right-style: solid; border-top-color: transparent; } - /*!rtl:end:ignore*/ } } @@ -165,23 +162,19 @@ $arrow-size: 8px; .components-popover:not(.is-mobile).is-right & { position: absolute; - /*!rtl:ignore*/ left: 100%; } .components-popover:not(.is-mobile):not(.is-middle).is-right & { - /*!rtl:ignore*/ margin-left: -24px; } .components-popover:not(.is-mobile).is-left & { position: absolute; - /*!rtl:ignore*/ right: 100%; } .components-popover:not(.is-mobile):not(.is-middle).is-left & { - /*!rtl:ignore*/ margin-right: -24px; } } @@ -211,3 +204,4 @@ $arrow-size: 8px; .components-popover__close.components-icon-button { z-index: z-index(".components-popover__close"); } +/*!rtl:end:ignore*/ diff --git a/packages/components/src/popover/utils.js b/packages/components/src/popover/utils.js index e7b98c5f5a4df9..292f7ab3276775 100644 --- a/packages/components/src/popover/utils.js +++ b/packages/components/src/popover/utils.js @@ -4,6 +4,7 @@ */ const HEIGHT_OFFSET = 10; // used by the arrow and a bit of empty space const isMobileViewport = () => window.innerWidth < 782; +const isRTL = () => document.documentElement.dir === 'rtl'; /** * Utility used to compute the popover position over the xAxis @@ -18,6 +19,12 @@ const isMobileViewport = () => window.innerWidth < 782; */ export function computePopoverXAxisPosition( anchorRect, contentSize, xAxis, chosenYAxis ) { const { width } = contentSize; + // Correct xAxis for RTL support + if ( xAxis === 'left' && isRTL() ) { + xAxis = 'right'; + } else if ( xAxis === 'right' && isRTL() ) { + xAxis = 'left'; + } // x axis alignment choices const anchorMidPoint = Math.round( anchorRect.left + ( anchorRect.width / 2 ) ); diff --git a/packages/components/src/server-side-render/index.js b/packages/components/src/server-side-render/index.js index 0876caccc9a6de..ed11eac1c518bf 100644 --- a/packages/components/src/server-side-render/index.js +++ b/packages/components/src/server-side-render/index.js @@ -1,7 +1,7 @@ /** * External dependencies. */ -import { isEqual } from 'lodash'; +import { isEqual, debounce } from 'lodash'; /** * WordPress dependencies. @@ -39,6 +39,9 @@ export class ServerSideRender extends Component { componentDidMount() { this.isStillMounted = true; this.fetch( this.props ); + // Only debounce once the initial fetch occurs to ensure that the first + // renders show data as soon as possible. + this.fetch = debounce( this.fetch, 500 ); } componentWillUnmount() { @@ -52,27 +55,32 @@ export class ServerSideRender extends Component { } fetch( props ) { + if ( ! this.isStillMounted ) { + return; + } if ( null !== this.state.response ) { this.setState( { response: null } ); } const { block, attributes = null, urlQueryArgs = {} } = props; const path = rendererPath( block, attributes, urlQueryArgs ); - - return apiFetch( { path } ) + // Store the latest fetch request so that when we process it, we can + // check if it is the current request, to avoid race conditions on slow networks. + const fetchRequest = this.currentFetchRequest = apiFetch( { path } ) .then( ( response ) => { - if ( this.isStillMounted && response && response.rendered ) { + if ( this.isStillMounted && fetchRequest === this.currentFetchRequest && response && response.rendered ) { this.setState( { response: response.rendered } ); } } ) .catch( ( error ) => { - if ( this.isStillMounted ) { + if ( this.isStillMounted && fetchRequest === this.currentFetchRequest ) { this.setState( { response: { error: true, errorMsg: error.message, } } ); } } ); + return fetchRequest; } render() { diff --git a/packages/components/src/spinner/style.scss b/packages/components/src/spinner/style.scss index a8b34fdba0573b..286b8204edd7d5 100644 --- a/packages/components/src/spinner/style.scss +++ b/packages/components/src/spinner/style.scss @@ -19,6 +19,15 @@ height: 4px; border-radius: 100%; transform-origin: 6px 6px; - @include animate_rotation; + animation: components-spinner__animation 1s infinite linear; + } +} + +@keyframes components-spinner__animation { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); } } diff --git a/packages/components/src/tab-panel/test/index.js b/packages/components/src/tab-panel/test/index.js index 7c45af6b45c636..153441cd195316 100644 --- a/packages/components/src/tab-panel/test/index.js +++ b/packages/components/src/tab-panel/test/index.js @@ -13,11 +13,6 @@ import TabPanel from '../'; */ import { Component } from '@wordpress/element'; -/** - * Mock Functions - */ -jest.mock( '@wordpress/deprecated', () => jest.fn() ); - describe( 'TabPanel', () => { const getElementByClass = ( wrapper, className ) => { return TestUtils.findRenderedDOMComponentWithClass( wrapper, className ); diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md index 1c7e7b35f7a82f..0914387d78744d 100644 --- a/packages/compose/CHANGELOG.md +++ b/packages/compose/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- `remountOnPropChange` has been removed. + ## 2.1.2 (2018-11-09) ## 2.1.1 (2018-11-09) diff --git a/packages/compose/package.json b/packages/compose/package.json index 6c8b8f019f1504..dc7a4ea89316ee 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/compose", - "version": "2.1.2", + "version": "3.0.0", "description": "WordPress higher-order components (HOCs).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -22,15 +22,14 @@ "react-native": "src/index", "dependencies": { "@babel/runtime": "^7.0.0", - "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "lodash": "^4.17.10" }, "devDependencies": { "enzyme": "^3.7.0", - "react-dom": "^16.4.1", - "react-test-renderer": "^16.4.1" + "react-dom": "^16.6.3", + "react-test-renderer": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/packages/compose/src/index.js b/packages/compose/src/index.js index 4c617480d032d0..379ed6a4ab54de 100644 --- a/packages/compose/src/index.js +++ b/packages/compose/src/index.js @@ -6,7 +6,6 @@ import { flowRight } from 'lodash'; export { default as createHigherOrderComponent } from './create-higher-order-component'; export { default as ifCondition } from './if-condition'; export { default as pure } from './pure'; -export { default as remountOnPropChange } from './remount-on-prop-change'; export { default as withGlobalEvents } from './with-global-events'; export { default as withInstanceId } from './with-instance-id'; export { default as withSafeTimeout } from './with-safe-timeout'; diff --git a/packages/compose/src/remount-on-prop-change/index.js b/packages/compose/src/remount-on-prop-change/index.js deleted file mode 100644 index ff6155b3f071f5..00000000000000 --- a/packages/compose/src/remount-on-prop-change/index.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; -import deprecated from '@wordpress/deprecated'; - -/** - * Internal dependencies - */ -import createHigherOrderComponent from '../create-higher-order-component'; - -/** - * Higher-order component creator, creating a new component that remounts - * the wrapped component each time a given prop value changes. - * - * @param {string} propName Prop name to monitor. - * - * @return {Function} Higher-order component. - */ -const remountOnPropChange = ( propName ) => createHigherOrderComponent( ( WrappedComponent ) => { - deprecated( 'remountOnPropChange', { - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return class extends Component { - constructor( props ) { - super( ...arguments ); - this.state = { - propChangeId: 0, - propValue: props[ propName ], - }; - } - - static getDerivedStateFromProps( props, state ) { - if ( props[ propName ] === state.propValue ) { - return null; - } - - return { - propChangeId: state.propChangeId + 1, - propValue: props[ propName ], - }; - } - - render() { - return <WrappedComponent key={ this.state.propChangeId } { ...this.props } />; - } - }; -}, 'remountOnPropChange' ); - -export default remountOnPropChange; diff --git a/packages/compose/src/remount-on-prop-change/test/index.js b/packages/compose/src/remount-on-prop-change/test/index.js deleted file mode 100644 index a2b49449c6c091..00000000000000 --- a/packages/compose/src/remount-on-prop-change/test/index.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * External dependencies - */ -import TestRenderer from 'react-test-renderer'; - -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; -import deprecated from '@wordpress/deprecated'; - -/** - * Internal dependencies - */ -import remountOnPropChange from '../'; - -jest.mock( '@wordpress/deprecated', () => jest.fn() ); - -describe( 'remountOnPropChange', () => { - let count = 0; - class MountCounter extends Component { - constructor() { - super( ...arguments ); - this.state = { - count: 0, - }; - } - - componentDidMount() { - count++; - this.setState( { - count: count, - } ); - } - - render() { - return this.state.count; - } - } - - beforeEach( () => { - count = 0; - } ); - - it( 'Should not remount the inner component if the prop value doesn’t change', () => { - const Wrapped = remountOnPropChange( 'monitor' )( MountCounter ); - const testRenderer = TestRenderer.create( - <Wrapped monitor="unchanged" other="1" /> - ); - - expect( testRenderer.toJSON() ).toBe( '1' ); - - // Changing an unmonitored prop - testRenderer.update( - <Wrapped monitor="unchanged" other="2" /> - ); - expect( testRenderer.toJSON() ).toBe( '1' ); - - expect( deprecated ).toHaveBeenCalled(); - } ); - - it( 'Should remount the inner component if the prop value changes', () => { - const Wrapped = remountOnPropChange( 'monitor' )( MountCounter ); - const testRenderer = TestRenderer.create( - <Wrapped monitor="initial" /> - ); - - expect( testRenderer.toJSON() ).toBe( '1' ); - - // Changing an the monitored prop remounts the component - testRenderer.update( - <Wrapped monitor="updated" /> - ); - expect( testRenderer.toJSON() ).toBe( '2' ); - - expect( deprecated ).toHaveBeenCalled(); - } ); -} ); diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index e08dec7a0b911f..aebc238e5fe4aa 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.13 (2018-11-15) + +## 2.0.12 (2018-11-12) + ## 2.0.11 (2018-11-09) ## 2.0.10 (2018-11-09) diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 72d5d06b05c0c6..0ae20c85125e77 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-data", - "version": "2.0.11", + "version": "2.0.13", "description": "Access to and manipulation of core WordPress entities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 079ee7ad7eadc3..ebbcfb7e761500 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -127,3 +127,17 @@ export function* saveEntityRecord( kind, name, record ) { return updatedRecord; } + +/** + * Returns an action object used in signalling that Upload permissions have been received. + * + * @param {boolean} hasUploadPermissions Does the user have permission to upload files? + * + * @return {Object} Action object. + */ +export function receiveUploadPermissions( hasUploadPermissions ) { + return { + type: 'RECEIVE_UPLOAD_PERMISSIONS', + hasUploadPermissions, + }; +} diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 1d8aa2fab6cb3d..b0c5718ab9b888 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -41,12 +41,10 @@ const entityActions = defaultEntities.reduce( ( result, entity ) => { return result; }, {} ); -const store = registerStore( REDUCER_KEY, { +registerStore( REDUCER_KEY, { reducer, controls, actions: { ...actions, ...entityActions }, selectors: { ...selectors, ...entitySelectors }, resolvers: { ...resolvers, ...entityResolvers }, } ); - -export default store; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 6d286514a10f67..d37969cbdae145 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -217,6 +217,23 @@ export function embedPreviews( state = {}, action ) { return state; } +/** + * Reducer managing Upload permissions. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function hasUploadPermissions( state = {}, action ) { + switch ( action.type ) { + case 'RECEIVE_UPLOAD_PERMISSIONS': + return action.hasUploadPermissions; + } + + return state; +} + export default combineReducers( { terms, users, @@ -224,4 +241,5 @@ export default combineReducers( { themeSupports, entities, embedPreviews, + hasUploadPermissions, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 6f6cd0e1d0a8c0..b8bd4ed2382854 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { find } from 'lodash'; +import { find, includes, get, hasIn } from 'lodash'; /** * WordPress dependencies @@ -16,6 +16,7 @@ import { receiveEntityRecords, receiveThemeSupports, receiveEmbedPreview, + receiveUploadPermissions, } from './actions'; import { getKindEntities } from './entities'; import { apiFetch } from './controls'; @@ -97,3 +98,23 @@ export function* getEmbedPreview( url ) { yield receiveEmbedPreview( url, false ); } } + +/** + * Requests Upload Permissions from the REST API. + */ +export function* hasUploadPermissions() { + const response = yield apiFetch( { path: '/wp/v2/media', method: 'OPTIONS', parse: false } ); + + let allowHeader; + if ( hasIn( response, [ 'headers', 'get' ] ) ) { + // If the request is fetched using the fetch api, the header can be + // retrieved using the 'get' method. + allowHeader = response.headers.get( 'allow' ); + } else { + // If the request was preloaded server-side and is returned by the + // preloading middleware, the header will be a simple property. + allowHeader = get( response, [ 'headers', 'Allow' ], '' ); + } + + yield receiveUploadPermissions( includes( allowHeader, 'POST' ) ); +} diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index 57e8a758ca3929..95e9f867aa3c02 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -169,3 +169,14 @@ export function isPreviewEmbedFallback( state, url ) { } return preview.html === oEmbedLinkCheck; } + +/** + * Return Upload Permissions. + * + * @param {Object} state State tree. + * + * @return {boolean} Upload Permissions. + */ +export function hasUploadPermissions( state ) { + return state.hasUploadPermissions; +} diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 6dad7cf833a791..24d44618c11ffd 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -1,3 +1,16 @@ +## 4.0.0 (2018-11-15) + +### Breaking Changes + +- `registry.registerReducer` has been removed. Use `registry.registerStore` instead. +- `registry.registerSelectors` has been removed. Use `registry.registerStore` instead. +- `registry.registerActions` has been removed. Use `registry.registerStore` instead. +- `registry.registerResolvers` has been removed. Use `registry.registerStore` instead. + +### Bug Fix + +- Resolve an issue where `withSelect`'s `mapSelectToProps` would not be rerun if the wrapped component had incurred a store change during its mount lifecycle. + ## 3.1.2 (2018-11-09) ## 3.1.1 (2018-11-09) diff --git a/packages/data/README.md b/packages/data/README.md index dfe9783d830d6d..f6ca4c665c8fff 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -286,6 +286,8 @@ const SaleButton = withDispatch( ( dispatch, ownProps ) => { // <SaleButton>Start Sale!</SaleButton> ``` +*Note:* It is important that the `mapDispatchToProps` function always returns an object with the same keys. For example, it should not contain conditions under which a different value would be returned. + ## Generic Stores The `@wordpress/data` module offers a more advanced and generic interface for the purposes of integrating other data systems and situations where more direct control over a data system is needed. In this case, a data store will need to be implemented outside of `@wordpress/data` and then plugged in via three functions: diff --git a/packages/data/package.json b/packages/data/package.json index 8fc4107aae4b5f..c0bd206f435136 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data", - "version": "3.1.2", + "version": "4.0.0", "description": "Data module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -23,7 +23,6 @@ "dependencies": { "@babel/runtime": "^7.0.0", "@wordpress/compose": "file:../compose", - "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/redux-routine": "file:../redux-routine", @@ -36,7 +35,7 @@ "devDependencies": { "deep-freeze": "^0.0.1", "enzyme": "^3.7.0", - "react-test-renderer": "^16.4.1" + "react-test-renderer": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/packages/data/src/components/with-dispatch/index.js b/packages/data/src/components/with-dispatch/index.js index 85a7b0961a1aab..3c302c557187fa 100644 --- a/packages/data/src/components/with-dispatch/index.js +++ b/packages/data/src/components/with-dispatch/index.js @@ -7,7 +7,7 @@ import { mapValues } from 'lodash'; * WordPress dependencies */ import { Component } from '@wordpress/element'; -import { pure, compose, createHigherOrderComponent } from '@wordpress/compose'; +import { createHigherOrderComponent } from '@wordpress/compose'; /** * Internal dependencies @@ -27,63 +27,57 @@ import { RegistryConsumer } from '../registry-provider'; * @return {Component} Enhanced component with merged dispatcher props. */ const withDispatch = ( mapDispatchToProps ) => createHigherOrderComponent( - compose( [ - pure, - ( WrappedComponent ) => { - class ComponentWithDispatch extends Component { - constructor( props ) { - super( ...arguments ); + ( WrappedComponent ) => { + class ComponentWithDispatch extends Component { + constructor( props ) { + super( ...arguments ); - this.proxyProps = {}; - this.setProxyProps( props ); - } + this.proxyProps = {}; - componentDidUpdate() { - this.setProxyProps( this.props ); - } + this.setProxyProps( props ); + } - proxyDispatch( propName, ...args ) { - // Original dispatcher is a pre-bound (dispatching) action creator. - mapDispatchToProps( this.props.registry.dispatch, this.props.ownProps )[ propName ]( ...args ); - } + proxyDispatch( propName, ...args ) { + // Original dispatcher is a pre-bound (dispatching) action creator. + mapDispatchToProps( this.props.registry.dispatch, this.props.ownProps )[ propName ]( ...args ); + } - setProxyProps( props ) { - // Assign as instance property so that in subsequent render - // reconciliation, the prop values are referentially equal. - // Importantly, note that while `mapDispatchToProps` is - // called, it is done only to determine the keys for which - // proxy functions should be created. The actual registry - // dispatch does not occur until the function is called. - const propsToDispatchers = mapDispatchToProps( this.props.registry.dispatch, props.ownProps ); - this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => { - // Prebind with prop name so we have reference to the original - // dispatcher to invoke. Track between re-renders to avoid - // creating new function references every render. - if ( this.proxyProps.hasOwnProperty( propName ) ) { - return this.proxyProps[ propName ]; - } + setProxyProps( props ) { + // Assign as instance property so that in subsequent render + // reconciliation, the prop values are referentially equal. + // Importantly, note that while `mapDispatchToProps` is + // called, it is done only to determine the keys for which + // proxy functions should be created. The actual registry + // dispatch does not occur until the function is called. + const propsToDispatchers = mapDispatchToProps( this.props.registry.dispatch, props.ownProps ); + this.proxyProps = mapValues( propsToDispatchers, ( dispatcher, propName ) => { + // Prebind with prop name so we have reference to the original + // dispatcher to invoke. Track between re-renders to avoid + // creating new function references every render. + if ( this.proxyProps.hasOwnProperty( propName ) ) { + return this.proxyProps[ propName ]; + } - return this.proxyDispatch.bind( this, propName ); - } ); - } + return this.proxyDispatch.bind( this, propName ); + } ); + } - render() { - return <WrappedComponent { ...this.props.ownProps } { ...this.proxyProps } />; - } + render() { + return <WrappedComponent { ...this.props.ownProps } { ...this.proxyProps } />; } + } - return ( ownProps ) => ( - <RegistryConsumer> - { ( registry ) => ( - <ComponentWithDispatch - ownProps={ ownProps } - registry={ registry } - /> - ) } - </RegistryConsumer> - ); - }, - ] ), + return ( ownProps ) => ( + <RegistryConsumer> + { ( registry ) => ( + <ComponentWithDispatch + ownProps={ ownProps } + registry={ registry } + /> + ) } + </RegistryConsumer> + ); + }, 'withDispatch' ); diff --git a/packages/data/src/components/with-select/index.js b/packages/data/src/components/with-select/index.js index de681581904c54..c471bce36388c5 100644 --- a/packages/data/src/components/with-select/index.js +++ b/packages/data/src/components/with-select/index.js @@ -48,6 +48,8 @@ const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( ( Wrapped constructor( props ) { super( props ); + this.onStoreChange = this.onStoreChange.bind( this ); + this.subscribe( props.registry ); this.mergeProps = getNextMergeProps( props ); @@ -55,6 +57,14 @@ const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( ( Wrapped componentDidMount() { this.canRunSelection = true; + + // A state change may have occurred between the constructor and + // mount of the component (e.g. during the wrapped component's own + // constructor), in which case selection should be rerun. + if ( this.hasQueuedSelection ) { + this.hasQueuedSelection = false; + this.onStoreChange(); + } } componentWillUnmount() { @@ -103,29 +113,31 @@ const withSelect = ( mapSelectToProps ) => createHigherOrderComponent( ( Wrapped return true; } - subscribe( registry ) { - this.unsubscribe = registry.subscribe( () => { - if ( ! this.canRunSelection ) { - return; - } + onStoreChange() { + if ( ! this.canRunSelection ) { + this.hasQueuedSelection = true; + return; + } - const nextMergeProps = getNextMergeProps( this.props ); - if ( isShallowEqual( this.mergeProps, nextMergeProps ) ) { - return; - } + const nextMergeProps = getNextMergeProps( this.props ); + if ( isShallowEqual( this.mergeProps, nextMergeProps ) ) { + return; + } + + this.mergeProps = nextMergeProps; - this.mergeProps = nextMergeProps; - - // Schedule an update. Merge props are not assigned to state - // because derivation of merge props from incoming props occurs - // within shouldComponentUpdate, where setState is not allowed. - // setState is used here instead of forceUpdate because forceUpdate - // bypasses shouldComponentUpdate altogether, which isn't desireable - // if both state and props change within the same render. - // Unfortunately this requires that next merge props are generated - // twice. - this.setState( {} ); - } ); + // Schedule an update. Merge props are not assigned to state since + // derivation of merge props from incoming props occurs within + // shouldComponentUpdate, where setState is not allowed. setState + // is used here instead of forceUpdate because forceUpdate bypasses + // shouldComponentUpdate altogether, which isn't desireable if both + // state and props change within the same render. Unfortunately, + // this requires that next merge props are generated twice. + this.setState( {} ); + } + + subscribe( registry ) { + this.unsubscribe = registry.subscribe( this.onStoreChange ); } render() { diff --git a/packages/data/src/components/with-select/test/index.js b/packages/data/src/components/with-select/test/index.js index 2af54df5170204..f42926e02266f7 100644 --- a/packages/data/src/components/with-select/test/index.js +++ b/packages/data/src/components/with-select/test/index.js @@ -7,6 +7,7 @@ import TestRenderer from 'react-test-renderer'; * WordPress dependencies */ import { compose } from '@wordpress/compose'; +import { Component } from '@wordpress/element'; /** * Internal dependencies @@ -45,11 +46,11 @@ describe( 'withSelect', () => { <div>{ props.data }</div> ) ); - const Component = withSelect( mapSelectToProps )( OriginalComponent ); + const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> - <Component keyName="reactKey" /> + <DataBoundComponent keyName="reactKey" /> </RegistryProvider> ); const testInstance = testRenderer.root; @@ -94,14 +95,14 @@ describe( 'withSelect', () => { </button> ) ); - const Component = compose( [ + const DataBoundComponent = compose( [ withSelect( mapSelectToProps ), withDispatch( mapDispatchToProps ), ] )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> - <Component /> + <DataBoundComponent /> </RegistryProvider> ); const testInstance = testRenderer.root; @@ -114,15 +115,74 @@ describe( 'withSelect', () => { testInstance.findByType( 'button' ).props.onClick(); expect( testInstance.findByType( 'button' ).props.children ).toBe( 1 ); - // 3 times = + // 2 times = // 1. Initial mount // 2. When click handler is called - // 3. After select updates its merge props - expect( mapDispatchToProps ).toHaveBeenCalledTimes( 3 ); + expect( mapDispatchToProps ).toHaveBeenCalledTimes( 2 ); expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); expect( OriginalComponent ).toHaveBeenCalledTimes( 2 ); } ); + it( 'should rerun if had dispatched action during mount', () => { + registry.registerStore( 'counter', { + reducer: ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + 1; + } + + return state; + }, + selectors: { + getCount: ( state ) => state, + }, + actions: { + increment: () => ( { type: 'increment' } ), + }, + } ); + + class OriginalComponent extends Component { + constructor( props ) { + super( ...arguments ); + + props.increment(); + } + + componentDidMount() { + this.props.increment(); + } + + render() { + return <div>{ this.props.count }</div>; + } + } + + jest.spyOn( OriginalComponent.prototype, 'render' ); + + const mapSelectToProps = jest.fn().mockImplementation( ( _select, ownProps ) => ( { + count: _select( 'counter' ).getCount( ownProps.offset ), + } ) ); + + const mapDispatchToProps = jest.fn().mockImplementation( ( _dispatch ) => ( { + increment: _dispatch( 'counter' ).increment, + } ) ); + + const DataBoundComponent = compose( [ + withSelect( mapSelectToProps ), + withDispatch( mapDispatchToProps ), + ] )( OriginalComponent ); + + const testRenderer = TestRenderer.create( + <RegistryProvider value={ registry }> + <DataBoundComponent /> + </RegistryProvider> + ); + const testInstance = testRenderer.root; + + expect( testInstance.findByType( 'div' ).props.children ).toBe( 2 ); + expect( mapSelectToProps ).toHaveBeenCalledTimes( 2 ); + expect( OriginalComponent.prototype.render ).toHaveBeenCalledTimes( 2 ); + } ); + it( 'should rerun selection on props changes', () => { registry.registerStore( 'counter', { reducer: ( state = 0, action ) => { @@ -145,11 +205,11 @@ describe( 'withSelect', () => { <div>{ props.count }</div> ) ); - const Component = withSelect( mapSelectToProps )( OriginalComponent ); + const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> - <Component offset={ 0 } /> + <DataBoundComponent offset={ 0 } /> </RegistryProvider> ); const testInstance = testRenderer.root; @@ -159,7 +219,7 @@ describe( 'withSelect', () => { testRenderer.update( <RegistryProvider value={ registry }> - <Component offset={ 10 } /> + <DataBoundComponent offset={ 10 } /> </RegistryProvider> ); @@ -180,11 +240,11 @@ describe( 'withSelect', () => { const OriginalComponent = jest.fn().mockImplementation( () => <div /> ); - const Component = compose( [ + const DataBoundComponent = compose( [ withSelect( mapSelectToProps ), ] )( OriginalComponent ); - const Parent = ( props ) => <Component propName={ props.propName } />; + const Parent = ( props ) => <DataBoundComponent propName={ props.propName } />; const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> @@ -222,11 +282,11 @@ describe( 'withSelect', () => { const OriginalComponent = jest.fn().mockImplementation( () => <div /> ); - const Component = withSelect( mapSelectToProps )( OriginalComponent ); + const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); TestRenderer.create( <RegistryProvider value={ registry }> - <Component /> + <DataBoundComponent /> </RegistryProvider> ); @@ -251,13 +311,13 @@ describe( 'withSelect', () => { const OriginalComponent = jest.fn().mockImplementation( () => <div /> ); - const Component = compose( [ + const DataBoundComponent = compose( [ withSelect( mapSelectToProps ), ] )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> - <Component /> + <DataBoundComponent /> </RegistryProvider> ); @@ -266,7 +326,7 @@ describe( 'withSelect', () => { testRenderer.update( <RegistryProvider value={ registry }> - <Component propName="foo" /> + <DataBoundComponent propName="foo" /> </RegistryProvider> ); @@ -286,13 +346,13 @@ describe( 'withSelect', () => { const OriginalComponent = jest.fn().mockImplementation( () => <div /> ); - const Component = compose( [ + const DataBoundComponent = compose( [ withSelect( mapSelectToProps ), ] )( OriginalComponent ); TestRenderer.create( <RegistryProvider value={ registry }> - <Component /> + <DataBoundComponent /> </RegistryProvider> ); @@ -322,11 +382,11 @@ describe( 'withSelect', () => { const OriginalComponent = jest.fn() .mockImplementation( ( props ) => <div>{ JSON.stringify( props ) }</div> ); - const Component = withSelect( mapSelectToProps )( OriginalComponent ); + const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> - <Component propName="foo" /> + <DataBoundComponent propName="foo" /> </RegistryProvider> ); const testInstance = testRenderer.root; @@ -339,7 +399,7 @@ describe( 'withSelect', () => { testRenderer.update( <RegistryProvider value={ registry }> - <Component propName="bar" /> + <DataBoundComponent propName="bar" /> </RegistryProvider> ); @@ -369,11 +429,11 @@ describe( 'withSelect', () => { ( props ) => <div>{ props.count || 'Unknown' }</div> ) ); - const Component = withSelect( mapSelectToProps )( OriginalComponent ); + const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ registry }> - <Component pass={ false } /> + <DataBoundComponent pass={ false } /> </RegistryProvider> ); const testInstance = testRenderer.root; @@ -384,7 +444,7 @@ describe( 'withSelect', () => { testRenderer.update( <RegistryProvider value={ registry }> - <Component pass /> + <DataBoundComponent pass /> </RegistryProvider> ); @@ -394,7 +454,7 @@ describe( 'withSelect', () => { testRenderer.update( <RegistryProvider value={ registry }> - <Component pass={ false } /> + <DataBoundComponent pass={ false } /> </RegistryProvider> ); @@ -465,11 +525,11 @@ describe( 'withSelect', () => { <div>{ props.value }</div> ) ); - const Component = withSelect( mapSelectToProps )( OriginalComponent ); + const DataBoundComponent = withSelect( mapSelectToProps )( OriginalComponent ); const testRenderer = TestRenderer.create( <RegistryProvider value={ firstRegistry }> - <Component /> + <DataBoundComponent /> </RegistryProvider> ); const testInstance = testRenderer.root; @@ -491,7 +551,7 @@ describe( 'withSelect', () => { testRenderer.update( <RegistryProvider value={ secondRegistry }> - <Component /> + <DataBoundComponent /> </RegistryProvider> ); diff --git a/packages/data/src/index.js b/packages/data/src/index.js index e521d5f14842b2..e1b9a5fcb1e555 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -32,8 +32,4 @@ export const dispatch = defaultRegistry.dispatch; export const subscribe = defaultRegistry.subscribe; export const registerGenericStore = defaultRegistry.registerGenericStore; export const registerStore = defaultRegistry.registerStore; -export const registerReducer = defaultRegistry.registerReducer; -export const registerActions = defaultRegistry.registerActions; -export const registerSelectors = defaultRegistry.registerSelectors; -export const registerResolvers = defaultRegistry.registerResolvers; export const use = defaultRegistry.use; diff --git a/packages/data/src/namespace-store.js b/packages/data/src/namespace-store.js index 5aa68bc9289352..2b8c283c3a478f 100644 --- a/packages/data/src/namespace-store.js +++ b/packages/data/src/namespace-store.js @@ -24,34 +24,14 @@ import createResolversCacheMiddleware from './resolvers-cache-middleware'; * @return {Object} Store Object. */ export default function createNamespace( key, options, registry ) { - // TODO: After register[Reducer|Actions|Selectors|Resolvers] are deprecated and removed, - // this function can be greatly simplified because it should no longer be called to modify - // a namespace, but only to create one, and only once for each namespace. + const reducer = options.reducer; + const store = createReduxStore( reducer, key, registry ); - // TODO: After removing `registry.namespaces`and making stores immutable after create, - // reducer, store, actinos, selectors, and resolvers can all be removed from here. - let { - reducer, - store, - actions, - selectors, - resolvers, - } = registry.namespaces[ key ] || {}; - - if ( options.reducer ) { - reducer = options.reducer; - store = createReduxStore( reducer, key, registry ); - } + let selectors, actions, resolvers; if ( options.actions ) { - if ( ! store ) { - throw new TypeError( 'Cannot specify actions when no reducer is present' ); - } actions = mapActions( options.actions, store ); } if ( options.selectors ) { - if ( ! store ) { - throw new TypeError( 'Cannot specify selectors when no reducer is present' ); - } selectors = mapSelectors( options.selectors, store ); } if ( options.resolvers ) { @@ -79,6 +59,8 @@ export default function createNamespace( key, options, registry ) { } ); }; + // This can be simplified to just { subscribe, getSelectors, getActions } + // Once we remove the use function. return { reducer, store, diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index fc9724ca5358bd..acca73b93129a3 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -6,11 +6,6 @@ import { mapValues, } from 'lodash'; -/** - * WordPress dependencies - */ -import deprecated from '@wordpress/deprecated'; - /** * Internal dependencies */ @@ -140,63 +135,6 @@ export function createRegistry( storeConfigs = {} ) { use, }; - // - // Deprecated - // - registry.registerReducer = ( reducerKey, reducer ) => { - deprecated( 'registry.registerReducer', { - alternative: 'registry.registerStore', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - const namespace = createNamespace( reducerKey, { reducer }, registry ); - registerGenericStore( reducerKey, namespace ); - return namespace.store; - }; - - // - // Deprecated - // - registry.registerActions = ( reducerKey, actions ) => { - deprecated( 'registry.registerActions', { - alternative: 'registry.registerStore', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - const namespace = createNamespace( reducerKey, { actions }, registry ); - registerGenericStore( reducerKey, namespace ); - }; - - // - // Deprecated - // - registry.registerSelectors = ( reducerKey, selectors ) => { - deprecated( 'registry.registerSelectors', { - alternative: 'registry.registerStore', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - const namespace = createNamespace( reducerKey, { selectors }, registry ); - registerGenericStore( reducerKey, namespace ); - }; - - // - // Deprecated - // - registry.registerResolvers = ( reducerKey, resolvers ) => { - deprecated( 'registry.registerResolvers', { - alternative: 'registry.registerStore', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - const namespace = createNamespace( reducerKey, { resolvers }, registry ); - registerGenericStore( reducerKey, namespace ); - }; - /** * Registers a standard `@wordpress/data` store. * diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js index f0d483a9a16638..70dc39fae0a070 100644 --- a/packages/data/src/test/registry.js +++ b/packages/data/src/test/registry.js @@ -178,49 +178,40 @@ describe( 'createRegistry', () => { expect( registry.select( 'butcher' ).getPrice( 'chicken' ) ).toBe( 2 ); expect( registry.select( 'butcher' ).getPrice( 'ribs' ) ).toBe( 6 ); } ); - } ); - // TODO: Refactor this into registerStore tests after this function is removed. - describe( 'registerReducer', () => { it( 'Should append reducers to the state', () => { const reducer1 = () => 'chicken'; const reducer2 = () => 'ribs'; - const store = registry.registerReducer( 'red1', reducer1 ); + const store = registry.registerStore( 'red1', { reducer: reducer1 } ); expect( store.getState() ).toEqual( 'chicken' ); - const store2 = registry.registerReducer( 'red2', reducer2 ); + const store2 = registry.registerStore( 'red2', { reducer: reducer2 } ); expect( store2.getState() ).toEqual( 'ribs' ); - - // This uses deprecated functions and will produce a warning. - expect( console ).toHaveWarned(); } ); - } ); - // TODO: Refactor this into registerStore tests after this function is removed. - describe( 'registerResolvers', () => { it( 'should not do anything for selectors which do not have resolvers', () => { - registry.registerReducer( 'demo', ( state = 'OK' ) => state ); - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + resolvers: {}, } ); - registry.registerResolvers( 'demo', {} ); expect( registry.select( 'demo' ).getValue() ).toBe( 'OK' ); - - // This uses deprecated functions and will produce a warning. - expect( console ).toHaveWarned(); } ); it( 'should behave as a side effect for the given selector, with arguments', () => { const resolver = jest.fn(); - - registry.registerReducer( 'demo', ( state = 'OK' ) => state ); - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registry.registerResolvers( 'demo', { - getValue: resolver, + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + resolvers: { + getValue: resolver, + }, } ); const value = registry.select( 'demo' ).getValue( 'arg1', 'arg2' ); @@ -234,13 +225,14 @@ describe( 'createRegistry', () => { it( 'should support the object resolver definition', () => { const resolver = jest.fn(); - - registry.registerReducer( 'demo', ( state = 'OK' ) => state ); - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registry.registerResolvers( 'demo', { - getValue: { fulfill: resolver }, + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => state, + selectors: { + getValue: ( state ) => state, + }, + resolvers: { + getValue: { fulfill: resolver }, + }, } ); const value = registry.select( 'demo' ).getValue( 'arg1', 'arg2' ); @@ -252,32 +244,32 @@ describe( 'createRegistry', () => { return { type: 'SET_PAGE', page, result: [] }; } ); - const store = registry.registerReducer( 'demo', ( state = {}, action ) => { - switch ( action.type ) { - case 'SET_PAGE': - return { - ...state, - [ action.page ]: action.result, - }; - } - - return state; - } ); - - store.dispatch( { type: 'SET_PAGE', page: 4, result: [] } ); + const store = registry.registerStore( 'demo', { + reducer: ( state = {}, action ) => { + switch ( action.type ) { + case 'SET_PAGE': + return { + ...state, + [ action.page ]: action.result, + }; + } - registry.registerSelectors( 'demo', { - getPage: ( state, page ) => state[ page ], - } ); - registry.registerResolvers( 'demo', { - getPage: { - fulfill, - isFulfilled( state, page ) { - return state.hasOwnProperty( page ); + return state; + }, + selectors: { + getPage: ( state, page ) => state[ page ], + }, + resolvers: { + getPage: { + fulfill, + isFulfilled( state, page ) { + return state.hasOwnProperty( page ); + }, }, }, } ); + store.dispatch( { type: 'SET_PAGE', page: 4, result: [] } ); registry.select( 'demo' ).getPage( 1 ); registry.select( 'demo' ).getPage( 2 ); @@ -307,14 +299,16 @@ describe( 'createRegistry', () => { } ); it( 'should resolve action to dispatch', () => { - registry.registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registry.registerResolvers( 'demo', { - getValue: () => ( { type: 'SET_OK' } ), + registry.registerStore( 'demo', { + reducer: ( state = 'NOTOK', action ) => { + return action.type === 'SET_OK' ? 'OK' : state; + }, + selectors: { + getValue: ( state ) => state, + }, + resolvers: { + getValue: () => ( { type: 'SET_OK' } ), + }, } ); const promise = subscribeUntil( [ @@ -328,14 +322,16 @@ describe( 'createRegistry', () => { } ); it( 'should resolve promise action to dispatch', () => { - registry.registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' ? 'OK' : state; - } ); - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registry.registerResolvers( 'demo', { - getValue: () => Promise.resolve( { type: 'SET_OK' } ), + registry.registerStore( 'demo', { + reducer: ( state = 'NOTOK', action ) => { + return action.type === 'SET_OK' ? 'OK' : state; + }, + selectors: { + getValue: ( state ) => state, + }, + resolvers: { + getValue: () => Promise.resolve( { type: 'SET_OK' } ), + }, } ); const promise = subscribeUntil( [ @@ -350,20 +346,22 @@ describe( 'createRegistry', () => { it( 'should resolve promise non-action to dispatch', ( done ) => { let shouldThrow = false; - registry.registerReducer( 'demo', ( state = 'OK' ) => { - if ( shouldThrow ) { - throw 'Should not have dispatched'; - } + registry.registerStore( 'demo', { + reducer: ( state = 'OK' ) => { + if ( shouldThrow ) { + throw 'Should not have dispatched'; + } - return state; + return state; + }, + selectors: { + getValue: ( state ) => state, + }, + resolvers: { + getValue: () => Promise.resolve(), + }, } ); shouldThrow = true; - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registry.registerResolvers( 'demo', { - getValue: () => Promise.resolve(), - } ); registry.select( 'demo' ).getValue(); @@ -373,14 +371,16 @@ describe( 'createRegistry', () => { } ); it( 'should not dispatch resolved promise action on subsequent selector calls', () => { - registry.registerReducer( 'demo', ( state = 'NOTOK', action ) => { - return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK'; - } ); - registry.registerSelectors( 'demo', { - getValue: ( state ) => state, - } ); - registry.registerResolvers( 'demo', { - getValue: () => Promise.resolve( { type: 'SET_OK' } ), + registry.registerStore( 'demo', { + reducer: ( state = 'NOTOK', action ) => { + return action.type === 'SET_OK' && state === 'NOTOK' ? 'OK' : 'NOTOK'; + }, + selectors: { + getValue: ( state ) => state, + }, + resolvers: { + getValue: () => Promise.resolve( { type: 'SET_OK' } ), + }, } ); const promise = subscribeUntil( () => registry.select( 'demo' ).getValue() === 'OK' ); @@ -425,13 +425,14 @@ describe( 'createRegistry', () => { describe( 'select', () => { it( 'registers multiple selectors to the public API', () => { - const store = registry.registerReducer( 'reducer1', () => 'state1' ); const selector1 = jest.fn( () => 'result1' ); const selector2 = jest.fn( () => 'result2' ); - - registry.registerSelectors( 'reducer1', { - selector1, - selector2, + const store = registry.registerStore( 'reducer1', { + reducer: () => 'state1', + selectors: { + selector1, + selector2, + }, } ); expect( registry.select( 'reducer1' ).selector1() ).toEqual( 'result1' ); @@ -445,9 +446,11 @@ describe( 'createRegistry', () => { describe( 'subscribe', () => { it( 'registers multiple selectors to the public API', () => { let incrementedValue = null; - const store = registry.registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 ); - registry.registerSelectors( 'myAwesomeReducer', { - globalSelector: ( state ) => state, + const store = registry.registerStore( 'myAwesomeReducer', { + reducer: ( state = 0 ) => state + 1, + selectors: { + globalSelector: ( state ) => state, + }, } ); const unsubscribe = registry.subscribe( () => { incrementedValue = registry.select( 'myAwesomeReducer' ).globalSelector(); @@ -469,7 +472,9 @@ describe( 'createRegistry', () => { } ); it( 'snapshots listeners on change, avoiding a later listener if subscribed during earlier callback', () => { - const store = registry.registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 ); + const store = registry.registerStore( 'myAwesomeReducer', { + reducer: ( state = 0 ) => state + 1, + } ); const secondListener = jest.fn(); const firstListener = jest.fn( () => { subscribeWithUnsubscribe( secondListener ); @@ -483,7 +488,9 @@ describe( 'createRegistry', () => { } ); it( 'snapshots listeners on change, calling a later listener even if unsubscribed during earlier callback', () => { - const store = registry.registerReducer( 'myAwesomeReducer', ( state = 0 ) => state + 1 ); + const store = registry.registerStore( 'myAwesomeReducer', { + reducer: ( state = 0 ) => state + 1, + } ); const firstListener = jest.fn( () => { secondUnsubscribe(); } ); @@ -498,7 +505,9 @@ describe( 'createRegistry', () => { } ); it( 'does not call listeners if state has not changed', () => { - const store = registry.registerReducer( 'unchanging', ( state = {} ) => state ); + const store = registry.registerStore( 'unchanging', { + reducer: ( state = {} ) => state, + } ); const listener = jest.fn(); subscribeWithUnsubscribe( listener ); @@ -510,23 +519,22 @@ describe( 'createRegistry', () => { describe( 'dispatch', () => { it( 'registers actions to the public API', () => { - const store = registry.registerReducer( 'counter', ( state = 0, action ) => { - if ( action.type === 'increment' ) { - return state + action.count; - } - return state; - } ); const increment = ( count = 1 ) => ( { type: 'increment', count } ); - registry.registerActions( 'counter', { - increment, + const store = registry.registerStore( 'counter', { + reducer: ( state = 0, action ) => { + if ( action.type === 'increment' ) { + return state + action.count; + } + return state; + }, + actions: { + increment, + }, } ); registry.dispatch( 'counter' ).increment(); // state = 1 registry.dispatch( 'counter' ).increment( 4 ); // state = 5 expect( store.getState() ).toBe( 5 ); - - // This uses deprecated functions and will produce a warning. - expect( console ).toHaveWarned(); } ); } ); diff --git a/packages/date/CHANGELOG.md b/packages/date/CHANGELOG.md index ff9266b3285bd2..e6aa964c70518d 100644 --- a/packages/date/CHANGELOG.md +++ b/packages/date/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- `getSettings` has been removed. Please use `__experimentalGetSettings` instead. +- `moment` has been removed from the public API for the date module. + ## 2.2.1 (2018-11-09) ## 2.2.0 (2018-11-09) diff --git a/packages/date/package.json b/packages/date/package.json index d408bc1fd0f0d4..847cdf2762ad44 100644 --- a/packages/date/package.json +++ b/packages/date/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/date", - "version": "2.2.1", + "version": "3.0.0", "description": "Date module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/date/src/index.js b/packages/date/src/index.js index 8580f97062f27a..4bd87396a58aad 100644 --- a/packages/date/src/index.js +++ b/packages/date/src/index.js @@ -5,11 +5,6 @@ import momentLib from 'moment'; import 'moment-timezone'; import 'moment-timezone/moment-timezone-utils'; -/** - * WordPress dependencies - */ -import deprecated from '@wordpress/deprecated'; - // Changes made here will likely need to be made in `lib/client-assets.php` as // well because it uses the `setSettings()` function to change these settings. let settings = { @@ -94,17 +89,6 @@ export function __experimentalGetSettings() { return settings; } -// deprecations -export function getSettings() { - deprecated( 'wp.date.getSettings', { - version: '4.4', - alternative: 'wp.date.__experimentalGetSettings', - plugin: 'Gutenberg', - hint: 'Unstable APIs are strongly discouraged to be used, and are subject to removal without notice.', - } ); - return settings; -} - function setupWPTimezone() { // Create WP timezone based off dateSettings. momentLib.tz.add( momentLib.tz.pack( { @@ -115,19 +99,6 @@ function setupWPTimezone() { } ) ); } -// Create a new moment object which mirrors moment but includes -// the attached timezone, instead of setting a default timezone on -// the global moment object. -export const moment = ( ...args ) => { - deprecated( 'wp.date.moment', { - version: '4.4', - alternative: 'the moment script as a dependency', - plugin: 'Gutenberg', - } ); - - return momentLib.tz( ...args, 'WP' ); -}; - // Date constants. /** * Number of seconds in one minute. diff --git a/packages/dom/src/dom.js b/packages/dom/src/dom.js index 9f65e47559ac33..c718cdbebe1907 100644 --- a/packages/dom/src/dom.js +++ b/packages/dom/src/dom.js @@ -497,11 +497,25 @@ export function isEntirelySelected( element ) { const { startContainer, endContainer, startOffset, endOffset } = range; - return ( + if ( startContainer === element && endContainer === element && startOffset === 0 && endOffset === element.childNodes.length + ) { + return true; + } + + const lastChild = element.lastChild; + const lastChildContentLength = lastChild.nodeType === TEXT_NODE ? + lastChild.data.length : + lastChild.childNodes.length; + + return ( + startContainer === element.firstChild && + endContainer === element.lastChild && + startOffset === 0 && + endOffset === lastChildContentLength ); } diff --git a/packages/edit-post/CHANGELOG.md b/packages/edit-post/CHANGELOG.md index f3fcee5b7aaef2..4eeda8d66d7502 100644 --- a/packages/edit-post/CHANGELOG.md +++ b/packages/edit-post/CHANGELOG.md @@ -1,6 +1,16 @@ -## 3.0.0 (Unreleased) +## 3.1.0 (Unreleased) -### Breaking Changes +### New Feature + +- The new `AdminNotices` component will transparently upgrade any `.notice` elements on the page to the equivalent `@wordpress/notices` module notice state. + +## 3.0.2 (2018-11-15) + +## 3.0.1 (2018-11-12) + +## 3.0.0 (2018-11-12) + +### Breaking Change - `isEditorSidebarPanelOpened` selector (`core/edit-post`) has been removed. Please use `isEditorPanelEnabled` instead. - `toggleGeneralSidebarEditorPanel` action (`core/edit-post`) has been removed. Please use `toggleEditorPanelOpened` instead. diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index b86e62bc153f7c..05132ff4e3974a 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-post", - "version": "2.1.1", + "version": "3.0.2", "description": "Edit Post module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-post/src/components/admin-notices/index.js b/packages/edit-post/src/components/admin-notices/index.js new file mode 100644 index 00000000000000..af04efaa9953a3 --- /dev/null +++ b/packages/edit-post/src/components/admin-notices/index.js @@ -0,0 +1,105 @@ +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; +import { withDispatch } from '@wordpress/data'; + +/** + * Mapping of server-supported notice class names to an equivalent notices + * module status. + * + * @type {Map} + */ +const NOTICE_CLASS_STATUSES = { + 'notice-success': 'success', + updated: 'success', + 'notice-warning': 'warning', + 'notice-error': 'error', + error: 'error', + 'notice-info': 'info', +}; + +/** + * Returns an array of admin notice Elements. + * + * @return {Element[]} Admin notice elements. + */ +function getAdminNotices() { + // The order is reversed to match expectations of rendered order, since a + // NoticesList is itself rendered in reverse order (newest to oldest). + return [ ...document.querySelectorAll( '#wpbody-content > .notice' ) ].reverse(); +} + +/** + * Given an admin notice Element, returns the relevant notice content HTML. + * + * @param {Element} element Admin notice element. + * + * @return {Element} Upgraded notice HTML. + */ +function getNoticeHTML( element ) { + const fragments = []; + + for ( const child of element.childNodes ) { + if ( child.nodeType !== window.Node.ELEMENT_NODE ) { + const value = child.nodeValue.trim(); + if ( value ) { + fragments.push( child.nodeValue ); + } + } else if ( ! child.classList.contains( 'notice-dismiss' ) ) { + fragments.push( child.outerHTML ); + } + } + + return fragments.join( '' ); +} + +/** + * Given an admin notice Element, returns the upgraded status type, or + * undefined if one cannot be determined (i.e. one is not assigned). + * + * @param {Element} element Admin notice element. + * + * @return {?string} Upgraded status type. + */ +function getNoticeStatus( element ) { + for ( const className of element.classList ) { + if ( NOTICE_CLASS_STATUSES.hasOwnProperty( className ) ) { + return NOTICE_CLASS_STATUSES[ className ]; + } + } +} + +export class AdminNotices extends Component { + componentDidMount() { + this.convertNotices(); + } + + convertNotices() { + const { createNotice } = this.props; + getAdminNotices().forEach( ( element ) => { + // Convert and create. + const status = getNoticeStatus( element ); + const content = getNoticeHTML( element ); + const isDismissible = element.classList.contains( 'is-dismissible' ); + createNotice( status, content, { + speak: false, + __unstableHTML: true, + isDismissible, + } ); + + // Remove (now-redundant) admin notice element. + element.parentNode.removeChild( element ); + } ); + } + + render() { + return null; + } +} + +export default withDispatch( ( dispatch ) => { + const { createNotice } = dispatch( 'core/notices' ); + + return { createNotice }; +} )( AdminNotices ); diff --git a/packages/edit-post/src/components/admin-notices/test/index.js b/packages/edit-post/src/components/admin-notices/test/index.js new file mode 100644 index 00000000000000..1dcda9a00a2755 --- /dev/null +++ b/packages/edit-post/src/components/admin-notices/test/index.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import renderer from 'react-test-renderer'; + +/** + * Internal dependencies + */ +import { AdminNotices } from '../'; + +describe( 'AdminNotices', () => { + beforeEach( () => { + // The superfluous whitespace is intentional in verifying expected + // outputs of (a) non-element first child of the element (whitespace + // text node) and (b) untrimmed content. + document.body.innerHTML = ` + <div id="wpbody-content"> + <div class="notice updated is-dismissible"> + <p>My <strong>notice</strong> text</p> + <p>My second line of text</p> + <button type="button" class="notice-dismiss"> + <span class="screen-reader-text">Dismiss this notice.</span> + </button> + </div> + <div class="notice notice-warning">Warning</div> + <aside class="elsewhere"> + <div class="notice">Ignore me</div> + </aside> + </div> + `; + } ); + + it( 'should upgrade notices', () => { + const createNotice = jest.fn(); + + renderer.create( <AdminNotices createNotice={ createNotice } /> ); + + expect( createNotice ).toHaveBeenCalledTimes( 2 ); + expect( createNotice.mock.calls[ 0 ] ).toEqual( [ + 'warning', + 'Warning', + { + speak: false, + __unstableHTML: true, + isDismissible: false, + }, + ] ); + expect( createNotice.mock.calls[ 1 ] ).toEqual( [ + 'success', + '<p>My <strong>notice</strong> text</p><p>My second line of text</p>', + { + speak: false, + __unstableHTML: true, + isDismissible: true, + }, + ] ); + + // Verify all but `<aside>` are removed. + expect( document.getElementById( 'wpbody-content' ).childElementCount ).toBe( 1 ); + } ); +} ); diff --git a/packages/edit-post/src/components/fullscreen-mode/index.js b/packages/edit-post/src/components/fullscreen-mode/index.js index 97f1629e46af28..481dfb388e18f6 100644 --- a/packages/edit-post/src/components/fullscreen-mode/index.js +++ b/packages/edit-post/src/components/fullscreen-mode/index.js @@ -4,9 +4,25 @@ import { Component } from '@wordpress/element'; import { withSelect } from '@wordpress/data'; -class FullscreenMode extends Component { +export class FullscreenMode extends Component { componentDidMount() { + this.isSticky = false; this.sync(); + + // `is-fullscreen-mode` is set in PHP as a body class by Gutenberg, and this causes + // `sticky-menu` to be applied by WordPress and prevents the admin menu being scrolled + // even if `is-fullscreen-mode` is then removed. Let's remove `sticky-menu` here as + // a consequence of the FullscreenMode setup + if ( document.body.classList.contains( 'sticky-menu' ) ) { + this.isSticky = true; + document.body.classList.remove( 'sticky-menu' ); + } + } + + componentWillUnmount() { + if ( this.isSticky ) { + document.body.classList.add( 'sticky-menu' ); + } } componentDidUpdate( prevProps ) { diff --git a/packages/edit-post/src/components/fullscreen-mode/style.scss b/packages/edit-post/src/components/fullscreen-mode/style.scss index 4afd36c4b8eb6f..8e5b6e2218561d 100644 --- a/packages/edit-post/src/components/fullscreen-mode/style.scss +++ b/packages/edit-post/src/components/fullscreen-mode/style.scss @@ -1,4 +1,4 @@ -body.is-fullscreen-mode { +body.js.is-fullscreen-mode { // Reset the html.wp-topbar padding // Because this uses negative margins, we have to compensate for the height. margin-top: -$admin-bar-height-big; @@ -19,9 +19,16 @@ body.is-fullscreen-mode { } // Animations - @include fade_in(0.3s); + @include edit-post__fade-in-animation(0.3s); .edit-post-header { - @include slide_in_top(); + transform: translateY(-100%); + animation: edit-post-fullscreen-mode__slide-in-animation 0.1s forwards; + } +} + +@keyframes edit-post-fullscreen-mode__slide-in-animation { + 100% { + transform: translateY(0%); } } diff --git a/packages/edit-post/src/components/fullscreen-mode/test/index.js b/packages/edit-post/src/components/fullscreen-mode/test/index.js new file mode 100644 index 00000000000000..e61f5e1838ff2a --- /dev/null +++ b/packages/edit-post/src/components/fullscreen-mode/test/index.js @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies. + */ +import { FullscreenMode } from '..'; + +describe( 'FullscreenMode', () => { + it( 'fullscreen mode to be added to document body when active', () => { + shallow( <FullscreenMode isActive={ true } /> ); + + expect( document.body.classList.contains( 'is-fullscreen-mode' ) ).toBe( true ); + } ); + + it( 'fullscreen mode not to be added to document body when active', () => { + shallow( <FullscreenMode isActive={ false } /> ); + + expect( document.body.classList.contains( 'is-fullscreen-mode' ) ).toBe( false ); + } ); + + it( 'sticky-menu to be removed from the body class if present', () => { + document.body.classList.add( 'sticky-menu' ); + + shallow( <FullscreenMode isActive={ false } /> ); + + expect( document.body.classList.contains( 'sticky-menu' ) ).toBe( false ); + } ); + + it( 'sticky-menu to be restored when component unmounted and originally present', () => { + document.body.classList.add( 'sticky-menu' ); + + const mode = shallow( <FullscreenMode isActive={ false } /> ); + mode.unmount(); + + expect( document.body.classList.contains( 'sticky-menu' ) ).toBe( true ); + } ); +} ); diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index 320b5c3cd51e82..e5e92af1f2a645 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -22,7 +22,11 @@ import { import FullscreenModeClose from '../fullscreen-mode-close'; function HeaderToolbar( { hasFixedToolbar, isLargeViewport, mode } ) { - const toolbarAriaLabel = hasFixedToolbar ? __( 'Document and block tools' ) : __( 'Document tools' ); + const toolbarAriaLabel = hasFixedToolbar ? + /* translators: accessibility text for the editor toolbar when Top Toolbar is on */ + __( 'Document and block tools' ) : + /* translators: accessibility text for the editor toolbar when Top Toolbar is off */ + __( 'Document tools' ); return ( <NavigableToolbar diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index c63a7058eeb7cc..6fb3ec96eab749 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -51,7 +51,10 @@ function Header( { forceIsSaving={ isSaving } /> ) } - <PostPreviewButton /> + <PostPreviewButton + forceIsAutosaveable={ hasActiveMetaboxes } + forcePreviewLink={ isSaving ? null : undefined } + /> <PostPublishButtonOrToggle forceIsDirty={ hasActiveMetaboxes } forceIsSaving={ isSaving } diff --git a/packages/edit-post/src/components/header/writing-menu/index.js b/packages/edit-post/src/components/header/writing-menu/index.js index 3b2ff3e47f5f31..e7b9c3dc791ade 100644 --- a/packages/edit-post/src/components/header/writing-menu/index.js +++ b/packages/edit-post/src/components/header/writing-menu/index.js @@ -17,7 +17,7 @@ function WritingMenu( { onClose } ) { > <FeatureToggle feature="fixedToolbar" - label={ __( 'Unified Toolbar' ) } + label={ __( 'Top Toolbar' ) } info={ __( 'Access all block and document tools in a single place' ) } onToggle={ onClose } /> <FeatureToggle diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss b/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss index 46ceb34c2b3b04..9bb49eee5c1890 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/style.scss @@ -1,7 +1,7 @@ .edit-post-keyboard-shortcut-help { &__title { font-size: 1rem; - font-weight: bold; + font-weight: 600; } &__section { @@ -11,7 +11,7 @@ &__section-title { font-size: 0.9rem; - font-weight: bold; + font-weight: 600; } &__shortcut { @@ -27,7 +27,7 @@ &__shortcut-term { order: 1; - font-weight: bold; + font-weight: 600; margin: 0 0 0 1rem; } diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index aa43ceaba75d0a..ed5773e5182f5b 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -37,6 +37,7 @@ import Sidebar from '../sidebar'; import PluginPostPublishPanel from '../sidebar/plugin-post-publish-panel'; import PluginPrePublishPanel from '../sidebar/plugin-pre-publish-panel'; import FullscreenMode from '../fullscreen-mode'; +import AdminNotices from '../admin-notices'; function Layout( { mode, @@ -69,6 +70,7 @@ function Layout( { <BrowserURL /> <UnsavedChangesWarning /> <AutosaveMonitor /> + <AdminNotices /> <Header /> <div className="edit-post-layout__content" diff --git a/packages/edit-post/src/components/layout/style.scss b/packages/edit-post/src/components/layout/style.scss index ae925dbdd181e0..0cd4a03af0dfeb 100644 --- a/packages/edit-post/src/components/layout/style.scss +++ b/packages/edit-post/src/components/layout/style.scss @@ -128,7 +128,8 @@ left: auto; width: $sidebar-width; border-left: $border-width solid $light-gray-500; - @include slide_in_right; + transform: translateX(+100%); + animation: edit-post-layout__slide-in-animation 0.1s forwards; body.is-fullscreen-mode & { top: 0; @@ -136,6 +137,12 @@ } } +@keyframes edit-post-layout__slide-in-animation { + 100% { + transform: translateX(0%); + } +} + .edit-post-layout .editor-post-publish-panel__header-publish-button { // Match the size of the Publish... button. .components-button.is-large { diff --git a/packages/edit-post/src/components/options-modal/options/enable-panel.js b/packages/edit-post/src/components/options-modal/options/enable-panel.js index d21f1051cb039f..d47ab67e7e6d92 100644 --- a/packages/edit-post/src/components/options-modal/options/enable-panel.js +++ b/packages/edit-post/src/components/options-modal/options/enable-panel.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; +import { compose, ifCondition } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; /** @@ -10,9 +10,14 @@ import { withSelect, withDispatch } from '@wordpress/data'; import BaseOption from './base'; export default compose( - withSelect( ( select, { panelName } ) => ( { - isChecked: select( 'core/edit-post' ).isEditorPanelEnabled( panelName ), - } ) ), + withSelect( ( select, { panelName } ) => { + const { isEditorPanelEnabled, isEditorPanelRemoved } = select( 'core/edit-post' ); + return { + isRemoved: isEditorPanelRemoved( panelName ), + isChecked: isEditorPanelEnabled( panelName ), + }; + } ), + ifCondition( ( { isRemoved } ) => ! isRemoved ), withDispatch( ( dispatch, { panelName } ) => ( { onChange: () => dispatch( 'core/edit-post' ).toggleEditorPanelEnabled( panelName ), } ) ) diff --git a/packages/edit-post/src/components/options-modal/style.scss b/packages/edit-post/src/components/options-modal/style.scss index f5734a5a69c9dd..28916c0be35e59 100644 --- a/packages/edit-post/src/components/options-modal/style.scss +++ b/packages/edit-post/src/components/options-modal/style.scss @@ -1,7 +1,7 @@ .edit-post-options-modal { &__title { font-size: 1rem; - font-weight: bold; + font-weight: 600; } &__section { @@ -10,7 +10,7 @@ &__section-title { font-size: 0.9rem; - font-weight: bold; + font-weight: 600; } &__option { diff --git a/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap index 67df8a5bd60893..f5764f8ab4accf 100644 --- a/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap @@ -28,22 +28,22 @@ exports[`OptionsModal should match snapshot when the modal is active 1`] = ` <WithSelect(PostTaxonomies) taxonomyWrapper={[Function]} /> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) label="Featured Image" panelName="featured-image" /> <PostExcerptCheck> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) label="Excerpt" panelName="post-excerpt" /> </PostExcerptCheck> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) label="Discussion" panelName="discussion-panel" /> <WithSelect(PageAttributesCheck)> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) label="Page Attributes" panelName="page-attributes" /> diff --git a/packages/edit-post/src/components/options-modal/test/__snapshots__/meta-boxes-section.js.snap b/packages/edit-post/src/components/options-modal/test/__snapshots__/meta-boxes-section.js.snap index 42c22d90307184..b30ba56cfdb8f2 100644 --- a/packages/edit-post/src/components/options-modal/test/__snapshots__/meta-boxes-section.js.snap +++ b/packages/edit-post/src/components/options-modal/test/__snapshots__/meta-boxes-section.js.snap @@ -17,12 +17,12 @@ exports[`MetaBoxesSection renders a Custom Fields option and meta box options 1` <WithSelect(EnableCustomFieldsOption) label="Custom Fields" /> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) key="test1" label="Meta Box 1" panelName="meta-box-test1" /> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) key="test2" label="Meta Box 2" panelName="meta-box-test2" @@ -34,12 +34,12 @@ exports[`MetaBoxesSection renders meta box options 1`] = ` <Section title="Advanced Panels" > - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) key="test1" label="Meta Box 1" panelName="meta-box-test1" /> - <WithSelect(WithDispatch(BaseOption)) + <WithSelect(IfCondition(WithDispatch(BaseOption))) key="test2" label="Meta Box 2" panelName="meta-box-test2" diff --git a/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/test/__snapshots__/index.js.snap index d5bb5b82a90935..79459ca118d2f6 100644 --- a/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/sidebar/plugin-post-publish-panel/test/__snapshots__/index.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PluginPostPublishPanel renders fill properly 1`] = `"<div class=\\"components-panel__body my-plugin-post-publish-panel is-opened\\"><h2 class=\\"components-panel__body-title\\"><button type=\\"button\\" aria-expanded=\\"true\\" class=\\"components-button components-panel__body-toggle\\"><svg class=\\"components-panel__arrow\\" width=\\"24px\\" height=\\"24px\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" role=\\"img\\" aria-hidden=\\"true\\" focusable=\\"false\\"><g><path fill=\\"none\\" d=\\"M0,0h24v24H0V0z\\"></path></g><g><path d=\\"M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z\\"></path></g></svg>My panel title</button></h2>My panel content</div>"`; +exports[`PluginPostPublishPanel renders fill properly 1`] = `"<div class=\\"components-panel__body my-plugin-post-publish-panel is-opened\\"><h2 class=\\"components-panel__body-title\\"><button type=\\"button\\" aria-expanded=\\"true\\" class=\\"components-button components-panel__body-toggle\\"><span aria-hidden=\\"true\\"><svg class=\\"components-panel__arrow\\" width=\\"24px\\" height=\\"24px\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" role=\\"img\\" aria-hidden=\\"true\\" focusable=\\"false\\"><g><path fill=\\"none\\" d=\\"M0,0h24v24H0V0z\\"></path></g><g><path d=\\"M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z\\"></path></g></svg></span>My panel title</button></h2>My panel content</div>"`; diff --git a/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/test/__snapshots__/index.js.snap index 0321e1b11b72ed..df197d90cc84c8 100644 --- a/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/sidebar/plugin-pre-publish-panel/test/__snapshots__/index.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PluginPrePublishPanel renders fill properly 1`] = `"<div class=\\"components-panel__body my-plugin-pre-publish-panel is-opened\\"><h2 class=\\"components-panel__body-title\\"><button type=\\"button\\" aria-expanded=\\"true\\" class=\\"components-button components-panel__body-toggle\\"><svg class=\\"components-panel__arrow\\" width=\\"24px\\" height=\\"24px\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" role=\\"img\\" aria-hidden=\\"true\\" focusable=\\"false\\"><g><path fill=\\"none\\" d=\\"M0,0h24v24H0V0z\\"></path></g><g><path d=\\"M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z\\"></path></g></svg>My panel title</button></h2>My panel content</div>"`; +exports[`PluginPrePublishPanel renders fill properly 1`] = `"<div class=\\"components-panel__body my-plugin-pre-publish-panel is-opened\\"><h2 class=\\"components-panel__body-title\\"><button type=\\"button\\" aria-expanded=\\"true\\" class=\\"components-button components-panel__body-toggle\\"><span aria-hidden=\\"true\\"><svg class=\\"components-panel__arrow\\" width=\\"24px\\" height=\\"24px\\" viewBox=\\"0 0 24 24\\" xmlns=\\"http://www.w3.org/2000/svg\\" role=\\"img\\" aria-hidden=\\"true\\" focusable=\\"false\\"><g><path fill=\\"none\\" d=\\"M0,0h24v24H0V0z\\"></path></g><g><path d=\\"M12,8l-6,6l1.41,1.41L12,10.83l4.59,4.58L18,14L12,8z\\"></path></g></svg></span>My panel title</button></h2>My panel content</div>"`; diff --git a/packages/edit-post/src/components/sidebar/post-link/index.js b/packages/edit-post/src/components/sidebar/post-link/index.js new file mode 100644 index 00000000000000..64b694a21ab8b5 --- /dev/null +++ b/packages/edit-post/src/components/sidebar/post-link/index.js @@ -0,0 +1,157 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { PanelBody, TextControl, ExternalLink } from '@wordpress/components'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose, ifCondition, withState } from '@wordpress/compose'; +import { cleanForSlug } from '@wordpress/editor'; + +/** + * Module Constants + */ +const PANEL_NAME = 'post-link'; + +function PostLink( { + isOpened, + onTogglePanel, + isEditable, + postLink, + permalinkParts, + editPermalink, + forceEmptyField, + setState, + postTitle, + postSlug, + postID, +} ) { + const { prefix, suffix } = permalinkParts; + let prefixElement, postNameElement, suffixElement; + const currentSlug = postSlug || cleanForSlug( postTitle ) || postID; + if ( isEditable ) { + prefixElement = prefix && ( + <span className="edit-post-post-link__link-prefix">{ prefix }</span> + ); + postNameElement = currentSlug && ( + <span className="edit-post-post-link__link-post-name">{ currentSlug }</span> + ); + suffixElement = suffix && ( + <span className="edit-post-post-link__link-suffix">{ suffix }</span> + ); + } + + return ( + <PanelBody + title={ __( 'Permalink' ) } + opened={ isOpened } + onToggle={ onTogglePanel } + > + { isEditable && ( + <TextControl + label={ __( 'URL' ) } + value={ forceEmptyField ? '' : currentSlug } + onChange={ ( newValue ) => { + editPermalink( newValue ); + // When we delete the field the permalink gets + // reverted to the original value. + // The forceEmptyField logic allows the user to have + // the field temporarily empty while typing. + if ( ! newValue ) { + if ( ! forceEmptyField ) { + setState( { + forceEmptyField: true, + } ); + } + return; + } + if ( forceEmptyField ) { + setState( { + forceEmptyField: false, + } ); + } + } } + onBlur={ ( event ) => { + editPermalink( cleanForSlug( event.target.value ) ); + if ( forceEmptyField ) { + setState( { + forceEmptyField: false, + } ); + } + } } + /> + ) } + <p className="edit-post-post-link__preview-label"> + { __( 'Preview' ) } + </p> + <ExternalLink + className="edit-post-post-link__link" + href={ postLink } + target="_blank" + > + { isEditable ? + ( <Fragment> + { prefixElement }{ postNameElement }{ suffixElement } + </Fragment> ) : + postLink + } + </ExternalLink> + </PanelBody> + ); +} + +export default compose( [ + withSelect( ( select ) => { + const { + isEditedPostNew, + isPermalinkEditable, + getCurrentPost, + isCurrentPostPublished, + getPermalinkParts, + getEditedPostAttribute, + } = select( 'core/editor' ); + const { + isEditorPanelOpened, + } = select( 'core/edit-post' ); + const { + getPostType, + } = select( 'core' ); + + const { link, id } = getCurrentPost(); + const postTypeName = getEditedPostAttribute( 'type' ); + const postType = getPostType( postTypeName ); + return { + isNew: isEditedPostNew(), + postLink: link, + isEditable: isPermalinkEditable(), + isPublished: isCurrentPostPublished(), + isOpened: isEditorPanelOpened( PANEL_NAME ), + permalinkParts: getPermalinkParts(), + isViewable: get( postType, [ 'viewable' ], false ), + postTitle: getEditedPostAttribute( 'title' ), + postSlug: getEditedPostAttribute( 'slug' ), + postID: id, + }; + } ), + ifCondition( ( { isNew, postLink, isViewable } ) => { + return ! isNew && postLink && isViewable; + } ), + withDispatch( ( dispatch ) => { + const { toggleEditorPanelOpened } = dispatch( 'core/edit-post' ); + const { editPost } = dispatch( 'core/editor' ); + return { + onTogglePanel: () => toggleEditorPanelOpened( PANEL_NAME ), + editPermalink: ( newSlug ) => { + editPost( { slug: newSlug } ); + }, + }; + } ), + withState( { + forceEmptyField: false, + } ), +] )( PostLink ); diff --git a/packages/edit-post/src/components/sidebar/post-link/style.scss b/packages/edit-post/src/components/sidebar/post-link/style.scss new file mode 100644 index 00000000000000..a20084e63ab241 --- /dev/null +++ b/packages/edit-post/src/components/sidebar/post-link/style.scss @@ -0,0 +1,11 @@ +.edit-post-post-link__link-post-name { + font-weight: 600; +} + +.edit-post-post-link__preview-label { + margin: 0; +} + +.edit-post-post-link__link { + word-wrap: break-word; +} diff --git a/packages/edit-post/src/components/sidebar/settings-header/index.js b/packages/edit-post/src/components/sidebar/settings-header/index.js index 35d731881b5673..33a3bbc27e29ab 100644 --- a/packages/edit-post/src/components/sidebar/settings-header/index.js +++ b/packages/edit-post/src/components/sidebar/settings-header/index.js @@ -1,22 +1,16 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; -import { __, _n, sprintf } from '@wordpress/i18n'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { withDispatch } from '@wordpress/data'; /** * Internal dependencies */ import SidebarHeader from '../sidebar-header'; -const SettingsHeader = ( { count, openDocumentSettings, openBlockSettings, sidebarName } ) => { - // Do not display "0 Blocks". - const blockCount = count === 0 ? 1 : count; - const blockLabel = blockCount === 1 ? - __( 'Block' ) : - sprintf( _n( '%d Block', '%d Blocks', blockCount ), blockCount ); - +const SettingsHeader = ( { openDocumentSettings, openBlockSettings, sidebarName } ) => { + const blockLabel = __( 'Block' ); const [ documentAriaLabel, documentActiveClass ] = sidebarName === 'edit-post/document' ? // translators: ARIA label for the Document Settings sidebar tab, selected. [ __( 'Document settings (selected)' ), 'is-active' ] : @@ -61,21 +55,16 @@ const SettingsHeader = ( { count, openDocumentSettings, openBlockSettings, sideb ); }; -export default compose( - withSelect( ( select ) => ( { - count: select( 'core/editor' ).getSelectedBlockCount(), - } ) ), - withDispatch( ( dispatch ) => { - const { openGeneralSidebar } = dispatch( 'core/edit-post' ); - const { clearSelectedBlock } = dispatch( 'core/editor' ); - return { - openDocumentSettings() { - openGeneralSidebar( 'edit-post/document' ); - clearSelectedBlock(); - }, - openBlockSettings() { - openGeneralSidebar( 'edit-post/block' ); - }, - }; - } ), -)( SettingsHeader ); +export default withDispatch( ( dispatch ) => { + const { openGeneralSidebar } = dispatch( 'core/edit-post' ); + const { clearSelectedBlock } = dispatch( 'core/editor' ); + return { + openDocumentSettings() { + openGeneralSidebar( 'edit-post/document' ); + clearSelectedBlock(); + }, + openBlockSettings() { + openGeneralSidebar( 'edit-post/block' ); + }, + }; +} )( SettingsHeader ); diff --git a/packages/edit-post/src/components/sidebar/settings-sidebar/index.js b/packages/edit-post/src/components/sidebar/settings-sidebar/index.js index c817b39f7604a5..3d4fa8dfb0ced9 100644 --- a/packages/edit-post/src/components/sidebar/settings-sidebar/index.js +++ b/packages/edit-post/src/components/sidebar/settings-sidebar/index.js @@ -17,6 +17,7 @@ import LastRevision from '../last-revision'; import PostTaxonomies from '../post-taxonomies'; import FeaturedImage from '../featured-image'; import PostExcerpt from '../post-excerpt'; +import PostLink from '../post-link'; import DiscussionPanel from '../discussion-panel'; import PageAttributes from '../page-attributes'; import MetaBoxes from '../../meta-boxes'; @@ -32,6 +33,7 @@ const SettingsSidebar = ( { sidebarName } ) => ( <Fragment> <PostStatus /> <LastRevision /> + <PostLink /> <PostTaxonomies /> <FeaturedImage /> <PostExcerpt /> diff --git a/packages/edit-post/src/components/text-editor/style.scss b/packages/edit-post/src/components/text-editor/style.scss index d783b20d4ebe42..b04479300535f9 100644 --- a/packages/edit-post/src/components/text-editor/style.scss +++ b/packages/edit-post/src/components/text-editor/style.scss @@ -19,6 +19,7 @@ } .edit-post-text-editor { + width: 100%; margin-left: $grid-size-large; margin-right: $grid-size-large; diff --git a/packages/edit-post/src/components/visual-editor/style.scss b/packages/edit-post/src/components/visual-editor/style.scss index 0463c005479ef4..67128f843aca47 100644 --- a/packages/edit-post/src/components/visual-editor/style.scss +++ b/packages/edit-post/src/components/visual-editor/style.scss @@ -103,12 +103,6 @@ // WritingFlow click redirector. outline: 1px solid transparent; } - - @include break-small() { - .editor-default-block-appender__content { - padding: 0 $block-padding; - } - } } // Ensure that the height of the first appender, and the one between blocks, is the same as text. diff --git a/packages/edit-post/src/hooks/components/media-upload/index.js b/packages/edit-post/src/hooks/components/media-upload/index.js index b2eaa4d86fef5c..9160f4f84c15b5 100644 --- a/packages/edit-post/src/hooks/components/media-upload/index.js +++ b/packages/edit-post/src/hooks/components/media-upload/index.js @@ -67,7 +67,7 @@ const getAttachmentsCollection = ( ids ) => { order: 'ASC', orderby: 'post__in', post__in: ids, - per_page: 100, + posts_per_page: -1, query: true, type: 'image', } ); @@ -163,6 +163,8 @@ class MediaUpload extends Component { } onOpen() { + this.updateCollection(); + if ( ! this.props.value ) { return; } @@ -184,6 +186,22 @@ class MediaUpload extends Component { } } + updateCollection() { + const frameContent = this.frame.content.get(); + if ( frameContent && frameContent.collection ) { + const collection = frameContent.collection; + + // clean all attachments we have in memory. + collection.toArray().forEach( ( model ) => model.trigger( 'destroy', model ) ); + + // reset has more flag, if library had small amount of items all items may have been loaded before. + collection.mirroring._hasMore = true; + + // request items + collection.more(); + } + } + openModal() { this.frame.open(); } diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index 0832cbd141f697..26a2c71ea446c6 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -5,6 +5,7 @@ import '@wordpress/core-data'; import '@wordpress/editor'; import '@wordpress/nux'; import '@wordpress/viewport'; +import '@wordpress/notices'; import { registerCoreBlocks } from '@wordpress/block-library'; import { render, unmountComponentAtNode } from '@wordpress/element'; import { dispatch } from '@wordpress/data'; diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js index 37c03750c3b775..2e2af7a2295f45 100644 --- a/packages/edit-post/src/store/actions.js +++ b/packages/edit-post/src/store/actions.js @@ -111,6 +111,20 @@ export function toggleEditorPanelOpened( panelName ) { }; } +/** + * Returns an action object used to remove a panel from the editor. + * + * @param {string} panelName A string that identifies the panel to remove. + * + * @return {Object} Action object. + */ +export function removeEditorPanel( panelName ) { + return { + type: 'REMOVE_PANEL', + panelName, + }; +} + /** * Returns an action object used to toggle a feature flag. * diff --git a/packages/edit-post/src/store/effects.js b/packages/edit-post/src/store/effects.js index f0b6d39a107aa3..699b08f0636ed2 100644 --- a/packages/edit-post/src/store/effects.js +++ b/packages/edit-post/src/store/effects.js @@ -45,24 +45,26 @@ const effects = { let wasSavingPost = select( 'core/editor' ).isSavingPost(); let wasAutosavingPost = select( 'core/editor' ).isAutosavingPost(); + let wasPreviewingPost = select( 'core/editor' ).isPreviewingPost(); // Save metaboxes when performing a full save on the post. subscribe( () => { const isSavingPost = select( 'core/editor' ).isSavingPost(); const isAutosavingPost = select( 'core/editor' ).isAutosavingPost(); + const isPreviewingPost = select( 'core/editor' ).isPreviewingPost(); const hasActiveMetaBoxes = select( 'core/edit-post' ).hasMetaBoxes(); - // Save metaboxes on save completion when past save wasn't an autosave. + // Save metaboxes on save completion, except for autosaves that are not a post preview. const shouldTriggerMetaboxesSave = ( - hasActiveMetaBoxes && - wasSavingPost && - ! wasAutosavingPost && - ! isSavingPost && - ! isAutosavingPost + hasActiveMetaBoxes && ( + ( wasSavingPost && ! isSavingPost && ! wasAutosavingPost ) || + ( wasAutosavingPost && wasPreviewingPost && ! isPreviewingPost ) + ) ); // Save current state for next inspection. wasSavingPost = isSavingPost; wasAutosavingPost = isAutosavingPost; + wasPreviewingPost = isPreviewingPost; if ( shouldTriggerMetaboxesSave ) { store.dispatch( requestMetaBoxUpdates() ); diff --git a/packages/edit-post/src/store/reducer.js b/packages/edit-post/src/store/reducer.js index 95067632740e66..a016c701582a90 100644 --- a/packages/edit-post/src/store/reducer.js +++ b/packages/edit-post/src/store/reducer.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { get } from 'lodash'; +import { get, includes } from 'lodash'; /** * WordPress dependencies @@ -105,6 +105,28 @@ export const preferences = combineReducers( { }, } ); +/** + * Reducer storing the list of all programmatically removed panels. + * + * @param {Array} state Current state. + * @param {Object} action Action object. + * + * @return {Array} Updated state. + */ +export function removedPanels( state = [], action ) { + switch ( action.type ) { + case 'REMOVE_PANEL': + if ( ! includes( state, action.panelName ) ) { + return [ + ...state, + action.panelName, + ]; + } + } + + return state; +} + /** * Reducer returning the next active general sidebar state. The active general * sidebar is a unique name to identify either an editor or plugin sidebar. @@ -123,15 +145,6 @@ export function activeGeneralSidebar( state = DEFAULT_ACTIVE_GENERAL_SIDEBAR, ac return state; } -export function panel( state = 'document', action ) { - switch ( action.type ) { - case 'SET_ACTIVE_PANEL': - return action.panel; - } - - return state; -} - /** * Reducer for storing the name of the open modal, or null if no modal is open. * @@ -207,10 +220,10 @@ const metaBoxes = combineReducers( { } ); export default combineReducers( { - preferences, activeGeneralSidebar, - panel, activeModal, - publishSidebarActive, metaBoxes, + preferences, + publishSidebarActive, + removedPanels, } ); diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index f01e1e22ed8aba..c77a3add99e4b7 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -99,6 +99,19 @@ export function isPublishSidebarOpened( state ) { return state.publishSidebarActive; } +/** + * Returns true if the given panel was programmatically removed, or false otherwise. + * All panels are not removed by default. + * + * @param {Object} state Global application state. + * @param {string} panelName A string that identifies the panel. + * + * @return {boolean} Whether or not the panel is removed. + */ +export function isEditorPanelRemoved( state, panelName ) { + return includes( state.removedPanels, panelName ); +} + /** * Returns true if the given panel is enabled, or false otherwise. Panels are * enabled by default. @@ -110,7 +123,9 @@ export function isPublishSidebarOpened( state ) { */ export function isEditorPanelEnabled( state, panelName ) { const panels = getPreference( state, 'panels' ); - return get( panels, [ panelName, 'enabled' ], true ); + + return ! isEditorPanelRemoved( state, panelName ) && + get( panels, [ panelName, 'enabled' ], true ); } /** diff --git a/packages/edit-post/src/store/test/actions.js b/packages/edit-post/src/store/test/actions.js index f7bbc4c06eda86..07dc4b81ece682 100644 --- a/packages/edit-post/src/store/test/actions.js +++ b/packages/edit-post/src/store/test/actions.js @@ -4,6 +4,7 @@ import { toggleEditorPanelEnabled, toggleEditorPanelOpened, + removeEditorPanel, openGeneralSidebar, closeGeneralSidebar, openPublishSidebar, @@ -59,6 +60,15 @@ describe( 'actions', () => { } ); } ); + describe( 'removeEditorPanel', () => { + it( 'should return a REMOVE_PANEL action', () => { + expect( removeEditorPanel( 'post-status' ) ).toEqual( { + type: 'REMOVE_PANEL', + panelName: 'post-status', + } ); + } ); + } ); + describe( 'toggleEditorPanelEnabled', () => { it( 'should return a TOGGLE_PANEL_ENABLED action', () => { expect( toggleEditorPanelEnabled( 'post-status' ) ).toEqual( { diff --git a/packages/edit-post/src/store/test/reducer.js b/packages/edit-post/src/store/test/reducer.js index c64d90f48d1553..66027cd21dc443 100644 --- a/packages/edit-post/src/store/test/reducer.js +++ b/packages/edit-post/src/store/test/reducer.js @@ -13,6 +13,7 @@ import { activeModal, isSavingMetaBoxes, metaBoxLocations, + removedPanels, } from '../reducer'; describe( 'state', () => { @@ -313,4 +314,24 @@ describe( 'state', () => { } ); } ); } ); + + describe( 'removedPanels', () => { + it( 'should remove panel', () => { + const original = deepFreeze( [] ); + const state = removedPanels( original, { + type: 'REMOVE_PANEL', + panelName: 'post-status', + } ); + expect( state ).toEqual( [ 'post-status' ] ); + } ); + + it( 'should not remove already removed panel', () => { + const original = deepFreeze( [ 'post-status' ] ); + const state = removedPanels( original, { + type: 'REMOVE_PANEL', + panelName: 'post-status', + } ); + expect( state ).toBe( original ); + } ); + } ); } ); diff --git a/packages/edit-post/src/store/test/selectors.js b/packages/edit-post/src/store/test/selectors.js index 4a13ec8e4392cb..b035169b3044d8 100644 --- a/packages/edit-post/src/store/test/selectors.js +++ b/packages/edit-post/src/store/test/selectors.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + /** * Internal dependencies */ @@ -16,6 +21,7 @@ import { getActiveMetaBoxLocations, isMetaBoxLocationActive, isEditorPanelEnabled, + isEditorPanelRemoved, } from '../selectors'; describe( 'selectors', () => { @@ -195,6 +201,26 @@ describe( 'selectors', () => { } ); } ); + describe( 'isEditorPanelRemoved', () => { + it( 'should return false by default', () => { + const state = deepFreeze( { + removedPanels: [], + } ); + + expect( isEditorPanelRemoved( state, 'post-status' ) ).toBe( false ); + } ); + + it( 'should return true when panel was removed', () => { + const state = deepFreeze( { + removedPanels: [ + 'post-status', + ], + } ); + + expect( isEditorPanelRemoved( state, 'post-status' ) ).toBe( true ); + } ); + } ); + describe( 'isEditorPanelEnabled', () => { it( 'should return true by default', () => { const state = { @@ -229,6 +255,21 @@ describe( 'selectors', () => { expect( isEditorPanelEnabled( state, 'post-status' ) ).toBe( false ); } ); + + it( 'should return false when a panel is enabled but removed', () => { + const state = deepFreeze( { + preferences: { + panels: { + 'post-status': { + enabled: true, + }, + }, + }, + removedPanels: [ 'post-status' ], + } ); + + expect( isEditorPanelEnabled( state, 'post-status' ) ).toBe( false ); + } ); } ); describe( 'isEditorPanelOpened', () => { diff --git a/packages/edit-post/src/style.scss b/packages/edit-post/src/style.scss index 23c040743210c9..44ad95c3351652 100644 --- a/packages/edit-post/src/style.scss +++ b/packages/edit-post/src/style.scss @@ -10,6 +10,7 @@ @import "./components/sidebar/style.scss"; @import "./components/sidebar/last-revision/style.scss"; @import "./components/sidebar/post-author/style.scss"; +@import "./components/sidebar/post-link/style.scss"; @import "./components/sidebar/post-schedule/style.scss"; @import "./components/sidebar/post-status/style.scss"; @import "./components/sidebar/post-visibility/style.scss"; @@ -20,28 +21,15 @@ @import "./components/visual-editor/style.scss"; @import "./components/options-modal/style.scss"; -// Fade animations -@keyframes animate_fade { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} -@keyframes move_background { - from { - background-position: 0 0; - } - to { - background-position: 28px 0; - } -} +/** + * Animations + */ -@keyframes loading_fade { +// These keyframes should not be part of the _animations.scss mixins file. +// Because keyframe animations can't be defined as mixins properly, they are duplicated. +// Since hey are intended only for the editor, we add them here instead. +@keyframes edit-post__loading-fade-animation { 0% { opacity: 0.5; } @@ -53,15 +41,12 @@ } } -@keyframes slide_in_right { - 100% { - transform: translateX(0%); +@keyframes edit-post__fade-in-animation { + from { + opacity: 0; } -} - -@keyframes slide_in_top { - 100% { - transform: translateY(0%); + to { + opacity: 1; } } diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 0a5c92529c16be..65eb76bccf8f7b 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -1,18 +1,52 @@ -## 7.0.0 (Unreleased) +## 9.0.0 (Unreleased) ### Breaking Changes -- The `PanelColor` component has been removed. +- `PostPublishPanelToggle` has been removed. Use `PostPublishButton` instead. -## 6.2.1 (2018-11-09) +## 8.0.0 (2018-11-15) -### New Features +### Breaking Changes + +- The reusable blocks actions and selectors have been marked as experimental. + +### Bug Fixes + +- Stop propagating to DOM elements the `focusOnMount` prop from `NavigableToolbar` components + +## 7.0.1 (2018-11-12) + +### Polish + +- Remove unnecessary `locale` prop usage [#11649](https://github.com/WordPress/gutenberg/pull/11649) + +### Bug Fixes + +- Fix multi-selection triggering too often when using floated blocks. + +## 7.0.0 (2018-11-12) + +### Breaking Change + +- The `PanelColor` component has been removed. + +### New Feature - In `NavigableToolbar`, a property focusOnMount was added, if true, the toolbar will get focus as soon as it mounted. Defaults to false. +### Bug Fixes + +- Avoid unnecessary re-renders when navigating between blocks. +- PostPublishPanel: return focus to element that opened the panel +- Capture focus on self in InsertionPoint inserter +- Correct insertion point opacity selector +- Set code editor as RTL + +## 6.2.1 (2018-11-09) + ### Deprecations -- `wp.editor.PostPublishPanelToggle` has been deprecated in favor of `wp.editor.PostPublishButton`. +- `PostPublishPanelToggle` has been deprecated in favor of `PostPublishButton`. ### Polish @@ -78,7 +112,7 @@ ### Deprecations -- `wp.editor.PanelColor` has been deprecated in favor of `wp.editor.PanelColorSettings`. +- `PanelColor` has been deprecated in favor of `PanelColorSettings`. ### New Features @@ -95,7 +129,7 @@ - The `value` property in color objects passed by `withColors` has been removed. Use `color` property instead. - `RichText` `getSettings` prop has been removed. The `unstableGetSettings` prop is available if continued use is required. Unstable APIs are strongly discouraged to be used, and are subject to removal without notice, even as part of a minor release. - `RichText` `onSetup` prop has been removed. The `unstableOnSetup` prop is available if continued use is required. Unstable APIs are strongly discouraged to be used, and are subject to removal without notice, even as part of a minor release. -- `wp.editor.RichTextProvider` has been removed. Please use `wp.data.select( 'core/editor' )` methods instead. +- `RichTextProvider` has been removed. Please use `wp.data.select( 'core/editor' )` methods instead. ### Deprecations @@ -123,12 +157,12 @@ - `getSharedBlocks` selector has been removed. Use `getReusableBlocks` instead. - `editorMediaUpload` has been removed. Use `mediaUpload` instead. - Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. -- `wp.editor.DocumentTitle` component has been removed. +- `DocumentTitle` component has been removed. - `getDocumentTitle` selector (`core/editor`) has been removed. ### Deprecations -- `wp.editor.RichTextProvider` flagged for deprecation. Please use `wp.data.select( 'core/editor' )` methods instead. +- `RichTextProvider` flagged for deprecation. Please use `wp.data.select( 'core/editor' )` methods instead. ### Bug Fixes diff --git a/packages/editor/package.json b/packages/editor/package.json index ec8fcc15bcff50..71b536aa1ba38b 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/editor", - "version": "6.2.1", + "version": "8.0.0", "description": "Building blocks for WordPress editors.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -62,8 +62,8 @@ "devDependencies": { "deep-freeze": "^0.0.1", "enzyme": "^3.7.0", - "react-dom": "^16.4.1", - "react-test-renderer": "^16.4.1" + "react-dom": "^16.6.3", + "react-test-renderer": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/packages/editor/src/components/autocompleters/block.js b/packages/editor/src/components/autocompleters/block.js index e48bf2f48e7380..e235c9a384aadb 100644 --- a/packages/editor/src/components/autocompleters/block.js +++ b/packages/editor/src/components/autocompleters/block.js @@ -40,8 +40,9 @@ function defaultGetInserterItems( rootClientId ) { * block is selected. */ function defaultGetSelectedBlockName() { - const selectedBlock = select( 'core/editor' ).getSelectedBlock(); - return selectedBlock ? selectedBlock.name : null; + const { getSelectedBlockClientId, getBlockName } = select( 'core/editor' ); + const selectedBlockClientId = getSelectedBlockClientId(); + return selectedBlockClientId ? getBlockName( selectedBlockClientId ) : null; } /** diff --git a/packages/editor/src/components/block-compare/style.scss b/packages/editor/src/components/block-compare/style.scss index 38ad759c4070cc..64112d2e559fa9 100644 --- a/packages/editor/src/components/block-compare/style.scss +++ b/packages/editor/src/components/block-compare/style.scss @@ -73,6 +73,6 @@ .editor-block-compare__heading { font-size: 1em; - font-weight: normal; + font-weight: 400; } } diff --git a/packages/editor/src/components/block-drop-zone/index.js b/packages/editor/src/components/block-drop-zone/index.js index 3af8b0cff3a6f7..3e9c0db427e58a 100644 --- a/packages/editor/src/components/block-drop-zone/index.js +++ b/packages/editor/src/components/block-drop-zone/index.js @@ -19,6 +19,11 @@ import { Component } from '@wordpress/element'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; +/** + * Internal dependencies + */ +import MediaUploadCheck from '../media-upload/check'; + const parseDropEvent = ( event ) => { let result = { srcRootClientId: null, @@ -111,14 +116,16 @@ class BlockDropZone extends Component { const isAppender = index === undefined; return ( - <DropZone - className={ classnames( 'editor-block-drop-zone', { - 'is-appender': isAppender, - } ) } - onFilesDrop={ this.onFilesDrop } - onHTMLDrop={ this.onHTMLDrop } - onDrop={ this.onDrop } - /> + <MediaUploadCheck> + <DropZone + className={ classnames( 'editor-block-drop-zone', { + 'is-appender': isAppender, + } ) } + onFilesDrop={ this.onFilesDrop } + onHTMLDrop={ this.onHTMLDrop } + onDrop={ this.onDrop } + /> + </MediaUploadCheck> ); } } diff --git a/packages/editor/src/components/block-icon/index.js b/packages/editor/src/components/block-icon/index.js index 466f09763beb6c..d0a9cd044fb029 100644 --- a/packages/editor/src/components/block-icon/index.js +++ b/packages/editor/src/components/block-icon/index.js @@ -23,7 +23,7 @@ export default function BlockIcon( { icon, showColors = false, className } ) { } : {}; return ( - <div + <span style={ style } className={ classnames( 'editor-block-icon', @@ -32,6 +32,6 @@ export default function BlockIcon( { icon, showColors = false, className } ) { ) } > { renderedIcon } - </div> + </span> ); } diff --git a/packages/editor/src/components/block-icon/test/index.js b/packages/editor/src/components/block-icon/test/index.js index 0712cc2f6d5dfc..8c74330ad34d49 100644 --- a/packages/editor/src/components/block-icon/test/index.js +++ b/packages/editor/src/components/block-icon/test/index.js @@ -20,28 +20,28 @@ describe( 'BlockIcon', () => { expect( wrapper.containsMatchingElement( <Icon icon="format-image" /> ) ).toBe( true ); } ); - it( 'renders a div without the has-colors classname', () => { + it( 'renders a span without the has-colors classname', () => { const wrapper = shallow( <BlockIcon icon="format-image" /> ); - expect( wrapper.find( 'div' ).hasClass( 'has-colors' ) ).toBe( false ); + expect( wrapper.find( 'span' ).hasClass( 'has-colors' ) ).toBe( false ); } ); - it( 'renders a div with the has-colors classname', () => { + it( 'renders a span with the has-colors classname', () => { const wrapper = shallow( <BlockIcon icon="format-image" showColors /> ); - expect( wrapper.find( 'div' ).hasClass( 'has-colors' ) ).toBe( true ); + expect( wrapper.find( 'span' ).hasClass( 'has-colors' ) ).toBe( true ); } ); it( 'skips adding background and foreground styles when colors are not enabled', () => { const wrapper = shallow( <BlockIcon icon={ { background: 'white', foreground: 'black' } } /> ); - expect( wrapper.find( 'div' ).prop( 'style' ) ).toEqual( {} ); + expect( wrapper.find( 'span' ).prop( 'style' ) ).toEqual( {} ); } ); it( 'adds background and foreground styles when colors are enabled', () => { const wrapper = shallow( <BlockIcon icon={ { background: 'white', foreground: 'black' } } showColors /> ); - expect( wrapper.find( 'div' ).prop( 'style' ) ).toEqual( { + expect( wrapper.find( 'span' ).prop( 'style' ) ).toEqual( { backgroundColor: 'white', color: 'black', } ); diff --git a/packages/editor/src/components/block-inspector/index.js b/packages/editor/src/components/block-inspector/index.js index 1b51fedac4a1a4..737b0863cbc2ef 100644 --- a/packages/editor/src/components/block-inspector/index.js +++ b/packages/editor/src/components/block-inspector/index.js @@ -20,20 +20,20 @@ import BlockIcon from '../block-icon'; import InspectorControls from '../inspector-controls'; import InspectorAdvancedControls from '../inspector-advanced-controls'; import BlockStyles from '../block-styles'; +import MultiSelectionInspector from '../multi-selection-inspector'; -const BlockInspector = ( { selectedBlock, blockType, count, hasBlockStyles } ) => { +const BlockInspector = ( { selectedBlockClientId, selectedBlockName, blockType, count, hasBlockStyles } ) => { if ( count > 1 ) { - return <span className="editor-block-inspector__multi-blocks">{ __( 'Coming Soon' ) }</span>; + return <MultiSelectionInspector />; } - const isSelectedBlockUnregistered = - !! selectedBlock && selectedBlock.name === getUnregisteredTypeHandlerName(); + const isSelectedBlockUnregistered = selectedBlockName === getUnregisteredTypeHandlerName(); /* * If the selected block is of an unregistered type, avoid showing it as an actual selection * because we want the user to focus on the unregistered block warning, not block settings. */ - if ( ! selectedBlock || isSelectedBlockUnregistered ) { + if ( ! blockType || ! selectedBlockClientId || isSelectedBlockUnregistered ) { return <span className="editor-block-inspector__no-blocks">{ __( 'No block selected.' ) }</span>; } @@ -53,7 +53,7 @@ const BlockInspector = ( { selectedBlock, blockType, count, hasBlockStyles } ) = initialOpen={ false } > <BlockStyles - clientId={ selectedBlock.clientId } + clientId={ selectedBlockClientId } /> </PanelBody> </div> @@ -79,15 +79,17 @@ const BlockInspector = ( { selectedBlock, blockType, count, hasBlockStyles } ) = export default withSelect( ( select ) => { - const { getSelectedBlock, getSelectedBlockCount } = select( 'core/editor' ); + const { getSelectedBlockClientId, getSelectedBlockCount, getBlockName } = select( 'core/editor' ); const { getBlockStyles } = select( 'core/blocks' ); - const selectedBlock = getSelectedBlock(); - const blockType = selectedBlock && getBlockType( selectedBlock.name ); - const blockStyles = selectedBlock && getBlockStyles( selectedBlock.name ); + const selectedBlockClientId = getSelectedBlockClientId(); + const selectedBlockName = selectedBlockClientId && getBlockName( selectedBlockClientId ); + const blockType = selectedBlockClientId && getBlockType( selectedBlockName ); + const blockStyles = selectedBlockClientId && getBlockStyles( selectedBlockName ); return { count: getSelectedBlockCount(), hasBlockStyles: blockStyles && blockStyles.length > 0, - selectedBlock, + selectedBlockName, + selectedBlockClientId, blockType, }; } diff --git a/packages/editor/src/components/block-inspector/style.scss b/packages/editor/src/components/block-inspector/style.scss index ce9497de9ef65b..e238a085616846 100644 --- a/packages/editor/src/components/block-inspector/style.scss +++ b/packages/editor/src/components/block-inspector/style.scss @@ -1,5 +1,4 @@ -.editor-block-inspector__no-blocks, -.editor-block-inspector__multi-blocks { +.editor-block-inspector__no-blocks { display: block; font-size: $default-font-size; background: $white; @@ -7,9 +6,6 @@ text-align: center; } -.editor-block-inspector__multi-blocks { - border-bottom: $border-width solid $light-gray-500; -} .editor-block-inspector__card { display: flex; diff --git a/packages/editor/src/components/block-list/block-contextual-toolbar.js b/packages/editor/src/components/block-list/block-contextual-toolbar.js index 7efe23aaea989c..8e6726e78f118e 100644 --- a/packages/editor/src/components/block-list/block-contextual-toolbar.js +++ b/packages/editor/src/components/block-list/block-contextual-toolbar.js @@ -14,7 +14,8 @@ function BlockContextualToolbar( { focusOnMount } ) { <NavigableToolbar focusOnMount={ focusOnMount } className="editor-block-contextual-toolbar" - aria-label={ __( 'Block Toolbar' ) } + /* translators: accessibility text for the block toolbar */ + aria-label={ __( 'Block tools' ) } > <BlockToolbar /> </NavigableToolbar> diff --git a/packages/editor/src/components/block-list/block.js b/packages/editor/src/components/block-list/block.js index 614fbe9f762f07..e57a221531dcc0 100644 --- a/packages/editor/src/components/block-list/block.js +++ b/packages/editor/src/components/block-list/block.js @@ -179,10 +179,7 @@ export class BlockListBlock extends Component { }, {} ); if ( size( metaAttributes ) ) { - this.props.onMetaChange( { - ...this.props.meta, - ...metaAttributes, - } ); + this.props.onMetaChange( metaAttributes ); } } @@ -427,7 +424,7 @@ export class BlockListBlock extends Component { // Empty paragraph blocks should always show up as unselected. const showEmptyBlockSideInserter = ( isSelected || isHovered ) && isEmptyDefaultBlock && isValid; const showSideInserter = ( isSelected || isHovered ) && isEmptyDefaultBlock; - const shouldAppearSelected = ! isFocusMode && ! hasFixedToolbar && ! showSideInserter && isSelected && ! isTypingWithinBlock; + const shouldAppearSelected = ! isFocusMode && ! showSideInserter && isSelected && ! isTypingWithinBlock; const shouldAppearHovered = ! isFocusMode && ! hasFixedToolbar && isHovered && ! isEmptyDefaultBlock; // We render block movers and block settings to keep them tabbale even if hidden const shouldRenderMovers = ! isFocusMode && ( isSelected || hoverArea === 'left' ) && ! showEmptyBlockSideInserter && ! isMultiSelecting && ! isPartOfMultiSelection && ! isTypingWithinBlock; @@ -646,7 +643,6 @@ const applyWithSelect = withSelect( ( select, { clientId, rootClientId, isLargeV isTyping, isCaretWithinFormattedText, getBlockIndex, - getEditedPostAttribute, getBlockMode, isSelectionEnabled, getSelectedBlocksInitialCaretPosition, @@ -673,7 +669,6 @@ const applyWithSelect = withSelect( ( select, { clientId, rootClientId, isLargeV isTypingWithinBlock: ( isSelected || isParentOfSelectedBlock ) && isTyping(), isCaretWithinFormattedText: isCaretWithinFormattedText(), order: getBlockIndex( clientId, rootClientId ), - meta: getEditedPostAttribute( 'meta' ), mode: getBlockMode( clientId ), isSelectionEnabled: isSelectionEnabled(), initialPosition: getSelectedBlocksInitialCaretPosition(), diff --git a/packages/editor/src/components/block-list/breadcrumb.js b/packages/editor/src/components/block-list/breadcrumb.js index 2699fb22811614..ca14b1d346c947 100644 --- a/packages/editor/src/components/block-list/breadcrumb.js +++ b/packages/editor/src/components/block-list/breadcrumb.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ @@ -53,12 +48,10 @@ export class BlockBreadcrumb extends Component { } render() { - const { clientId, rootClientId, isLight } = this.props; + const { clientId, rootClientId } = this.props; return ( - <div className={ classnames( 'editor-block-list__breadcrumb', { - 'is-light': isLight, - } ) }> + <div className={ 'editor-block-list__breadcrumb' }> <Toolbar> { rootClientId && ( <Fragment> @@ -75,12 +68,11 @@ export class BlockBreadcrumb extends Component { export default compose( [ withSelect( ( select, ownProps ) => { - const { getBlockRootClientId, getEditorSettings } = select( 'core/editor' ); + const { getBlockRootClientId } = select( 'core/editor' ); const { clientId } = ownProps; return { rootClientId: getBlockRootClientId( clientId ), - isLight: getEditorSettings().hasFixedToolbar, }; } ), ] )( BlockBreadcrumb ); diff --git a/packages/editor/src/components/block-list/index.js b/packages/editor/src/components/block-list/index.js index 7d589a329fca37..b8e7e2926225fc 100644 --- a/packages/editor/src/components/block-list/index.js +++ b/packages/editor/src/components/block-list/index.js @@ -22,6 +22,7 @@ import { compose } from '@wordpress/compose'; */ import BlockListBlock from './block'; import BlockListAppender from '../block-list-appender'; +import { getBlockDOMNode } from '../../utils/dom'; class BlockList extends Component { constructor( props ) { @@ -78,8 +79,15 @@ class BlockList extends Component { this.props.onStartMultiSelect(); } - const boundaries = this.nodes[ this.selectionAtStart ].getBoundingClientRect(); - const y = clientY - boundaries.top; + const blockContentBoundaries = getBlockDOMNode( this.selectionAtStart ).getBoundingClientRect(); + + // prevent multi-selection from triggering when the selected block is a float + // and the cursor is still between the top and the bottom of the block. + if ( clientY >= blockContentBoundaries.top && clientY <= blockContentBoundaries.bottom ) { + return; + } + + const y = clientY - blockContentBoundaries.top; const key = findLast( this.coordMapKeys, ( coordY ) => coordY < y ); this.onSelectionChange( this.coordMap[ key ] ); diff --git a/packages/editor/src/components/block-list/style.scss b/packages/editor/src/components/block-list/style.scss index a1f80ddf8c9ee9..e7e13f8a9e06aa 100644 --- a/packages/editor/src/components/block-list/style.scss +++ b/packages/editor/src/components/block-list/style.scss @@ -201,10 +201,16 @@ // Warnings &.has-warning .editor-block-list__block-edit { - > :not(.editor-warning) { + // When a block has a warning, you shouldn't be able to manipulate the contents. + > * { pointer-events: none; user-select: none; } + + // Allow the warning action buttons to be manipulable. + .editor-warning { + pointer-events: all; + } } &.has-warning:not(.is-hovered) .editor-block-list__block-edit::before { @@ -312,6 +318,14 @@ right: 0; } + // Position the sticky toolbar correctly beyond the mobile breakpoint. + @include break-small() { + &[data-align="right"] .editor-block-contextual-toolbar, + &[data-align="left"] .editor-block-contextual-toolbar { + top: $block-padding; + } + } + // Left &[data-align="left"] { // This is in the editor only; the image should be floated on the frontend. @@ -416,8 +430,8 @@ // Full-wide &[data-align="full"] { - // Position hover label on the right - > .editor-block-list__block-edit .editor-block-list__breadcrumb { + // Position hover label on the right for the top level block. + > .editor-block-list__block-edit > .editor-block-list__breadcrumb { right: 0; } @@ -672,7 +686,8 @@ // Don't show the sibling inserter before the selected block. .edit-post-layout:not(.has-fixed-toolbar) { // The child selector is necessary for this to work properly in nested contexts. - .is-selected > .editor-block-list__insertion-point-inserter { + .is-selected > .editor-block-list__insertion-point > .editor-block-list__insertion-point-inserter, + .is-focused > .editor-block-list__insertion-point > .editor-block-list__insertion-point-inserter { opacity: 0; pointer-events: none; @@ -793,12 +808,6 @@ margin-left: $block-padding + $border-width; } - // Don't do it for wide elements, this causes a horizontal scrollbar. - &[data-align="full"] .editor-block-contextual-toolbar { - margin-left: -$block-padding - $block-side-ui-width; - margin-right: -$block-padding - $block-side-ui-width; - } - // Reset pointer-events on children. .editor-block-contextual-toolbar > * { pointer-events: auto; @@ -895,15 +904,10 @@ // Animate in .editor-block-list__block:hover & { opacity: 0; - @include fade_in(60ms, 0.5s); + @include edit-post__fade-in-animation(60ms, 0.5s); } } - &.is-light .components-toolbar { - background: rgba($white, 0.5); - color: $dark-gray-700; - } - // Position the breadcrumb closer on mobile. [data-align="left"] &, [data-align="right"] & { diff --git a/packages/editor/src/components/block-mover/style.scss b/packages/editor/src/components/block-mover/style.scss index ee793843e2ebe4..8c6a0b6e4136cb 100644 --- a/packages/editor/src/components/block-mover/style.scss +++ b/packages/editor/src/components/block-mover/style.scss @@ -3,7 +3,7 @@ opacity: 0; &.is-visible { - @include fade_in; + @include edit-post__fade-in-animation; } // 24px is the smallest size of a good pressable button. diff --git a/packages/editor/src/components/block-navigation/index.js b/packages/editor/src/components/block-navigation/index.js index d1c1b919ed7192..117640b5677fe2 100644 --- a/packages/editor/src/components/block-navigation/index.js +++ b/packages/editor/src/components/block-navigation/index.js @@ -8,7 +8,7 @@ import classnames from 'classnames'; * WordPress dependencies */ import { withSelect, withDispatch } from '@wordpress/data'; -import { MenuItem, MenuGroup } from '@wordpress/components'; +import { Button, NavigableMenu } from '@wordpress/components'; import { getBlockType } from '@wordpress/blocks'; import { compose } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; @@ -25,23 +25,30 @@ function BlockNavigationList( { showNestedBlocks, } ) { return ( - <ul className="editor-block-navigation__list" role="presentation"> + /* + * Disable reason: The `list` ARIA role is redundant but + * Safari+VoiceOver won't announce the list otherwise. + */ + /* eslint-disable jsx-a11y/no-redundant-roles */ + <ul className="editor-block-navigation__list" role="list"> { map( blocks, ( block ) => { const blockType = getBlockType( block.name ); + const isSelected = block.clientId === selectedBlockClientId; + return ( - <li key={ block.clientId } role="presentation"> - <div role="presentation" className="editor-block-navigation__item"> - <MenuItem + <li key={ block.clientId }> + <div className="editor-block-navigation__item"> + <Button className={ classnames( 'editor-block-navigation__item-button', { 'is-selected': block.clientId === selectedBlockClientId, } ) } onClick={ () => selectBlock( block.clientId ) } - isSelected={ block.clientId === selectedBlockClientId } - role="menuitemradio" + isSelected={ isSelected } > <BlockIcon icon={ blockType.icon } showColors /> { blockType.title } - </MenuItem> + { isSelected && <span className="screen-reader-text">{ __( '(selected block)' ) }</span> } + </Button> </div> { showNestedBlocks && !! block.innerBlocks && !! block.innerBlocks.length && ( <BlockNavigationList @@ -55,6 +62,7 @@ function BlockNavigationList( { ); } ) } </ul> + /* eslint-enable jsx-a11y/no-redundant-roles */ ); } @@ -67,7 +75,11 @@ function BlockNavigation( { rootBlock, rootBlocks, selectedBlockClientId, select ); return ( - <MenuGroup label={ __( 'Block Navigation' ) }> + <NavigableMenu + role="presentation" + className="editor-block-navigation__container" + > + <p className="editor-block-navigation__label">{ __( 'Block Navigation' ) }</p> { hasHierarchy && ( <BlockNavigationList blocks={ [ rootBlock ] } @@ -90,7 +102,7 @@ function BlockNavigation( { rootBlock, rootBlocks, selectedBlockClientId, select { __( 'No blocks created yet.' ) } </p> ) } - </MenuGroup> + </NavigableMenu> ); } diff --git a/packages/editor/src/components/block-navigation/style.scss b/packages/editor/src/components/block-navigation/style.scss index e5e1dd0994dd84..f5a06ea1b8097b 100644 --- a/packages/editor/src/components/block-navigation/style.scss +++ b/packages/editor/src/components/block-navigation/style.scss @@ -1,6 +1,15 @@ $tree-border-width: 2px; $tree-item-height: 36px; +.editor-block-navigation__container { + padding: $grid-size - $border-width; +} + +.editor-block-navigation__label { + margin: 0 0 $grid-size; + color: $dark-gray-300; +} + .editor-block-navigation__list, .editor-block-navigation__paragraph { padding: 0; @@ -50,15 +59,26 @@ $tree-item-height: 36px; } .editor-block-navigation__item-button { - padding: 6px; display: flex; align-items: center; + width: 100%; + padding: 6px; + text-align: left; + color: $dark-gray-600; border-radius: 4px; .editor-block-icon { margin-right: 6px; } + &:hover:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__hover; + } + + &:focus:not(:disabled):not([aria-disabled="true"]) { + @include menu-style__focus; + } + &.is-selected, &.is-selected:focus { color: $dark-gray-700; diff --git a/packages/editor/src/components/block-styles/style.scss b/packages/editor/src/components/block-styles/style.scss index 3a6b7806b9c242..53a609012f74e4 100644 --- a/packages/editor/src/components/block-styles/style.scss +++ b/packages/editor/src/components/block-styles/style.scss @@ -29,7 +29,7 @@ .editor-block-styles__item-preview { outline: $border-width solid transparent; // Shown in Windows High Contrast mode. - box-shadow: inset 0 0 0 1px rgba($dark-gray-900, 0.2); + border: 1px solid rgba($dark-gray-900, 0.2); overflow: hidden; padding: 0; text-align: initial; diff --git a/packages/editor/src/components/block-toolbar/index.js b/packages/editor/src/components/block-toolbar/index.js index 511ed1d7da0536..e37fbdb2e46aa3 100644 --- a/packages/editor/src/components/block-toolbar/index.js +++ b/packages/editor/src/components/block-toolbar/index.js @@ -43,18 +43,19 @@ function BlockToolbar( { blockClientIds, isValid, mode } ) { export default withSelect( ( select ) => { const { - getSelectedBlock, + getSelectedBlockClientId, getBlockMode, getMultiSelectedBlockClientIds, + isBlockValid, } = select( 'core/editor' ); - const block = getSelectedBlock(); - const blockClientIds = block ? - [ block.clientId ] : + const selectedBlockClientId = getSelectedBlockClientId(); + const blockClientIds = selectedBlockClientId ? + [ selectedBlockClientId ] : getMultiSelectedBlockClientIds(); return { blockClientIds, - isValid: block ? block.isValid : null, - mode: block ? getBlockMode( block.clientId ) : null, + isValid: selectedBlockClientId ? isBlockValid( selectedBlockClientId ) : null, + mode: selectedBlockClientId ? getBlockMode( selectedBlockClientId ) : null, }; } )( BlockToolbar ); diff --git a/packages/editor/src/components/copy-handler/index.js b/packages/editor/src/components/copy-handler/index.js index fb5a205c0b8928..e74cde91bd2aa2 100644 --- a/packages/editor/src/components/copy-handler/index.js +++ b/packages/editor/src/components/copy-handler/index.js @@ -26,18 +26,18 @@ class CopyHandler extends Component { } onCopy( event ) { - const { multiSelectedBlocks, selectedBlock } = this.props; + const { hasMultiSelection, selectedBlockClientIds, getBlocksByClientId } = this.props; - if ( ! multiSelectedBlocks.length && ! selectedBlock ) { + if ( selectedBlockClientIds.length === 0 ) { return; } // Let native copy behaviour take over in input fields. - if ( selectedBlock && documentHasSelection() ) { + if ( ! hasMultiSelection && documentHasSelection() ) { return; } - const serialized = serialize( selectedBlock || multiSelectedBlocks ); + const serialized = serialize( getBlocksByClientId( selectedBlockClientIds ) ); event.clipboardData.setData( 'text/plain', serialized ); event.clipboardData.setData( 'text/html', serialized ); @@ -46,12 +46,12 @@ class CopyHandler extends Component { } onCut( event ) { - const { multiSelectedBlockClientIds } = this.props; + const { hasMultiSelection, selectedBlockClientIds } = this.props; this.onCopy( event ); - if ( multiSelectedBlockClientIds.length ) { - this.props.onRemove( multiSelectedBlockClientIds ); + if ( hasMultiSelection ) { + this.props.onRemove( selectedBlockClientIds ); } } @@ -63,14 +63,22 @@ class CopyHandler extends Component { export default compose( [ withSelect( ( select ) => { const { - getMultiSelectedBlocks, getMultiSelectedBlockClientIds, - getSelectedBlock, + getSelectedBlockClientId, + getBlocksByClientId, + hasMultiSelection, } = select( 'core/editor' ); + + const selectedBlockClientId = getSelectedBlockClientId(); + const selectedBlockClientIds = selectedBlockClientId ? [ selectedBlockClientId ] : getMultiSelectedBlockClientIds(); + return { - multiSelectedBlocks: getMultiSelectedBlocks(), - multiSelectedBlockClientIds: getMultiSelectedBlockClientIds(), - selectedBlock: getSelectedBlock(), + hasMultiSelection: hasMultiSelection(), + selectedBlockClientIds, + + // We only care about this value when the copy is performed + // We call it dynamically in the event handler to avoid unnecessary re-renders. + getBlocksByClientId, }; } ), withDispatch( ( dispatch ) => ( { diff --git a/packages/editor/src/components/default-block-appender/index.js b/packages/editor/src/components/default-block-appender/index.js index a1855e93f2ae3c..ecfe98d351a905 100644 --- a/packages/editor/src/components/default-block-appender/index.js +++ b/packages/editor/src/components/default-block-appender/index.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import { get } from 'lodash'; import TextareaAutosize from 'react-autosize-textarea'; /** @@ -67,12 +66,11 @@ export function DefaultBlockAppender( { } export default compose( withSelect( ( select, ownProps ) => { - const { getBlockCount, getBlock, getEditorSettings, getTemplateLock } = select( 'core/editor' ); + const { getBlockCount, getBlockName, isBlockValid, getEditorSettings, getTemplateLock } = select( 'core/editor' ); const isEmpty = ! getBlockCount( ownProps.rootClientId ); - const lastBlock = getBlock( ownProps.lastBlockClientId ); - const isLastBlockDefault = get( lastBlock, [ 'name' ] ) === getDefaultBlockName(); - const isLastBlockValid = get( lastBlock, [ 'isValid' ] ); + const isLastBlockDefault = getBlockName( ownProps.lastBlockClientId ) === getDefaultBlockName(); + const isLastBlockValid = isBlockValid( ownProps.lastBlockClientId ); const { bodyPlaceholder } = getEditorSettings(); return { diff --git a/packages/editor/src/components/default-block-appender/style.scss b/packages/editor/src/components/default-block-appender/style.scss index 48285e0c860156..9f30f7f31b5964 100644 --- a/packages/editor/src/components/default-block-appender/style.scss +++ b/packages/editor/src/components/default-block-appender/style.scss @@ -8,8 +8,6 @@ background: none; box-shadow: none; display: block; - margin-left: $border-width; - margin-right: $border-width; cursor: text; width: 100%; outline: $border-width solid transparent; @@ -17,7 +15,9 @@ resize: none; // Emulate the dimensions of a paragraph block. - padding: 0 #{ $block-padding }; + // On mobile and in nested contexts, the plus to add blocks shows up on the right. + // The rightmost padding makes sure it doesn't overlap text. + padding: 0 #{ $block-padding + $icon-button-size } 0 $block-padding; // Use opacity to work in various editor styles. color: $dark-opacity-300; diff --git a/packages/editor/src/components/editor-global-keyboard-shortcuts/index.js b/packages/editor/src/components/editor-global-keyboard-shortcuts/index.js index cb416cf44ae82d..844697def5941a 100644 --- a/packages/editor/src/components/editor-global-keyboard-shortcuts/index.js +++ b/packages/editor/src/components/editor-global-keyboard-shortcuts/index.js @@ -155,10 +155,10 @@ export default compose( [ isEditedPostDirty, getBlockRootClientId, getTemplateLock, - getSelectedBlock, + getSelectedBlockClientId, } = select( 'core/editor' ); - const block = getSelectedBlock(); - const selectedBlockClientIds = block ? [ block.clientId ] : getMultiSelectedBlockClientIds(); + const selectedBlockClientId = getSelectedBlockClientId(); + const selectedBlockClientIds = selectedBlockClientId ? [ selectedBlockClientId ] : getMultiSelectedBlockClientIds(); return { rootBlocksClientIds: getBlockOrder(), diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 55b798561a6022..ebd0a59331198c 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -27,6 +27,7 @@ export { export { default as ServerSideRender } from './server-side-render'; export { default as MediaPlaceholder } from './media-placeholder'; export { default as MediaUpload } from './media-upload'; +export { default as MediaUploadCheck } from './media-upload/check'; export { default as URLInput } from './url-input'; export { default as URLInputButton } from './url-input/button'; export { default as URLPopover } from './url-popover'; @@ -62,7 +63,6 @@ export { default as PostPreviewButton } from './post-preview-button'; export { default as PostPublishButton } from './post-publish-button'; export { default as PostPublishButtonLabel } from './post-publish-button/label'; export { default as PostPublishPanel } from './post-publish-panel'; -export { default as PostPublishPanelToggle } from './post-publish-panel/toggle'; export { default as PostSavedState } from './post-saved-state'; export { default as PostSchedule } from './post-schedule'; export { default as PostScheduleCheck } from './post-schedule/check'; diff --git a/packages/editor/src/components/inner-blocks/index.js b/packages/editor/src/components/inner-blocks/index.js index 21831632211819..c4cb0ac5d40999 100644 --- a/packages/editor/src/components/inner-blocks/index.js +++ b/packages/editor/src/components/inner-blocks/index.js @@ -23,7 +23,9 @@ import { withBlockEditContext } from '../block-edit/context'; class InnerBlocks extends Component { constructor() { super( ...arguments ); - + this.state = { + templateInProcess: !! this.props.template, + }; this.updateNestedSettings(); } @@ -39,7 +41,12 @@ class InnerBlocks extends Component { const { innerBlocks } = this.props.block; // only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists if ( innerBlocks.length === 0 || this.getTemplateLock() === 'all' ) { - return this.synchronizeBlocksWithTemplate(); + this.synchronizeBlocksWithTemplate(); + } + if ( this.state.templateInProcess ) { + this.setState( { + templateInProcess: false, + } ); } } @@ -93,12 +100,10 @@ class InnerBlocks extends Component { render() { const { clientId, - allowedBlocks, - templateLock, - template, isSmallScreen, isSelectedBlockInRoot, } = this.props; + const { templateInProcess } = this.state; const classes = classnames( 'editor-inner-blocks', { 'has-overlay': isSmallScreen && ! isSelectedBlockInRoot, @@ -106,10 +111,11 @@ class InnerBlocks extends Component { return ( <div className={ classes }> - <BlockList - rootClientId={ clientId } - { ...{ allowedBlocks, templateLock, template } } - /> + { ! templateInProcess && ( + <BlockList + rootClientId={ clientId } + /> + ) } </div> ); } diff --git a/packages/editor/src/components/inserter/index.js b/packages/editor/src/components/inserter/index.js index 8017fa4b65c1a2..1133f6e827faed 100644 --- a/packages/editor/src/components/inserter/index.js +++ b/packages/editor/src/components/inserter/index.js @@ -103,7 +103,7 @@ export default compose( [ const { getEditedPostAttribute, getBlockInsertionPoint, - getInserterItems, + hasInserterItems, } = select( 'core/editor' ); if ( rootClientId === undefined && index === undefined ) { @@ -117,7 +117,7 @@ export default compose( [ return { title: getEditedPostAttribute( 'title' ), - hasItems: getInserterItems( rootClientId ).length > 0, + hasItems: hasInserterItems( rootClientId ), rootClientId, index, }; diff --git a/packages/editor/src/components/media-placeholder/index.js b/packages/editor/src/components/media-placeholder/index.js index 9294141b823639..c1b64eb0d9a64a 100644 --- a/packages/editor/src/components/media-placeholder/index.js +++ b/packages/editor/src/components/media-placeholder/index.js @@ -17,11 +17,14 @@ import { } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import { withSelect } from '@wordpress/data'; /** * Internal dependencies */ import MediaUpload from '../media-upload'; +import MediaUploadCheck from '../media-upload/check'; import URLPopover from '../url-popover'; import { mediaUpload } from '../../utils/'; @@ -49,7 +52,7 @@ const InsertFromURLPopover = ( { src, onChange, onSubmit, onClose } ) => ( </URLPopover> ); -class MediaPlaceholder extends Component { +export class MediaPlaceholder extends Component { constructor() { super( ...arguments ); this.state = { @@ -131,7 +134,8 @@ class MediaPlaceholder extends Component { onHTMLDrop = noop, multiple = false, notices, - allowedTypes, + allowedTypes = [], + hasUploadPermissions, } = this.props; const { @@ -141,6 +145,11 @@ class MediaPlaceholder extends Component { let instructions = labels.instructions || ''; let title = labels.title || ''; + + if ( ! hasUploadPermissions && ! onSelectURL ) { + instructions = __( 'To edit this block, you need permission to upload media.' ); + } + if ( ! instructions || ! title ) { const isOneType = 1 === allowedTypes.length; const isAudio = isOneType && 'audio' === allowedTypes[ 0 ]; @@ -148,14 +157,26 @@ class MediaPlaceholder extends Component { const isVideo = isOneType && 'video' === allowedTypes[ 0 ]; if ( ! instructions ) { - instructions = __( 'Drag a media file, upload a new one or select a file from your library.' ); + if ( hasUploadPermissions ) { + instructions = __( 'Drag a media file, upload a new one or select a file from your library.' ); - if ( isAudio ) { - instructions = __( 'Drag an audio, upload a new one or select a file from your library.' ); - } else if ( isImage ) { - instructions = __( 'Drag an image, upload a new one or select a file from your library.' ); - } else if ( isVideo ) { - instructions = __( 'Drag a video, upload a new one or select a file from your library.' ); + if ( isAudio ) { + instructions = __( 'Drag an audio, upload a new one or select a file from your library.' ); + } else if ( isImage ) { + instructions = __( 'Drag an image, upload a new one or select a file from your library.' ); + } else if ( isVideo ) { + instructions = __( 'Drag a video, upload a new one or select a file from your library.' ); + } + } else if ( ! hasUploadPermissions && onSelectURL ) { + instructions = __( 'Given your current role, you can only link a media file, you cannot upload.' ); + + if ( isAudio ) { + instructions = __( 'Given your current role, you can only link an audio, you cannot upload.' ); + } else if ( isImage ) { + instructions = __( 'Given your current role, you can only link an image, you cannot upload.' ); + } else if ( isVideo ) { + instructions = __( 'Given your current role, you can only link a video, you cannot upload.' ); + } } } @@ -180,35 +201,37 @@ class MediaPlaceholder extends Component { className={ classnames( 'editor-media-placeholder', className ) } notices={ notices } > - <DropZone - onFilesDrop={ this.onFilesUpload } - onHTMLDrop={ onHTMLDrop } - /> - <FormFileUpload - isLarge - className="editor-media-placeholder__button" - onChange={ this.onUpload } - accept={ accept } - multiple={ multiple } - > - { __( 'Upload' ) } - </FormFileUpload> - <MediaUpload - gallery={ multiple && this.onlyAllowsImages() } - multiple={ multiple } - onSelect={ onSelect } - allowedTypes={ allowedTypes } - value={ value.id } - render={ ( { open } ) => ( - <Button - isLarge - className="editor-media-placeholder__button" - onClick={ open } - > - { __( 'Media Library' ) } - </Button> - ) } - /> + <MediaUploadCheck> + <DropZone + onFilesDrop={ this.onFilesUpload } + onHTMLDrop={ onHTMLDrop } + /> + <FormFileUpload + isLarge + className="editor-media-placeholder__button" + onChange={ this.onUpload } + accept={ accept } + multiple={ multiple } + > + { __( 'Upload' ) } + </FormFileUpload> + <MediaUpload + gallery={ multiple && this.onlyAllowsImages() } + multiple={ multiple } + onSelect={ onSelect } + allowedTypes={ allowedTypes } + value={ value.id } + render={ ( { open } ) => ( + <Button + isLarge + className="editor-media-placeholder__button" + onClick={ open } + > + { __( 'Media Library' ) } + </Button> + ) } + /> + </MediaUploadCheck> { onSelectURL && ( <div className="editor-media-placeholder__url-input-container"> <Button @@ -234,4 +257,15 @@ class MediaPlaceholder extends Component { } } -export default withFilters( 'editor.MediaPlaceholder' )( MediaPlaceholder ); +const applyWithSelect = withSelect( ( select ) => { + const { hasUploadPermissions } = select( 'core' ); + + return { + hasUploadPermissions: hasUploadPermissions(), + }; +} ); + +export default compose( + applyWithSelect, + withFilters( 'editor.MediaPlaceholder' ), +)( MediaPlaceholder ); diff --git a/packages/editor/src/components/media-placeholder/styles.native.scss b/packages/editor/src/components/media-placeholder/styles.native.scss index d14186a943b485..e05681743ee961 100644 --- a/packages/editor/src/components/media-placeholder/styles.native.scss +++ b/packages/editor/src/components/media-placeholder/styles.native.scss @@ -9,7 +9,7 @@ .emptyStateTitle { text-align: center; - font-weight: bold; + font-weight: 600; padding-bottom: 12; } diff --git a/packages/editor/src/components/media-placeholder/test/index.js b/packages/editor/src/components/media-placeholder/test/index.js new file mode 100644 index 00000000000000..6d7050eb84f062 --- /dev/null +++ b/packages/editor/src/components/media-placeholder/test/index.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import { MediaPlaceholder } from '../'; + +jest.mock( '../../media-upload/check', () => () => null ); + +describe( 'MediaPlaceholder', () => { + it( 'renders successfully when allowedTypes property is not specified', () => { + expect( () => mount( + <MediaPlaceholder hasUploadPermissions={ false } /> + ) ).not.toThrow(); + } ); +} ); diff --git a/packages/editor/src/components/media-upload/README.md b/packages/editor/src/components/media-upload/README.md index ae670b227ea925..1dc11cb2a37ed3 100644 --- a/packages/editor/src/components/media-upload/README.md +++ b/packages/editor/src/components/media-upload/README.md @@ -24,25 +24,28 @@ You can check how this component is implemented for the edit post page using `wp ## Usage +To make sure the current user has Upload permissions, you need to wrap the MediaUpload component into the MediaUploadCheck one. ```jsx import { Button } from '@wordpress/components'; -import { MediaUpload } from '@wordpress/editor'; +import { MediaUpload, MediaUploadCheck } from '@wordpress/editor'; const ALLOWED_MEDIA_TYPES = [ 'audio' ]; function MyMediaUploader() { return ( - <MediaUpload - onSelect={ ( media ) => console.log( 'selected ' + media.length ) } - allowedTypes={ ALLOWED_MEDIA_TYPES } - value={ mediaId } - render={ ( { open } ) => ( - <Button onClick={ open }> - Open Media Library - </Button> - ) } - /> + <MediaUploadCheck> + <MediaUpload + onSelect={ ( media ) => console.log( 'selected ' + media.length ) } + allowedTypes={ ALLOWED_MEDIA_TYPES } + value={ mediaId } + render={ ( { open } ) => ( + <Button onClick={ open }> + Open Media Library + </Button> + ) } + /> + </MediaUploadCheck> ); } ``` diff --git a/packages/editor/src/components/media-upload/check.js b/packages/editor/src/components/media-upload/check.js new file mode 100644 index 00000000000000..d72b48497e0afb --- /dev/null +++ b/packages/editor/src/components/media-upload/check.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +export function MediaUploadCheck( { hasUploadPermissions, fallback = null, children } ) { + return hasUploadPermissions ? children : fallback; +} + +export default withSelect( ( select ) => { + const { hasUploadPermissions } = select( 'core' ); + + return { + hasUploadPermissions: hasUploadPermissions(), + }; +} )( MediaUploadCheck ); diff --git a/packages/editor/src/components/multi-selection-inspector/index.js b/packages/editor/src/components/multi-selection-inspector/index.js new file mode 100644 index 00000000000000..5378c5fe6f539f --- /dev/null +++ b/packages/editor/src/components/multi-selection-inspector/index.js @@ -0,0 +1,49 @@ +/** + * WordPress dependencies + */ +import { sprintf, _n } from '@wordpress/i18n'; +import { withSelect } from '@wordpress/data'; +import { serialize } from '@wordpress/blocks'; +import { count as wordCount } from '@wordpress/wordcount'; +import { + Path, + SVG, +} from '@wordpress/components'; + +/** + * Internal Dependencies + */ +import BlockIcon from '../block-icon'; + +function MultiSelectionInspector( { blocks } ) { + const words = wordCount( serialize( blocks ), 'words' ); + + return ( + <div className="editor-multi-selection-inspector__card"> + <BlockIcon icon={ + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M3 5H1v16c0 1.1.9 2 2 2h16v-2H3V5zm18-4H7c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm0 16H7V3h14v14z" /></SVG> + } showColors /> + <div className="editor-multi-selection-inspector__card-content"> + <div className="editor-multi-selection-inspector__card-title"> + { + /* translators: %d: number of blocks */ + sprintf( _n( '%d block', '%d blocks', blocks.length ), blocks.length ) + } + </div> + <div className="editor-multi-selection-inspector__card-description"> + { + /* translators: %d: number of words */ + sprintf( _n( '%d word', '%d words', words ), words ) + } + </div> + </div> + </div> + ); +} + +export default withSelect( ( select ) => { + const { getMultiSelectedBlocks } = select( 'core/editor' ); + return { + blocks: getMultiSelectedBlocks(), + }; +} )( MultiSelectionInspector ); diff --git a/packages/editor/src/components/multi-selection-inspector/style.scss b/packages/editor/src/components/multi-selection-inspector/style.scss new file mode 100644 index 00000000000000..36023adaef78d5 --- /dev/null +++ b/packages/editor/src/components/multi-selection-inspector/style.scss @@ -0,0 +1,27 @@ +.editor-multi-selection-inspector__card { + display: flex; + align-items: flex-start; + margin: -16px; + padding: 16px; +} + +.editor-multi-selection-inspector__card-content { + flex-grow: 1; +} + +.editor-multi-selection-inspector__card-title { + font-weight: 500; + margin-bottom: 5px; +} + +.editor-multi-selection-inspector__card-description { + font-size: $default-font-size; +} + +.editor-multi-selection-inspector__card .editor-block-icon { + margin-left: -2px; + margin-right: 10px; + padding: 0 3px; + width: $icon-button-size; + height: $icon-button-size-small; +} diff --git a/packages/editor/src/components/navigable-toolbar/index.js b/packages/editor/src/components/navigable-toolbar/index.js index 9183e7b7d19785..b4fbbf7f3f241f 100644 --- a/packages/editor/src/components/navigable-toolbar/index.js +++ b/packages/editor/src/components/navigable-toolbar/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { cond, matchesProperty } from 'lodash'; +import { cond, matchesProperty, omit } from 'lodash'; /** * WordPress dependencies @@ -75,7 +75,9 @@ class NavigableToolbar extends Component { role="toolbar" ref={ this.toolbar } onKeyDown={ this.switchOnKeyDown } - { ...props } + { ...omit( props, [ + 'focusOnMount', + ] ) } > <KeyboardShortcuts bindGlobal diff --git a/packages/editor/src/components/plain-text/index.native.js b/packages/editor/src/components/plain-text/index.native.js index c9ee8c69780716..ae98e6a3f9ce6a 100644 --- a/packages/editor/src/components/plain-text/index.native.js +++ b/packages/editor/src/components/plain-text/index.native.js @@ -3,19 +3,36 @@ */ import { TextInput } from 'react-native'; +/** + * WordPress dependencies + */ +import { Component } from '@wordpress/element'; + /** * Internal dependencies */ import styles from './style.scss'; -function PlainText( { onChange, className, ...props } ) { - return ( - <TextInput - className={ [ styles[ 'editor-plain-text' ], className ] } - onChangeText={ ( text ) => onChange( text ) } - { ...props } - /> - ); -} +export default class PlainText extends Component { + componentDidMount() { + // if isSelected is true, we should request the focus on this TextInput + if ( ( this._input.isFocused() === false ) && ( this._input.props.isSelected === true ) ) { + this.focus(); + } + } + + focus() { + this._input.focus(); + } -export default PlainText; + render() { + return ( + <TextInput + ref={ ( x ) => this._input = x } + className={ [ styles[ 'editor-plain-text' ], this.props.className ] } + onChangeText={ ( text ) => this.props.onChange( text ) } + { ...this.props } + /> + ); + } +} diff --git a/packages/editor/src/components/post-featured-image/index.js b/packages/editor/src/components/post-featured-image/index.js index 67f02c9b7a1d15..0c77b6dbc69443 100644 --- a/packages/editor/src/components/post-featured-image/index.js +++ b/packages/editor/src/components/post-featured-image/index.js @@ -17,6 +17,7 @@ import { withSelect, withDispatch } from '@wordpress/data'; */ import PostFeaturedImageCheck from './check'; import MediaUpload from '../media-upload'; +import MediaUploadCheck from '../media-upload/check'; const ALLOWED_MEDIA_TYPES = [ 'image' ]; @@ -27,6 +28,7 @@ const DEFAULT_REMOVE_FEATURE_IMAGE_LABEL = __( 'Remove image' ); function PostFeaturedImage( { currentPostId, featuredImageId, onUpdateImage, onRemoveImage, media, postType } ) { const postLabel = get( postType, [ 'labels' ], {} ); + const instructions = <p>{ __( 'To edit the featured image, you need permission to upload media.' ) }</p>; let mediaWidth, mediaHeight, mediaSourceUrl; if ( media ) { @@ -46,59 +48,67 @@ function PostFeaturedImage( { currentPostId, featuredImageId, onUpdateImage, onR <PostFeaturedImageCheck> <div className="editor-post-featured-image"> { !! featuredImageId && - <MediaUpload - title={ postLabel.featured_image || DEFAULT_FEATURE_IMAGE_LABEL } - onSelect={ onUpdateImage } - allowedTypes={ ALLOWED_MEDIA_TYPES } - modalClass="editor-post-featured-image__media-modal" - render={ ( { open } ) => ( - <Button className="editor-post-featured-image__preview" onClick={ open } aria-label={ __( 'Edit or update the image' ) }> - { media && - <ResponsiveWrapper - naturalWidth={ mediaWidth } - naturalHeight={ mediaHeight } - > - <img src={ mediaSourceUrl } alt="" /> - </ResponsiveWrapper> - } - { ! media && <Spinner /> } - </Button> - ) } - value={ featuredImageId } - /> + <MediaUploadCheck fallback={ instructions }> + <MediaUpload + title={ postLabel.featured_image || DEFAULT_FEATURE_IMAGE_LABEL } + onSelect={ onUpdateImage } + allowedTypes={ ALLOWED_MEDIA_TYPES } + modalClass="editor-post-featured-image__media-modal" + render={ ( { open } ) => ( + <Button className="editor-post-featured-image__preview" onClick={ open } aria-label={ __( 'Edit or update the image' ) }> + { media && + <ResponsiveWrapper + naturalWidth={ mediaWidth } + naturalHeight={ mediaHeight } + > + <img src={ mediaSourceUrl } alt="" /> + </ResponsiveWrapper> + } + { ! media && <Spinner /> } + </Button> + ) } + value={ featuredImageId } + /> + </MediaUploadCheck> } { !! featuredImageId && media && ! media.isLoading && - <MediaUpload - title={ postLabel.featured_image || DEFAULT_FEATURE_IMAGE_LABEL } - onSelect={ onUpdateImage } - allowedTypes={ ALLOWED_MEDIA_TYPES } - modalClass="editor-post-featured-image__media-modal" - render={ ( { open } ) => ( - <Button onClick={ open } isDefault isLarge> - { __( 'Replace image' ) } - </Button> - ) } - /> - } - { ! featuredImageId && - <div> + <MediaUploadCheck> <MediaUpload title={ postLabel.featured_image || DEFAULT_FEATURE_IMAGE_LABEL } onSelect={ onUpdateImage } allowedTypes={ ALLOWED_MEDIA_TYPES } modalClass="editor-post-featured-image__media-modal" render={ ( { open } ) => ( - <Button className="editor-post-featured-image__toggle" onClick={ open }> - { postLabel.set_featured_image || DEFAULT_SET_FEATURE_IMAGE_LABEL } + <Button onClick={ open } isDefault isLarge> + { __( 'Replace image' ) } </Button> ) } /> + </MediaUploadCheck> + } + { ! featuredImageId && + <div> + <MediaUploadCheck fallback={ instructions }> + <MediaUpload + title={ postLabel.featured_image || DEFAULT_FEATURE_IMAGE_LABEL } + onSelect={ onUpdateImage } + allowedTypes={ ALLOWED_MEDIA_TYPES } + modalClass="editor-post-featured-image__media-modal" + render={ ( { open } ) => ( + <Button className="editor-post-featured-image__toggle" onClick={ open }> + { postLabel.set_featured_image || DEFAULT_SET_FEATURE_IMAGE_LABEL } + </Button> + ) } + /> + </MediaUploadCheck> </div> } { !! featuredImageId && - <Button onClick={ onRemoveImage } isLink isDestructive> - { postLabel.remove_featured_image || DEFAULT_REMOVE_FEATURE_IMAGE_LABEL } - </Button> + <MediaUploadCheck> + <Button onClick={ onRemoveImage } isLink isDestructive> + { postLabel.remove_featured_image || DEFAULT_REMOVE_FEATURE_IMAGE_LABEL } + </Button> + </MediaUploadCheck> } </div> </PostFeaturedImageCheck> diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js index 112dca7689e55e..db8de6f00579c2 100644 --- a/packages/editor/src/components/post-locked-modal/index.js +++ b/packages/editor/src/components/post-locked-modal/index.js @@ -2,6 +2,7 @@ * External dependencies */ import jQuery from 'jquery'; +import { get } from 'lodash'; /** * WordPress dependencies @@ -118,7 +119,7 @@ class PostLockedModal extends Component { } render() { - const { user, postId, isLocked, isTakeover, postLockUtils } = this.props; + const { user, postId, isLocked, isTakeover, postLockUtils, postType } = this.props; if ( ! isLocked ) { return null; } @@ -133,7 +134,10 @@ class PostLockedModal extends Component { action: 'edit', _wpnonce: postLockUtils.nonce, } ); - const allPosts = getWPAdminURL( 'edit.php' ); + const allPostsUrl = getWPAdminURL( 'edit.php', { + post_type: get( postType, [ 'slug' ] ), + } ); + const allPostsLabel = get( postType, [ 'labels', 'all_items' ] ); return ( <Modal title={ isTakeover ? __( 'Someone else has taken over this post.' ) : __( 'This post is already being edited.' ) } @@ -155,19 +159,19 @@ class PostLockedModal extends Component { <div> { userDisplayName ? sprintf( - /* translators: 'post' is generic and may be of any type (post, page, etc.). */ - __( '%s now has editing control of this post. Don\'t worry, your changes up to this moment have been saved' ), + /* translators: %s: user's display name */ + __( '%s now has editing control of this post. Don’t worry, your changes up to this moment have been saved.' ), userDisplayName ) : - /* translators: 'post' is generic and may be of any type (post, page, etc.). */ - __( 'Another user now has editing control of this post. Don\'t worry, your changes up to this moment have been saved' ) + __( 'Another user now has editing control of this post. Don’t worry, your changes up to this moment have been saved.' ) } </div> - <p> - <a href={ allPosts }> - { __( 'View all posts' ) } - </a> - </p> + + <div className="editor-post-locked-modal__buttons"> + <Button isPrimary isLarge href={ allPostsUrl }> + { allPostsLabel } + </Button> + </div> </div> ) } { ! isTakeover && ( @@ -175,18 +179,17 @@ class PostLockedModal extends Component { <div> { userDisplayName ? sprintf( - /* translators: 'post' is generic and may be of any type (post, page, etc.). */ + /* translators: %s: user's display name */ __( '%s is currently working on this post, which means you cannot make changes, unless you take over.' ), userDisplayName ) : - /* translators: 'post' is generic and may be of any type (post, page, etc.). */ __( 'Another user is currently working on this post, which means you cannot make changes, unless you take over.' ) } </div> <div className="editor-post-locked-modal__buttons"> - <Button isDefault isLarge href={ allPosts }> - { __( 'All Posts' ) } + <Button isDefault isLarge href={ allPostsUrl }> + { allPostsLabel } </Button> <PostPreviewButton /> <Button isPrimary isLarge href={ unlockUrl }> @@ -209,7 +212,9 @@ export default compose( getPostLockUser, getCurrentPostId, getActivePostLock, + getEditedPostAttribute, } = select( 'core/editor' ); + const { getPostType } = select( 'core' ); return { isLocked: isPostLocked(), isTakeover: isPostLockTakeover(), @@ -217,6 +222,7 @@ export default compose( postId: getCurrentPostId(), postLockUtils: getEditorSettings().postLockUtils, activePostLock: getActivePostLock(), + postType: getPostType( getEditedPostAttribute( 'type' ) ), }; } ), withDispatch( ( dispatch ) => { diff --git a/packages/editor/src/components/post-permalink/editor.js b/packages/editor/src/components/post-permalink/editor.js index 3831d8a800579f..593af16f2c4686 100644 --- a/packages/editor/src/components/post-permalink/editor.js +++ b/packages/editor/src/components/post-permalink/editor.js @@ -7,19 +7,24 @@ import { __ } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import { compose } from '@wordpress/compose'; +/** + * Internal dependencies + */ +import { cleanForSlug } from '../../utils/url'; + class PostPermalinkEditor extends Component { - constructor( { permalinkParts } ) { + constructor( { permalinkParts, slug } ) { super( ...arguments ); this.state = { - editedPostName: permalinkParts.postName, + editedPostName: slug || permalinkParts.postName, }; this.onSavePermalink = this.onSavePermalink.bind( this ); } onSavePermalink( event ) { - const postName = this.state.editedPostName.replace( /\s+/g, '-' ); + const postName = cleanForSlug( this.state.editedPostName ); event.preventDefault(); diff --git a/packages/editor/src/components/post-permalink/index.js b/packages/editor/src/components/post-permalink/index.js index a7364322d4c7df..767f2507f77172 100644 --- a/packages/editor/src/components/post-permalink/index.js +++ b/packages/editor/src/components/post-permalink/index.js @@ -17,7 +17,7 @@ import { safeDecodeURI } from '@wordpress/url'; * Internal Dependencies */ import PostPermalinkEditor from './editor.js'; -import { getWPAdminURL } from '../../utils/url'; +import { getWPAdminURL, cleanForSlug } from '../../utils/url'; class PostPermalink extends Component { constructor() { @@ -57,14 +57,19 @@ class PostPermalink extends Component { } render() { - const { isNew, postLink, isEditable, samplePermalink, isPublished } = this.props; - const { isCopied, isEditingPermalink } = this.state; - const ariaLabel = isCopied ? __( 'Permalink copied' ) : __( 'Copy the permalink' ); + const { isNew, postLink, permalinkParts, postSlug, postTitle, postID, isEditable, isPublished } = this.props; if ( isNew || ! postLink ) { return null; } + const { isCopied, isEditingPermalink } = this.state; + const ariaLabel = isCopied ? __( 'Permalink copied' ) : __( 'Copy the permalink' ); + + const { prefix, suffix } = permalinkParts; + const slug = postSlug || cleanForSlug( postTitle ) || postID; + const samplePermalink = ( isEditable ) ? prefix + slug + suffix : prefix; + return ( <div className="editor-post-permalink"> <ClipboardButton @@ -92,6 +97,7 @@ class PostPermalink extends Component { { isEditingPermalink && <PostPermalinkEditor + slug={ slug } onSave={ () => this.setState( { isEditingPermalink: false } ) } /> } @@ -128,18 +134,22 @@ export default compose( [ isEditedPostNew, isPermalinkEditable, getCurrentPost, - getPermalink, + getPermalinkParts, + getEditedPostAttribute, isCurrentPostPublished, } = select( 'core/editor' ); - const { link } = getCurrentPost(); + const { id, link } = getCurrentPost(); return { isNew: isEditedPostNew(), postLink: link, + permalinkParts: getPermalinkParts(), + postSlug: getEditedPostAttribute( 'slug' ), isEditable: isPermalinkEditable(), - samplePermalink: getPermalink(), isPublished: isCurrentPostPublished(), + postTitle: getEditedPostAttribute( 'title' ), + postID: id, }; } ), withDispatch( ( dispatch ) => { diff --git a/packages/editor/src/components/post-preview-button/index.js b/packages/editor/src/components/post-preview-button/index.js index ca86f8f28c7fd0..5c31e9e271de8f 100644 --- a/packages/editor/src/components/post-preview-button/index.js +++ b/packages/editor/src/components/post-preview-button/index.js @@ -98,10 +98,6 @@ export class PostPreviewButton extends Component { // unintentional forceful redirects. if ( previewLink && ! prevProps.previewLink ) { this.setPreviewWindowLink( previewLink ); - - // Once popup redirect is evaluated, even if already closed, delete - // reference to avoid later assignment of location in post update. - delete this.previewWindow; } } @@ -151,7 +147,11 @@ export class PostPreviewButton extends Component { // Request an autosave. This happens asynchronously and causes the component // to update when finished. - this.props.autosave(); + if ( this.props.isDraft ) { + this.props.savePost( { isPreview: true } ); + } else { + this.props.autosave( { isPreview: true } ); + } // Display a 'Generating preview' message in the Preview tab while we wait for the // autosave to finish. @@ -191,30 +191,34 @@ export class PostPreviewButton extends Component { } export default compose( [ - withSelect( ( select ) => { + withSelect( ( select, { forcePreviewLink, forceIsAutosaveable } ) => { const { getCurrentPostId, getCurrentPostAttribute, - getAutosaveAttribute, getEditedPostAttribute, isEditedPostSaveable, isEditedPostAutosaveable, + getEditedPostPreviewLink, } = select( 'core/editor' ); const { getPostType, } = select( 'core' ); + + const previewLink = getEditedPostPreviewLink(); const postType = getPostType( getEditedPostAttribute( 'type' ) ); return { postId: getCurrentPostId(), currentPostLink: getCurrentPostAttribute( 'link' ), - previewLink: getAutosaveAttribute( 'preview_link' ), + previewLink: forcePreviewLink !== undefined ? forcePreviewLink : previewLink, isSaveable: isEditedPostSaveable(), - isAutosaveable: isEditedPostAutosaveable(), + isAutosaveable: forceIsAutosaveable || isEditedPostAutosaveable(), isViewable: get( postType, [ 'viewable' ], false ), + isDraft: [ 'draft', 'auto-draft' ].indexOf( getEditedPostAttribute( 'status' ) ) !== -1, }; } ), withDispatch( ( dispatch ) => ( { autosave: dispatch( 'core/editor' ).autosave, + savePost: dispatch( 'core/editor' ).savePost, } ) ), ifCondition( ( { isViewable } ) => isViewable ), ] )( PostPreviewButton ); diff --git a/packages/editor/src/components/post-publish-panel/index.js b/packages/editor/src/components/post-publish-panel/index.js index 0b38b0dd78400d..0aef7ae9a3393e 100644 --- a/packages/editor/src/components/post-publish-panel/index.js +++ b/packages/editor/src/components/post-publish-panel/index.js @@ -40,8 +40,8 @@ export class PostPublishPanel extends Component { } onSubmit() { - const { onClose, hasPublishAction } = this.props; - if ( ! hasPublishAction ) { + const { onClose, hasPublishAction, isPostTypeViewable } = this.props; + if ( ! hasPublishAction || ! isPostTypeViewable ) { onClose(); } } @@ -61,7 +61,7 @@ export class PostPublishPanel extends Component { PrePublishExtension, ...additionalProps } = this.props; - const propsForPanel = omit( additionalProps, [ 'hasPublishAction', 'isDirty' ] ); + const propsForPanel = omit( additionalProps, [ 'hasPublishAction', 'isDirty', 'isPostTypeViewable' ] ); const isPublishedOrScheduled = isPublished || ( isScheduled && isBeingScheduled ); const isPrePublish = ! isPublishedOrScheduled && ! isSaving; const isPostPublish = isPublishedOrScheduled && ! isSaving; @@ -112,8 +112,10 @@ export class PostPublishPanel extends Component { export default compose( [ withSelect( ( select ) => { + const { getPostType } = select( 'core' ); const { getCurrentPost, + getEditedPostAttribute, isCurrentPostPublished, isCurrentPostScheduled, isEditedPostBeingScheduled, @@ -121,8 +123,11 @@ export default compose( [ isSavingPost, } = select( 'core/editor' ); const { isPublishSidebarEnabled } = select( 'core/editor' ); + const postType = getPostType( getEditedPostAttribute( 'type' ) ); + return { hasPublishAction: get( getCurrentPost(), [ '_links', 'wp:action-publish' ], false ), + isPostTypeViewable: get( postType, [ 'viewable' ], false ), isBeingScheduled: isEditedPostBeingScheduled(), isDirty: isEditedPostDirty(), isPublished: isCurrentPostPublished(), diff --git a/packages/editor/src/components/post-publish-panel/test/toggle.js b/packages/editor/src/components/post-publish-panel/test/toggle.js deleted file mode 100644 index 1f1a4971dbd35b..00000000000000 --- a/packages/editor/src/components/post-publish-panel/test/toggle.js +++ /dev/null @@ -1,112 +0,0 @@ -/** - * External dependencies - */ -import { shallow } from 'enzyme'; - -/** - * Internal dependencies - */ -import { PostPublishPanelToggle } from '../toggle'; - -describe( 'PostPublishPanelToggle', () => { - describe( 'disabled', () => { - it( 'should be disabled if post is currently saving', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isPublishable - isSaveable - isSaving - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( true ); - expect( console ).toHaveWarnedWith( - 'PostPublishPanelToggle is deprecated and will be removed from Gutenberg in 4.5. Please use PostPublishButton instead.' - ); - } ); - - it( 'should be disabled if post is currently force saving', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isPublishable - isSaveable - forceIsSaving - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( true ); - } ); - - it( 'should be disabled if post is not publishable and not forceIsDirty', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isSaveable - isPublishable={ false } - forceIsDirty={ false } - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( true ); - } ); - - it( 'should be disabled if post is not saveable', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isSaveable={ false } - isPublishable - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( true ); - } ); - - it( 'should be disabled if post is published', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isSaveable - isPublishable - isPublished - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( true ); - } ); - - it( 'should be enabled if post is saveable but not publishable and forceIsDirty is true', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isSaveable - isPublishable={ false } - forceIsDirty={ true } - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( false ); - } ); - - it( 'should be enabled if post is publishave and saveable', () => { - const wrapper = shallow( - <PostPublishPanelToggle - isPublishable - isSaveable - /> - ); - - expect( wrapper.prop( 'disabled' ) ).toBe( false ); - } ); - - it( 'should display Schedule… if able to be scheduled', () => { - const wrapper = shallow( - <PostPublishPanelToggle isPublishable isSaveable isBeingScheduled /> - ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Schedule…' ); - } ); - - it( 'should display Publish… if able to be published', () => { - const wrapper = shallow( - <PostPublishPanelToggle isPublishable isSaveable hasPublishAction /> - ); - expect( wrapper.childAt( 0 ).text() ).toBe( 'Publish…' ); - } ); - } ); -} ); diff --git a/packages/editor/src/components/post-publish-panel/toggle.js b/packages/editor/src/components/post-publish-panel/toggle.js deleted file mode 100644 index 0cf8b56019cb47..00000000000000 --- a/packages/editor/src/components/post-publish-panel/toggle.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * WordPress Dependencies - */ -import { Button } from '@wordpress/components'; -import { compose } from '@wordpress/compose'; -import deprecated from '@wordpress/deprecated'; -import { __ } from '@wordpress/i18n'; -import { withSelect } from '@wordpress/data'; -import { DotTip } from '@wordpress/nux'; - -export function PostPublishPanelToggle( { - isSaving, - isPublishable, - isSaveable, - isPublished, - isBeingScheduled, - onToggle, - isOpen, - forceIsSaving, - forceIsDirty, -} ) { - const isButtonDisabled = - isPublished || - isSaving || - forceIsSaving || - ! isSaveable || - ( ! isPublishable && ! forceIsDirty ); - - deprecated( 'PostPublishPanelToggle', { - version: '4.5', - alternative: 'PostPublishButton', - plugin: 'Gutenberg', - } ); - - return ( - <Button - className="editor-post-publish-panel__toggle" - isPrimary - onClick={ onToggle } - aria-expanded={ isOpen } - disabled={ isButtonDisabled } - isBusy={ isSaving && isPublished } - > - { isBeingScheduled ? __( 'Schedule…' ) : __( 'Publish…' ) } - <DotTip tipId="core/editor.publish"> - { __( 'Finished writing? That’s great, let’s get this published right now. Just click “Publish” and you’re good to go.' ) } - </DotTip> - </Button> - ); -} - -export default compose( [ - withSelect( ( select ) => { - const { - isSavingPost, - isEditedPostSaveable, - isEditedPostPublishable, - isCurrentPostPublished, - isEditedPostBeingScheduled, - } = select( 'core/editor' ); - return { - isSaving: isSavingPost(), - isSaveable: isEditedPostSaveable(), - isPublishable: isEditedPostPublishable(), - isPublished: isCurrentPostPublished(), - isBeingScheduled: isEditedPostBeingScheduled(), - }; - } ), -] )( PostPublishPanelToggle ); diff --git a/packages/editor/src/components/post-saved-state/style.scss b/packages/editor/src/components/post-saved-state/style.scss index 0b74a6820fff54..68ca157e2077d4 100644 --- a/packages/editor/src/components/post-saved-state/style.scss +++ b/packages/editor/src/components/post-saved-state/style.scss @@ -5,7 +5,7 @@ overflow: hidden; &.is-saving { - animation: loading_fade 0.5s infinite; + animation: edit-post__loading-fade-animation 0.5s infinite; } .dashicon { diff --git a/packages/editor/src/components/post-schedule/index.js b/packages/editor/src/components/post-schedule/index.js index daf37674780abf..78a8a60c583b25 100644 --- a/packages/editor/src/components/post-schedule/index.js +++ b/packages/editor/src/components/post-schedule/index.js @@ -22,7 +22,6 @@ export function PostSchedule( { date, onUpdateDate } ) { key="date-time-picker" currentDate={ date } onChange={ onUpdateDate } - locale={ settings.l10n.locale } is12Hour={ is12HourTime } /> ); diff --git a/packages/editor/src/components/post-text-editor/index.js b/packages/editor/src/components/post-text-editor/index.js index 4b70526f2bc3ea..c08e59f35a4009 100644 --- a/packages/editor/src/components/post-text-editor/index.js +++ b/packages/editor/src/components/post-text-editor/index.js @@ -73,6 +73,7 @@ export class PostTextEditor extends Component { </label> <Textarea autoComplete="off" + dir="auto" value={ value } onChange={ this.edit } onBlur={ this.stopEditing } diff --git a/packages/editor/src/components/post-title/style.scss b/packages/editor/src/components/post-title/style.scss index 0c8fc2c4ca3e88..71fc698dea2024 100644 --- a/packages/editor/src/components/post-title/style.scss +++ b/packages/editor/src/components/post-title/style.scss @@ -18,6 +18,7 @@ color: $dark-gray-900; transition: border 0.1s ease-out; padding: #{ $block-padding + 5px } $block-padding; + word-break: keep-all; // Stack borders on mobile. border: $border-width solid transparent; @@ -46,7 +47,7 @@ } } - &:not(.is-focus-mode):not(.has-fixed-toolbar) { + &:not(.is-focus-mode) { &.is-selected .editor-post-title__input { // use opacity to work in various editor styles border-color: $dark-opacity-light-500; @@ -55,7 +56,9 @@ border-color: $light-opacity-light-500; } } + } + &:not(.is-focus-mode):not(.has-fixed-toolbar) { .editor-post-title__input:hover { border-color: theme(outlines); } diff --git a/packages/editor/src/components/post-visibility/style.scss b/packages/editor/src/components/post-visibility/style.scss index ad02ee0f7cbb94..6efc2afc59ea1e 100644 --- a/packages/editor/src/components/post-visibility/style.scss +++ b/packages/editor/src/components/post-visibility/style.scss @@ -33,3 +33,7 @@ margin-left: 28px; } } + +.edit-post-post-visibility__dialog.components-popover.is-bottom { + z-index: z-index(".edit-post-post-visibility__dialog.components-popover.is-bottom"); +} diff --git a/packages/editor/src/components/rich-text/index.js b/packages/editor/src/components/rich-text/index.js index 45158696b7a3d2..43df39403e3bbd 100644 --- a/packages/editor/src/components/rich-text/index.js +++ b/packages/editor/src/components/rich-text/index.js @@ -3,11 +3,13 @@ */ import classnames from 'classnames'; import { - defer, find, isNil, isEqual, omit, + pickBy, + get, + isPlainObject, } from 'lodash'; import memize from 'memize'; @@ -15,13 +17,9 @@ import memize from 'memize'; * WordPress dependencies */ import { Component, Fragment, RawHTML } from '@wordpress/element'; -import { - isHorizontalEdge, - getRectangleFromRange, - getScrollContainer, -} from '@wordpress/dom'; +import { isHorizontalEdge } from '@wordpress/dom'; import { createBlobURL } from '@wordpress/blob'; -import { BACKSPACE, DELETE, ENTER, rawShortcut } from '@wordpress/keycodes'; +import { BACKSPACE, DELETE, ENTER } from '@wordpress/keycodes'; import { withDispatch, withSelect } from '@wordpress/data'; import { pasteHandler, children, getBlockTransforms, findTransform } from '@wordpress/blocks'; import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; @@ -41,12 +39,14 @@ import { getSelectionStart, getSelectionEnd, remove, + removeFormat, isCollapsed, LINE_SEPARATOR, charAt, } from '@wordpress/rich-text'; import { decodeEntities } from '@wordpress/html-entities'; -import { withFilters } from '@wordpress/components'; +import { withFilters, IsolatedEventContainer } from '@wordpress/components'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -59,6 +59,8 @@ import TinyMCE, { TINYMCE_ZWSP } from './tinymce'; import { pickAriaProps } from './aria'; import { getPatterns } from './patterns'; import { withBlockEditContext } from '../block-edit/context'; +import { ListEdit } from './list-edit'; +import { RemoveBrowserShortcuts } from './remove-browser-shortcuts'; /** * Browser dependencies @@ -78,20 +80,27 @@ export class RichText extends Component { this.multilineWrapperTags = [ 'ul', 'ol' ]; } - this.onInit = this.onInit.bind( this ); - this.getSettings = this.getSettings.bind( this ); + if ( this.props.onSplit ) { + this.onSplit = this.props.onSplit; + + deprecated( 'wp.editor.RichText onSplit prop', { + plugin: 'Gutenberg', + alternative: 'wp.editor.RichText unstableOnSplit prop', + } ); + } else if ( this.props.unstableOnSplit ) { + this.onSplit = this.props.unstableOnSplit; + } + this.onSetup = this.onSetup.bind( this ); this.onFocus = this.onFocus.bind( this ); this.onChange = this.onChange.bind( this ); - this.onNodeChange = this.onNodeChange.bind( this ); this.onDeleteKeyDown = this.onDeleteKeyDown.bind( this ); this.onKeyDown = this.onKeyDown.bind( this ); - this.onKeyUp = this.onKeyUp.bind( this ); - this.onPropagateUndo = this.onPropagateUndo.bind( this ); this.onPaste = this.onPaste.bind( this ); this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); this.setFocusedElement = this.setFocusedElement.bind( this ); this.onInput = this.onInput.bind( this ); + this.onCompositionEnd = this.onCompositionEnd.bind( this ); this.onSelectionChange = this.onSelectionChange.bind( this ); this.getRecord = this.getRecord.bind( this ); this.createRecord = this.createRecord.bind( this ); @@ -106,16 +115,17 @@ export class RichText extends Component { this.savedContent = value; this.patterns = getPatterns( { onReplace, - multilineTag: this.multilineTag, + onCreateUndoLevel: this.onCreateUndoLevel, valueToFormat: this.valueToFormat, + onChange: this.onChange, } ); - this.enterPatterns = getBlockTransforms( 'from' ).filter( ( { type, trigger } ) => - type === 'pattern' && trigger === 'enter' - ); + this.enterPatterns = getBlockTransforms( 'from' ) + .filter( ( { type } ) => type === 'enter' ); this.state = {}; this.usedDeprecatedChildrenSource = Array.isArray( value ); + this.lastHistoryValue = value; } componentDidMount() { @@ -134,31 +144,6 @@ export class RichText extends Component { return this.editableRef === document.activeElement; } - /** - * Retrieves the settings for this block. - * - * Allows passing in settings which will be overwritten. - * - * @param {Object} settings The settings to overwrite. - * @return {Object} The settings for this block. - */ - getSettings( settings ) { - settings = { - ...settings, - forced_root_block: this.multilineTag || false, - // Allow TinyMCE to keep one undo level for comparing changes. - // Prevent it otherwise from accumulating any history. - custom_undo_redo_levels: 1, - }; - - const { unstableGetSettings } = this.props; - if ( unstableGetSettings ) { - settings = unstableGetSettings( settings ); - } - - return settings; - } - /** * Handles the onSetup event for the TinyMCE component. * @@ -169,17 +154,6 @@ export class RichText extends Component { */ onSetup( editor ) { this.editor = editor; - - editor.on( 'init', this.onInit ); - editor.on( 'nodechange', this.onNodeChange ); - editor.on( 'BeforeExecCommand', this.onPropagateUndo ); - // The change event in TinyMCE fires every time an undo level is added. - editor.on( 'change', this.onCreateUndoLevel ); - - const { unstableOnSetup } = this.props; - if ( unstableOnSetup ) { - unstableOnSetup( editor ); - } } setFocusedElement() { @@ -188,35 +162,6 @@ export class RichText extends Component { } } - onInit() { - this.editor.shortcuts.add( rawShortcut.primary( 'z' ), '', 'Undo' ); - this.editor.shortcuts.add( rawShortcut.primaryShift( 'z' ), '', 'Redo' ); - - // Remove TinyMCE Core shortcut for consistency with global editor - // shortcuts. Also clashes with Mac browsers. - this.editor.shortcuts.remove( 'meta+y', '', 'Redo' ); - } - - /** - * Handles an undo event from TinyMCE. - * - * @param {UndoEvent} event The undo event as triggered by TinyMCE. - */ - onPropagateUndo( event ) { - const { onUndo, onRedo } = this.props; - const { command } = event; - - if ( command === 'Undo' && onUndo ) { - defer( onUndo ); - event.preventDefault(); - } - - if ( command === 'Redo' && onRedo ) { - defer( onRedo ); - event.preventDefault(); - } - } - /** * Get the current record (value and selection) from props and state. * @@ -322,24 +267,23 @@ export class RichText extends Component { window.console.log( 'Received item:\n\n', file ); if ( shouldReplace ) { - // Necessary to allow the paste bin to be removed without errors. - this.props.setTimeout( () => this.props.onReplace( content ) ); - } else if ( this.props.onSplit ) { - // Necessary to get the right range. - // Also done in the TinyMCE paste plugin. - this.props.setTimeout( () => this.splitContent( content ) ); + this.props.onReplace( content ); + } else if ( this.onSplit ) { + this.splitContent( content ); } return; } + const record = this.getRecord(); + // There is a selection, check if a URL is pasted. - if ( ! this.editor.selection.isCollapsed() ) { + if ( ! isCollapsed( record ) ) { const pastedText = ( html || plainText ).replace( /<[^>]+>/g, '' ).trim(); // A URL was pasted, turn the selection into a link if ( isURL( pastedText ) ) { - this.onChange( applyFormat( this.getRecord(), { + this.onChange( applyFormat( record, { type: 'a', attributes: { href: decodeEntities( pastedText ), @@ -359,7 +303,7 @@ export class RichText extends Component { if ( shouldReplace ) { mode = 'BLOCKS'; - } else if ( this.props.onSplit ) { + } else if ( this.onSplit ) { mode = 'AUTO'; } @@ -373,8 +317,8 @@ export class RichText extends Component { if ( typeof content === 'string' ) { const recordToInsert = create( { html: content } ); - this.onChange( insert( this.getRecord(), recordToInsert ) ); - } else if ( this.props.onSplit ) { + this.onChange( insert( record, recordToInsert ) ); + } else if ( this.onSplit ) { if ( ! content.length ) { return; } @@ -413,12 +357,34 @@ export class RichText extends Component { /** * Handle input on the next selection change event. + * + * @param {SyntheticEvent} event Synthetic input event. */ - onInput() { + onInput( event ) { + // For Input Method Editor (IME), used in Chinese, Japanese, and Korean + // (CJK), do not trigger a change if characters are being composed. + // Browsers setting `isComposing` to `true` will usually emit a final + // `input` event when the characters are composed. + if ( event.nativeEvent.isComposing ) { + return; + } + const record = this.createRecord(); const transformed = this.patterns.reduce( ( accumlator, transform ) => transform( accumlator ), record ); - this.onChange( transformed ); + this.onChange( transformed, { + withoutHistory: true, + } ); + + // Create an undo level when input stops for over a second. + this.props.clearTimeout( this.onInput.timeout ); + this.onInput.timeout = this.props.setTimeout( this.onCreateUndoLevel, 1000 ); + } + + onCompositionEnd() { + // Ensure the value is up-to-date for browsers that don't emit a final + // input event after composition. + this.onChange( this.createRecord() ); } /** @@ -444,46 +410,51 @@ export class RichText extends Component { } } + /** + * Calls all registered onChangeEditableValue handlers. + * + * @param {Array} formats The formats of the latest rich-text value. + * @param {string} text The text of the latest rich-text value. + */ + onChangeEditableValue( { formats, text } ) { + get( this.props, [ 'onChangeEditableValue' ], [] ).forEach( ( eventHandler ) => { + eventHandler( formats, text ); + } ); + } + /** * Sync the value to global state. The node tree and selection will also be * updated if differences are found. * - * @param {Object} record The record to sync and apply. - * @param {boolean} _withoutApply If true, the record won't be applied to - * the live DOM. + * @param {Object} record The record to sync and apply. + * @param {Object} $2 Named options. + * @param {boolean} $2.withoutHistory If true, no undo level will be + * created. */ - onChange( record, _withoutApply ) { - if ( ! _withoutApply ) { - this.applyRecord( record ); - } + onChange( record, { withoutHistory } = {} ) { + this.applyRecord( record ); const { start, end } = record; + this.onChangeEditableValue( record ); + this.savedContent = this.valueToFormat( record ); this.props.onChange( this.savedContent ); this.setState( { start, end } ); - } - onCreateUndoLevel( event ) { - // TinyMCE fires a `change` event when the first letter in an instance - // is typed. This should not create a history record in Gutenberg. - // https://github.com/tinymce/tinymce/blob/4.7.11/src/core/main/ts/api/UndoManager.ts#L116-L125 - // In other cases TinyMCE won't fire a `change` with at least a previous - // record present, so this is a reliable check. - // https://github.com/tinymce/tinymce/blob/4.7.11/src/core/main/ts/api/UndoManager.ts#L272-L275 - if ( event && event.lastLevel === null ) { - return; + if ( ! withoutHistory ) { + this.onCreateUndoLevel(); } + } - // Always ensure the content is up-to-date. This is needed because e.g. - // making something bold will trigger a TinyMCE change event but no - // input event. Avoid dispatching an action if the original event is - // blur because the content will already be up-to-date. - if ( ! event || ! event.originalEvent || event.originalEvent.type !== 'blur' ) { - this.onChange( this.createRecord(), true ); + onCreateUndoLevel() { + // If the content is the same, no level needs to be created. + if ( this.lastHistoryValue === this.savedContent ) { + return; } this.props.onCreateUndoLevel(); + this.lastHistoryValue = this.savedContent; } /** @@ -549,8 +520,8 @@ export class RichText extends Component { const start = getSelectionStart( value ); const end = getSelectionEnd( value ); - // Always handle uncollapsed selections ourselves. - if ( ! isCollapsed( value ) ) { + // Always handle full content deletion ourselves. + if ( start === 0 && end !== 0 && end === value.text.length ) { this.onChange( remove( value ) ); event.preventDefault(); return; @@ -605,12 +576,12 @@ export class RichText extends Component { } if ( this.multilineTag ) { - if ( this.props.onSplit && isEmptyLine( record ) ) { - this.props.onSplit( ...split( record ).map( this.valueToFormat ) ); + if ( this.onSplit && isEmptyLine( record ) ) { + this.onSplit( ...split( record ).map( this.valueToFormat ) ); } else { this.onChange( insertLineSeparator( record ) ); } - } else if ( event.shiftKey || ! this.props.onSplit ) { + } else if ( event.shiftKey || ! this.onSplit ) { const text = getTextContent( record ); const length = text.length; let toInsert = '\n'; @@ -632,54 +603,6 @@ export class RichText extends Component { } } - /** - * Handles a keyup event. - * - * @param {number} $1.keyCode The key code that has been pressed on the - * keyboard. - */ - onKeyUp( { keyCode } ) { - // The input event does not fire when the whole field is selected and - // BACKSPACE is pressed. - if ( keyCode === BACKSPACE ) { - this.onChange( this.createRecord(), true ); - } - - // `scrollToRect` is called on `nodechange`, whereas calling it on - // `keyup` *when* moving to a new RichText element results in incorrect - // scrolling. Though the following allows false positives, it results - // in much smoother scrolling. - if ( this.props.isViewportSmall && keyCode !== BACKSPACE && keyCode !== ENTER ) { - this.scrollToRect( getRectangleFromRange( this.editor.selection.getRng() ) ); - } - } - - scrollToRect( rect ) { - const { top: caretTop } = rect; - const container = getScrollContainer( this.editableRef ); - - if ( ! container ) { - return; - } - - // When scrolling, avoid positioning the caret at the very top of - // the viewport, providing some "air" and some textual context for - // the user, and avoiding toolbars. - const graceOffset = 100; - - // Avoid pointless scrolling by establishing a threshold under - // which scrolling should be skipped; - const epsilon = 10; - const delta = caretTop - graceOffset; - - if ( Math.abs( delta ) > epsilon ) { - container.scrollTo( - container.scrollLeft, - container.scrollTop + delta, - ); - } - } - /** * Splits the content at the location of the selection. * @@ -691,10 +614,9 @@ export class RichText extends Component { * @param {Object} context The context for splitting. */ splitContent( blocks = [], context = {} ) { - const { onSplit } = this.props; const record = this.createRecord(); - if ( ! onSplit ) { + if ( ! this.onSplit ) { return; } @@ -726,30 +648,7 @@ export class RichText extends Component { after = this.valueToFormat( after ); } - onSplit( before, after, ...blocks ); - } - - onNodeChange( { parents } ) { - if ( ! this.isActive() ) { - return; - } - - if ( this.props.isViewportSmall ) { - let rect; - const selectedAnchor = find( parents, ( node ) => node.tagName === 'A' ); - if ( selectedAnchor ) { - // If we selected a link, position the Link UI below the link - rect = selectedAnchor.getBoundingClientRect(); - } else { - // Otherwise, position the Link UI below the cursor or text selection - rect = getRectangleFromRange( this.editor.selection.getRng() ); - } - - // Originally called on `focusin`, that hook turned out to be - // premature. On `nodechange` we can work with the finalized TinyMCE - // instance and scroll to proper position. - this.scrollToRect( rect ); - } + this.onSplit( before, after, ...blocks ); } componentDidUpdate( prevProps ) { @@ -801,6 +700,11 @@ export class RichText extends Component { return false; } + // Allow primitives and arrays: + if ( ! isPlainObject( this.props[ name ] ) ) { + return this.props[ name ] !== prevProps[ name ]; + } + return Object.keys( this.props[ name ] ).some( ( subName ) => { return this.props[ name ][ subName ] !== prevProps[ name ][ subName ]; } ); @@ -808,10 +712,30 @@ export class RichText extends Component { if ( shouldReapply ) { const record = this.formatToValue( value ); + + // Maintain the previous selection: + record.start = this.state.start; + record.end = this.state.end; + this.applyRecord( record ); } } + /** + * Get props that are provided by formats to modify RichText. + * + * @return {Object} Props that start with 'format_'. + */ + getFormatProps() { + return pickBy( this.props, ( propValue, name ) => name.startsWith( 'format_' ) ); + } + + /** + * Converts the outside data structure to our internal representation. + * + * @param {*} value The outside value, data type depends on props. + * @return {Object} An internal rich-text value. + */ formatToValue( value ) { // Handle deprecated `children` and `node` sources. if ( Array.isArray( value ) ) { @@ -853,7 +777,35 @@ export class RichText extends Component { } ).body.innerHTML; } + /** + * Removes editor only formats from the value. + * + * Editor only formats are applied using `prepareEditableTree`, so we need to + * remove them before converting the internal state + * + * @param {Object} value The internal rich-text value. + * @return {Object} A new rich-text value. + */ + removeEditorOnlyFormats( value ) { + this.props.formatTypes.forEach( ( formatType ) => { + // Remove formats created by prepareEditableTree, because they are editor only. + if ( formatType.__experimentalCreatePrepareEditableTree ) { + value = removeFormat( value, formatType.name, 0, value.text.length ); + } + } ); + + return value; + } + + /** + * Converts the internal value to the external data format. + * + * @param {Object} value The internal rich-text value. + * @return {*} The external data format, data type depends on props. + */ valueToFormat( value ) { + value = this.removeEditorOnlyFormats( value ); + // Handle deprecated `children` and `node` sources. if ( this.usedDeprecatedChildrenSource ) { return children.fromDOM( unstableToDom( { @@ -886,6 +838,7 @@ export class RichText extends Component { keepPlaceholderOnFocus = false, isSelected, autocompleters, + onTagNameChange, } = this.props; const MultilineTag = this.multilineTag; @@ -903,15 +856,22 @@ export class RichText extends Component { <div className={ classes } onFocus={ this.setFocusedElement } > + { isSelected && this.editor && this.multilineTag === 'li' && ( + <ListEdit + editor={ this.editor } + onTagNameChange={ onTagNameChange } + tagName={ Tagname } + /> + ) } { isSelected && ! inlineToolbar && ( <BlockFormatControls> <FormatToolbar controls={ formattingControls } /> </BlockFormatControls> ) } { isSelected && inlineToolbar && ( - <div className="editor-rich-text__inline-toolbar"> + <IsolatedEventContainer className="editor-rich-text__inline-toolbar"> <FormatToolbar controls={ formattingControls } /> - </div> + </IsolatedEventContainer> ) } <Autocomplete onReplace={ this.props.onReplace } @@ -923,7 +883,6 @@ export class RichText extends Component { <Fragment> <TinyMCE tagName={ Tagname } - getSettings={ this.getSettings } onSetup={ this.onSetup } style={ style } defaultValue={ this.valueToEditableHTML( record ) } @@ -938,8 +897,8 @@ export class RichText extends Component { key={ key } onPaste={ this.onPaste } onInput={ this.onInput } + onCompositionEnd={ this.onCompositionEnd } onKeyDown={ this.onKeyDown } - onKeyUp={ this.onKeyUp } onFocus={ this.onFocus } multilineTag={ this.multilineTag } multilineWrapperTags={ this.multilineWrapperTags } @@ -957,6 +916,7 @@ export class RichText extends Component { </Fragment> ) } </Autocomplete> + { isSelected && <RemoveBrowserShortcuts /> } </div> ); } @@ -993,13 +953,13 @@ const RichTextContainer = compose( [ }; } ), withSelect( ( select ) => { - const { isViewportMatch } = select( 'core/viewport' ); const { canUserUseUnfilteredHTML, isCaretWithinFormattedText } = select( 'core/editor' ); + const { getFormatTypes } = select( 'core/rich-text' ); return { - isViewportSmall: isViewportMatch( '< small' ), canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(), isCaretWithinFormattedText: isCaretWithinFormattedText(), + formatTypes: getFormatTypes(), }; } ), withDispatch( ( dispatch ) => { diff --git a/packages/editor/src/components/rich-text/index.native.js b/packages/editor/src/components/rich-text/index.native.js index a64a3bd422b9f4..5b378ccd447394 100644 --- a/packages/editor/src/components/rich-text/index.native.js +++ b/packages/editor/src/components/rich-text/index.native.js @@ -360,6 +360,7 @@ export class RichText extends Component { onBackspace={ this.onBackspace } onContentSizeChange={ this.onContentSizeChange } onActiveFormatsChange={ this.onActiveFormatsChange } + isSelected={ this.props.isSelected } color={ 'black' } maxImagesWidth={ 200 } style={ style } diff --git a/packages/editor/src/components/rich-text/list-edit.js b/packages/editor/src/components/rich-text/list-edit.js new file mode 100644 index 00000000000000..9d7e40f4b414cd --- /dev/null +++ b/packages/editor/src/components/rich-text/list-edit.js @@ -0,0 +1,101 @@ +/** + * WordPress dependencies + */ + +import { Toolbar } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ + +import { RichTextShortcut } from './shortcut'; +import BlockFormatControls from '../block-format-controls'; + +function isListRootSelected( editor ) { + return ( + ! editor.selection || + editor.selection.getNode().closest( 'ol,ul' ) === editor.getBody() + ); +} + +function isActiveListType( editor, tagName, rootTagName ) { + if ( document.activeElement !== editor.getBody() ) { + return tagName === rootTagName; + } + + const listItem = editor.selection.getNode(); + const list = listItem.closest( 'ol,ul' ); + + if ( ! list ) { + return; + } + + return list.nodeName.toLowerCase() === tagName; +} + +export const ListEdit = ( { editor, onTagNameChange, tagName } ) => ( + <Fragment> + <RichTextShortcut + type="primary" + character="[" + onUse={ () => editor.execCommand( 'Outdent' ) } + /> + <RichTextShortcut + type="primary" + character="]" + onUse={ () => editor.execCommand( 'Indent' ) } + /> + <RichTextShortcut + type="primary" + character="m" + onUse={ () => editor.execCommand( 'Indent' ) } + /> + <RichTextShortcut + type="primaryShift" + character="m" + onUse={ () => editor.execCommand( 'Outdent' ) } + /> + <BlockFormatControls> + <Toolbar + controls={ [ + { + icon: 'editor-ul', + title: __( 'Convert to unordered list' ), + isActive: isActiveListType( editor, 'ul', tagName ), + onClick() { + if ( isListRootSelected( editor ) ) { + onTagNameChange( 'ul' ); + } else { + editor.execCommand( 'InsertUnorderedList' ); + } + }, + }, + { + icon: 'editor-ol', + title: __( 'Convert to ordered list' ), + isActive: isActiveListType( editor, 'ol', tagName ), + onClick() { + if ( isListRootSelected( editor ) ) { + onTagNameChange( 'ol' ); + } else { + editor.execCommand( 'InsertOrderedList' ); + } + }, + }, + { + icon: 'editor-outdent', + title: __( 'Outdent list item' ), + onClick: () => editor.execCommand( 'Outdent' ), + }, + { + icon: 'editor-indent', + title: __( 'Indent list item' ), + onClick: () => editor.execCommand( 'Indent' ), + }, + ] } + /> + </BlockFormatControls> + </Fragment> +); diff --git a/packages/editor/src/components/rich-text/patterns.js b/packages/editor/src/components/rich-text/patterns.js index 5cb16b5fe68b6e..a9e9a8010149d7 100644 --- a/packages/editor/src/components/rich-text/patterns.js +++ b/packages/editor/src/components/rich-text/patterns.js @@ -1,18 +1,18 @@ -/** - * External dependencies - */ -import { filter } from 'lodash'; - /** * WordPress dependencies */ import { getBlockTransforms, findTransform } from '@wordpress/blocks'; -import { remove, applyFormat, getTextContent } from '@wordpress/rich-text'; - -export function getPatterns( { onReplace, multiline, valueToFormat } ) { - const patterns = filter( getBlockTransforms( 'from' ), ( { type, trigger } ) => { - return type === 'pattern' && trigger === undefined; - } ); +import { + remove, + applyFormat, + getTextContent, + getSelectionStart, + slice, +} from '@wordpress/rich-text'; + +export function getPatterns( { onReplace, valueToFormat, onCreateUndoLevel, onChange } ) { + const prefixTransforms = getBlockTransforms( 'from' ) + .filter( ( { type } ) => type === 'prefix' ); return [ ( record ) => { @@ -20,50 +20,61 @@ export function getPatterns( { onReplace, multiline, valueToFormat } ) { return record; } + const start = getSelectionStart( record ); const text = getTextContent( record ); - const transformation = findTransform( patterns, ( item ) => { - return item.regExp.test( text ); + const characterBefore = text.slice( start - 1, start ); + + if ( ! /\s/.test( characterBefore ) ) { + return record; + } + + const trimmedTextBefore = text.slice( 0, start ).trim(); + const transformation = findTransform( prefixTransforms, ( { prefix } ) => { + return trimmedTextBefore === prefix; } ); if ( ! transformation ) { return record; } - const result = text.match( transformation.regExp ); - - const block = transformation.transform( { - content: valueToFormat( remove( record, 0, result[ 0 ].length ) ), - match: result, - } ); + const content = valueToFormat( slice( record, start, text.length ) ); + const block = transformation.transform( content ); + onCreateUndoLevel(); onReplace( [ block ] ); return record; }, ( record ) => { - if ( multiline ) { + const BACKTICK = '`'; + const start = getSelectionStart( record ); + const text = getTextContent( record ); + const characterBefore = text.slice( start - 1, start ); + + // Quick check the text for the necessary character. + if ( characterBefore !== BACKTICK ) { return record; } - const text = getTextContent( record ); + const textBefore = text.slice( 0, start - 1 ); + const indexBefore = textBefore.lastIndexOf( BACKTICK ); - // Quick check the text for the necessary character. - if ( text.indexOf( '`' ) === -1 ) { + if ( indexBefore === -1 ) { return record; } - const match = text.match( /`([^`]+)`/ ); + const startIndex = indexBefore; + const endIndex = start - 2; - if ( ! match ) { + if ( startIndex === endIndex ) { return record; } - const start = match.index; - const end = start + match[ 1 ].length; + onChange( record ); - record = remove( record, start, start + 1 ); - record = remove( record, end, end + 1 ); - record = applyFormat( record, { type: 'code' }, start, end ); + record = remove( record, startIndex, startIndex + 1 ); + record = remove( record, endIndex, endIndex + 1 ); + record = applyFormat( record, { type: 'code' }, startIndex, endIndex ); return record; }, diff --git a/packages/editor/src/components/rich-text/remove-browser-shortcuts.js b/packages/editor/src/components/rich-text/remove-browser-shortcuts.js new file mode 100644 index 00000000000000..165566a5bbbd16 --- /dev/null +++ b/packages/editor/src/components/rich-text/remove-browser-shortcuts.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { fromPairs } from 'lodash'; + +/** + * WordPress dependencies + */ +import { rawShortcut } from '@wordpress/keycodes'; +import { KeyboardShortcuts } from '@wordpress/components'; + +/** + * Set of keyboard shortcuts handled internally by RichText. + * + * @type {Array} + */ +const HANDLED_SHORTCUTS = [ + rawShortcut.primary( 'z' ), + rawShortcut.primaryShift( 'z' ), + rawShortcut.primary( 'y' ), +]; + +/** + * An instance of a KeyboardShortcuts element pre-bound for the handled + * shortcuts. Since shortcuts never change, the element can be considered + * static, and can be skipped in reconciliation. + * + * @type {WPElement} + */ +const SHORTCUTS_ELEMENT = ( + <KeyboardShortcuts + bindGlobal + shortcuts={ fromPairs( HANDLED_SHORTCUTS.map( ( shortcut ) => { + return [ shortcut, ( event ) => event.preventDefault() ]; + } ) ) } + /> +); + +/** + * Component which registered keyboard event handlers to prevent default + * behaviors for key combinations otherwise handled internally by RichText. + * + * @return {WPElement} WordPress element. + */ +export const RemoveBrowserShortcuts = () => SHORTCUTS_ELEMENT; diff --git a/packages/editor/src/components/rich-text/style.scss b/packages/editor/src/components/rich-text/style.scss index d474ac3af42115..b4ebf2b4b8c69f 100644 --- a/packages/editor/src/components/rich-text/style.scss +++ b/packages/editor/src/components/rich-text/style.scss @@ -97,6 +97,15 @@ & > p { margin-top: 0; } + + // Ensure that if placeholder wraps (mobile/nested contexts) the clickable area is full-height. + height: 100%; + + // On mobile and in nested contexts, the plus to add blocks shows up on the right. + // This padding makes sure it doesn't overlap text. + & + .editor-rich-text__tinymce { + padding-right: $icon-button-size; + } } // Placeholder text. diff --git a/packages/editor/src/components/rich-text/test/index.js b/packages/editor/src/components/rich-text/test/index.js index bca5420da2d0d6..01b14cfce1f88c 100644 --- a/packages/editor/src/components/rich-text/test/index.js +++ b/packages/editor/src/components/rich-text/test/index.js @@ -1,44 +1,9 @@ -/** - * External dependencies - */ -import { shallow } from 'enzyme'; - -/** - * WordPress dependencies - */ -import { create } from '@wordpress/rich-text'; - /** * Internal dependencies */ -import { RichText } from '../'; import { diffAriaProps, pickAriaProps } from '../aria'; describe( 'RichText', () => { - describe( 'Component', () => { - describe( '.getSettings', () => { - const value = create(); - const settings = { - setting: 'hi', - }; - - test( 'should return expected settings', () => { - const wrapper = shallow( <RichText value={ value } /> ); - expect( wrapper.instance().getSettings( settings ) ).toEqual( { - setting: 'hi', - forced_root_block: false, - custom_undo_redo_levels: 1, - } ); - } ); - - test( 'should be overriden', () => { - const mock = jest.fn().mockImplementation( () => 'mocked' ); - - expect( shallow( <RichText value={ value } unstableGetSettings={ mock } /> ).instance().getSettings( settings ) ).toEqual( 'mocked' ); - } ); - } ); - } ); - describe( 'pickAriaProps()', () => { it( 'should should filter all properties to only those begining with "aria-"', () => { expect( pickAriaProps( { diff --git a/packages/editor/src/components/rich-text/tinymce.js b/packages/editor/src/components/rich-text/tinymce.js index 9efd051c669082..0085343bdbd27f 100644 --- a/packages/editor/src/components/rich-text/tinymce.js +++ b/packages/editor/src/components/rich-text/tinymce.js @@ -10,6 +10,7 @@ import classnames from 'classnames'; */ import { Component, createElement } from '@wordpress/element'; import { BACKSPACE, DELETE, ENTER, LEFT, RIGHT } from '@wordpress/keycodes'; +import { isEntirelySelected } from '@wordpress/dom'; /** * Internal dependencies @@ -175,7 +176,8 @@ export default class TinyMCE extends Component { } initialize() { - const settings = this.props.getSettings( { + const { multilineTag } = this.props; + const settings = { theme: false, inline: true, toolbar: false, @@ -189,7 +191,16 @@ export default class TinyMCE extends Component { verify_html: false, inline_boundaries_selector: 'a[href],code,b,i,strong,em,del,ins,sup,sub', plugins: [], - } ); + forced_root_block: multilineTag || false, + // Allow TinyMCE to keep one undo level for comparing changes. + // Prevent it otherwise from accumulating any history. + custom_undo_redo_levels: 1, + lists_indent_on_tab: false, + }; + + if ( multilineTag === 'li' ) { + settings.plugins.push( 'lists' ); + } tinymce.init( { ...settings, @@ -211,7 +222,18 @@ export default class TinyMCE extends Component { } ); editor.on( 'init', () => { - // See https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/keyboard/FormatShortcuts.ts + // History is handled internally by RichText. + // + // See: https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/api/UndoManager.ts + [ 'z', 'y' ].forEach( ( character ) => { + editor.shortcuts.remove( `meta+${ character }` ); + } ); + editor.shortcuts.remove( 'meta+shift+z' ); + + // Reset TinyMCE's default formatting shortcuts, since + // RichText supports only registered formats. + // + // See: https://github.com/tinymce/tinymce/blob/master/src/core/main/ts/keyboard/FormatShortcuts.ts [ 'b', 'i', 'u' ].forEach( ( character ) => { editor.shortcuts.remove( `meta+${ character }` ); } ); @@ -219,6 +241,7 @@ export default class TinyMCE extends Component { editor.shortcuts.remove( `access+${ number }` ); } ); + // Restore the original `setHTML` once initialized. editor.dom.setHTML = setHTML; } ); @@ -250,11 +273,13 @@ export default class TinyMCE extends Component { onKeyDown( event ) { const { keyCode } = event; - const { startContainer, startOffset, endContainer, endOffset } = getSelection().getRangeAt( 0 ); - const isCollapsed = startContainer === endContainer && startOffset === endOffset; + const isDelete = keyCode === DELETE || keyCode === BACKSPACE; // Disables TinyMCE behaviour. - if ( keyCode === ENTER || ( ! isCollapsed && ( keyCode === DELETE || keyCode === BACKSPACE ) ) ) { + if ( + keyCode === ENTER || + ( isDelete && isEntirelySelected( this.editorNode ) ) + ) { event.preventDefault(); // For some reason this is needed to also prevent the insertion of // line breaks. @@ -314,7 +339,7 @@ export default class TinyMCE extends Component { onPaste, onInput, onKeyDown, - onKeyUp, + onCompositionEnd, } = this.props; /* @@ -343,7 +368,7 @@ export default class TinyMCE extends Component { onInput, onFocus: this.onFocus, onKeyDown, - onKeyUp, + onCompositionEnd, } ); } } diff --git a/packages/editor/src/components/url-input/index.js b/packages/editor/src/components/url-input/index.js index c841d1acf35991..8e25784e6e15be 100644 --- a/packages/editor/src/components/url-input/index.js +++ b/packages/editor/src/components/url-input/index.js @@ -9,7 +9,7 @@ import scrollIntoView from 'dom-scroll-into-view'; * WordPress dependencies */ import { __, sprintf, _n } from '@wordpress/i18n'; -import { Component, Fragment, createRef } from '@wordpress/element'; +import { Component, createRef } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; import { UP, DOWN, ENTER, TAB } from '@wordpress/keycodes'; import { Spinner, withSpokenMessages, Popover } from '@wordpress/components'; @@ -138,6 +138,38 @@ class URLInput extends Component { // If the suggestions are not shown or loading, we shouldn't handle the arrow keys // We shouldn't preventDefault to allow block arrow keys navigation if ( ! showSuggestions || ! posts.length || loading ) { + // In the Windows version of Firefox the up and down arrows don't move the caret + // within an input field like they do for Mac Firefox/Chrome/Safari. This causes + // a form of focus trapping that is disruptive to the user experience. This disruption + // only happens if the caret is not in the first or last position in the text input. + // See: https://github.com/WordPress/gutenberg/issues/5693#issuecomment-436684747 + switch ( event.keyCode ) { + // When UP is pressed, if the caret is at the start of the text, move it to the 0 + // position. + case UP: { + if ( 0 !== event.target.selectionStart ) { + event.stopPropagation(); + event.preventDefault(); + + // Set the input caret to position 0 + event.target.setSelectionRange( 0, 0 ); + } + break; + } + // When DOWN is pressed, if the caret is not at the end of the text, move it to the + // last position. + case DOWN: { + if ( this.props.value.length !== event.target.selectionStart ) { + event.stopPropagation(); + event.preventDefault(); + + // Set the input caret to the last position + event.target.setSelectionRange( this.props.value.length, this.props.value.length ); + } + break; + } + } + return; } @@ -199,28 +231,26 @@ class URLInput extends Component { const { showSuggestions, posts, selectedSuggestion, loading } = this.state; /* eslint-disable jsx-a11y/no-autofocus */ return ( - <Fragment> - <div className="editor-url-input"> - <input - autoFocus={ autoFocus } - type="text" - aria-label={ __( 'URL' ) } - required - value={ value } - onChange={ this.onChange } - onInput={ stopEventPropagation } - placeholder={ __( 'Paste URL or type to search' ) } - onKeyDown={ this.onKeyDown } - role="combobox" - aria-expanded={ showSuggestions } - aria-autocomplete="list" - aria-owns={ `editor-url-input-suggestions-${ instanceId }` } - aria-activedescendant={ selectedSuggestion !== null ? `editor-url-input-suggestion-${ instanceId }-${ selectedSuggestion }` : undefined } - ref={ this.inputRef } - /> + <div className="editor-url-input"> + <input + autoFocus={ autoFocus } + type="text" + aria-label={ __( 'URL' ) } + required + value={ value } + onChange={ this.onChange } + onInput={ stopEventPropagation } + placeholder={ __( 'Paste URL or type to search' ) } + onKeyDown={ this.onKeyDown } + role="combobox" + aria-expanded={ showSuggestions } + aria-autocomplete="list" + aria-owns={ `editor-url-input-suggestions-${ instanceId }` } + aria-activedescendant={ selectedSuggestion !== null ? `editor-url-input-suggestion-${ instanceId }-${ selectedSuggestion }` : undefined } + ref={ this.inputRef } + /> - { ( loading ) && <Spinner /> } - </div> + { ( loading ) && <Spinner /> } { showSuggestions && !! posts.length && <Popover position="bottom" noArrow focusOnMount={ false }> @@ -249,7 +279,7 @@ class URLInput extends Component { </div> </Popover> } - </Fragment> + </div> ); /* eslint-enable jsx-a11y/no-autofocus */ } diff --git a/packages/editor/src/components/url-input/style.scss b/packages/editor/src/components/url-input/style.scss index 0946802bcbd059..64429e6d6c4e40 100644 --- a/packages/editor/src/components/url-input/style.scss +++ b/packages/editor/src/components/url-input/style.scss @@ -39,7 +39,7 @@ $input-size: 300px; transition: all 0.15s ease-in-out; padding: 4px 0; // To match the url-input width: input width + padding + 2 buttons. - width: $input-size + 2 + 2 * $icon-button-size; + width: $input-size + 2; overflow-y: auto; } diff --git a/packages/editor/src/components/warning/style.scss b/packages/editor/src/components/warning/style.scss index bf1ae88e5e07c9..ecfb3f0c99770f 100644 --- a/packages/editor/src/components/warning/style.scss +++ b/packages/editor/src/components/warning/style.scss @@ -34,7 +34,6 @@ .editor-warning__action { margin: 0 6px 0 0; - margin-left: 0; } } diff --git a/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap b/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap index 868585dc2952ae..873091eacb77d8 100644 --- a/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap +++ b/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap @@ -8,9 +8,9 @@ src: url(sansation_light.woff); `; exports[`CSS selector wrap should ignore keyframes 1`] = ` -"@keyframes move_background { +"@keyframes edit-post__fade-in-animation { from { -background-position: 0 0; +opacity: 0; } }" `; diff --git a/packages/editor/src/editor-styles/transforms/test/wrap.js b/packages/editor/src/editor-styles/transforms/test/wrap.js index ba2e02cd6b00a0..5500d4a747e41a 100644 --- a/packages/editor/src/editor-styles/transforms/test/wrap.js +++ b/packages/editor/src/editor-styles/transforms/test/wrap.js @@ -40,9 +40,9 @@ describe( 'CSS selector wrap', () => { it( 'should ignore keyframes', () => { const callback = wrap( '.my-namespace' ); const input = ` - @keyframes move_background { + @keyframes edit-post__fade-in-animation { from { - background-position: 0 0; + opacity: 0; } }`; const output = traverse( input, callback ); diff --git a/packages/editor/src/hooks/align.js b/packages/editor/src/hooks/align.js index 7b80924c433e20..7b7062ba558f5f 100644 --- a/packages/editor/src/hooks/align.js +++ b/packages/editor/src/hooks/align.js @@ -2,20 +2,72 @@ * External dependencies */ import classnames from 'classnames'; -import { assign, get, has, includes } from 'lodash'; +import { assign, get, has, includes, without } from 'lodash'; /** * WordPress dependencies */ -import { createHigherOrderComponent } from '@wordpress/compose'; +import { compose, createHigherOrderComponent } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; -import { hasBlockSupport, getBlockSupport, getBlockType } from '@wordpress/blocks'; +import { getBlockSupport, getBlockType, hasBlockSupport } from '@wordpress/blocks'; +import { withSelect } from '@wordpress/data'; /** * Internal dependencies */ import { BlockControls, BlockAlignmentToolbar } from '../components'; +/** + * An array which includes all possible valid alignments, + * used to validate if an alignment is valid or not. + * + * @constant + * @type {string[]} +*/ +const ALL_ALIGNMENTS = [ 'left', 'center', 'right', 'wide', 'full' ]; + +/** + * An array which includes all wide alignments. + * In order for this alignments to be valid they need to be supported by the block, + * and by the theme. + * + * @constant + * @type {string[]} +*/ +const WIDE_ALIGNMENTS = [ 'wide', 'full' ]; + +/** + * Returns the valid alignments. + * Takes into consideration the aligns supported by a block, if the block supports wide controls or not and if theme supports wide controls or not. + * Exported just for testing purposes, not exported outside the module. + * + * @param {?boolean|string[]} blockAlign Aligns supported by the block. + * @param {?boolean} hasWideBlockSupport True if block supports wide alignments. And False otherwise. + * @param {?boolean} hasWideEnabled True if theme supports wide alignments. And False otherwise. + * + * @return {string[]} Valid alignments. + */ +export function getValidAlignments( blockAlign, hasWideBlockSupport = true, hasWideEnabled = true ) { + let validAlignments; + if ( Array.isArray( blockAlign ) ) { + validAlignments = blockAlign; + } else if ( blockAlign === true ) { + // `true` includes all alignments... + validAlignments = ALL_ALIGNMENTS; + } else { + validAlignments = []; + } + + if ( + ! hasWideEnabled || + ( blockAlign === true && ! hasWideBlockSupport ) + ) { + return without( validAlignments, ...WIDE_ALIGNMENTS ); + } + + return validAlignments; +} + /** * Filters registered block settings, extending attributes to include `align`. * @@ -39,33 +91,6 @@ export function addAttribute( settings ) { return settings; } -/** - * Returns an array of valid alignments for a block type depending on its - * defined supports. Returns an empty array if block does not support align. - * - * @param {string} blockName Block name to check - * @return {string[]} Valid alignments for block - */ -export function getBlockValidAlignments( blockName ) { - // Explicitly defined array set of valid alignments - const blockAlign = getBlockSupport( blockName, 'align' ); - if ( Array.isArray( blockAlign ) ) { - return blockAlign; - } - - const validAlignments = []; - if ( true === blockAlign ) { - // `true` includes all alignments... - validAlignments.push( 'left', 'center', 'right' ); - - if ( hasBlockSupport( blockName, 'alignWide', true ) ) { - validAlignments.push( 'wide', 'full' ); - } - } - - return validAlignments; -} - /** * Override the default edit UI to include new toolbar controls for block * alignment, if block defines support. @@ -73,46 +98,57 @@ export function getBlockValidAlignments( blockName ) { * @param {Function} BlockEdit Original component * @return {Function} Wrapped component */ -export const withToolbarControls = createHigherOrderComponent( ( BlockEdit ) => { - return ( props ) => { - const validAlignments = getBlockValidAlignments( props.name ); - - const updateAlignment = ( nextAlign ) => { - if ( ! nextAlign ) { - const blockType = getBlockType( props.name ); - const blockDefaultAlign = get( blockType, [ 'attributes', 'align', 'default' ] ); - if ( blockDefaultAlign ) { - nextAlign = ''; +export const withToolbarControls = createHigherOrderComponent( + ( BlockEdit ) => ( + ( props ) => { + const { name: blockName } = props; + // Compute valid alignments without taking into account, + // if the theme supports wide alignments or not. + // BlockAlignmentToolbar takes into account the theme support. + const validAlignments = getValidAlignments( + getBlockSupport( blockName, 'align' ), + hasBlockSupport( blockName, 'alignWide', true ), + ); + + const updateAlignment = ( nextAlign ) => { + if ( ! nextAlign ) { + const blockType = getBlockType( props.name ); + const blockDefaultAlign = get( blockType, [ 'attributes', 'align', 'default' ] ); + if ( blockDefaultAlign ) { + nextAlign = ''; + } } - } - props.setAttributes( { align: nextAlign } ); - }; - - return [ - validAlignments.length > 0 && props.isSelected && ( - <BlockControls key="align-controls"> - <BlockAlignmentToolbar - value={ props.attributes.align } - onChange={ updateAlignment } - controls={ validAlignments } - /> - </BlockControls> - ), - <BlockEdit key="edit" { ...props } />, - ]; - }; -}, 'withToolbarControls' ); - -/** - * Override the default block element to add alignment wrapper props. - * - * @param {Function} BlockListBlock Original component - * @return {Function} Wrapped component - */ -export const withDataAlign = createHigherOrderComponent( ( BlockListBlock ) => { - return ( props ) => { - const { align } = props.block.attributes; - const validAlignments = getBlockValidAlignments( props.block.name ); + props.setAttributes( { align: nextAlign } ); + }; + + return [ + validAlignments.length > 0 && props.isSelected && ( + <BlockControls key="align-controls"> + <BlockAlignmentToolbar + value={ props.attributes.align } + onChange={ updateAlignment } + controls={ validAlignments } + /> + </BlockControls> + ), + <BlockEdit key="edit" { ...props } />, + ]; + } + ), + 'withToolbarControls' +); + +// Exported just for testing purposes, not exported outside the module. +export const insideSelectWithDataAlign = ( BlockListBlock ) => ( + ( props ) => { + const { block, hasWideEnabled } = props; + const { name: blockName } = block; + const { align } = block.attributes; + const validAlignments = getValidAlignments( + getBlockSupport( blockName, 'align' ), + hasBlockSupport( blockName, 'alignWide', true ), + hasWideEnabled + ); let wrapperProps = props.wrapperProps; if ( includes( validAlignments, align ) ) { @@ -120,8 +156,28 @@ export const withDataAlign = createHigherOrderComponent( ( BlockListBlock ) => { } return <BlockListBlock { ...props } wrapperProps={ wrapperProps } />; - }; -}, 'withDataAlign' ); + } +); + +/** + * Override the default block element to add alignment wrapper props. + * + * @param {Function} BlockListBlock Original component + * @return {Function} Wrapped component + */ +export const withDataAlign = createHigherOrderComponent( + compose( [ + withSelect( + ( select ) => { + const { getEditorSettings } = select( 'core/editor' ); + return { + hasWideEnabled: !! getEditorSettings().alignWide, + }; + } + ), + insideSelectWithDataAlign, + ] ) +); /** * Override props assigned to save component to inject alignment class name if @@ -134,8 +190,16 @@ export const withDataAlign = createHigherOrderComponent( ( BlockListBlock ) => { */ export function addAssignedAlign( props, blockType, attributes ) { const { align } = attributes; - - if ( includes( getBlockValidAlignments( blockType ), align ) ) { + const blockAlign = getBlockSupport( blockType, 'align' ); + const hasWideBlockSupport = hasBlockSupport( blockType, 'alignWide', true ); + const isAlignValid = includes( + // Compute valid alignments without taking into account, + // if the theme supports wide alignments or not. + // This way changing themes does not impacts the block save. + getValidAlignments( blockAlign, hasWideBlockSupport ), + align + ); + if ( isAlignValid ) { props.className = classnames( `align${ align }`, props.className ); } diff --git a/packages/editor/src/hooks/test/align.js b/packages/editor/src/hooks/test/align.js index ccc678eee8d772..5d3f3dba87c287 100644 --- a/packages/editor/src/hooks/test/align.js +++ b/packages/editor/src/hooks/test/align.js @@ -18,9 +18,9 @@ import { * Internal dependencies */ import { - getBlockValidAlignments, + getValidAlignments, withToolbarControls, - withDataAlign, + insideSelectWithDataAlign, addAssignedAlign, } from '../align'; @@ -58,49 +58,61 @@ describe( 'align', () => { } ); } ); - describe( 'getBlockValidAlignments()', () => { + describe( 'getValidAlignments()', () => { it( 'should return an empty array if block does not define align support', () => { - registerBlockType( 'core/foo', blockSettings ); - const validAlignments = getBlockValidAlignments( 'core/foo' ); + expect( getValidAlignments() ).toEqual( [] ); + } ); - expect( validAlignments ).toEqual( [] ); + it( 'should return all custom aligns set', () => { + expect( + getValidAlignments( [ 'left', 'right' ] ) + ).toEqual( + [ 'left', 'right' ] + ); } ); - it( 'should return all custom align set', () => { - registerBlockType( 'core/foo', { - ...blockSettings, - supports: { - align: [ 'left', 'right' ], - }, - } ); - const validAlignments = getBlockValidAlignments( 'core/foo' ); + it( 'should return all aligns if block defines align support as true', () => { + expect( + getValidAlignments( true ) + ).toEqual( + [ 'left', 'center', 'right', 'wide', 'full' ] + ); + } ); - expect( validAlignments ).toEqual( [ 'left', 'right' ] ); + it( 'should return all aligns except wide if wide align explicitly false on the block', () => { + expect( + getValidAlignments( true, false, true ) + ).toEqual( [ 'left', 'center', 'right' ] ); + + expect( + getValidAlignments( true, false, false ) + ).toEqual( [ 'left', 'center', 'right' ] ); } ); - it( 'should return all aligns if block defines align support', () => { - registerBlockType( 'core/foo', { - ...blockSettings, - supports: { - align: true, - }, - } ); - const validAlignments = getBlockValidAlignments( 'core/foo' ); + it( 'should return all aligns except wide if wide align is not supported by the theme', () => { + expect( + getValidAlignments( true, true, false ) + ).toEqual( [ 'left', 'center', 'right' ] ); - expect( validAlignments ).toEqual( [ 'left', 'center', 'right', 'wide', 'full' ] ); + expect( + getValidAlignments( true, false, false ) + ).toEqual( [ 'left', 'center', 'right' ] ); } ); - it( 'should return all aligns except wide if wide align explicitly false', () => { - registerBlockType( 'core/foo', { - ...blockSettings, - supports: { - align: true, - alignWide: false, - }, - } ); - const validAlignments = getBlockValidAlignments( 'core/foo' ); + it( 'should not remove wide aligns if they are not supported by the block and were set using an array in supports align', () => { + expect( + getValidAlignments( [ 'left', 'right', 'wide', 'full' ], false, true ) + ).toEqual( [ 'left', 'right', 'wide', 'full' ] ); + } ); - expect( validAlignments ).toEqual( [ 'left', 'center', 'right' ] ); + it( 'should remove wide aligns if they are not supported by the theme and were set using an array in supports align', () => { + expect( + getValidAlignments( [ 'left', 'right', 'wide', 'full' ], true, false ) + ).toEqual( [ 'left', 'right' ] ); + + expect( + getValidAlignments( [ 'left', 'right', 'wide', 'full' ], false, false ) + ).toEqual( [ 'left', 'right' ] ); } ); } ); @@ -153,11 +165,11 @@ describe( 'align', () => { ...blockSettings, supports: { align: true, - alignWide: false, + alignWide: true, }, } ); - const EnhancedComponent = withDataAlign( ( { wrapperProps } ) => ( + const EnhancedComponent = insideSelectWithDataAlign( ( { wrapperProps } ) => ( <div { ...wrapperProps } /> ) ); @@ -166,16 +178,43 @@ describe( 'align', () => { block={ { name: 'core/foo', attributes: { - align: 'left', + align: 'wide', }, } } /> ); expect( wrapper.toTree().rendered.props.wrapperProps ).toEqual( { - 'data-align': 'left', + 'data-align': 'wide', } ); } ); + it( 'should not render wide/full wrapper props if wide controls are not enabled', () => { + registerBlockType( 'core/foo', { + ...blockSettings, + supports: { + align: true, + alignWide: true, + }, + } ); + + const EnhancedComponent = insideSelectWithDataAlign( ( { wrapperProps } ) => ( + <div { ...wrapperProps } /> + ) ); + + const wrapper = renderer.create( + <EnhancedComponent + block={ { + name: 'core/foo', + attributes: { + align: 'wide', + }, + } } + hasWideEnabled={ false } + /> + ); + expect( wrapper.toTree().rendered.props.wrapperProps ).toEqual( undefined ); + } ); + it( 'should not render invalid align', () => { registerBlockType( 'core/foo', { ...blockSettings, @@ -185,7 +224,7 @@ describe( 'align', () => { }, } ); - const EnhancedComponent = withDataAlign( ( { wrapperProps } ) => ( + const EnhancedComponent = insideSelectWithDataAlign( ( { wrapperProps } ) => ( <div { ...wrapperProps } /> ) ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index a77b27add51c28..6302ce88947560 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -1,17 +1,12 @@ /** * External Dependencies */ -import { partial, castArray } from 'lodash'; +import { castArray } from 'lodash'; /** * WordPress dependencies */ -import { - getDefaultBlockName, - createBlock, -} from '@wordpress/blocks'; -import deprecated from '@wordpress/deprecated'; -import { dispatch } from '@wordpress/data'; +import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; /** * Returns an action object used in signalling that editor has initialized with @@ -401,7 +396,7 @@ export function editPost( edits ) { * Returns an action object to save the post. * * @param {Object} options Options for the save. - * @param {boolean} options.autosave Perform an autosave if true. + * @param {boolean} options.isAutosave Perform an autosave if true. * * @return {Object} Action object. */ @@ -444,10 +439,12 @@ export function mergeBlocks( firstBlockClientId, secondBlockClientId ) { /** * Returns an action object used in signalling that the post should autosave. * + * @param {Object?} options Extra flags to identify the autosave. + * * @return {Object} Action object. */ -export function autosave() { - return savePost( { autosave: true } ); +export function autosave( options ) { + return savePost( { isAutosave: true, ...options } ); } /** @@ -787,113 +784,3 @@ export function unlockPostSaving( lockName ) { lockName, }; } - -// -// Deprecated -// - -export function createNotice( status, content, options ) { - deprecated( 'createNotice action (`core/editor` store)', { - alternative: 'createNotice action (`core/notices` store)', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - dispatch( 'core/notices' ).createNotice( status, content, options ); - - return { type: '__INERT__' }; -} - -export function removeNotice( id ) { - deprecated( 'removeNotice action (`core/editor` store)', { - alternative: 'removeNotice action (`core/notices` store)', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - dispatch( 'core/notices' ).removeNotice( id ); - - return { type: '__INERT__' }; -} - -export const createSuccessNotice = partial( createNotice, 'success' ); -export const createInfoNotice = partial( createNotice, 'info' ); -export const createErrorNotice = partial( createNotice, 'error' ); -export const createWarningNotice = partial( createNotice, 'warning' ); - -// -// Deprecated -// - -export function fetchReusableBlocks( id ) { - deprecated( "wp.data.dispatch( 'core/editor' ).fetchReusableBlocks( id )", { - alternative: "wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalFetchReusableBlocks( id ); -} - -export function receiveReusableBlocks( results ) { - deprecated( "wp.data.dispatch( 'core/editor' ).receiveReusableBlocks( results )", { - alternative: "wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalReceiveReusableBlocks( results ); -} - -export function saveReusableBlock( id ) { - deprecated( "wp.data.dispatch( 'core/editor' ).saveReusableBlock( id )", { - alternative: "wp.data.dispatch( 'core' ).saveEntityRecord( 'postType', 'wp_block', reusableBlock )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalSaveReusableBlock( id ); -} - -export function deleteReusableBlock( id ) { - deprecated( 'deleteReusableBlock action (`core/editor` store)', { - alternative: '__experimentalDeleteReusableBlock action (`core/edtior` store)', - plugin: 'Gutenberg', - version: '4.4.0', - hint: 'Using experimental APIs is strongly discouraged as they are subject to removal without notice.', - } ); - - return __experimentalDeleteReusableBlock( id ); -} - -export function updateReusableBlockTitle( id, title ) { - deprecated( "wp.data.dispatch( 'core/editor' ).updateReusableBlockTitle( id, title )", { - alternative: "wp.data.dispatch( 'core' ).saveEntityRecord( 'postType', 'wp_block', reusableBlock )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalUpdateReusableBlockTitle( id, title ); -} - -export function convertBlockToStatic( id ) { - deprecated( 'convertBlockToStatic action (`core/editor` store)', { - alternative: '__experimentalConvertBlockToStatic action (`core/edtior` store)', - plugin: 'Gutenberg', - version: '4.4.0', - hint: 'Using experimental APIs is strongly discouraged as they are subject to removal without notice.', - } ); - - return __experimentalConvertBlockToStatic( id ); -} - -export function convertBlockToReusable( id ) { - deprecated( 'convertBlockToReusable action (`core/editor` store)', { - alternative: '__experimentalConvertBlockToReusable action (`core/edtior` store)', - plugin: 'Gutenberg', - version: '4.4.0', - hint: 'Using experimental APIs is strongly discouraged as they are subject to removal without notice.', - } ); - - return __experimentalConvertBlockToReusable( id ); -} diff --git a/packages/editor/src/store/effects.js b/packages/editor/src/store/effects.js index 3afa0f1e055a10..7c05730540dbe5 100644 --- a/packages/editor/src/store/effects.js +++ b/packages/editor/src/store/effects.js @@ -14,7 +14,7 @@ import { doBlocksMatchTemplate, synchronizeBlocksWithTemplate, } from '@wordpress/blocks'; -import { __, sprintf } from '@wordpress/i18n'; +import { _n, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -33,7 +33,7 @@ import { getBlocks, getBlockCount, getPreviousBlockClientId, - getSelectedBlock, + getSelectedBlockClientId, getSelectedBlockCount, getTemplate, getTemplateLock, @@ -104,7 +104,7 @@ export function selectPreviousBlock( action, store ) { const firstRemovedBlockClientId = action.clientIds[ 0 ]; const state = store.getState(); - const currentSelectedBlock = getSelectedBlock( state ); + const selectedBlockClientId = getSelectedBlockClientId( state ); // recreate the state before the block was removed. const previousState = { ...state, editor: { present: last( state.editor.past ) } }; @@ -118,7 +118,7 @@ export function selectPreviousBlock( action, store ) { // Dispatch select block action if the currently selected block // is not already the block we want to be selected. - if ( blockClientIdToSelect !== currentSelectedBlock ) { + if ( blockClientIdToSelect !== selectedBlockClientId ) { return selectBlock( blockClientIdToSelect, -1 ); } } @@ -267,6 +267,7 @@ export default { MULTI_SELECT: ( action, { getState } ) => { const blockCount = getSelectedBlockCount( getState() ); - speak( sprintf( __( '%s blocks selected.' ), blockCount ), 'assertive' ); + /* translators: %s: number of selected blocks */ + speak( sprintf( _n( '%s block selected.', '%s blocks selected.', blockCount ), blockCount ), 'assertive' ); }, }; diff --git a/packages/editor/src/store/effects/posts.js b/packages/editor/src/store/effects/posts.js index c88fe00f4d878f..a1a24a1f6d3be6 100644 --- a/packages/editor/src/store/effects/posts.js +++ b/packages/editor/src/store/effects/posts.js @@ -2,7 +2,7 @@ * External dependencies */ import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; -import { pick, includes } from 'lodash'; +import { get, pick, includes } from 'lodash'; /** * WordPress dependencies @@ -28,7 +28,6 @@ import { getEditedPostContent, getAutosave, getCurrentPostType, - isEditedPostAutosaveable, isEditedPostSaveable, isEditedPostNew, POST_UPDATE_TRANSACTION_ID, @@ -51,12 +50,11 @@ export const requestPostUpdate = async ( action, store ) => { const { dispatch, getState } = store; const state = getState(); const post = getCurrentPost( state ); - const isAutosave = !! action.options.autosave; + const isAutosave = !! action.options.isAutosave; // Prevent save if not saveable. - const isSaveable = isAutosave ? isEditedPostAutosaveable : isEditedPostSaveable; - - if ( ! isSaveable( state ) ) { + // We don't check for dirtiness here as this can be overriden in the UI. + if ( ! isEditedPostSaveable( state ) ) { return; } @@ -91,7 +89,7 @@ export const requestPostUpdate = async ( action, store ) => { dispatch( { type: 'REQUEST_POST_UPDATE_START', optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, - isAutosave, + options: action.options, } ); // Optimistically apply updates under the assumption that the post @@ -110,7 +108,6 @@ export const requestPostUpdate = async ( action, store ) => { ...pick( post, [ 'title', 'content', 'excerpt' ] ), ...getAutosave( state ), ...toSend, - parent: post.id, }; request = apiFetch( { @@ -151,7 +148,7 @@ export const requestPostUpdate = async ( action, store ) => { type: isRevision ? REVERT : COMMIT, id: POST_UPDATE_TRANSACTION_ID, }, - isAutosave, + options: action.options, postType, } ); } catch ( error ) { @@ -161,6 +158,7 @@ export const requestPostUpdate = async ( action, store ) => { post, edits, error, + options: action.options, } ); } }; @@ -184,7 +182,7 @@ export const requestPostUpdateSuccess = ( action ) => { const willPublish = includes( publishStatus, post.status ); let noticeMessage; - let shouldShowLink = true; + let shouldShowLink = get( postType, [ 'viewable' ], false ); if ( ! isPublished && ! willPublish ) { // If saving a non-published post, don't show notice. @@ -313,8 +311,8 @@ export const refreshPost = async ( action, store ) => { const postTypeSlug = getCurrentPostType( getState() ); const postType = await resolveSelector( 'core', 'getPostType', postTypeSlug ); const newPost = await apiFetch( { - path: `/wp/v2/${ postType.rest_base }/${ post.id }`, - data: { context: 'edit' }, + // Timestamp arg allows caller to bypass browser caching, which is expected for this specific function. + path: `/wp/v2/${ postType.rest_base }/${ post.id }?context=edit&_timestamp=${ Date.now() }`, } ); dispatch( resetPost( newPost ) ); }; diff --git a/packages/editor/src/store/effects/reusable-blocks.js b/packages/editor/src/store/effects/reusable-blocks.js index 0c569728b6a39d..07ed88ab69d520 100644 --- a/packages/editor/src/store/effects/reusable-blocks.js +++ b/packages/editor/src/store/effects/reusable-blocks.js @@ -24,7 +24,6 @@ import { dispatch as dataDispatch } from '@wordpress/data'; /** * Internal dependencies */ -import { resolveSelector } from './utils'; import { __experimentalReceiveReusableBlocks as receiveReusableBlocksAction, removeBlocks, @@ -57,16 +56,16 @@ export const fetchReusableBlocks = async ( action, store ) => { // TODO: these are potentially undefined, this fix is in place // until there is a filter to not use reusable blocks if undefined - const postType = await resolveSelector( 'core', 'getPostType', 'wp_block' ); + const postType = await apiFetch( { path: '/wp/v2/types/wp_block' } ); if ( ! postType ) { return; } let result; if ( id ) { - result = apiFetch( { path: `/wp/v2/${ postType.rest_base }/${ id }?context=edit` } ); + result = apiFetch( { path: `/wp/v2/${ postType.rest_base }/${ id }` } ); } else { - result = apiFetch( { path: `/wp/v2/${ postType.rest_base }?per_page=-1&context=edit` } ); + result = apiFetch( { path: `/wp/v2/${ postType.rest_base }?per_page=-1` } ); } try { @@ -109,7 +108,7 @@ export const fetchReusableBlocks = async ( action, store ) => { export const saveReusableBlocks = async ( action, store ) => { // TODO: these are potentially undefined, this fix is in place // until there is a filter to not use reusable blocks if undefined - const postType = await resolveSelector( 'core', 'getPostType', 'wp_block' ); + const postType = await apiFetch( { path: '/wp/v2/types/wp_block' } ); if ( ! postType ) { return; } @@ -153,7 +152,7 @@ export const saveReusableBlocks = async ( action, store ) => { export const deleteReusableBlocks = async ( action, store ) => { // TODO: these are potentially undefined, this fix is in place // until there is a filter to not use reusable blocks if undefined - const postType = await resolveSelector( 'core', 'getPostType', 'wp_block' ); + const postType = await apiFetch( { path: '/wp/v2/types/wp_block' } ); if ( ! postType ) { return; } diff --git a/packages/editor/src/store/effects/test/reusable-blocks.js b/packages/editor/src/store/effects/test/reusable-blocks.js index 2cc3b83e4f36f7..1e13dba9f97142 100644 --- a/packages/editor/src/store/effects/test/reusable-blocks.js +++ b/packages/editor/src/store/effects/test/reusable-blocks.js @@ -83,7 +83,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } @@ -130,7 +130,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } @@ -172,7 +172,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } @@ -202,7 +202,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } @@ -236,7 +236,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } @@ -284,7 +284,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } @@ -330,7 +330,7 @@ describe( 'reusable blocks effects', () => { } ); apiFetch.mockImplementation( ( options ) => { - if ( options.path === '/wp/v2/types/wp_block?context=edit' ) { + if ( options.path === '/wp/v2/types/wp_block' ) { return postTypePromise; } diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 6bfb468cd71578..d241bb008f45a1 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -22,6 +22,7 @@ import { */ import { isReusableBlock } from '@wordpress/blocks'; import { combineReducers } from '@wordpress/data'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies @@ -35,6 +36,16 @@ import { } from './defaults'; import { insertAt, moveTo } from './array'; +/** + * Set of post properties for which edits should assume a merging behavior, + * assuming an object value. + * + * @type {Set} + */ +const EDIT_MERGE_PROPERTIES = new Set( [ + 'meta', +] ); + /** * Returns a post attribute value, flattening nested rendered content using its * raw value in place of its original object form. @@ -101,6 +112,28 @@ function getFlattenedBlocks( blocks ) { return flattenedBlocks; } +/** + * Given a block order map object, returns *all* of the block client IDs that are + * a descendant of the given root client ID. + * + * Calling this with `rootClientId` set to `''` results in a list of client IDs + * that are in the post. That is, it excludes blocks like fetched reusable + * blocks which are stored into state but not visible. + * + * @param {Object} blocksOrder Object that maps block client IDs to a list of + * nested block client IDs. + * @param {?string} rootClientId The root client ID to search. Defaults to ''. + * + * @return {Array} List of descendant client IDs. + */ +function getNestedBlockClientIds( blocksOrder, rootClientId = '' ) { + return reduce( blocksOrder[ rootClientId ], ( result, clientId ) => [ + ...result, + clientId, + ...getNestedBlockClientIds( blocksOrder, clientId ), + ], [] ); +} + /** * Returns an object against which it is safe to perform mutating operations, * given the original object and its current working copy. @@ -211,6 +244,35 @@ const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => { return reducer( state, action ); }; +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `RESET_BLOCKS` action. When dispatched, this action will replace all + * blocks that exist in the post, leaving blocks that exist only in state (e.g. + * reusable blocks) alone. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withBlockReset = ( reducer ) => ( state, action ) => { + if ( state && action.type === 'RESET_BLOCKS' ) { + const visibleClientIds = getNestedBlockClientIds( state.order ); + return { + ...state, + byClientId: { + ...omit( state.byClientId, visibleClientIds ), + ...getFlattenedBlocks( action.blocks ), + }, + order: { + ...omit( state.order, visibleClientIds ), + ...mapBlockOrder( action.blocks ), + }, + }; + } + + return reducer( state, action ); +}; + /** * Undoable reducer returning the editor post state, including blocks parsed * from current HTML markup. @@ -244,7 +306,14 @@ export const editor = flow( [ // Only assign into result if not already same value if ( value !== state[ key ] ) { result = getMutateSafeObject( state, result ); - result[ key ] = value; + + if ( EDIT_MERGE_PROPERTIES.has( key ) ) { + // Merge properties should assign to current value. + result[ key ] = { ...result[ key ], ...value }; + } else { + // Otherwise override. + result[ key ] = value; + } } return result; @@ -264,7 +333,7 @@ export const editor = flow( [ ( key ) => getPostRawValue( action.post[ key ] ); return reduce( state, ( result, value, key ) => { - if ( value !== getCanonicalValue( key ) ) { + if ( ! isEqual( value, getCanonicalValue( key ) ) ) { return result; } @@ -280,6 +349,8 @@ export const editor = flow( [ blocks: flow( [ combineReducers, + withBlockReset, + // Track whether changes exist, resetting at each post save. Relies on // editor initialization firing post reset as an effect. withChangeDetection( { @@ -289,7 +360,6 @@ export const editor = flow( [ ] )( { byClientId( state = {}, action ) { switch ( action.type ) { - case 'RESET_BLOCKS': case 'SETUP_EDITOR_STATE': return getFlattenedBlocks( action.blocks ); @@ -392,7 +462,6 @@ export const editor = flow( [ order( state = {}, action ) { switch ( action.type ) { - case 'RESET_BLOCKS': case 'SETUP_EDITOR_STATE': return mapBlockOrder( action.blocks ); @@ -899,7 +968,7 @@ export function saving( state = {}, action ) { requesting: true, successful: false, error: null, - isAutosave: action.isAutosave, + options: action.options || {}, }; case 'REQUEST_POST_UPDATE_SUCCESS': @@ -907,6 +976,7 @@ export function saving( state = {}, action ) { requesting: false, successful: true, error: null, + options: action.options || {}, }; case 'REQUEST_POST_UPDATE_FAILURE': @@ -914,6 +984,7 @@ export function saving( state = {}, action ) { requesting: false, successful: false, error: action.error, + options: action.options || {}, }; } @@ -1133,13 +1204,29 @@ export function autosave( state = null, action ) { title, excerpt, content, - preview_link: post.preview_link, }; + } - case 'REQUEST_POST_UPDATE': + return state; +} + +/** + * Reducer returning the poost preview link + * + * @param {string?} state The preview link + * @param {Object} action Dispatched action. + * + * @return {string?} Updated state. + */ +export function previewLink( state = null, action ) { + switch ( action.type ) { + case 'REQUEST_POST_UPDATE_SUCCESS': + return action.post.preview_link || addQueryArgs( action.post.link, { preview: true } ); + + case 'REQUEST_POST_UPDATE_START': // Invalidate known preview link when autosave starts. - if ( state && action.options.autosave ) { - return omit( state, 'preview_link' ); + if ( state && action.options.isPreview ) { + return null; } break; } @@ -1163,6 +1250,7 @@ export default optimist( combineReducers( { reusableBlocks, template, autosave, + previewLink, settings, postSavingLock, } ) ); diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 619c0e381712a5..5cede64dd95ade 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -15,7 +15,6 @@ import { map, orderBy, reduce, - size, some, } from 'lodash'; import createSelector from 'rememo'; @@ -34,8 +33,7 @@ import { } from '@wordpress/blocks'; import { isInTheFuture, getDate } from '@wordpress/date'; import { removep } from '@wordpress/autop'; -import { select } from '@wordpress/data'; -import deprecated from '@wordpress/deprecated'; +import { addQueryArgs } from '@wordpress/url'; /** * Dependencies @@ -601,6 +599,19 @@ export function getBlockName( state, clientId ) { return block ? block.name : null; } +/** + * Returns whether a block is valid or not. + * + * @param {Object} state Editor state. + * @param {string} clientId Block client ID. + * + * @return {boolean} Is Valid. + */ +export function isBlockValid( state, clientId ) { + const block = state.editor.present.blocks.byClientId[ clientId ]; + return !! block && block.isValid; +} + /** * Returns a block given its client ID. This is a parsed copy of the block, * containing its `blockName`, `clientId`, and current `attributes` state. This @@ -728,16 +739,17 @@ export const getClientIdsWithDescendants = createSelector( */ export const getGlobalBlockCount = createSelector( ( state, blockName ) => { + const clientIds = getClientIdsWithDescendants( state ); if ( ! blockName ) { - return size( state.editor.present.blocks.byClientId ); + return clientIds.length; } - return reduce( - state.editor.present.blocks.byClientId, - ( count, block ) => block.name === blockName ? count + 1 : count, - 0 - ); + return reduce( clientIds, ( count, clientId ) => { + const block = state.editor.present.blocks.byClientId[ clientId ]; + return block.name === blockName ? count + 1 : count; + }, 0 ); }, ( state ) => [ + state.editor.present.blocks.order, state.editor.present.blocks.byClientId, ] ); @@ -1106,6 +1118,29 @@ export function getLastMultiSelectedBlockClientId( state ) { return last( getMultiSelectedBlockClientIds( state ) ) || null; } +/** + * Checks if possibleAncestorId is an ancestor of possibleDescendentId. + * + * @param {Object} state Editor state. + * @param {string} possibleAncestorId Possible ancestor client ID. + * @param {string} possibleDescendentId Possible descent client ID. + * + * @return {boolean} True if possibleAncestorId is an ancestor + * of possibleDescendentId, and false otherwise. + */ +const isAncestorOf = createSelector( + ( state, possibleAncestorId, possibleDescendentId ) => { + let idToCheck = possibleDescendentId; + while ( possibleAncestorId !== idToCheck && idToCheck ) { + idToCheck = getBlockRootClientId( state, idToCheck ); + } + return possibleAncestorId === idToCheck; + }, + ( state ) => [ + state.editor.present.blocks, + ], +); + /** * Returns true if a multi-selection exists, and the block corresponding to the * specified client ID is the first block of the multi-selection set, or false @@ -1484,7 +1519,35 @@ export function didPostSaveRequestFail( state ) { * @return {boolean} Whether the post is autosaving. */ export function isAutosavingPost( state ) { - return isSavingPost( state ) && state.saving.isAutosave; + return isSavingPost( state ) && !! state.saving.options.isAutosave; +} + +/** + * Returns true if the post is being previewed, or false otherwise. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether the post is being previewed. + */ +export function isPreviewingPost( state ) { + return isSavingPost( state ) && !! state.saving.options.isPreview; +} + +/** + * Returns the post preview link + * + * @param {Object} state Global application state. + * + * @return {string?} Preview Link. + */ +export function getEditedPostPreviewLink( state ) { + const featuredImageId = getEditedPostAttribute( state, 'featured_media' ); + const previewLink = state.previewLink; + if ( previewLink && featuredImageId ) { + return addQueryArgs( previewLink, { _thumbnail_id: featuredImageId } ); + } + + return previewLink; } /** @@ -1503,14 +1566,14 @@ export function getSuggestedPostFormat( state ) { // If there is only one block in the content of the post grab its name // so we can derive a suitable post format from it. if ( blocks.length === 1 ) { - name = getBlock( state, blocks[ 0 ] ).name; + name = getBlockName( state, blocks[ 0 ] ); } // If there are two blocks in the content and the last one is a text blocks // grab the name of the first one to also suggest a post format from it. if ( blocks.length === 2 ) { - if ( getBlock( state, blocks[ 1 ] ).name === 'core/paragraph' ) { - name = getBlock( state, blocks[ 0 ] ).name; + if ( getBlockName( state, blocks[ 1 ] ) === 'core/paragraph' ) { + name = getBlockName( state, blocks[ 0 ] ); } } @@ -1602,6 +1665,8 @@ export const getEditedPostContent = createSelector( /** * Determines if the given block type is allowed to be inserted into the block list. + * This function is not exported and not memoized because using a memoized selector + * inside another memoized selector is just a waste of time. * * @param {Object} state Editor state. * @param {string} blockName The name of the block type, e.g.' core/paragraph'. @@ -1609,53 +1674,64 @@ export const getEditedPostContent = createSelector( * * @return {boolean} Whether the given block type is allowed to be inserted. */ -export const canInsertBlockType = createSelector( - ( state, blockName, rootClientId = null ) => { - const checkAllowList = ( list, item, defaultResult = null ) => { - if ( isBoolean( list ) ) { - return list; - } - if ( isArray( list ) ) { - return includes( list, item ); - } - return defaultResult; - }; - - const blockType = getBlockType( blockName ); - if ( ! blockType ) { - return false; +const canInsertBlockTypeUnmemoized = ( state, blockName, rootClientId = null ) => { + const checkAllowList = ( list, item, defaultResult = null ) => { + if ( isBoolean( list ) ) { + return list; } + if ( isArray( list ) ) { + return includes( list, item ); + } + return defaultResult; + }; - const { allowedBlockTypes } = getEditorSettings( state ); + const blockType = getBlockType( blockName ); + if ( ! blockType ) { + return false; + } - const isBlockAllowedInEditor = checkAllowList( allowedBlockTypes, blockName, true ); - if ( ! isBlockAllowedInEditor ) { - return false; - } + const { allowedBlockTypes } = getEditorSettings( state ); - const isLocked = !! getTemplateLock( state, rootClientId ); - if ( isLocked ) { - return false; - } + const isBlockAllowedInEditor = checkAllowList( allowedBlockTypes, blockName, true ); + if ( ! isBlockAllowedInEditor ) { + return false; + } - const parentBlockListSettings = getBlockListSettings( state, rootClientId ); - const parentAllowedBlocks = get( parentBlockListSettings, [ 'allowedBlocks' ] ); - const hasParentAllowedBlock = checkAllowList( parentAllowedBlocks, blockName ); + const isLocked = !! getTemplateLock( state, rootClientId ); + if ( isLocked ) { + return false; + } - const blockAllowedParentBlocks = blockType.parent; - const parentName = getBlockName( state, rootClientId ); - const hasBlockAllowedParent = checkAllowList( blockAllowedParentBlocks, parentName ); + const parentBlockListSettings = getBlockListSettings( state, rootClientId ); + const parentAllowedBlocks = get( parentBlockListSettings, [ 'allowedBlocks' ] ); + const hasParentAllowedBlock = checkAllowList( parentAllowedBlocks, blockName ); - if ( hasParentAllowedBlock !== null && hasBlockAllowedParent !== null ) { - return hasParentAllowedBlock || hasBlockAllowedParent; - } else if ( hasParentAllowedBlock !== null ) { - return hasParentAllowedBlock; - } else if ( hasBlockAllowedParent !== null ) { - return hasBlockAllowedParent; - } + const blockAllowedParentBlocks = blockType.parent; + const parentName = getBlockName( state, rootClientId ); + const hasBlockAllowedParent = checkAllowList( blockAllowedParentBlocks, parentName ); - return true; - }, + if ( hasParentAllowedBlock !== null && hasBlockAllowedParent !== null ) { + return hasParentAllowedBlock || hasBlockAllowedParent; + } else if ( hasParentAllowedBlock !== null ) { + return hasParentAllowedBlock; + } else if ( hasBlockAllowedParent !== null ) { + return hasBlockAllowedParent; + } + + return true; +}; + +/** + * Determines if the given block type is allowed to be inserted into the block list. + * + * @param {Object} state Editor state. + * @param {string} blockName The name of the block type, e.g.' core/paragraph'. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be inserted. + */ +export const canInsertBlockType = createSelector( + canInsertBlockTypeUnmemoized, ( state, blockName, rootClientId ) => [ state.blockListSettings[ rootClientId ], state.editor.present.blocks.byClientId[ rootClientId ], @@ -1678,6 +1754,58 @@ function getInsertUsage( state, id ) { return state.preferences.insertUsage[ id ] || null; } +/** + * Returns whether we can show a block type in the inserter + * + * @param {Object} state Global State + * @param {Object} blockType BlockType + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be shown in the inserter. + */ +const canIncludeBlockTypeInInserter = ( state, blockType, rootClientId ) => { + if ( ! hasBlockSupport( blockType, 'inserter', true ) ) { + return false; + } + + return canInsertBlockTypeUnmemoized( state, blockType.name, rootClientId ); +}; + +/** + * Returns whether we can show a reusable block in the inserter + * + * @param {Object} state Global State + * @param {Object} reusableBlock Reusable block object + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Whether the given block type is allowed to be shown in the inserter. + */ +const canIncludeReusableBlockInInserter = ( state, reusableBlock, rootClientId ) => { + if ( ! canInsertBlockTypeUnmemoized( state, 'core/block', rootClientId ) ) { + return false; + } + + const referencedBlockName = getBlockName( state, reusableBlock.clientId ); + if ( ! referencedBlockName ) { + return false; + } + + const referencedBlockType = getBlockType( referencedBlockName ); + if ( ! referencedBlockType ) { + return false; + } + + if ( ! canInsertBlockTypeUnmemoized( state, referencedBlockName, rootClientId ) ) { + return false; + } + + if ( isAncestorOf( state, reusableBlock.clientId, rootClientId ) ) { + return false; + } + + return true; +}; + /** * Determines the items that appear in the inserter. Includes both static * items (e.g. a regular block type) and dynamic items (e.g. a reusable block). @@ -1750,14 +1878,6 @@ export const getInserterItems = createSelector( } }; - const shouldIncludeBlockType = ( blockType ) => { - if ( ! hasBlockSupport( blockType, 'inserter', true ) ) { - return false; - } - - return canInsertBlockType( state, blockType.name, rootClientId ); - }; - const buildBlockTypeInserterItem = ( blockType ) => { const id = blockType.name; @@ -1784,33 +1904,11 @@ export const getInserterItems = createSelector( }; }; - const shouldIncludeReusableBlock = ( reusableBlock ) => { - if ( ! canInsertBlockType( state, 'core/block', rootClientId ) ) { - return false; - } - - const referencedBlock = getBlock( state, reusableBlock.clientId ); - if ( ! referencedBlock ) { - return false; - } - - const referencedBlockType = getBlockType( referencedBlock.name ); - if ( ! referencedBlockType ) { - return false; - } - - if ( ! canInsertBlockType( state, referencedBlockType.name, rootClientId ) ) { - return false; - } - - return true; - }; - const buildReusableBlockInserterItem = ( reusableBlock ) => { const id = `core/block/${ reusableBlock.id }`; - const referencedBlock = getBlock( state, reusableBlock.clientId ); - const referencedBlockType = getBlockType( referencedBlock.name ); + const referencedBlockName = getBlockName( state, reusableBlock.clientId ); + const referencedBlockType = getBlockType( referencedBlockName ); const { time, count = 0 } = getInsertUsage( state, id ) || {}; const utility = calculateUtility( 'reusable', count, false ); @@ -1831,11 +1929,11 @@ export const getInserterItems = createSelector( }; const blockTypeInserterItems = getBlockTypes() - .filter( shouldIncludeBlockType ) + .filter( ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) ) .map( buildBlockTypeInserterItem ); const reusableBlockInserterItems = __experimentalGetReusableBlocks( state ) - .filter( shouldIncludeReusableBlock ) + .filter( ( block ) => canIncludeReusableBlockInInserter( state, block, rootClientId ) ) .map( buildReusableBlockInserterItem ); return orderBy( @@ -1855,6 +1953,39 @@ export const getInserterItems = createSelector( ], ); +/** + * Determines whether there are items to show in the inserter. + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {boolean} Items that appear in inserter. + */ +export const hasInserterItems = createSelector( + ( state, rootClientId = null ) => { + const hasBlockType = some( + getBlockTypes(), + ( blockType ) => canIncludeBlockTypeInInserter( state, blockType, rootClientId ) + ); + if ( hasBlockType ) { + return true; + } + const hasReusableBlock = some( + __experimentalGetReusableBlocks( state ), + ( block ) => canIncludeReusableBlockInInserter( state, block, rootClientId ) + ); + + return hasReusableBlock; + }, + ( state, rootClientId ) => [ + state.blockListSettings[ rootClientId ], + state.editor.present.blocks, + state.settings.allowedBlockTypes, + state.settings.templateLock, + state.reusableBlocks.data, + getBlockTypes(), + ], +); + /** * Returns the reusable block with the given ID. * @@ -2161,58 +2292,3 @@ export function isPublishSidebarEnabled( state ) { } return PREFERENCES_DEFAULTS.isPublishSidebarEnabled; } - -// -// Deprecated -// - -export function getNotices() { - deprecated( 'getNotices selector (`core/editor` store)', { - alternative: 'getNotices selector (`core/notices` store)', - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return select( 'core/notices' ).getNotices(); -} - -export function getReusableBlock( state, ref ) { - deprecated( "wp.data.select( 'core/editor' ).getReusableBlock( ref )", { - alternative: "wp.data.select( 'core' ).getEntityRecord( 'postType', 'wp_block', ref )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalGetReusableBlock( state, ref ); -} - -export function isSavingReusableBlock( state, ref ) { - deprecated( 'isSavingReusableBlock selector (`core/editor` store)', { - alternative: '__experimentalIsSavingReusableBlock selector (`core/edtior` store)', - plugin: 'Gutenberg', - version: '4.4.0', - hint: 'Using experimental APIs is strongly discouraged as they are subject to removal without notice.', - } ); - - return __experimentalIsSavingReusableBlock( state, ref ); -} - -export function isFetchingReusableBlock( state, ref ) { - deprecated( "wp.data.select( 'core/editor' ).isFetchingReusableBlock( ref )", { - alternative: "wp.data.select( 'core' ).isResolving( 'getEntityRecord', 'wp_block', ref )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalIsFetchingReusableBlock( state, ref ); -} - -export function getReusableBlocks( state ) { - deprecated( "wp.data.select( 'core/editor' ).getReusableBlocks( ref )", { - alternative: "wp.data.select( 'core' ).getEntityRecords( 'postType', 'wp_block' )", - plugin: 'Gutenberg', - version: '4.4.0', - } ); - - return __experimentalGetReusableBlocks( state ); -} diff --git a/packages/editor/src/store/test/effects.js b/packages/editor/src/store/test/effects.js index ce54c418d5fa11..48985b9cacfbc5 100644 --- a/packages/editor/src/store/test/effects.js +++ b/packages/editor/src/store/test/effects.js @@ -245,6 +245,7 @@ describe( 'effects', () => { item_reverted_to_draft: 'Post reverted to draft.', item_updated: 'Post updated.', }, + viewable: true, } ); it( 'should dispatch notices when publishing or scheduling a post', () => { @@ -265,6 +266,22 @@ describe( 'effects', () => { ); } ); + it( 'should dispatch notices when publishing or scheduling an unviewable post', () => { + const previousPost = getDraftPost(); + const post = getPublishedPost(); + const postType = { ...getPostType(), viewable: false }; + + handler( { post, previousPost, postType } ); + + expect( dataDispatch( 'core/notices' ).createSuccessNotice ).toHaveBeenCalledWith( + 'Post published.', + { + id: SAVE_POST_NOTICE_ID, + actions: [], + } + ); + } ); + it( 'should dispatch notices when reverting a published post to a draft', () => { const previousPost = getPublishedPost(); const post = getDraftPost(); diff --git a/packages/editor/src/store/test/reducer.js b/packages/editor/src/store/test/reducer.js index d40b07b0b36bd7..2ddb631b5e2139 100644 --- a/packages/editor/src/store/test/reducer.js +++ b/packages/editor/src/store/test/reducer.js @@ -1040,6 +1040,90 @@ describe( 'state', () => { } ); } ); + it( 'should merge object values', () => { + const original = editor( undefined, { + type: 'EDIT_POST', + edits: { + meta: { + a: 1, + }, + }, + } ); + + const state = editor( original, { + type: 'EDIT_POST', + edits: { + meta: { + b: 2, + }, + }, + } ); + + expect( state.present.edits ).toEqual( { + meta: { + a: 1, + b: 2, + }, + } ); + } ); + + it( 'return state by reference on unchanging update', () => { + const original = editor( undefined, {} ); + + const state = editor( original, { + type: 'UPDATE_POST', + edits: {}, + } ); + + expect( state.present.edits ).toBe( original.present.edits ); + } ); + + it( 'unset reset post values which match by canonical value', () => { + const original = editor( undefined, { + type: 'EDIT_POST', + edits: { + title: 'modified title', + }, + } ); + + const state = editor( original, { + type: 'RESET_POST', + post: { + title: { + raw: 'modified title', + }, + }, + } ); + + expect( state.present.edits ).toEqual( {} ); + } ); + + it( 'unset reset post values by deep match', () => { + const original = editor( undefined, { + type: 'EDIT_POST', + edits: { + title: 'modified title', + meta: { + a: 1, + b: 2, + }, + }, + } ); + + const state = editor( original, { + type: 'UPDATE_POST', + edits: { + title: 'modified title', + meta: { + a: 1, + b: 2, + }, + }, + } ); + + expect( state.present.edits ).toEqual( {} ); + } ); + it( 'should omit content when resetting', () => { // Use case: When editing in Text mode, we defer to content on // the property, but we reset blocks by parse when switching @@ -1074,6 +1158,58 @@ describe( 'state', () => { } ); describe( 'blocks', () => { + it( 'should not reset any blocks that are not in the post', () => { + const actions = [ + { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'block1', + innerBlocks: [ + { clientId: 'block11', innerBlocks: [] }, + { clientId: 'block12', innerBlocks: [] }, + ], + }, + ], + }, + { + type: 'RECEIVE_BLOCKS', + blocks: [ + { + clientId: 'block2', + innerBlocks: [ + { clientId: 'block21', innerBlocks: [] }, + { clientId: 'block22', innerBlocks: [] }, + ], + }, + ], + }, + ]; + const original = deepFreeze( actions.reduce( editor, undefined ) ); + + const state = editor( original, { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'block3', + innerBlocks: [ + { clientId: 'block31', innerBlocks: [] }, + { clientId: 'block32', innerBlocks: [] }, + ], + }, + ], + } ); + + expect( state.present.blocks.byClientId ).toEqual( { + block2: { clientId: 'block2' }, + block21: { clientId: 'block21' }, + block22: { clientId: 'block22' }, + block3: { clientId: 'block3' }, + block31: { clientId: 'block31' }, + block32: { clientId: 'block32' }, + } ); + } ); + describe( 'byClientId', () => { it( 'should return with attribute block updates', () => { const original = deepFreeze( editor( undefined, { @@ -1807,6 +1943,7 @@ describe( 'state', () => { requesting: true, successful: false, error: null, + options: {}, } ); } ); @@ -1818,6 +1955,7 @@ describe( 'state', () => { requesting: false, successful: true, error: null, + options: {}, } ); } ); @@ -1836,6 +1974,7 @@ describe( 'state', () => { code: 'pretend_error', message: 'update failed', }, + options: {}, } ); } ); } ); @@ -2283,7 +2422,6 @@ describe( 'state', () => { raw: 'The Excerpt', }, status: 'draft', - preview_link: 'https://wordpress.org/?p=1&preview=true', }, } ); @@ -2291,7 +2429,6 @@ describe( 'state', () => { title: 'The Title', content: 'The Content', excerpt: 'The Excerpt', - preview_link: 'https://wordpress.org/?p=1&preview=true', } ); } ); } ); diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index 1470f2261db39c..751bfccc2945f0 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -2511,54 +2511,44 @@ describe( 'selectors', () => { } ); describe( 'getGlobalBlockCount', () => { - it( 'should return the global number of top-level blocks in the post', () => { - const state = { - editor: { - present: { - blocks: { - byClientId: { - 23: { clientId: 23, name: 'core/heading', attributes: {} }, - 123: { clientId: 123, name: 'core/paragraph', attributes: {} }, - }, + const state = { + editor: { + present: { + blocks: { + byClientId: { + 123: { clientId: 123, name: 'core/heading', attributes: {} }, + 456: { clientId: 456, name: 'core/paragraph', attributes: {} }, + 789: { clientId: 789, name: 'core/paragraph', attributes: {} }, + }, + order: { + '': [ 123, 456 ], }, }, }, - }; + }, + }; + it( 'should return the global number of blocks in the post', () => { expect( getGlobalBlockCount( state ) ).toBe( 2 ); } ); - it( 'should return the global umber of blocks of a given type', () => { - const state = { - editor: { - present: { - blocks: { - byClientId: { - 123: { clientId: 123, name: 'core/columns', attributes: {} }, - 456: { clientId: 456, name: 'core/paragraph', attributes: {} }, - 789: { clientId: 789, name: 'core/paragraph', attributes: {} }, - 124: { clientId: 123, name: 'core/heading', attributes: {} }, - }, - }, - }, - }, - }; - - expect( getGlobalBlockCount( state, 'core/heading' ) ).toBe( 1 ); + it( 'should return the global number of blocks in the post of a given type', () => { + expect( getGlobalBlockCount( state, 'core/paragraph' ) ).toBe( 1 ); } ); it( 'should return 0 if no blocks exist', () => { - const state = { + const emptyState = { editor: { present: { blocks: { byClientId: {}, + order: {}, }, }, }, }; - expect( getGlobalBlockCount( state ) ).toBe( 0 ); - expect( getGlobalBlockCount( state, 'core/heading' ) ).toBe( 0 ); + expect( getGlobalBlockCount( emptyState ) ).toBe( 0 ); + expect( getGlobalBlockCount( emptyState, 'core/heading' ) ).toBe( 0 ); } ); } ); @@ -4229,6 +4219,121 @@ describe( 'selectors', () => { } ); } ); + it( 'should not list a reusable block item if it is being inserted inside it self', () => { + const state = { + editor: { + present: { + blocks: { + byClientId: { + block1ref: { + name: 'core/block', + clientId: 'block1ref', + attributes: { + ref: 1, + }, + }, + itselfBlock1: { name: 'core/test-block-a' }, + itselfBlock2: { name: 'core/test-block-b' }, + }, + order: { + '': [ 'block1ref' ], + }, + }, + edits: {}, + }, + }, + initialEdits: {}, + reusableBlocks: { + data: { + 1: { clientId: 'itselfBlock1', title: 'Reusable Block 1' }, + 2: { clientId: 'itselfBlock2', title: 'Reusable Block 2' }, + }, + }, + currentPost: {}, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state, 'itselfBlock1' ); + const reusableBlockItems = filter( items, [ 'name', 'core/block' ] ); + expect( reusableBlockItems ).toHaveLength( 1 ); + expect( reusableBlockItems[ 0 ] ).toEqual( { + id: 'core/block/2', + name: 'core/block', + initialAttributes: { ref: 2 }, + title: 'Reusable Block 2', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); + + it( 'should not list a reusable block item if it is being inserted inside a descendent', () => { + const state = { + editor: { + present: { + blocks: { + byClientId: { + block2ref: { + name: 'core/block', + clientId: 'block1ref', + attributes: { + ref: 2, + }, + }, + referredBlock1: { name: 'core/test-block-a' }, + referredBlock2: { name: 'core/test-block-b' }, + childReferredBlock2: { name: 'core/test-block-a' }, + grandchildReferredBlock2: { name: 'core/test-block-b' }, + }, + order: { + '': [ 'block2ref' ], + referredBlock2: [ 'childReferredBlock2' ], + childReferredBlock2: [ 'grandchildReferredBlock2' ], + }, + }, + edits: {}, + }, + }, + initialEdits: {}, + reusableBlocks: { + data: { + 1: { clientId: 'referredBlock1', title: 'Reusable Block 1' }, + 2: { clientId: 'referredBlock2', title: 'Reusable Block 2' }, + }, + }, + currentPost: {}, + preferences: { + insertUsage: {}, + }, + blockListSettings: {}, + settings: {}, + }; + const items = getInserterItems( state, 'grandchildReferredBlock2' ); + const reusableBlockItems = filter( items, [ 'name', 'core/block' ] ); + expect( reusableBlockItems ).toHaveLength( 1 ); + expect( reusableBlockItems[ 0 ] ).toEqual( { + id: 'core/block/1', + name: 'core/block', + initialAttributes: { ref: 1 }, + title: 'Reusable Block 1', + icon: { + src: 'test', + }, + category: 'reusable', + keywords: [], + isDisabled: false, + utility: 0, + frecency: 0, + } ); + } ); it( 'should order items by descending utility and frecency', () => { const state = { editor: { @@ -4278,8 +4383,12 @@ describe( 'selectors', () => { byClientId: { block1: { name: 'core/test-block-a' }, block2: { name: 'core/test-block-a' }, + block3: { name: 'core/test-block-a' }, + block4: { name: 'core/test-block-a' }, + }, + order: { + '': [ 'block3', 'block4' ], }, - order: {}, }, edits: {}, }, @@ -4302,14 +4411,14 @@ describe( 'selectors', () => { const stateSecondBlockRestricted = { ...state, blockListSettings: { - block2: { + block4: { allowedBlocks: [ 'core/test-block-b' ], }, }, }; - const firstBlockFirstCall = getInserterItems( state, 'block1' ); - const firstBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block1' ); + const firstBlockFirstCall = getInserterItems( state, 'block3' ); + const firstBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block3' ); expect( firstBlockFirstCall ).toBe( firstBlockSecondCall ); expect( firstBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ 'core/test-block-b', @@ -4319,8 +4428,8 @@ describe( 'selectors', () => { 'core/block/2', ] ); - const secondBlockFirstCall = getInserterItems( state, 'block2' ); - const secondBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block2' ); + const secondBlockFirstCall = getInserterItems( state, 'block4' ); + const secondBlockSecondCall = getInserterItems( stateSecondBlockRestricted, 'block4' ); expect( secondBlockFirstCall.map( ( item ) => item.id ) ).toEqual( [ 'core/test-block-b', 'core/test-freeform', diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 972322a31a8b6c..5b79e1b52407ac 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -23,6 +23,7 @@ @import "./components/inserter/style.scss"; @import "./components/inserter-list-item/style.scss"; @import "./components/media-placeholder/style.scss"; +@import "./components/multi-selection-inspector/style.scss"; @import "./components/page-attributes/style.scss"; @import "./components/panel-color-settings/style.scss"; @import "./components/plain-text/style.scss"; diff --git a/packages/editor/src/utils/index.js b/packages/editor/src/utils/index.js index 3a49f66b86c902..0f246c16e4aec8 100644 --- a/packages/editor/src/utils/index.js +++ b/packages/editor/src/utils/index.js @@ -4,3 +4,4 @@ import mediaUpload from './media-upload'; export { mediaUpload }; +export { cleanForSlug } from './url.js'; diff --git a/packages/editor/src/utils/test/url.js b/packages/editor/src/utils/test/url.js new file mode 100644 index 00000000000000..9aca0fc8503e50 --- /dev/null +++ b/packages/editor/src/utils/test/url.js @@ -0,0 +1,10 @@ +/** + * Internal dependencies + */ +import { cleanForSlug } from '../url'; + +describe( 'cleanForSlug()', () => { + it( 'Should return string prepared for use as url slug', () => { + expect( cleanForSlug( ' /Déjà_vu. ' ) ).toBe( 'deja-vu' ); + } ); +} ); diff --git a/packages/editor/src/utils/url.js b/packages/editor/src/utils/url.js index 566775289f9777..371cdedc93ff45 100644 --- a/packages/editor/src/utils/url.js +++ b/packages/editor/src/utils/url.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { deburr, toLower, trim } from 'lodash'; + /** * WordPress dependencies */ @@ -16,3 +21,23 @@ import { addQueryArgs } from '@wordpress/url'; export function getWPAdminURL( page, query ) { return addQueryArgs( page, query ); } + +/** + * Performs some basic cleanup of a string for use as a post slug + * + * This replicates some of what santize_title() does in WordPress core, but + * is only designed to approximate what the slug will be. + * + * Converts whitespace, periods, forward slashes and underscores to hyphens. + * Converts Latin-1 Supplement and Latin Extended-A letters to basic Latin + * letters. Removes combining diacritical marks. Converts remaining string + * to lowercase. It does not touch octets, HTML entities, or other encoded + * characters. + * + * @param {string} string Title or slug to be processed + * + * @return {string} Processed string + */ +export function cleanForSlug( string ) { + return toLower( deburr( trim( string.replace( /[\s\./_]+/g, '-' ), '-' ) ) ); +} diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md index dd8128f44998a5..5cf43d26f2f3b8 100644 --- a/packages/element/CHANGELOG.md +++ b/packages/element/CHANGELOG.md @@ -1,3 +1,5 @@ +## 2.1.8 (2018-11-15) + ## 2.1.7 (2018-11-09) ## 2.1.6 (2018-11-09) diff --git a/packages/element/package.json b/packages/element/package.json index 58868f40d783a7..5e80ae9220c0d7 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/element", - "version": "2.1.7", + "version": "2.1.8", "description": "Element React module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -24,8 +24,8 @@ "@babel/runtime": "^7.0.0", "@wordpress/escape-html": "file:../escape-html", "lodash": "^4.17.10", - "react": "^16.4.1", - "react-dom": "^16.4.1" + "react": "^16.6.3", + "react-dom": "^16.6.3" }, "devDependencies": { "enzyme": "^3.7.0" diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 2badd261b817cf..eb34600a9949e1 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -1,3 +1,19 @@ +## 1.2.3 (Unreleased) + +### Bug fixes +- Link URL validation now works correctly when a URL includes a fragment. Previously any URL containing a fragment failed validation. +- Link URL validation catches an incorrect number of forward slashes following a url using the http protocol. + +## 1.2.2 (2018-11-15) + +## 1.2.1 (2018-11-12) + +## 1.2.0 (2018-11-12) + +## New Feature + +- Add URL validation to links. + ## 1.1.1 (2018-11-09) ## 1.1.0 (2018-11-09) diff --git a/packages/format-library/package.json b/packages/format-library/package.json index 6f1c0b2baf9828..ada7e7d088d2b3 100644 --- a/packages/format-library/package.json +++ b/packages/format-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/format-library", - "version": "1.1.1", + "version": "1.2.2", "description": "Format library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/format-library/src/image/index.js b/packages/format-library/src/image/index.js index 7deae883d3b3b4..c26bcca626a5dd 100644 --- a/packages/format-library/src/image/index.js +++ b/packages/format-library/src/image/index.js @@ -3,9 +3,9 @@ */ import { Path, SVG } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Fragment, Component } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { insertObject } from '@wordpress/rich-text'; -import { MediaUpload, RichTextInserterItem } from '@wordpress/editor'; +import { MediaUpload, RichTextInserterItem, MediaUploadCheck } from '@wordpress/editor'; const ALLOWED_MEDIA_TYPES = [ 'image' ]; @@ -46,7 +46,7 @@ export const image = { const { value, onChange } = this.props; return ( - <Fragment> + <MediaUploadCheck> <RichTextInserterItem name={ name } icon={ <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><Path d="M4 16h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v9c0 1.1.9 2 2 2zM4 5h10v9H4V5zm14 9v2h4v-2h-4zM2 20h20v-2H2v2zm6.4-8.8L7 9.4 5 12h8l-2.6-3.4-2 2.6z" /></SVG> } @@ -73,7 +73,7 @@ export const image = { return null; } } /> } - </Fragment> + </MediaUploadCheck> ); } }, diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index 2384835b65656c..350c94386ed857 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -14,7 +14,7 @@ import { ToggleControl, withSpokenMessages, } from '@wordpress/components'; -import { ESCAPE, LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; import { prependHTTP, safeDecodeURI, filterURLForDisplay } from '@wordpress/url'; import { create, @@ -84,12 +84,27 @@ const LinkEditor = ( { value, onChangeInputValue, onKeyDown, submitLink, autocom /* eslint-enable jsx-a11y/no-noninteractive-element-interactions */ ); -const LinkViewer = ( { url, editLink } ) => { +const LinkViewerUrl = ( { url } ) => { const prependedURL = prependHTTP( url ); const linkClassName = classnames( 'editor-format-toolbar__link-container-value', { 'has-invalid-link': ! isValidHref( prependedURL ), } ); + if ( ! url ) { + return <span className={ linkClassName }></span>; + } + + return ( + <ExternalLink + className={ linkClassName } + href={ url } + > + { filterURLForDisplay( safeDecodeURI( url ) ) } + </ExternalLink> + ); +}; + +const LinkViewer = ( { url, editLink } ) => { return ( // Disable reason: KeyPress must be suppressed so the block doesn't hide the toolbar /* eslint-disable jsx-a11y/no-static-element-interactions */ @@ -97,12 +112,7 @@ const LinkViewer = ( { url, editLink } ) => { className="editor-format-toolbar__link-container-content" onKeyPress={ stopKeyPropagation } > - <ExternalLink - className={ linkClassName } - href={ url } - > - { filterURLForDisplay( safeDecodeURI( url ) ) } - </ExternalLink> + <LinkViewerUrl url={ url } /> <IconButton icon="edit" label={ __( 'Edit' ) } onClick={ editLink } /> </div> /* eslint-enable jsx-a11y/no-static-element-interactions */ @@ -122,7 +132,10 @@ class InlineLinkUI extends Component { this.resetState = this.resetState.bind( this ); this.autocompleteRef = createRef(); - this.state = {}; + this.state = { + opensInNewWindow: false, + inputValue: '', + }; } static getDerivedStateFromProps( props, state ) { @@ -143,11 +156,6 @@ class InlineLinkUI extends Component { } onKeyDown( event ) { - if ( event.keyCode === ESCAPE ) { - event.stopPropagation(); - this.resetState(); - } - if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( event.keyCode ) > -1 ) { // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. event.stopPropagation(); @@ -159,7 +167,7 @@ class InlineLinkUI extends Component { } setLinkTarget( opensInNewWindow ) { - const { activeAttributes: { url }, value, onChange } = this.props; + const { activeAttributes: { url = '' }, value, onChange } = this.props; this.setState( { opensInNewWindow } ); @@ -234,6 +242,7 @@ class InlineLinkUI extends Component { > <URLPopover onClickOutside={ this.onClickOutside } + onClose={ this.resetState } focusOnMount={ showInput ? 'firstElement' : false } renderSettings={ () => ( <ToggleControl diff --git a/packages/format-library/src/link/test/utils.js b/packages/format-library/src/link/test/utils.js index 19feb519e6490e..aec4af14588885 100644 --- a/packages/format-library/src/link/test/utils.js +++ b/packages/format-library/src/link/test/utils.js @@ -26,6 +26,8 @@ describe( 'isValidHref', () => { expect( isValidHref( 'https://test.com' ) ).toBe( true ); expect( isValidHref( 'http://test-with-hyphen.com' ) ).toBe( true ); expect( isValidHref( 'http://test.com/' ) ).toBe( true ); + expect( isValidHref( 'http://test.com#fragment' ) ).toBe( true ); + expect( isValidHref( 'http://test.com/path#fragment' ) ).toBe( true ); expect( isValidHref( 'http://test.com/with/path/separators' ) ).toBe( true ); expect( isValidHref( 'http://test.com/with?query=string&params' ) ).toBe( true ); } ); @@ -36,6 +38,7 @@ describe( 'isValidHref', () => { expect( isValidHref( 'mailto: test@somewhere.com' ) ).toBe( false ); expect( isValidHref( 'ht#tp://this/is/invalid' ) ).toBe( false ); expect( isValidHref( 'ht#tp://th&is/is/invalid' ) ).toBe( false ); + expect( isValidHref( 'http:/test.com' ) ).toBe( false ); expect( isValidHref( 'http://?test.com' ) ).toBe( false ); expect( isValidHref( 'http://#test.com' ) ).toBe( false ); expect( isValidHref( 'http://test.com?double?params' ) ).toBe( false ); diff --git a/packages/format-library/src/link/utils.js b/packages/format-library/src/link/utils.js index 498d96e26d9a74..a516917886a951 100644 --- a/packages/format-library/src/link/utils.js +++ b/packages/format-library/src/link/utils.js @@ -37,13 +37,19 @@ export function isValidHref( href ) { return false; } - // Does the href start with something that looks like a url protocol? + // Does the href start with something that looks like a URL protocol? if ( /^\S+:/.test( trimmedHref ) ) { const protocol = getProtocol( trimmedHref ); if ( ! isValidProtocol( protocol ) ) { return false; } + // Add some extra checks for http(s) URIs, since these are the most common use-case. + // This ensures URIs with an http protocol have exactly two forward slashes following the protocol. + if ( startsWith( protocol, 'http' ) && ! /^https?:\/\/[^\/\s]/i.test( trimmedHref ) ) { + return false; + } + const authority = getAuthority( trimmedHref ); if ( ! isValidAuthority( authority ) ) { return false; @@ -60,7 +66,7 @@ export function isValidHref( href ) { } const fragment = getFragment( trimmedHref ); - if ( fragment && ! isValidFragment( trimmedHref ) ) { + if ( fragment && ! isValidFragment( fragment ) ) { return false; } } diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index 6ea0be81af1d52..8eb9757eb2088c 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.1.0 (2018-11-15) + +### Enhancements + +- The module has been internally refactored to use [Tannin](https://github.com/aduth/tannin) in place of [Jed](https://github.com/messageformat/Jed/). This has no impact on the public interface of the module, but should come with considerable benefit to performance, memory usage, and bundle size. + ## 3.0.0 (2018-09-30) ### Breaking Changes diff --git a/packages/i18n/package.json b/packages/i18n/package.json index fc0998bb342d66..18158a6f0162e2 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/i18n", - "version": "3.0.1", + "version": "3.1.0", "description": "WordPress internationalization (i18n) library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -25,9 +25,10 @@ "dependencies": { "@babel/runtime": "^7.0.0", "gettext-parser": "^1.3.1", - "jed": "^1.1.1", "lodash": "^4.17.10", - "memize": "^1.0.5" + "memize": "^1.0.5", + "sprintf-js": "^1.1.1", + "tannin": "^1.0.1" }, "devDependencies": { "benchmark": "^2.1.4" diff --git a/packages/i18n/src/index.js b/packages/i18n/src/index.js index ccb9f9646e00b4..42266da0c9ff9f 100644 --- a/packages/i18n/src/index.js +++ b/packages/i18n/src/index.js @@ -1,10 +1,21 @@ /** * External dependencies */ -import Jed from 'jed'; +import Tannin from 'tannin'; import memoize from 'memize'; +import sprintfjs from 'sprintf-js'; -let i18n; +/** + * Default locale data to use for Tannin domain when not otherwise provided. + * Assumes an English plural forms expression. + * + * @type {Object} + */ +const DEFAULT_LOCALE_DATA = { + '': { + plural_forms: 'plural=(n!=1)', + }, +}; /** * Log to console, once per message; or more precisely, per referentially equal @@ -16,48 +27,39 @@ let i18n; const logErrorOnce = memoize( console.error ); // eslint-disable-line no-console /** - * Merges locale data into the Jed instance by domain. Creates a new Jed - * instance if one has not yet been assigned. - * - * @see http://messageformat.github.io/Jed/ + * The underlying instance of Tannin to which exported functions interface. * - * @param {?Object} localeData Locale data configuration. - * @param {?string} domain Domain for which configuration applies. + * @type {Tannin} */ -export function setLocaleData( localeData = { '': {} }, domain = 'default' ) { - if ( ! i18n ) { - i18n = new Jed( { - domain: 'default', - locale_data: { - default: { '': {} }, - }, - } ); - } - - i18n.options.locale_data[ domain ] = Object.assign( - {}, - i18n.options.locale_data[ domain ], - localeData - ); -} +const i18n = new Tannin( {} ); /** - * Returns the current Jed instance, initializing with a default configuration - * if not already assigned. + * Merges locale data into the Tannin instance by domain. Accepts data in a + * Jed-formatted JSON object shape. * - * @return {Jed} Jed instance. + * @see http://messageformat.github.io/Jed/ + * + * @param {?Object} data Locale data configuration. + * @param {?string} domain Domain for which configuration applies. */ -function getI18n() { - if ( ! i18n ) { - setLocaleData(); - } +export function setLocaleData( data, domain = 'default' ) { + i18n.data[ domain ] = { + ...DEFAULT_LOCALE_DATA, + ...i18n.data[ domain ], + ...data, + }; - return i18n; + // Populate default domain configuration (supported locale date which omits + // a plural forms expression). + i18n.data[ domain ][ '' ] = { + ...DEFAULT_LOCALE_DATA[ '' ], + ...i18n.data[ domain ][ '' ], + }; } /** - * Wrapper for Jed's `dcnpgettext`, its most qualified function. Absorbs errors - * which are thrown as the result of invalid translation. + * Wrapper for Tannin's `dcnpgettext`. Populates default locale data if not + * otherwise previously assigned. * * @param {?string} domain Domain to retrieve the translated text. * @param {?string} context Context information for the translators. @@ -69,15 +71,13 @@ function getI18n() { * * @return {string} The translated string. */ -const dcnpgettext = memoize( ( domain = 'default', context, single, plural, number ) => { - try { - return getI18n().dcnpgettext( domain, context, single, plural, number ); - } catch ( error ) { - logErrorOnce( 'Jed localization error: \n\n' + error.toString() ); - - return single; +function dcnpgettext( domain = 'default', context, single, plural, number ) { + if ( ! i18n.data[ domain ] ) { + setLocaleData( undefined, domain ); } -} ); + + return i18n.dcnpgettext( domain, context, single, plural, number ); +} /** * Retrieve the translation of text. @@ -158,9 +158,9 @@ export function _nx( single, plural, number, context, domain ) { */ export function sprintf( format, ...args ) { try { - return Jed.sprintf( format, ...args ); + return sprintfjs.sprintf( format, ...args ); } catch ( error ) { - logErrorOnce( 'Jed sprintf error: \n\n' + error.toString() ); + logErrorOnce( 'sprintf error: \n\n' + error.toString() ); return format; } diff --git a/packages/i18n/src/test/index.js b/packages/i18n/src/test/index.js index ede1b25927dba2..c79d24fb43bc26 100644 --- a/packages/i18n/src/test/index.js +++ b/packages/i18n/src/test/index.js @@ -22,40 +22,18 @@ const localeData = { 'hello %s': [ 'bonjour %s' ], - '%d banana': [ 'une banane', '%d bananes' ], + '%d banana': [ '%d banane', '%d bananes' ], - 'fruit\u0004%d apple': [ 'une pomme', '%d pommes' ], + 'fruit\u0004%d apple': [ '%d pomme', '%d pommes' ], }; const additionalLocaleData = { cheeseburger: [ 'hamburger au fromage' ], - '%d cat': [ 'un chat', '%d chats' ], + '%d cat': [ '%d chat', '%d chats' ], }; setLocaleData( localeData, 'test_domain' ); describe( 'i18n', () => { - describe( 'error absorb', () => { - it( '__', () => { - __( 'Hello', 'domain-without-data' ); - expect( console ).toHaveErrored(); - } ); - - it( '_x', () => { - _x( 'feed', 'verb', 'domain-without-data' ); - expect( console ).toHaveErrored(); - } ); - - it( '_n', () => { - _n( '%d banana', '%d bananas', 3, 'domain-without-data' ); - expect( console ).toHaveErrored(); - } ); - - it( '_nx', () => { - _nx( '%d apple', '%d apples', 3, 'fruit', 'domain-without-data' ); - expect( console ).toHaveErrored(); - } ); - } ); - describe( '__', () => { it( 'use the translation', () => { expect( __( 'hello', 'test_domain' ) ).toBe( 'bonjour' ); @@ -74,7 +52,7 @@ describe( 'i18n', () => { } ); it( 'use the singular form', () => { - expect( _n( '%d banana', '%d bananas', 1, 'test_domain' ) ).toBe( 'une banane' ); + expect( _n( '%d banana', '%d bananas', 1, 'test_domain' ) ).toBe( '%d banane' ); } ); } ); @@ -84,7 +62,7 @@ describe( 'i18n', () => { } ); it( 'use the singular form', () => { - expect( _nx( '%d apple', '%d apples', 1, 'fruit', 'test_domain' ) ).toBe( 'une pomme' ); + expect( _nx( '%d apple', '%d apples', 1, 'fruit', 'test_domain' ) ).toBe( '%d pomme' ); } ); } ); @@ -103,10 +81,24 @@ describe( 'i18n', () => { } ); } ); - describe( 'setAdditionalLocale', () => { + describe( 'setLocaleData', () => { beforeAll( () => { setLocaleData( additionalLocaleData, 'test_domain' ); } ); + + it( 'supports omitted plural forms expression', () => { + setLocaleData( { + '': { + domain: 'test_domain2', + lang: 'fr', + }, + + '%d banana': [ '%d banane', '%d bananes' ], + }, 'test_domain2' ); + + expect( _n( '%d banana', '%d bananes', 2, 'test_domain2' ) ).toBe( '%d bananes' ); + } ); + describe( '__', () => { it( 'existing translation still available', () => { expect( __( 'hello', 'test_domain' ) ).toBe( 'bonjour' ); @@ -123,7 +115,7 @@ describe( 'i18n', () => { } ); it( 'new singular form was added', () => { - expect( _n( '%d cat', '%d cats', 1, 'test_domain' ) ).toBe( 'un chat' ); + expect( _n( '%d cat', '%d cats', 1, 'test_domain' ) ).toBe( '%d chat' ); } ); it( 'new plural form was added', () => { diff --git a/packages/jest-console/package.json b/packages/jest-console/package.json index 1b94ffda29253c..a962af91ec62ff 100644 --- a/packages/jest-console/package.json +++ b/packages/jest-console/package.json @@ -26,7 +26,7 @@ "module": "build-module/index.js", "dependencies": { "@babel/runtime": "^7.0.0", - "jest-matcher-utils": "^22.4.3", + "jest-matcher-utils": "^23.6.0", "lodash": "^4.17.10" }, "peerDependencies": { diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json index ce4dee0522c9fa..29402dfb79ec8c 100644 --- a/packages/jest-preset-default/package.json +++ b/packages/jest-preset-default/package.json @@ -27,7 +27,7 @@ "main": "index.js", "dependencies": { "@wordpress/jest-console": "file:../jest-console", - "babel-jest": "^23.4.2", + "babel-jest": "^23.6.0", "enzyme": "^3.7.0", "enzyme-adapter-react-16": "^1.6.0", "jest-enzyme": "^6.0.2" diff --git a/packages/keycodes/src/index.js b/packages/keycodes/src/index.js index 611fc58c62279e..45e5dba7076743 100644 --- a/packages/keycodes/src/index.js +++ b/packages/keycodes/src/index.js @@ -43,7 +43,7 @@ export const CTRL = 'ctrl'; export const COMMAND = 'meta'; export const SHIFT = 'shift'; -const modifiers = { +export const modifiers = { primary: ( _isApple ) => _isApple() ? [ COMMAND ] : [ CTRL ], primaryShift: ( _isApple ) => _isApple() ? [ SHIFT, COMMAND ] : [ CTRL, SHIFT ], primaryAlt: ( _isApple ) => _isApple() ? [ ALT, COMMAND ] : [ CTRL, ALT ], diff --git a/packages/list-reusable-blocks/CHANGELOG.md b/packages/list-reusable-blocks/CHANGELOG.md index 2e04e42846146f..0712d0cf932867 100644 --- a/packages/list-reusable-blocks/CHANGELOG.md +++ b/packages/list-reusable-blocks/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.1.12 (2018-11-15) + +## 1.1.11 (2018-11-12) + +## 1.1.10 (2018-11-10) + ## 1.1.9 (2018-11-09) ## 1.1.8 (2018-11-09) diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json index 4e6cadf8d13f19..41a0a9031e5a6a 100644 --- a/packages/list-reusable-blocks/package.json +++ b/packages/list-reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/list-reusable-blocks", - "version": "1.1.9", + "version": "1.1.12", "description": "Adding Export/Import support to the reusable blocks listing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/notices/CHANGELOG.md b/packages/notices/CHANGELOG.md index 632ca5167774c2..6a38ce167d73af 100644 --- a/packages/notices/CHANGELOG.md +++ b/packages/notices/CHANGELOG.md @@ -1,3 +1,16 @@ +## 1.1.0 (Unreleased) + +### New Feature + +- The `createNotice` can now optionally accept a WPNotice object as the sole argument. +- New option `speak` enables control as to whether the notice content is announced to screen readers (defaults to `true`) + +### Bug Fixes + +- While `createNotice` only explicitly supported content of type `string`, it was not previously enforced. This has been corrected. + +## 1.0.5 (2018-11-15) + ## 1.0.4 (2018-11-09) ## 1.0.3 (2018-11-09) diff --git a/packages/notices/package.json b/packages/notices/package.json index ee99ad6e3b2e26..5a600a1b5c5552 100644 --- a/packages/notices/package.json +++ b/packages/notices/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/notices", - "version": "1.0.4", + "version": "1.0.5", "description": "State management for notices.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/notices/src/store/actions.js b/packages/notices/src/store/actions.js index 20d31d028c00b3..af74f5b6116fea 100644 --- a/packages/notices/src/store/actions.js +++ b/packages/notices/src/store/actions.js @@ -6,7 +6,7 @@ import { uniqueId } from 'lodash'; /** * Internal dependencies */ -import { DEFAULT_CONTEXT } from './constants'; +import { DEFAULT_CONTEXT, DEFAULT_STATUS } from './constants'; /** * Yields action objects used in signalling that a notice is to be created. @@ -23,18 +23,32 @@ import { DEFAULT_CONTEXT } from './constants'; * @param {?boolean} options.isDismissible Whether the notice can * be dismissed by user. * Defaults to `true`. + * @param {?boolean} options.speak Whether the notice + * content should be + * announced to screen + * readers. Defaults to + * `true`. * @param {?Array<WPNoticeAction>} options.actions User actions to be * presented with notice. */ -export function* createNotice( status = 'info', content, options = {} ) { +export function* createNotice( status = DEFAULT_STATUS, content, options = {} ) { const { + speak = true, isDismissible = true, context = DEFAULT_CONTEXT, id = uniqueId( context ), actions = [], + __unstableHTML, } = options; - yield { type: 'SPEAK', message: content }; + // The supported value shape of content is currently limited to plain text + // strings. To avoid setting expectation that e.g. a WPElement could be + // supported, cast to a string. + content = String( content ); + + if ( speak ) { + yield { type: 'SPEAK', message: content }; + } yield { type: 'CREATE_NOTICE', @@ -43,6 +57,7 @@ export function* createNotice( status = 'info', content, options = {} ) { id, status, content, + __unstableHTML, isDismissible, actions, }, diff --git a/packages/notices/src/store/constants.js b/packages/notices/src/store/constants.js index 4c6ded8a4a3084..2949bde0577db2 100644 --- a/packages/notices/src/store/constants.js +++ b/packages/notices/src/store/constants.js @@ -6,3 +6,10 @@ * @type {string} */ export const DEFAULT_CONTEXT = 'global'; + +/** + * Default notice status. + * + * @type {string} + */ +export const DEFAULT_STATUS = 'info'; diff --git a/packages/notices/src/store/selectors.js b/packages/notices/src/store/selectors.js index 743d9f23ff623f..9ba3cec0e63a06 100644 --- a/packages/notices/src/store/selectors.js +++ b/packages/notices/src/store/selectors.js @@ -22,11 +22,16 @@ const DEFAULT_NOTICES = []; * `info`, `error`, or `warning`. Defaults * to `info`. * @property {string} content Notice message. + * @property {string} __unstableHTML Notice message as raw HTML. Intended to + * serve primarily for compatibility of + * server-rendered notices, and SHOULD NOT + * be used for notices. It is subject to + * removal without notice. * @property {boolean} isDismissible Whether the notice can be dismissed by * user. Defaults to `true`. * @property {WPNoticeAction[]} actions User actions to present with notice. * - * @typedef {Notice} + * @typedef {WPNotice} */ /** @@ -48,7 +53,7 @@ const DEFAULT_NOTICES = []; * @param {Object} state Notices state. * @param {?string} context Optional grouping context. * - * @return {Notice[]} Array of notices. + * @return {WPNotice[]} Array of notices. */ export function getNotices( state, context = DEFAULT_CONTEXT ) { return state[ context ] || DEFAULT_NOTICES; diff --git a/packages/notices/src/store/test/actions.js b/packages/notices/src/store/test/actions.js index e20ba54e7c6a78..ac6bc522f273ac 100644 --- a/packages/notices/src/store/test/actions.js +++ b/packages/notices/src/store/test/actions.js @@ -9,14 +9,15 @@ import { createWarningNotice, removeNotice, } from '../actions'; -import { DEFAULT_CONTEXT } from '../constants'; +import { DEFAULT_CONTEXT, DEFAULT_STATUS } from '../constants'; describe( 'actions', () => { describe( 'createNotice', () => { + const id = 'my-id'; const status = 'status'; const content = 'my message'; - it( 'should yields actions when options is empty', () => { + it( 'yields actions when options is empty', () => { const result = createNotice( status, content ); expect( result.next().value ).toMatchObject( { @@ -37,8 +38,28 @@ describe( 'actions', () => { } ); } ); - it( 'should yields actions when options passed', () => { - const id = 'my-id'; + it( 'normalizes content to string', () => { + const result = createNotice( status, <strong>Hello</strong> ); + + expect( result.next().value ).toMatchObject( { + type: 'SPEAK', + message: expect.any( String ), + } ); + + expect( result.next().value ).toMatchObject( { + type: 'CREATE_NOTICE', + context: DEFAULT_CONTEXT, + notice: { + status, + content: expect.any( String ), + isDismissible: true, + id: expect.any( String ), + actions: [], + }, + } ); + } ); + + it( 'yields actions when options passed', () => { const context = 'foo'; const options = { id, @@ -65,6 +86,32 @@ describe( 'actions', () => { }, } ); } ); + + it( 'yields action when speak disabled', () => { + const result = createNotice( + undefined, + 'my <strong>message</strong>', + { + id, + __unstableHTML: true, + isDismissible: false, + speak: false, + } + ); + + expect( result.next().value ).toEqual( { + type: 'CREATE_NOTICE', + context: DEFAULT_CONTEXT, + notice: { + id, + status: DEFAULT_STATUS, + content: 'my <strong>message</strong>', + __unstableHTML: true, + isDismissible: false, + actions: [], + }, + } ); + } ); } ); describe( 'createSuccessNotice', () => { @@ -106,7 +153,7 @@ describe( 'actions', () => { type: 'CREATE_NOTICE', context: DEFAULT_CONTEXT, notice: { - status: 'info', + status: DEFAULT_STATUS, content, isDismissible: true, id: expect.any( String ), diff --git a/packages/nux/CHANGELOG.md b/packages/nux/CHANGELOG.md index 9b8e754221da83..1db9b6a1e2ae8f 100644 --- a/packages/nux/CHANGELOG.md +++ b/packages/nux/CHANGELOG.md @@ -1,3 +1,13 @@ +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- The id prop of DotTip has been removed. Please use the tipId prop instead. + +## 2.0.13 (2018-11-12) + +## 2.0.12 (2018-11-12) + ## 2.0.11 (2018-11-09) ## 2.0.10 (2018-11-09) @@ -22,4 +32,4 @@ ### Breaking Change -- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/packages/nux/package.json b/packages/nux/package.json index 50012ed481725d..d4e338cb4662dd 100644 --- a/packages/nux/package.json +++ b/packages/nux/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/nux", - "version": "2.0.11", + "version": "3.0.0", "description": "NUX (New User eXperience) module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -24,7 +24,6 @@ "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", - "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "lodash": "^4.17.10", diff --git a/packages/nux/src/components/dot-tip/index.js b/packages/nux/src/components/dot-tip/index.js index 45c7a768e2089b..022eb745352419 100644 --- a/packages/nux/src/components/dot-tip/index.js +++ b/packages/nux/src/components/dot-tip/index.js @@ -5,7 +5,6 @@ import { compose } from '@wordpress/compose'; import { Popover, Button, IconButton } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { withSelect, withDispatch } from '@wordpress/data'; -import deprecated from '@wordpress/deprecated'; function getAnchorRect( anchor ) { // The default getAnchorRect() excludes an element's top and bottom padding @@ -59,15 +58,7 @@ export function DotTip( { } export default compose( - withSelect( ( select, { tipId, id } ) => { - if ( id ) { - tipId = id; - deprecated( 'The id prop of wp.nux.DotTip', { - plugin: 'Gutenberg', - version: '4.4', - alternative: 'the tipId prop', - } ); - } + withSelect( ( select, { tipId } ) => { const { isTipVisible, getAssociatedGuide } = select( 'core/nux' ); const associatedGuide = getAssociatedGuide( tipId ); return { @@ -75,11 +66,11 @@ export default compose( hasNextTip: !! ( associatedGuide && associatedGuide.nextTipId ), }; } ), - withDispatch( ( dispatch, { tipId, id } ) => { + withDispatch( ( dispatch, { tipId } ) => { const { dismissTip, disableTips } = dispatch( 'core/nux' ); return { onDismiss() { - dismissTip( tipId || id ); + dismissTip( tipId ); }, onDisable() { disableTips(); diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md index 30967efbea2bb1..11b1389222d317 100644 --- a/packages/plugins/CHANGELOG.md +++ b/packages/plugins/CHANGELOG.md @@ -1,3 +1,5 @@ +## 2.0.9 (2018-11-15) + ## 2.0.8 (2018-11-09) ## 2.0.7 (2018-11-09) diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 38a61be8ff9c05..14810ef0814d04 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/plugins", - "version": "2.0.8", + "version": "2.0.9", "description": "Plugins module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/rich-text/CHANGELOG.md b/packages/rich-text/CHANGELOG.md index b940c65226bd26..d9c2664864efa9 100644 --- a/packages/rich-text/CHANGELOG.md +++ b/packages/rich-text/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- `toHTMLString` always expects an object instead of multiple arguments. + ## 2.0.4 (2018-11-09) ## 2.0.3 (2018-11-09) diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 719c7439e6e580..e14fcf4f8f5145 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/rich-text", - "version": "2.0.4", + "version": "3.0.0", "description": "Rich text value and manipulation API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -21,8 +21,8 @@ "react-native": "src/index", "dependencies": { "@babel/runtime": "^7.0.0", + "@wordpress/compose": "file:../compose", "@wordpress/data": "file:../data", - "@wordpress/deprecated": "file:../deprecated", "@wordpress/escape-html": "file:../escape-html", "lodash": "^4.17.10", "rememo": "^3.0.0" diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 5ab6a34e6c903e..fdbbde943aef1a 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -57,10 +57,6 @@ function toFormat( { type, attributes } ) { return attributes ? { type, attributes } : { type }; } - if ( formatType.__experimentalCreatePrepareEditableTree ) { - return null; - } - if ( ! attributes ) { return { type: formatType.name }; } diff --git a/packages/rich-text/src/insert-line-separator.js b/packages/rich-text/src/insert-line-separator.js index 17ab2957ed7298..24174668d25cb8 100644 --- a/packages/rich-text/src/insert-line-separator.js +++ b/packages/rich-text/src/insert-line-separator.js @@ -24,10 +24,11 @@ export function insertLineSeparator( ) { const beforeText = getTextContent( value ).slice( 0, startIndex ); const previousLineSeparatorIndex = beforeText.lastIndexOf( LINE_SEPARATOR ); + const previousLineSeparatorFormats = value.formats[ previousLineSeparatorIndex ]; let formats = [ , ]; - if ( previousLineSeparatorIndex !== -1 ) { - formats = [ value.formats[ previousLineSeparatorIndex ] ]; + if ( previousLineSeparatorFormats ) { + formats = [ previousLineSeparatorFormats ]; } const valueToInsert = { diff --git a/packages/rich-text/src/register-format-type.js b/packages/rich-text/src/register-format-type.js index 38af31b958e90c..8b61fc3a4308b1 100644 --- a/packages/rich-text/src/register-format-type.js +++ b/packages/rich-text/src/register-format-type.js @@ -1,8 +1,14 @@ +/** + * External dependencies + */ +import { mapKeys } from 'lodash'; + /** * WordPress dependencies */ -import { select, dispatch, withSelect } from '@wordpress/data'; +import { select, dispatch, withSelect, withDispatch } from '@wordpress/data'; import { addFilter } from '@wordpress/hooks'; +import { compose } from '@wordpress/compose'; /** * Registers a new format provided a unique name and an object defining its @@ -114,30 +120,89 @@ export function registerFormatType( name, settings ) { dispatch( 'core/rich-text' ).addFormatTypes( settings ); if ( - settings.__experimentalCreatePrepareEditableTree && settings.__experimentalGetPropsForEditableTreePreparation ) { addFilter( 'experimentalRichText', name, ( OriginalComponent ) => { - return withSelect( ( sel, { clientId, identifier } ) => ( { - [ `format_${ name }` ]: settings.__experimentalGetPropsForEditableTreePreparation( - sel, - { - richTextIdentifier: identifier, - blockClientId: clientId, + let Component = OriginalComponent; + if ( + settings.__experimentalCreatePrepareEditableTree || + settings.__experimentalCreateFormatToValue || + settings.__experimentalCreateValueToFormat + ) { + Component = ( props ) => { + const additionalProps = {}; + + if ( settings.__experimentalCreatePrepareEditableTree ) { + additionalProps.prepareEditableTree = [ + ...( props.prepareEditableTree || [] ), + settings.__experimentalCreatePrepareEditableTree( props[ `format_${ name }` ], { + richTextIdentifier: props.identifier, + blockClientId: props.clientId, + } ), + ]; } - ), - } ) )( ( props ) => ( - <OriginalComponent - { ...props } - prepareEditableTree={ [ - ...( props.prepareEditableTree || [] ), - settings.__experimentalCreatePrepareEditableTree( props[ `format_${ name }` ], { - richTextIdentifier: props.identifier, - blockClientId: props.clientId, - } ), - ] } - /> - ) ); + + if ( settings.__experimentalCreateOnChangeEditableValue ) { + const dispatchProps = Object.keys( props ).reduce( ( accumulator, propKey ) => { + const propValue = props[ propKey ]; + const keyPrefix = `format_${ name }_dispatch_`; + if ( propKey.startsWith( keyPrefix ) ) { + const realKey = propKey.replace( keyPrefix, '' ); + + accumulator[ realKey ] = propValue; + } + + return accumulator; + }, {} ); + + additionalProps.onChangeEditableValue = [ + ...( props.onChangeEditableValue || [] ), + settings.__experimentalCreateOnChangeEditableValue( { + ...props[ `format_${ name }` ], + ...dispatchProps, + }, { + richTextIdentifier: props.identifier, + blockClientId: props.clientId, + } ), + ]; + } + + return <OriginalComponent + { ...props } + { ...additionalProps } + />; + }; + } + + const hocs = [ + withSelect( ( sel, { clientId, identifier } ) => ( { + [ `format_${ name }` ]: settings.__experimentalGetPropsForEditableTreePreparation( + sel, + { + richTextIdentifier: identifier, + blockClientId: clientId, + } + ), + } ) ), + ]; + + if ( settings.__experimentalGetPropsForEditableTreeChangeHandler ) { + hocs.push( withDispatch( ( disp, { clientId, identifier } ) => { + const dispatchProps = settings.__experimentalGetPropsForEditableTreeChangeHandler( + disp, + { + richTextIdentifier: identifier, + blockClientId: clientId, + } + ); + + return mapKeys( dispatchProps, ( value, key ) => { + return `format_${ name }_dispatch_${ key }`; + } ); + } ) ); + } + + return compose( hocs )( Component ); } ); } diff --git a/packages/rich-text/src/test/insert-line-separator.js b/packages/rich-text/src/test/insert-line-separator.js index 3dd252278ebeb8..398b0a2b8834f3 100644 --- a/packages/rich-text/src/test/insert-line-separator.js +++ b/packages/rich-text/src/test/insert-line-separator.js @@ -54,7 +54,7 @@ describe( 'insertLineSeparator', () => { expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); } ); - it( 'should insert line separator in nested item', () => { + it( 'should insert line separator with previous line separator formats', () => { const value = { formats: [ , , , [ ol ], , ], text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a`, @@ -73,4 +73,24 @@ describe( 'insertLineSeparator', () => { expect( result ).toEqual( expected ); expect( getSparseArrayLength( result.formats ) ).toBe( 2 ); } ); + + it( 'should insert line separator without formats if previous line separator did not have any', () => { + const value = { + formats: [ , , , , , ], + text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a`, + start: 5, + end: 5, + }; + const expected = { + formats: [ , , , , , , ], + text: `1${ LINE_SEPARATOR }2${ LINE_SEPARATOR }a${ LINE_SEPARATOR }`, + start: 6, + end: 6, + }; + const result = insertLineSeparator( deepFreeze( value ) ); + + expect( result ).not.toBe( value ); + expect( result ).toEqual( expected ); + expect( getSparseArrayLength( result.formats ) ).toBe( 0 ); + } ); } ); diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js index 1965419bdd552e..7b6d2edfac2c33 100644 --- a/packages/rich-text/src/to-html-string.js +++ b/packages/rich-text/src/to-html-string.js @@ -7,7 +7,6 @@ import { escapeAttribute, isValidAttributeName, } from '@wordpress/escape-html'; -import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -28,19 +27,6 @@ import { toTree } from './to-tree'; * @return {string} HTML string. */ export function toHTMLString( { value, multilineTag, multilineWrapperTags } ) { - // Check other arguments for backward compatibility. - if ( value === undefined ) { - deprecated( 'wp.richText.toHTMLString positional parameters', { - version: '4.4', - alternative: 'named parameters', - plugin: 'Gutenberg', - } ); - - value = arguments[ 0 ]; - multilineTag = arguments[ 1 ]; - multilineWrapperTags = arguments[ 2 ]; - } - const tree = toTree( { value, multilineTag, diff --git a/packages/scripts/package.json b/packages/scripts/package.json index faac1e7c8c7882..9656fa50392b1e 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -38,7 +38,7 @@ "chalk": "^2.4.1", "cross-spawn": "^5.1.0", "eslint": "^4.19.1", - "jest": "^23.4.2", + "jest": "^23.6.0", "npm-package-json-lint": "^3.3.1", "read-pkg-up": "^1.0.1", "resolve-bin": "^0.4.0" diff --git a/packages/token-list/CHANGELOG.md b/packages/token-list/CHANGELOG.md index 25a7fe2de7b84a..8bf612a4669aa9 100644 --- a/packages/token-list/CHANGELOG.md +++ b/packages/token-list/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.1.0 (Unreleased) + +### Enhancements + +- Implements missing stringification behavior (i.e. `toString`), as prescribed in the standard to be the `value` property (the interface's `stringifier`) +- Implements missing iterator behavior + ## 1.0.0 (2018-09-05) - Initial release diff --git a/packages/token-list/src/index.js b/packages/token-list/src/index.js index 21d06edcc8cab9..b959c331e79af7 100644 --- a/packages/token-list/src/index.js +++ b/packages/token-list/src/index.js @@ -59,6 +59,29 @@ export default class TokenList { return this._valueAsArray.length; } + /** + * Returns the stringified form of the TokenList. + * + * @link https://dom.spec.whatwg.org/#DOMTokenList-stringification-behavior + * @link https://www.ecma-international.org/ecma-262/9.0/index.html#sec-tostring + * + * @return {string} Token set as string. + */ + toString() { + return this.value; + } + + /** + * Returns an iterator for the TokenList, iterating items of the set. + * + * @link https://dom.spec.whatwg.org/#domtokenlist + * + * @return {Generator} TokenList iterator. + */ + * [ Symbol.iterator ]() { + return yield* this._valueAsArray; + } + /** * Returns the token with index `index`. * diff --git a/packages/token-list/src/test/index.js b/packages/token-list/src/test/index.js index 708b0ebfda333d..d221abd620370e 100644 --- a/packages/token-list/src/test/index.js +++ b/packages/token-list/src/test/index.js @@ -25,15 +25,75 @@ describe( 'token-list', () => { expect( list.value ).toBe( 'abc' ); expect( list ).toHaveLength( 1 ); } ); + + describe( 'array method inheritence', () => { + it( 'entries', () => { + const list = new TokenList( 'abc ' ); + + expect( [ ...list.entries() ] ).toEqual( [ [ 0, 'abc' ] ] ); + } ); + + it( 'forEach', () => { + expect.assertions( 1 ); + + const list = new TokenList( 'abc ' ); + + list.forEach( ( item ) => expect( item ).toBe( 'abc' ) ); + } ); + + it( 'values', () => { + const list = new TokenList( 'abc ' ); + + expect( [ ...list.values() ] ).toEqual( [ 'abc' ] ); + } ); + + it( 'keys', () => { + const list = new TokenList( 'abc ' ); + + expect( [ ...list.keys() ] ).toEqual( [ 0 ] ); + } ); + } ); } ); describe( 'value', () => { + it( 'gets the stringified value', () => { + const list = new TokenList( 'abc ' ); + + expect( list.value ).toBe( 'abc' ); + } ); + it( 'sets to stringified value', () => { const list = new TokenList(); list.value = undefined; expect( list.value ).toBe( 'undefined' ); } ); + + it( 'is the stringifier of the instance', () => { + const list = new TokenList( 'abc ' ); + + expect( String( list ) ).toBe( 'abc' ); + } ); + } ); + + describe( 'Symbol.iterator', () => { + it( 'returns a generator', () => { + const list = new TokenList(); + + expect( list[ Symbol.iterator ]().next ).toEqual( expect.any( Function ) ); + } ); + + it( 'yields entries', () => { + expect.assertions( 2 ); + + const classes = [ 'abc', 'def' ]; + const list = new TokenList( classes.join( ' ' ) ); + + let i = 0; + for ( const item of list ) { + expect( item ).toBe( classes[ i++ ] ); + } + } ); } ); describe( 'item', () => { diff --git a/packages/url/CHANGELOG.md b/packages/url/CHANGELOG.md index 30dba55b01a2df..0ec863c87def5a 100644 --- a/packages/url/CHANGELOG.md +++ b/packages/url/CHANGELOG.md @@ -1,4 +1,10 @@ -## 2.3.0 (Unreleased) +## 2.3.1 (Unreleased) + +### Bug fixes +- The `isValidProtocol` function now correctly considers the protocol of the URL as only incoporating characters up to and including the colon (':'). +- `getFragment` is now greedier and matches fragments from the first occurence of the '#' symbol instead of the last. + +## 2.3.0 (2018-11-12) ### New Features diff --git a/packages/url/package.json b/packages/url/package.json index 86c9ad4a98a838..ddf20dd9cde912 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/url", - "version": "2.2.0", + "version": "2.3.0", "description": "WordPress URL utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/url/src/index.js b/packages/url/src/index.js index cd066baa2da436..2f399711cd637d 100644 --- a/packages/url/src/index.js +++ b/packages/url/src/index.js @@ -37,13 +37,13 @@ export function getProtocol( url ) { * * @param {string} protocol The url protocol. * - * @return {boolean} True if the argument is a valid protocol (e.g. http://, tel:). + * @return {boolean} True if the argument is a valid protocol (e.g. http:, tel:). */ export function isValidProtocol( protocol ) { if ( ! protocol ) { return false; } - return /^[a-z\-.\+]+[0-9]*:(?:\/\/)?\/?$/i.test( protocol ); + return /^[a-z\-.\+]+[0-9]*:$/i.test( protocol ); } /** @@ -138,7 +138,7 @@ export function isValidQueryString( queryString ) { * @return {?string} The fragment part of the URL. */ export function getFragment( url ) { - const matches = /^\S+(#[^\s\?]*)/.exec( url ); + const matches = /^\S+?(#[^\s\?]*)/.exec( url ); if ( matches ) { return matches[ 1 ]; } diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js index cf4d644bfad681..eaf4287462acc9 100644 --- a/packages/url/src/test/index.test.js +++ b/packages/url/src/test/index.test.js @@ -78,9 +78,7 @@ describe( 'isValidProtocol', () => { expect( isValidProtocol( 'tel:' ) ).toBe( true ); expect( isValidProtocol( 'http:' ) ).toBe( true ); expect( isValidProtocol( 'https:' ) ).toBe( true ); - expect( isValidProtocol( 'http://' ) ).toBe( true ); - expect( isValidProtocol( 'https://' ) ).toBe( true ); - expect( isValidProtocol( 'file:///' ) ).toBe( true ); + expect( isValidProtocol( 'file:' ) ).toBe( true ); expect( isValidProtocol( 'test.protocol:' ) ).toBe( true ); expect( isValidProtocol( 'test-protocol:' ) ).toBe( true ); expect( isValidProtocol( 'test+protocol:' ) ).toBe( true ); diff --git a/packages/viewport/CHANGELOG.md b/packages/viewport/CHANGELOG.md index 8339f78019566e..092b542e113d49 100644 --- a/packages/viewport/CHANGELOG.md +++ b/packages/viewport/CHANGELOG.md @@ -1,3 +1,5 @@ +## 2.0.11 (2018-11-15) + ## 2.0.10 (2018-11-09) ## 2.0.9 (2018-11-09) diff --git a/packages/viewport/package.json b/packages/viewport/package.json index bfdf56028da998..cca7198e6588b7 100644 --- a/packages/viewport/package.json +++ b/packages/viewport/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/viewport", - "version": "2.0.10", + "version": "2.0.11", "description": "Viewport module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -28,7 +28,7 @@ }, "devDependencies": { "deep-freeze": "^0.0.1", - "react-test-renderer": "^16.4.1" + "react-test-renderer": "^16.6.3" }, "publishConfig": { "access": "public" diff --git a/phpunit/class-block-type-test.php b/phpunit/class-block-type-test.php index cada34b83cc09a..648893082b1041 100644 --- a/phpunit/class-block-type-test.php +++ b/phpunit/class-block-type-test.php @@ -139,7 +139,8 @@ function test_prepare_attributes() { 'wrongType' => 5, 'wrongTypeDefaulted' => 5, /* missingDefaulted */ - 'undefined' => 'omit', + 'undefined' => 'include', + 'intendedNull' => null, ); $block_type = new WP_Block_Type( @@ -160,6 +161,10 @@ function test_prepare_attributes() { 'type' => 'string', 'default' => 'define', ), + 'intendedNull' => array( + 'type' => array( 'string', 'null' ), + 'default' => 'wrong', + ), ), ) ); @@ -169,14 +174,26 @@ function test_prepare_attributes() { $this->assertEquals( array( 'correct' => 'include', - 'wrongType' => null, + /* wrongType */ 'wrongTypeDefaulted' => 'defaulted', 'missingDefaulted' => 'define', + 'undefined' => 'include', + 'intendedNull' => null, ), $prepared_attributes ); } + function test_prepare_attributes_none_defined() { + $attributes = array( 'exists' => 'keep' ); + + $block_type = new WP_Block_Type( 'core/dummy', array() ); + + $prepared_attributes = $block_type->prepare_attributes_for_render( $attributes ); + + $this->assertEquals( $attributes, $prepared_attributes ); + } + function test_has_block_with_mixed_content() { $mixed_post_content = 'before' . '<!-- wp:core/dummy --><!-- /wp:core/dummy -->' . diff --git a/phpunit/class-do-blocks-test.php b/phpunit/class-do-blocks-test.php index 9da7ddc0fcb27d..147545e65ca0a2 100644 --- a/phpunit/class-do-blocks-test.php +++ b/phpunit/class-do-blocks-test.php @@ -9,6 +9,19 @@ * Test do_blocks */ class Do_Blocks_Test extends WP_UnitTestCase { + /** + * Tear down. + */ + function tearDown() { + parent::tearDown(); + + $registry = WP_Block_Type_Registry::get_instance(); + + if ( $registry->is_registered( 'core/dummy' ) ) { + $registry->unregister( 'core/dummy' ); + } + } + /** * Test do_blocks removes comment demarcations. * @@ -30,7 +43,7 @@ function test_the_content() { add_shortcode( 'someshortcode', array( $this, 'handle_shortcode' ) ); $classic_content = "Foo\n\n[someshortcode]\n\nBar\n\n[/someshortcode]\n\nBaz"; - $block_content = "<!-- wp:core/paragraph -->\n<p>Foo</p>\n<!-- /wp:core/paragraph -->\n\n<!-- wp:core/shortcode -->[someshortcode]\n\nBar\n\n[/someshortcode]<!-- /wp:core/shortcode -->\n\n<!-- wp:core/paragraph -->\n<p>Baz</p>\n<!-- /wp:core/paragraph -->"; + $block_content = "<!-- wp:core/paragraph --><p>Foo</p>\n<!-- /wp:core/paragraph -->\n\n<!-- wp:core/shortcode -->[someshortcode]\n\nBar\n\n[/someshortcode]<!-- /wp:core/shortcode -->\n\n<!-- wp:core/paragraph -->\n<p>Baz</p>\n<!-- /wp:core/paragraph -->"; $classic_filtered_content = apply_filters( 'the_content', $classic_content ); $block_filtered_content = apply_filters( 'the_content', $block_content ); @@ -41,7 +54,43 @@ function test_the_content() { $this->assertEquals( $classic_filtered_content, $block_filtered_content ); } + function test_can_nest_at_least_so_deep() { + $minimum_depth = 99; + + $content = 'deep inside'; + for ( $i = 0; $i < $minimum_depth; $i++ ) { + $content = '<!-- wp:dummy -->' . $content . '<!-- /wp:dummy -->'; + } + + $this->assertEquals( 'deep inside', do_blocks( $content ) ); + } + + function test_can_nest_at_least_so_deep_with_dynamic_blocks() { + $minimum_depth = 99; + + $content = '0'; + for ( $i = 0; $i < $minimum_depth; $i++ ) { + $content = '<!-- wp:dummy -->' . $content . '<!-- /wp:dummy -->'; + } + + register_block_type( + 'core/dummy', + array( + 'render_callback' => array( + $this, + 'render_dynamic_incrementer', + ), + ) + ); + + $this->assertEquals( $minimum_depth, (int) do_blocks( $content ) ); + } + function handle_shortcode( $atts, $content ) { return $content; } + + function render_dynamic_incrementer( $attrs, $content ) { + return (string) ( 1 + (int) $content ); + } } diff --git a/phpunit/class-dynamic-blocks-render-test.php b/phpunit/class-dynamic-blocks-render-test.php index 812d6aa8d21e1f..35799bc9bf73db 100644 --- a/phpunit/class-dynamic-blocks-render-test.php +++ b/phpunit/class-dynamic-blocks-render-test.php @@ -38,6 +38,10 @@ function render_dummy_block_numeric() { return 10; } + function render_serialize_dynamic_block( $attributes, $content ) { + return base64_encode( serialize( array( $attributes, $content ) ) ); + } + /** * Dummy block rendering function, creating a new WP_Query instance. * @@ -74,7 +78,14 @@ function tearDown() { $this->dummy_block_instance_number = 0; $registry = WP_Block_Type_Registry::get_instance(); - $registry->unregister( 'core/dummy' ); + + if ( $registry->is_registered( 'core/dummy' ) ) { + $registry->unregister( 'core/dummy' ); + } + + if ( $registry->is_registered( 'core/dynamic' ) ) { + $registry->unregister( 'core/dynamic' ); + } } /** @@ -164,4 +175,69 @@ function test_dynamic_block_renders_string() { $this->assertSame( '10', $rendered ); $this->assertInternalType( 'string', $rendered ); } + + function test_dynamic_block_gets_inner_html() { + register_block_type( + 'core/dynamic', + array( + 'render_callback' => array( + $this, + 'render_serialize_dynamic_block', + ), + ) + ); + + $output = do_blocks( '<!-- wp:dynamic -->inner<!-- /wp:dynamic -->' ); + + list( /* attrs */, $content ) = unserialize( base64_decode( $output ) ); + + $this->assertEquals( 'inner', $content ); + } + + function test_dynamic_block_gets_rendered_inner_blocks() { + register_block_type( + 'core/dummy', + array( + 'render_callback' => array( + $this, + 'render_dummy_block_numeric', + ), + ) + ); + register_block_type( + 'core/dynamic', + array( + 'render_callback' => array( + $this, + 'render_serialize_dynamic_block', + ), + ) + ); + + $output = do_blocks( '<!-- wp:dynamic -->before<!-- wp:dummy /-->after<!-- /wp:dynamic -->' ); + + list( /* attrs */, $content ) = unserialize( base64_decode( $output ) ); + + $this->assertEquals( 'before10after', $content ); + } + + function test_dynamic_block_gets_rendered_inner_dynamic_blocks() { + register_block_type( + 'core/dynamic', + array( + 'render_callback' => array( + $this, + 'render_serialize_dynamic_block', + ), + ) + ); + + $output = do_blocks( '<!-- wp:dynamic -->before<!-- wp:dynamic -->deep inner<!-- /wp:dynamic -->after<!-- /wp:dynamic -->' ); + + list( /* attrs */, $content ) = unserialize( base64_decode( $output ) ); + + $inner = $this->render_serialize_dynamic_block( array(), 'deep inner' ); + + $this->assertEquals( $content, 'before' . $inner . 'after' ); + } } diff --git a/phpunit/class-rest-autosaves-controller-test.php b/phpunit/class-rest-autosaves-controller-test.php index ede81c5f25e661..10d2b23ef97067 100644 --- a/phpunit/class-rest-autosaves-controller-test.php +++ b/phpunit/class-rest-autosaves-controller-test.php @@ -13,6 +13,7 @@ class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controller_Testcase { protected static $post_id; protected static $page_id; + protected static $draft_page_id; protected static $autosave_post_id; protected static $autosave_page_id; @@ -20,6 +21,10 @@ class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controlle protected static $editor_id; protected static $contributor_id; + protected static $parent_page_id; + protected static $child_page_id; + protected static $child_draft_page_id; + protected function set_post_data( $args = array() ) { $defaults = array( 'title' => 'Post Title', @@ -76,6 +81,33 @@ public static function wpSetUpBeforeClass( $factory ) { ) ); + self::$draft_page_id = $factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'draft', + ) + ); + self::$parent_page_id = $factory->post->create( + array( + 'post_type' => 'page', + ) + ); + self::$child_page_id = $factory->post->create( + array( + 'post_type' => 'page', + 'post_parent' => self::$parent_page_id, + ) + ); + self::$child_draft_page_id = $factory->post->create( + array( + 'post_type' => 'page', + 'post_parent' => self::$parent_page_id, + // The "update post" behavior of the autosave endpoint only occurs + // when saving a draft/auto-draft authored by the current user. + 'post_status' => 'draft', + 'post_author' => self::$editor_id, + ) + ); } public static function wpTearDownAfterClass() { @@ -96,9 +128,9 @@ public function setUp() { public function test_register_routes() { $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( '/wp/v2/posts/(?P<parent>[\d]+)/autosaves', $routes ); + $this->assertArrayHasKey( '/wp/v2/posts/(?P<id>[\d]+)/autosaves', $routes ); $this->assertArrayHasKey( '/wp/v2/posts/(?P<parent>[\d]+)/autosaves/(?P<id>[\d]+)', $routes ); - $this->assertArrayHasKey( '/wp/v2/pages/(?P<parent>[\d]+)/autosaves', $routes ); + $this->assertArrayHasKey( '/wp/v2/pages/(?P<id>[\d]+)/autosaves', $routes ); $this->assertArrayHasKey( '/wp/v2/pages/(?P<parent>[\d]+)/autosaves/(?P<id>[\d]+)', $routes ); } @@ -119,6 +151,21 @@ public function test_context_param() { $this->assertEqualSets( array( 'view', 'edit', 'embed' ), $data['endpoints'][0]['args']['context']['enum'] ); } + public function test_registered_query_params() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $keys = array_keys( $data['endpoints'][0]['args'] ); + sort( $keys ); + $this->assertEquals( + array( + 'context', + 'parent', + ), + $keys + ); + } + public function test_get_items() { wp_set_current_user( self::$editor_id ); $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves' ); @@ -517,4 +564,42 @@ public function test_get_item_sets_up_postdata() { $this->assertEquals( $parent_post_id, self::$post_id ); } + public function test_update_item_draft_page_with_parent() { + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/pages/' . self::$child_draft_page_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + + $params = $this->set_post_data( + array( + 'id' => self::$child_draft_page_id, + 'author' => self::$editor_id, + ) + ); + + $request->set_body_params( $params ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( self::$child_draft_page_id, $data['id'] ); + $this->assertEquals( self::$parent_page_id, $data['parent'] ); + } + + public function test_schema_validation_is_applied() { + wp_set_current_user( self::$editor_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/pages/' . self::$draft_page_id . '/autosaves' ); + $request->add_header( 'content-type', 'application/x-www-form-urlencoded' ); + + $params = $this->set_post_data( + array( + 'id' => self::$draft_page_id, + 'comment_status' => 'garbage', + ) + ); + + $request->set_body_params( $params ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertNotEquals( 'garbage', get_post( self::$draft_page_id )->comment_status ); + } } diff --git a/phpunit/class-rest-block-renderer-controller-test.php b/phpunit/class-rest-block-renderer-controller-test.php index 66191e122ededb..3691c4d162af4f 100644 --- a/phpunit/class-rest-block-renderer-controller-test.php +++ b/phpunit/class-rest-block-renderer-controller-test.php @@ -272,7 +272,9 @@ public function test_get_item_default_attributes() { $block_type = WP_Block_Type_Registry::get_instance()->get_registered( self::$block_name ); $defaults = array(); foreach ( $block_type->attributes as $key => $attribute ) { - $defaults[ $key ] = isset( $attribute['default'] ) ? $attribute['default'] : null; + if ( isset( $attribute['default'] ) ) { + $defaults[ $key ] = $attribute['default']; + } } $request = new WP_REST_Request( 'GET', self::$rest_api_route . self::$block_name ); diff --git a/phpunit/class-rest-blocks-controller-test.php b/phpunit/class-rest-blocks-controller-test.php index a5c58c6f49805a..5a9e71af7e33e9 100644 --- a/phpunit/class-rest-blocks-controller-test.php +++ b/phpunit/class-rest-blocks-controller-test.php @@ -18,11 +18,11 @@ class REST_Blocks_Controller_Test extends WP_UnitTestCase { protected static $post_id; /** - * Our fake user's ID. + * Our fake user IDs, keyed by their role. * - * @var int + * @var array */ - protected static $user_id; + protected static $user_ids; /** * Create fake data before our tests run. @@ -35,14 +35,14 @@ public static function wpSetUpBeforeClass( $factory ) { 'post_type' => 'wp_block', 'post_status' => 'publish', 'post_title' => 'My cool block', - 'post_content' => '<!-- wp:core/paragraph --><p>Hello!</p><!-- /wp:core/paragraph -->', + 'post_content' => '<!-- wp:paragraph --><p>Hello!</p><!-- /wp:paragraph -->', ) ); - self::$user_id = $factory->user->create( - array( - 'role' => 'editor', - ) + self::$user_ids = array( + 'editor' => $factory->user->create( array( 'role' => 'editor' ) ), + 'author' => $factory->user->create( array( 'role' => 'author' ) ), + 'contributor' => $factory->user->create( array( 'role' => 'contributor' ) ), ); } @@ -52,7 +52,9 @@ public static function wpSetUpBeforeClass( $factory ) { public static function wpTearDownAfterClass() { wp_delete_post( self::$post_id ); - self::delete_user( self::$user_id ); + foreach ( self::$user_ids as $user_id ) { + self::delete_user( $user_id ); + } } /** @@ -89,7 +91,7 @@ public function data_capabilities() { */ public function test_capabilities( $action, $role, $expected_status ) { if ( $role ) { - $user_id = $this->factory->user->create( array( 'role' => $role ) ); + $user_id = self::$user_ids[ $role ]; wp_set_current_user( $user_id ); } else { wp_set_current_user( 0 ); @@ -101,7 +103,7 @@ public function test_capabilities( $action, $role, $expected_status ) { $request->set_body_params( array( 'title' => 'Test', - 'content' => '<!-- wp:core/paragraph --><p>Test</p><!-- /wp:core/paragraph -->', + 'content' => '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->', ) ); @@ -124,7 +126,7 @@ public function test_capabilities( $action, $role, $expected_status ) { 'post_type' => 'wp_block', 'post_status' => 'publish', 'post_title' => 'My cool block', - 'post_content' => '<!-- wp:core/paragraph --><p>Hello!</p><!-- /wp:core/paragraph -->', + 'post_content' => '<!-- wp:paragraph --><p>Hello!</p><!-- /wp:paragraph -->', 'post_author' => $user_id, ) ); @@ -133,7 +135,7 @@ public function test_capabilities( $action, $role, $expected_status ) { $request->set_body_params( array( 'title' => 'Test', - 'content' => '<!-- wp:core/paragraph --><p>Test</p><!-- /wp:core/paragraph -->', + 'content' => '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->', ) ); @@ -154,7 +156,7 @@ public function test_capabilities( $action, $role, $expected_status ) { $request->set_body_params( array( 'title' => 'Test', - 'content' => '<!-- wp:core/paragraph --><p>Test</p><!-- /wp:core/paragraph -->', + 'content' => '<!-- wp:paragraph --><p>Test</p><!-- /wp:paragraph -->', ) ); @@ -171,9 +173,32 @@ public function test_capabilities( $action, $role, $expected_status ) { default: $this->fail( "'$action' is not a valid action." ); } + } - if ( isset( $user_id ) ) { - self::delete_user( $user_id ); - } + /** + * Check that the raw title and content of a block can be accessed when there + * is no set schema, and that the rendered content of a block is not included + * in the response. + */ + public function test_content() { + wp_set_current_user( self::$user_ids['author'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/blocks/' . self::$post_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( + array( + 'raw' => 'My cool block', + ), + $data['title'] + ); + $this->assertEquals( + array( + 'raw' => '<!-- wp:paragraph --><p>Hello!</p><!-- /wp:paragraph -->', + 'protected' => false, + ), + $data['content'] + ); } } diff --git a/phpunit/class-vendor-script-filename-test.php b/phpunit/class-vendor-script-filename-test.php index 005e8f17297b40..f053df2011159b 100644 --- a/phpunit/class-vendor-script-filename-test.php +++ b/phpunit/class-vendor-script-filename-test.php @@ -11,12 +11,12 @@ function vendor_script_filename_cases() { // Development mode scripts. array( 'react-handle', - 'https://unpkg.com/react@16.4.1/umd/react.development.js', + 'https://unpkg.com/react@16.6.3/umd/react.development.js', 'react-handle.HASH.js', ), array( 'react-dom-handle', - 'https://unpkg.com/react-dom@16.4.1/umd/react-dom.development.js', + 'https://unpkg.com/react-dom@16.6.3/umd/react-dom.development.js', 'react-dom-handle.HASH.js', ), array( @@ -32,12 +32,12 @@ function vendor_script_filename_cases() { // Production mode scripts. array( 'react-handle', - 'https://unpkg.com/react@16.4.1/umd/react.production.min.js', + 'https://unpkg.com/react@16.6.3/umd/react.production.min.js', 'react-handle.min.HASH.js', ), array( 'react-dom-handle', - 'https://unpkg.com/react-dom@16.4.1/umd/react-dom.production.min.js', + 'https://unpkg.com/react-dom@16.6.3/umd/react-dom.production.min.js', 'react-dom-handle.min.HASH.js', ), array( diff --git a/phpunit/fixtures/do-blocks-expected.html b/phpunit/fixtures/do-blocks-expected.html index 4a3dc379ef48f9..f4131820513347 100644 --- a/phpunit/fixtures/do-blocks-expected.html +++ b/phpunit/fixtures/do-blocks-expected.html @@ -2,13 +2,18 @@ <!--more--> + <p>First Gutenberg Paragraph</p> + <p>Second Auto Paragraph</p> + + <p>Third Gutenberg Paragraph</p> + <p>Third Auto Paragraph</p> <p>[someshortcode]</p> diff --git a/post-content.php b/post-content.php index 7dac5620a8efd6..f34262d1eed4c8 100644 --- a/post-content.php +++ b/post-content.php @@ -88,7 +88,7 @@ <p><?php _e( 'Blocks can be anything you need. For instance, you may want to add a subdued quote as part of the composition of your text, or you may prefer to display a giant stylized one. All of these options are available in the inserter.', 'gutenberg' ); ?></p> <!-- /wp:paragraph --> -<!-- wp:gallery {"columns":2} --> +<!-- wp:gallery {"ids":[null,null,null],"columns":2} --> <ul class="wp-block-gallery columns-2 is-cropped"> <li class="blocks-gallery-item"><figure><img src="https://cldup.com/n0g6ME5VKC.jpg" alt="" /></figure></li> <li class="blocks-gallery-item"><figure><img src="https://cldup.com/ZjESfxPI3R.jpg" alt="" /></figure></li> @@ -116,7 +116,7 @@ <p><?php _e( 'Sure, the full-wide image can be pretty big. But sometimes the image is worth it.', 'gutenberg' ); ?></p> <!-- /wp:paragraph --> -<!-- wp:gallery {"align":"wide","images":[{"url":"https://cldup.com/_rSwtEeDGD.jpg","alt":""},{"url":"https://cldup.com/L-cC3qX2DN.jpg","alt":""}]} --> +<!-- wp:gallery {"ids":[null,null],"align":"wide","images":[{"url":"https://cldup.com/_rSwtEeDGD.jpg","alt":""},{"url":"https://cldup.com/L-cC3qX2DN.jpg","alt":""}]} --> <ul class="wp-block-gallery alignwide columns-2 is-cropped"> <li class="blocks-gallery-item"><figure><img src="https://cldup.com/_rSwtEeDGD.jpg" alt="" /></figure></li> <li class="blocks-gallery-item"><figure><img src="https://cldup.com/L-cC3qX2DN.jpg" alt="" /></figure></li> diff --git a/test/e2e/specs/__snapshots__/container-blocks.test.js.snap b/test/e2e/specs/__snapshots__/container-blocks.test.js.snap index d48bb64ef8589e..3e2a04aae65dd4 100644 --- a/test/e2e/specs/__snapshots__/container-blocks.test.js.snap +++ b/test/e2e/specs/__snapshots__/container-blocks.test.js.snap @@ -8,16 +8,20 @@ exports[`Container block without paragraph support ensures we can use the altern <!-- /wp:test/container-without-paragraph -->" `; +exports[`InnerBlocks Template Sync Ensure inner block writing flow works as expected without additional paragraphs added 1`] = ` +"<!-- wp:test/test-inner-blocks-paragraph-placeholder --> +<!-- wp:paragraph {\\"placeholder\\":\\"Content…\\",\\"fontSize\\":\\"large\\"} --> +<p class=\\"has-large-font-size\\">Test Paragraph</p> +<!-- /wp:paragraph --> +<!-- /wp:test/test-inner-blocks-paragraph-placeholder -->" +`; + exports[`InnerBlocks Template Sync Ensures blocks without locking are kept intact even if they do not match the template 1`] = ` "<!-- wp:test/test-inner-blocks-no-locking --> <!-- wp:paragraph {\\"fontSize\\":\\"large\\"} --> <p class=\\"has-large-font-size\\">Content…</p> <!-- /wp:paragraph --> -<!-- wp:paragraph --> -<p></p> -<!-- /wp:paragraph --> - <!-- wp:paragraph --> <p>added paragraph</p> <!-- /wp:paragraph --> diff --git a/test/e2e/specs/__snapshots__/plugins-api.test.js.snap b/test/e2e/specs/__snapshots__/plugins-api.test.js.snap index 5916648d1dd2b5..0b62fc522bf7ef 100644 --- a/test/e2e/specs/__snapshots__/plugins-api.test.js.snap +++ b/test/e2e/specs/__snapshots__/plugins-api.test.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"<div class=\\"components-panel__header edit-post-sidebar-header__small\\"><span class=\\"edit-post-sidebar-header__title\\">(no title)</span><button type=\\"button\\" aria-label=\\"Close plugin\\" class=\\"components-button components-icon-button\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-no-alt\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z\\"></path></svg></button></div><div class=\\"components-panel__header edit-post-sidebar-header\\"><strong>Sidebar title plugin</strong><button type=\\"button\\" aria-label=\\"Unpin from toolbar\\" aria-expanded=\\"true\\" class=\\"components-button components-icon-button is-toggled\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-star-filled\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M10 1l3 6 6 .75-4.12 4.62L16 19l-6-3-6 3 1.13-6.63L1 7.75 7 7z\\"></path></svg></button><button type=\\"button\\" aria-label=\\"Close plugin\\" class=\\"components-button components-icon-button\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-no-alt\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z\\"></path></svg></button></div><div class=\\"components-panel\\"><div class=\\"components-panel__body is-opened\\"><div class=\\"components-panel__row\\"><label for=\\"title-plain-text\\">Title:</label><textarea class=\\"editor-plain-text\\" id=\\"title-plain-text\\" placeholder=\\"(no title)\\" rows=\\"1\\" style=\\"overflow: hidden; overflow-wrap: break-word; resize: none; height: 18px;\\"></textarea></div><div class=\\"components-panel__row\\"><button type=\\"button\\" class=\\"components-button is-button is-primary\\">Reset</button></div><button type=\\"button\\" class=\\"components-button is-button is-primary\\">Add annotation</button></div></div>"`; +exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"<div class=\\"components-panel__header edit-post-sidebar-header__small\\"><span class=\\"edit-post-sidebar-header__title\\">(no title)</span><button type=\\"button\\" aria-label=\\"Close plugin\\" class=\\"components-button components-icon-button\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-no-alt\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z\\"></path></svg></button></div><div class=\\"components-panel__header edit-post-sidebar-header\\"><strong>Sidebar title plugin</strong><button type=\\"button\\" aria-label=\\"Unpin from toolbar\\" aria-expanded=\\"true\\" class=\\"components-button components-icon-button is-toggled\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-star-filled\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M10 1l3 6 6 .75-4.12 4.62L16 19l-6-3-6 3 1.13-6.63L1 7.75 7 7z\\"></path></svg></button><button type=\\"button\\" aria-label=\\"Close plugin\\" class=\\"components-button components-icon-button\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-no-alt\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z\\"></path></svg></button></div><div class=\\"components-panel\\"><div class=\\"components-panel__body is-opened\\"><div class=\\"components-panel__row\\"><label for=\\"title-plain-text\\">Title:</label><textarea class=\\"editor-plain-text\\" id=\\"title-plain-text\\" placeholder=\\"(no title)\\" rows=\\"1\\" style=\\"overflow: hidden; overflow-wrap: break-word; resize: none; height: 18px;\\"></textarea></div><div class=\\"components-panel__row\\"><button type=\\"button\\" class=\\"components-button is-button is-primary\\">Reset</button></div></div></div>"`; diff --git a/test/e2e/specs/__snapshots__/rich-text.test.js.snap b/test/e2e/specs/__snapshots__/rich-text.test.js.snap index fc96d2d6f1fab5..e1324add1205e7 100644 --- a/test/e2e/specs/__snapshots__/rich-text.test.js.snap +++ b/test/e2e/specs/__snapshots__/rich-text.test.js.snap @@ -23,3 +23,15 @@ exports[`RichText should handle change in tag name gracefully 1`] = ` <h3></h3> <!-- /wp:heading -->" `; + +exports[`RichText should transform backtick to code 1`] = ` +"<!-- wp:paragraph --> +<p>A <code>backtick</code></p> +<!-- /wp:paragraph -->" +`; + +exports[`RichText should transform backtick to code 2`] = ` +"<!-- wp:paragraph --> +<p>A \`backtick\`</p> +<!-- /wp:paragraph -->" +`; diff --git a/test/e2e/specs/__snapshots__/undo.test.js.snap b/test/e2e/specs/__snapshots__/undo.test.js.snap index 812ab8e25993a3..1ae6c8e4458b79 100644 --- a/test/e2e/specs/__snapshots__/undo.test.js.snap +++ b/test/e2e/specs/__snapshots__/undo.test.js.snap @@ -13,3 +13,27 @@ exports[`undo Should undo to expected level intervals 1`] = ` <p>test</p> <!-- /wp:paragraph -->" `; + +exports[`undo should undo typing after a pause 1`] = ` +"<!-- wp:paragraph --> +<p>before pause after pause</p> +<!-- /wp:paragraph -->" +`; + +exports[`undo should undo typing after a pause 2`] = ` +"<!-- wp:paragraph --> +<p>before pause</p> +<!-- /wp:paragraph -->" +`; + +exports[`undo should undo typing after non input change 1`] = ` +"<!-- wp:paragraph --> +<p>before keyboard <strong>after keyboard</strong></p> +<!-- /wp:paragraph -->" +`; + +exports[`undo should undo typing after non input change 2`] = ` +"<!-- wp:paragraph --> +<p>before keyboard </p> +<!-- /wp:paragraph -->" +`; diff --git a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap index ee6bcfc536604e..77e8a9596b7729 100644 --- a/test/e2e/specs/__snapshots__/writing-flow.test.js.snap +++ b/test/e2e/specs/__snapshots__/writing-flow.test.js.snap @@ -32,6 +32,52 @@ exports[`adding blocks should clean TinyMCE content 2`] = ` <!-- /wp:paragraph -->" `; +exports[`adding blocks should create valid paragraph blocks when rapidly pressing Enter 1`] = ` +"<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p></p> +<!-- /wp:paragraph -->" +`; + exports[`adding blocks should insert line break at end 1`] = ` "<!-- wp:paragraph --> <p>a<br><br></p> @@ -75,3 +121,39 @@ exports[`adding blocks should navigate around inline boundaries 1`] = ` <p>BeforeThird</p> <!-- /wp:paragraph -->" `; + +exports[`adding blocks should not delete surrounding space when deleting a selected word 1`] = ` +"<!-- wp:paragraph --> +<p>alpha  gamma</p> +<!-- /wp:paragraph -->" +`; + +exports[`adding blocks should not delete surrounding space when deleting a selected word 2`] = ` +"<!-- wp:paragraph --> +<p>alpha beta gamma</p> +<!-- /wp:paragraph -->" +`; + +exports[`adding blocks should not delete surrounding space when deleting a word with Alt+Backspace 1`] = ` +"<!-- wp:paragraph --> +<p>alpha  gamma</p> +<!-- /wp:paragraph -->" +`; + +exports[`adding blocks should not delete surrounding space when deleting a word with Alt+Backspace 2`] = ` +"<!-- wp:paragraph --> +<p>alpha beta gamma</p> +<!-- /wp:paragraph -->" +`; + +exports[`adding blocks should not delete surrounding space when deleting a word with Backspace 1`] = ` +"<!-- wp:paragraph --> +<p>1  3</p> +<!-- /wp:paragraph -->" +`; + +exports[`adding blocks should not delete surrounding space when deleting a word with Backspace 2`] = ` +"<!-- wp:paragraph --> +<p>1 2 3</p> +<!-- /wp:paragraph -->" +`; diff --git a/test/e2e/specs/a11y.test.js b/test/e2e/specs/a11y.test.js index 3495df65a90a42..19403fe69e9e4f 100644 --- a/test/e2e/specs/a11y.test.js +++ b/test/e2e/specs/a11y.test.js @@ -2,7 +2,6 @@ * Internal dependencies */ import { - ACCESS_MODIFIER_KEYS, newPost, pressWithModifier, } from '../support/utils'; @@ -19,7 +18,7 @@ describe( 'a11y', () => { } ); it( 'tabs header bar', async () => { - await pressWithModifier( 'Control', '~' ); + await pressWithModifier( 'ctrl', '~' ); await page.keyboard.press( 'Tab' ); @@ -32,7 +31,7 @@ describe( 'a11y', () => { it( 'constrains focus to a modal when tabbing', async () => { // Open keyboard help modal. - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + await pressWithModifier( 'access', 'h' ); // The close button should not be focused by default; this is a strange UX // experience. @@ -46,7 +45,7 @@ describe( 'a11y', () => { } ); it( 'returns focus to the first tabbable in a modal after blurring a tabbable', async () => { - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + await pressWithModifier( 'access', 'h' ); // Click to move focus to an element after the last tabbable within the // modal. @@ -58,13 +57,13 @@ describe( 'a11y', () => { } ); it( 'returns focus to the last tabbable in a modal after blurring a tabbable and tabbing in reverse direction', async () => { - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + await pressWithModifier( 'access', 'h' ); // Click to move focus to an element before the first tabbable within // the modal. await page.click( '.components-modal__header-heading' ); - await pressWithModifier( 'Shift', 'Tab' ); + await pressWithModifier( 'shift', 'Tab' ); expect( await isCloseButtonFocused() ).toBe( true ); } ); diff --git a/test/e2e/specs/annotations.test.js b/test/e2e/specs/annotations.test.js new file mode 100644 index 00000000000000..3fa1eefdec9dda --- /dev/null +++ b/test/e2e/specs/annotations.test.js @@ -0,0 +1,173 @@ +/** + * Internal dependencies + */ +import { + clickOnMoreMenuItem, + newPost, +} from '../support/utils'; +import { activatePlugin, deactivatePlugin } from '../support/plugins'; + +const clickOnBlockSettingsMenuItem = async ( buttonLabel ) => { + await expect( page ).toClick( '.editor-block-settings-menu__toggle' ); + const itemButton = ( await page.$x( `//*[contains(@class, "editor-block-settings-menu__popover")]//button[contains(text(), '${ buttonLabel }')]` ) )[ 0 ]; + await itemButton.click(); +}; + +const ANNOTATIONS_SELECTOR = '.annotation-text-e2e-tests'; + +describe( 'Using Plugins API', () => { + beforeAll( async () => { + await activatePlugin( 'gutenberg-test-plugin-plugins-api' ); + } ); + + afterAll( async () => { + await deactivatePlugin( 'gutenberg-test-plugin-plugins-api' ); + } ); + + beforeEach( async () => { + await newPost(); + } ); + + /** + * Annotates the text in the first block from start to end. + * + * @param {number} start Position to start the annotation. + * @param {number} end Position to end the annotation. + * + * @return {void} + */ + async function annotateFirstBlock( start, end ) { + await page.focus( '#annotations-tests-range-start' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( start + '' ); + await page.focus( '#annotations-tests-range-end' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( end + '' ); + + // Click add annotation button. + const addAnnotationButton = ( await page.$x( "//button[contains(text(), 'Add annotation')]" ) )[ 0 ]; + await addAnnotationButton.click(); + } + + /** + * Presses the button that removes all annotations. + * + * @return {void} + */ + async function removeAnnotations() { + // Click remove annotations button. + const addAnnotationButton = ( await page.$x( "//button[contains(text(), 'Remove annotations')]" ) )[ 0 ]; + await addAnnotationButton.click(); + } + + /** + * Returns the inner text of the first text annotation on the page. + * + * @return {Promise<string>} The annotated text. + */ + async function getAnnotatedText() { + const annotations = await page.$$( ANNOTATIONS_SELECTOR ); + + const annotation = annotations[ 0 ]; + + return await page.evaluate( ( el ) => el.innerText, annotation ); + } + + /** + * Returns the inner HTML of the first RichText in the page. + * + * @return {Promise<string>} Inner HTML. + */ + async function getRichTextInnerHTML() { + const htmlContent = await page.$$( '.editor-rich-text__tinymce' ); + return await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + } + + describe( 'Annotations', () => { + it( 'Allows a block to be annotated', async () => { + await page.keyboard.type( 'Title' + '\n' + 'Paragraph to annotate' ); + + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + let annotations = await page.$$( ANNOTATIONS_SELECTOR ); + expect( annotations ).toHaveLength( 0 ); + + await annotateFirstBlock( 9, 13 ); + + annotations = await page.$$( ANNOTATIONS_SELECTOR ); + expect( annotations ).toHaveLength( 1 ); + + const text = await getAnnotatedText(); + expect( text ).toBe( ' to ' ); + + await clickOnBlockSettingsMenuItem( 'Edit as HTML' ); + + const htmlContent = await page.$$( '.editor-block-list__block-html-textarea' ); + const html = await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + + // There should be no <mark> tags in the raw content. + expect( html ).toBe( '&lt;p&gt;Paragraph to annotate&lt;/p&gt;' ); + } ); + + it( 'Keeps the cursor in the same location when applying annotation', async () => { + await page.keyboard.type( 'Title' + '\n' + 'ABC' ); + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + await annotateFirstBlock( 1, 2 ); + + // The selection should still be at the end, so test that by typing: + await page.keyboard.type( 'D' ); + + await removeAnnotations(); + const htmlContent = await page.$$( '.editor-rich-text__tinymce' ); + const html = await page.evaluate( ( el ) => { + return el.innerHTML; + }, htmlContent[ 0 ] ); + + expect( html ).toBe( 'ABCD' ); + } ); + + it( 'Moves when typing before it', async () => { + await page.keyboard.type( 'Title' + '\n' + 'ABC' ); + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + await annotateFirstBlock( 1, 2 ); + + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + + // Put an 1 after the A, it should not be annotated. + await page.keyboard.type( '1' ); + + const annotatedText = await getAnnotatedText(); + expect( annotatedText ).toBe( 'B' ); + + await removeAnnotations(); + const blockText = await getRichTextInnerHTML(); + expect( blockText ).toBe( 'A1BC' ); + } ); + + it( 'Grows when typing inside it', async () => { + await page.keyboard.type( 'Title' + '\n' + 'ABC' ); + await clickOnMoreMenuItem( 'Annotations Sidebar' ); + + await annotateFirstBlock( 1, 2 ); + + await page.keyboard.press( 'ArrowLeft' ); + + // Put an 1 after the A, it should not be annotated. + await page.keyboard.type( '2' ); + + const annotatedText = await getAnnotatedText(); + expect( annotatedText ).toBe( 'B2' ); + + await removeAnnotations(); + const blockText = await getRichTextInnerHTML(); + expect( blockText ).toBe( 'AB2C' ); + } ); + } ); +} ); diff --git a/test/e2e/specs/block-deletion.test.js b/test/e2e/specs/block-deletion.test.js index 7da0695f1ab436..6b8c97fc12dcee 100644 --- a/test/e2e/specs/block-deletion.test.js +++ b/test/e2e/specs/block-deletion.test.js @@ -6,7 +6,6 @@ import { getEditedPostContent, newPost, pressWithModifier, - ACCESS_MODIFIER_KEYS, } from '../support/utils'; const addThreeParagraphsToNewPost = async () => { @@ -50,7 +49,7 @@ describe( 'block deletion -', () => { it( 'results in two remaining blocks and positions the caret at the end of the second block', async () => { // Type some text to assert that the shortcut also deletes block content. await page.keyboard.type( 'this is block 2' ); - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'z' ); + await pressWithModifier( 'access', 'z' ); expect( await getEditedPostContent() ).toMatchSnapshot(); // Type additional text and assert that caret position is correct by comparing to snapshot. @@ -98,7 +97,7 @@ describe( 'block deletion -', () => { await page.keyboard.press( 'Enter' ); // Press the up arrow once to select the third and fourth blocks. - await pressWithModifier( 'Shift', 'ArrowUp' ); + await pressWithModifier( 'shift', 'ArrowUp' ); // Now that the block wrapper is selected, press backspace to delete it. await page.keyboard.press( 'Backspace' ); diff --git a/test/e2e/specs/block-hierarchy-navigation.test.js b/test/e2e/specs/block-hierarchy-navigation.test.js index 82b4a53fa01936..970a6fb140ef52 100644 --- a/test/e2e/specs/block-hierarchy-navigation.test.js +++ b/test/e2e/specs/block-hierarchy-navigation.test.js @@ -2,8 +2,6 @@ * Internal dependencies */ import { - ACCESS_MODIFIER_KEYS, - META_KEY, newPost, insertBlock, getEditedPostContent, @@ -12,7 +10,7 @@ import { } from '../support/utils'; async function openBlockNavigator() { - return pressWithModifier( ACCESS_MODIFIER_KEYS, 'o' ); + return pressWithModifier( 'access', 'o' ); } describe( 'Navigating the block hierarchy', () => { @@ -63,10 +61,10 @@ describe( 'Navigating the block hierarchy', () => { await page.keyboard.press( 'Enter' ); // Move focus to the sidebar area. - await pressWithModifier( 'Control', '`' ); - await pressWithModifier( 'Control', '`' ); - await pressWithModifier( 'Control', '`' ); - await pressWithModifier( 'Control', '`' ); + await pressWithModifier( 'ctrl', '`' ); + await pressWithModifier( 'ctrl', '`' ); + await pressWithModifier( 'ctrl', '`' ); + await pressWithModifier( 'ctrl', '`' ); await pressTimes( 'Tab', 4 ); // Tweak the columns count by increasing it by one. @@ -99,7 +97,7 @@ describe( 'Navigating the block hierarchy', () => { await page.keyboard.press( 'Space' ); // Replace its content. - await pressWithModifier( META_KEY, 'A' ); + await pressWithModifier( 'primary', 'A' ); await page.keyboard.type( 'and I say hello' ); expect( await getEditedPostContent() ).toMatchSnapshot(); diff --git a/test/e2e/specs/block-icons.test.js b/test/e2e/specs/block-icons.test.js index 0e44cddb124f44..4042b05a6f7003 100644 --- a/test/e2e/specs/block-icons.test.js +++ b/test/e2e/specs/block-icons.test.js @@ -2,7 +2,6 @@ * Internal dependencies */ import { - ACCESS_MODIFIER_KEYS, pressWithModifier, newPost, insertBlock, @@ -36,7 +35,7 @@ async function getFirstInserterIcon() { } async function selectFirstBlock() { - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'o' ); + await pressWithModifier( 'access', 'o' ); const navButtons = await page.$$( '.editor-block-navigation__item-button' ); await navButtons[ 0 ].click(); } diff --git a/test/e2e/specs/blocks/__snapshots__/code.test.js.snap b/test/e2e/specs/blocks/__snapshots__/code.test.js.snap new file mode 100644 index 00000000000000..50ee83d373c65e --- /dev/null +++ b/test/e2e/specs/blocks/__snapshots__/code.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Code can be created by three backticks and enter 1`] = ` +"<!-- wp:code --> +<pre class=\\"wp-block-code\\"><code>&lt;?php</code></pre> +<!-- /wp:code -->" +`; diff --git a/test/e2e/specs/blocks/__snapshots__/heading.test.js.snap b/test/e2e/specs/blocks/__snapshots__/heading.test.js.snap new file mode 100644 index 00000000000000..6cffb6ad28a9fc --- /dev/null +++ b/test/e2e/specs/blocks/__snapshots__/heading.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Separator can be created by prefixing existing content with number signs and a space 1`] = ` +"<!-- wp:heading {\\"level\\":4} --> +<h4>4</h4> +<!-- /wp:heading -->" +`; + +exports[`Separator can be created by prefixing number sign and a space 1`] = ` +"<!-- wp:heading {\\"level\\":3} --> +<h3>3</h3> +<!-- /wp:heading -->" +`; diff --git a/test/e2e/specs/blocks/__snapshots__/list.test.js.snap b/test/e2e/specs/blocks/__snapshots__/list.test.js.snap index dbc0bee94158fd..a804ea84132d3d 100644 --- a/test/e2e/specs/blocks/__snapshots__/list.test.js.snap +++ b/test/e2e/specs/blocks/__snapshots__/list.test.js.snap @@ -74,6 +74,12 @@ exports[`List can be created by using an asterisk at the start of a paragraph bl <!-- /wp:list -->" `; +exports[`List can undo asterisk transform 1`] = ` +"<!-- wp:paragraph --> +<p>1.</p> +<!-- /wp:paragraph -->" +`; + exports[`List should create paragraph on split at end and merge back with content 1`] = ` "<!-- wp:list --> <ul><li>one</li></ul> diff --git a/test/e2e/specs/blocks/__snapshots__/separator.test.js.snap b/test/e2e/specs/blocks/__snapshots__/separator.test.js.snap new file mode 100644 index 00000000000000..41466bc4de2500 --- /dev/null +++ b/test/e2e/specs/blocks/__snapshots__/separator.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Separator can be created by three dashes and enter 1`] = ` +"<!-- wp:separator --> +<hr class=\\"wp-block-separator\\"/> +<!-- /wp:separator -->" +`; diff --git a/test/e2e/specs/blocks/code.test.js b/test/e2e/specs/blocks/code.test.js new file mode 100644 index 00000000000000..85cdbe1dd715ff --- /dev/null +++ b/test/e2e/specs/blocks/code.test.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + newPost, +} from '../../support/utils'; + +describe( 'Code', () => { + beforeEach( async () => { + await newPost(); + } ); + + it( 'can be created by three backticks and enter', async () => { + await clickBlockAppender(); + await page.keyboard.type( '```' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '<?php' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/test/e2e/specs/blocks/heading.test.js b/test/e2e/specs/blocks/heading.test.js new file mode 100644 index 00000000000000..101dbf88317367 --- /dev/null +++ b/test/e2e/specs/blocks/heading.test.js @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + newPost, +} from '../../support/utils'; + +describe( 'Separator', () => { + beforeEach( async () => { + await newPost(); + } ); + + it( 'can be created by prefixing number sign and a space', async () => { + await clickBlockAppender(); + await page.keyboard.type( '### 3' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'can be created by prefixing existing content with number signs and a space', async () => { + await clickBlockAppender(); + await page.keyboard.type( '4' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( '#### ' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/test/e2e/specs/blocks/list.test.js b/test/e2e/specs/blocks/list.test.js index f28196cfbdffab..3d416b998d1371 100644 --- a/test/e2e/specs/blocks/list.test.js +++ b/test/e2e/specs/blocks/list.test.js @@ -46,6 +46,14 @@ describe( 'List', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + it( 'can undo asterisk transform', async () => { + await clickBlockAppender(); + await page.keyboard.type( '1. ' ); + await pressWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'can be created by typing "/list"', async () => { // Create a list with the slash block shortcut. await clickBlockAppender(); @@ -80,7 +88,7 @@ describe( 'List', () => { it( 'can be created by converting a paragraph with line breaks', async () => { await clickBlockAppender(); await page.keyboard.type( 'one' ); - await pressWithModifier( 'Shift', 'Enter' ); + await pressWithModifier( 'shift', 'Enter' ); await page.keyboard.type( 'two' ); await convertBlock( 'List' ); diff --git a/test/e2e/specs/blocks/separator.test.js b/test/e2e/specs/blocks/separator.test.js new file mode 100644 index 00000000000000..348d7a6ed45fc8 --- /dev/null +++ b/test/e2e/specs/blocks/separator.test.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { + clickBlockAppender, + getEditedPostContent, + newPost, +} from '../../support/utils'; + +describe( 'Separator', () => { + beforeEach( async () => { + await newPost(); + } ); + + it( 'can be created by three dashes and enter', async () => { + await clickBlockAppender(); + await page.keyboard.type( '---' ); + await page.keyboard.press( 'Enter' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); +} ); diff --git a/test/e2e/specs/change-detection.test.js b/test/e2e/specs/change-detection.test.js index 9ac12abbf5b097..8efe2fc1b00313 100644 --- a/test/e2e/specs/change-detection.test.js +++ b/test/e2e/specs/change-detection.test.js @@ -7,7 +7,6 @@ import { pressWithModifier, ensureSidebarOpened, publishPost, - META_KEY, } from '../support/utils'; describe( 'Change detection', () => { @@ -69,7 +68,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( META_KEY, 'S' ); + await pressWithModifier( 'primary', 'S' ); expect( hadInterceptedSave ).toBe( false ); } ); @@ -164,7 +163,7 @@ describe( 'Change detection', () => { page.waitForSelector( '.editor-post-saved-state.is-saved' ), // Keyboard shortcut Ctrl+S save. - pressWithModifier( META_KEY, 'S' ), + pressWithModifier( 'primary', 'S' ), ] ); await assertIsDirty( false ); @@ -178,13 +177,13 @@ describe( 'Change detection', () => { page.waitForSelector( '.editor-post-saved-state.is-saved' ), // Keyboard shortcut Ctrl+S save. - pressWithModifier( META_KEY, 'S' ), + pressWithModifier( 'primary', 'S' ), ] ); await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( META_KEY, 'S' ); + await pressWithModifier( 'primary', 'S' ); expect( hadInterceptedSave ).toBe( false ); } ); @@ -196,7 +195,7 @@ describe( 'Change detection', () => { await Promise.all( [ // Keyboard shortcut Ctrl+S save. - pressWithModifier( META_KEY, 'S' ), + pressWithModifier( 'primary', 'S' ), // Ensure save update fails and presents button. page.waitForXPath( @@ -222,7 +221,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( META_KEY, 'S' ); + await pressWithModifier( 'primary', 'S' ); await releaseSaveIntercept(); @@ -238,7 +237,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( META_KEY, 'S' ); + await pressWithModifier( 'primary', 'S' ); await page.type( '.editor-post-title__input', '!' ); @@ -255,7 +254,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( META_KEY, 'S' ); + await pressWithModifier( 'primary', 'S' ); // Dirty post while save is in-flight. await page.type( '.editor-post-title__input', '!' ); @@ -277,7 +276,7 @@ describe( 'Change detection', () => { await interceptSave(); // Keyboard shortcut Ctrl+S save. - await pressWithModifier( META_KEY, 'S' ); + await pressWithModifier( 'primary', 'S' ); await clickBlockAppender(); diff --git a/test/e2e/specs/container-blocks.test.js b/test/e2e/specs/container-blocks.test.js index 301f6a8cf12c92..bafa4415fd77cc 100644 --- a/test/e2e/specs/container-blocks.test.js +++ b/test/e2e/specs/container-blocks.test.js @@ -49,6 +49,15 @@ describe( 'InnerBlocks Template Sync', () => { await insertBlockAndAddParagraphInside( 'Test InnerBlocks locking all', 'test/test-inner-blocks-locking-all' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'Ensure inner block writing flow works as expected without additional paragraphs added', async () => { + const TEST_BLOCK_NAME = 'Test Inner Blocks Paragraph Placeholder'; + + await insertBlock( TEST_BLOCK_NAME ); + await page.keyboard.type( 'Test Paragraph' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); describe( 'Container block without paragraph support', () => { diff --git a/test/e2e/specs/deprecated-node-matcher.test.js b/test/e2e/specs/deprecated-node-matcher.test.js index 885ab8c9d4d6c0..a93f23f0de8837 100644 --- a/test/e2e/specs/deprecated-node-matcher.test.js +++ b/test/e2e/specs/deprecated-node-matcher.test.js @@ -5,7 +5,6 @@ import { newPost, insertBlock, getEditedPostContent, - META_KEY, pressWithModifier, } from '../support/utils'; import { activatePlugin, deactivatePlugin } from '../support/plugins'; @@ -38,7 +37,7 @@ describe( 'Deprecated Node Matcher', () => { await page.keyboard.down( 'Shift' ); await page.keyboard.press( 'ArrowLeft' ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); } ); diff --git a/test/e2e/specs/invalid-block.test.js b/test/e2e/specs/invalid-block.test.js new file mode 100644 index 00000000000000..4a3a9934b0eb9b --- /dev/null +++ b/test/e2e/specs/invalid-block.test.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import { + newPost, + clickBlockAppender, +} from '../support/utils'; + +describe( 'invalid blocks', () => { + beforeEach( async () => { + await newPost(); + } ); + + it( 'Should show an invalid block message with clickable options', async () => { + // Create an empty paragraph with the focus in the block + await clickBlockAppender(); + await page.keyboard.type( 'hello' ); + + // Click the 'more options' + await page.mouse.move( 200, 300, { steps: 10 } ); + await page.click( 'button[aria-label="More options"]' ); + + // Change to HTML mode and close the options + const changeModeButton = await page.waitForXPath( '//button[text()="Edit as HTML"]' ); + await changeModeButton.click(); + + // Focus on the textarea and enter an invalid paragraph + await page.click( '.editor-block-list__layout .editor-block-list__block .editor-block-list__block-html-textarea' ); + await page.keyboard.type( '<p>invalid paragraph' ); + + // Takes the focus away from the block so the invalid warning is triggered + await page.click( '.editor-post-save-draft' ); + expect( console ).toHaveErrored(); + expect( console ).toHaveWarned(); + + // Click on the 'resolve' button + await page.click( '.editor-warning__actions button' ); + + // Check we get the resolve modal with the appropriate contents + const htmlBlockContent = await page.$eval( '.editor-block-compare__html', ( node ) => node.textContent ); + expect( htmlBlockContent ).toEqual( '<p>hello</p><p>invalid paragraph' ); + } ); +} ); diff --git a/test/e2e/specs/links.test.js b/test/e2e/specs/links.test.js index 14d4c418b22804..a59041f4f78259 100644 --- a/test/e2e/specs/links.test.js +++ b/test/e2e/specs/links.test.js @@ -2,12 +2,12 @@ * Internal dependencies */ import { - META_KEY, clickBlockAppender, getEditedPostContent, newPost, pressWithModifier, pressTimes, + insertBlock, } from '../support/utils'; /** @@ -15,7 +15,6 @@ import { * * @type {string} */ -const SELECT_WORD_MODIFIER_KEYS = process.platform === 'darwin' ? [ 'Shift', 'Alt' ] : [ 'Shift', 'Control' ]; describe( 'Links', () => { beforeEach( async () => { @@ -37,7 +36,7 @@ describe( 'Links', () => { await page.keyboard.type( 'This is Gutenberg' ); // Select some text - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Click on the Link button await page.click( 'button[aria-label="Link"]' ); @@ -61,10 +60,10 @@ describe( 'Links', () => { await page.keyboard.type( 'This is Gutenberg' ); // Select some text - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Press Cmd+K to insert a link - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'primary', 'K' ); // Wait for the URL field to auto-focus await waitForAutoFocus(); @@ -88,7 +87,7 @@ describe( 'Links', () => { await moveMouse(); // Press Cmd+K to insert a link - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'primary', 'K' ); // Wait for the URL field to auto-focus await waitForAutoFocus(); @@ -109,10 +108,10 @@ describe( 'Links', () => { await page.keyboard.type( 'This is Gutenberg: https://wordpress.org/gutenberg' ); // Select the URL - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Click on the Link button await page.click( 'button[aria-label="Link"]' ); @@ -127,7 +126,7 @@ describe( 'Links', () => { await page.keyboard.type( 'This is Gutenberg' ); // Select some text - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Click on the Link button await page.click( 'button[aria-label="Link"]' ); @@ -148,7 +147,7 @@ describe( 'Links', () => { await page.keyboard.type( 'This is Gutenberg' ); // Select some text - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Click on the Link button await page.click( 'button[aria-label="Link"]' ); @@ -283,7 +282,7 @@ describe( 'Links', () => { await newPost(); await clickBlockAppender(); await page.keyboard.type( 'This is Gutenberg' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); await page.click( 'button[aria-label="Link"]' ); // Wait for the URL field to auto-focus @@ -323,10 +322,10 @@ describe( 'Links', () => { // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. await page.keyboard.type( 'This is Gutenberg' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Press Cmd+K to insert a link - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'primary', 'K' ); // Wait for the URL field to auto-focus await waitForAutoFocus(); @@ -359,10 +358,10 @@ describe( 'Links', () => { // Now in a new post and try to create a link from an autocomplete suggestion using the keyboard. await page.keyboard.type( 'This is Gutenberg' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); // Press Cmd+K to insert a link - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'primary', 'K' ); // Wait for the URL field to auto-focus await waitForAutoFocus(); @@ -378,7 +377,7 @@ describe( 'Links', () => { expect( await page.$( '.editor-url-popover' ) ).toBeNull(); // Press Cmd+K to insert a link - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'primary', 'K' ); // Wait for the URL field to auto-focus await waitForAutoFocus(); @@ -387,6 +386,21 @@ describe( 'Links', () => { // Expect the the escape key to dismiss the popover normally. await page.keyboard.press( 'Escape' ); expect( await page.$( '.editor-url-popover' ) ).toBeNull(); + + // Press Cmd+K to insert a link + await pressWithModifier( 'primary', 'K' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + expect( await page.$( '.editor-url-popover' ) ).not.toBeNull(); + + // Tab to the settings icon button. + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Tab' ); + + // Expect the the escape key to dismiss the popover normally. + await page.keyboard.press( 'Escape' ); + expect( await page.$( '.editor-url-popover' ) ).toBeNull(); } ); it( 'can be modified using the keyboard once a link has been set', async () => { @@ -395,8 +409,8 @@ describe( 'Links', () => { // Create a block with some text and format it as a link. await clickBlockAppender(); await page.keyboard.type( 'This is Gutenberg' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressWithModifier( 'primary', 'K' ); await waitForAutoFocus(); await page.keyboard.type( URL ); await page.keyboard.press( 'Enter' ); @@ -413,7 +427,7 @@ describe( 'Links', () => { // Press Cmd+K to edit the link and the url-input should become // focused with the value previously inserted. - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'primary', 'K' ); await waitForAutoFocus(); const activeElementParentClasses = await page.evaluate( () => Object.values( document.activeElement.parentElement.classList ) ); expect( activeElementParentClasses ).toContain( 'editor-url-input' ); @@ -424,12 +438,49 @@ describe( 'Links', () => { it( 'adds an assertive message for screenreader users when an invalid link is set', async () => { await clickBlockAppender(); await page.keyboard.type( 'This is Gutenberg' ); - await pressWithModifier( SELECT_WORD_MODIFIER_KEYS, 'ArrowLeft' ); - await pressWithModifier( META_KEY, 'K' ); + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); + await pressWithModifier( 'primary', 'K' ); await waitForAutoFocus(); await page.keyboard.type( 'http://#test.com' ); await page.keyboard.press( 'Enter' ); const assertiveContent = await page.evaluate( () => document.querySelector( '#a11y-speak-assertive' ).textContent ); expect( assertiveContent.trim() ).toBe( 'Warning: the link has been inserted but may have errors. Please test it.' ); } ); + + it( 'link popover remains visible after a mouse drag event', async () => { + // Create some blocks so we have components with event handlers on the page + for ( let loop = 0; loop < 5; loop++ ) { + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'This is Gutenberg' ); + } + + // Focus on first paragraph, so the link popover will appear over the subsequent ones + await page.click( '[aria-label="Block Navigation"]' ); + await page.click( '.editor-block-navigation__item button' ); + + // Select some text + await pressWithModifier( 'shiftAlt', 'ArrowLeft' ); + + // Click on the Link button + await page.click( 'button[aria-label="Link"]' ); + + // Wait for the URL field to auto-focus + await waitForAutoFocus(); + + // Click on the Link Settings button + await page.click( 'button[aria-label="Link Settings"]' ); + + // Move mouse over the 'open in new tab' section, then click and drag + const settings = await page.$( '.editor-url-popover__settings' ); + const bounds = await settings.boundingBox(); + + await page.mouse.move( bounds.x, bounds.y ); + await page.mouse.down(); + await page.mouse.move( bounds.x + ( bounds.width / 2 ), bounds.y, { steps: 10 } ); + await page.mouse.up(); + + // The link popover should still be visible + const popover = await page.$$( '.editor-url-popover' ); + expect( popover ).toHaveLength( 1 ); + } ); } ); diff --git a/test/e2e/specs/multi-block-selection.test.js b/test/e2e/specs/multi-block-selection.test.js index bd07dc8b099f87..959fc5bc447907 100644 --- a/test/e2e/specs/multi-block-selection.test.js +++ b/test/e2e/specs/multi-block-selection.test.js @@ -6,7 +6,6 @@ import { insertBlock, newPost, pressWithModifier, - META_KEY, } from '../support/utils'; describe( 'Multi-block selection', () => { @@ -60,7 +59,7 @@ describe( 'Multi-block selection', () => { // Multiselect via keyboard await page.click( 'body' ); - await pressWithModifier( META_KEY, 'a' ); + await pressWithModifier( 'primary', 'a' ); // Verify selection await expectMultiSelected( blocks, true ); @@ -73,8 +72,8 @@ describe( 'Multi-block selection', () => { // Select all via double shortcut. await page.click( firstBlockSelector ); - await pressWithModifier( META_KEY, 'a' ); - await pressWithModifier( META_KEY, 'a' ); + await pressWithModifier( 'primary', 'a' ); + await pressWithModifier( 'primary', 'a' ); await expectMultiSelected( blocks, true ); } ); @@ -127,8 +126,8 @@ describe( 'Multi-block selection', () => { await page.keyboard.type( 'Third Paragraph' ); // Multiselect via keyboard. - await pressWithModifier( META_KEY, 'a' ); - await pressWithModifier( META_KEY, 'a' ); + await pressWithModifier( 'primary', 'a' ); + await pressWithModifier( 'primary', 'a' ); // TODO: It would be great to do this test by spying on `wp.a11y.speak`, // but it's very difficult to do that because `wp.a11y` has diff --git a/test/e2e/specs/navigable-toolbar.test.js b/test/e2e/specs/navigable-toolbar.test.js index aa781f0fa57358..e62753149ace2e 100644 --- a/test/e2e/specs/navigable-toolbar.test.js +++ b/test/e2e/specs/navigable-toolbar.test.js @@ -44,7 +44,7 @@ describe( 'block toolbar', () => { await page.keyboard.type( 'Example' ); // Upward - await pressWithModifier( 'Alt', 'F10' ); + await pressWithModifier( 'alt', 'F10' ); expect( await isInBlockToolbar() ).toBe( true ); // Downward diff --git a/test/e2e/specs/new-post-default-content.test.js b/test/e2e/specs/new-post-default-content.test.js index 8d5adbe95f9ef0..28949da44f1b46 100644 --- a/test/e2e/specs/new-post-default-content.test.js +++ b/test/e2e/specs/new-post-default-content.test.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { newPost, getEditedPostContent, openDocumentSettingsSidebar } from '../support/utils'; +import { findSidebarPanelWithTitle, newPost, getEditedPostContent, openDocumentSettingsSidebar } from '../support/utils'; import { activatePlugin, deactivatePlugin } from '../support/plugins'; describe( 'new editor filtered state', () => { @@ -27,7 +27,7 @@ describe( 'new editor filtered state', () => { // open the sidebar, we want to see the excerpt. await openDocumentSettingsSidebar(); - const [ excerptButton ] = await page.$x( '//div[@class="edit-post-sidebar"]//button[@class="components-button components-panel__body-toggle"][contains(text(),"Excerpt")]' ); + const excerptButton = await findSidebarPanelWithTitle( 'Excerpt' ); if ( excerptButton ) { await excerptButton.click( 'button' ); } diff --git a/test/e2e/specs/plugins-api.test.js b/test/e2e/specs/plugins-api.test.js index bc841eb9e52f67..d149cf492a5a33 100644 --- a/test/e2e/specs/plugins-api.test.js +++ b/test/e2e/specs/plugins-api.test.js @@ -11,14 +11,6 @@ import { } from '../support/utils'; import { activatePlugin, deactivatePlugin } from '../support/plugins'; -const clickOnBlockSettingsMenuItem = async ( buttonLabel ) => { - await expect( page ).toClick( '.editor-block-settings-menu__toggle' ); - const itemButton = ( await page.$x( `//*[contains(@class, "editor-block-settings-menu__popover")]//button[contains(text(), '${ buttonLabel }')]` ) )[ 0 ]; - await itemButton.click(); -}; - -const ANNOTATIONS_SELECTOR = '.annotation-text-e2e-tests'; - describe( 'Using Plugins API', () => { beforeAll( async () => { await activatePlugin( 'gutenberg-test-plugin-plugins-api' ); @@ -83,36 +75,4 @@ describe( 'Using Plugins API', () => { expect( pluginSidebarClosed ).toBeNull(); } ); } ); - - describe( 'Annotations', () => { - it( 'Allows a block to be annotated', async () => { - await page.keyboard.type( 'Title' + '\n' + 'Paragraph to annotate' ); - await clickOnMoreMenuItem( 'Sidebar title plugin' ); - - let annotations = await page.$$( ANNOTATIONS_SELECTOR ); - expect( annotations ).toHaveLength( 0 ); - - // Click add annotation button. - const addAnnotationButton = ( await page.$x( "//button[contains(text(), 'Add annotation')]" ) )[ 0 ]; - await addAnnotationButton.click(); - - annotations = await page.$$( ANNOTATIONS_SELECTOR ); - expect( annotations ).toHaveLength( 1 ); - - const annotation = annotations[ 0 ]; - - const text = await page.evaluate( ( el ) => el.innerText, annotation ); - expect( text ).toBe( ' to ' ); - - await clickOnBlockSettingsMenuItem( 'Edit as HTML' ); - - const htmlContent = await page.$$( '.editor-block-list__block-html-textarea' ); - const html = await page.evaluate( ( el ) => { - return el.innerHTML; - }, htmlContent[ 0 ] ); - - // There should be no <mark> tags in the raw content. - expect( html ).toBe( '&lt;p&gt;Paragraph to annotate&lt;/p&gt;' ); - } ); - } ); } ); diff --git a/test/e2e/specs/post-visibility.test.js b/test/e2e/specs/post-visibility.test.js new file mode 100644 index 00000000000000..392fd3f9c6efdf --- /dev/null +++ b/test/e2e/specs/post-visibility.test.js @@ -0,0 +1,31 @@ +/** + * Internal dependencies + */ +import { + setViewport, + newPost, + openDocumentSettingsSidebar, +} from '../support/utils'; + +describe( 'Post visibility', () => { + [ 'large', 'small' ].forEach( ( viewport ) => { + it( `can be changed when the viewport is ${ viewport }`, async () => { + await setViewport( viewport ); + + await newPost(); + + await openDocumentSettingsSidebar(); + + await page.click( '.edit-post-post-visibility__toggle' ); + + const [ privateLabel ] = await page.$x( '//label[text()="Private"]' ); + await privateLabel.click(); + + const currentStatus = await page.evaluate( () => { + return wp.data.select( 'core/editor' ).getEditedPostAttribute( 'status' ); + } ); + + expect( currentStatus ).toBe( 'private' ); + } ); + } ); +} ); diff --git a/test/e2e/specs/preview.test.js b/test/e2e/specs/preview.test.js index f18eaa251e10f5..225db55a901317 100644 --- a/test/e2e/specs/preview.test.js +++ b/test/e2e/specs/preview.test.js @@ -71,7 +71,7 @@ describe( 'Preview', () => { return window.location.search.match( /[\?&]post=(\d+)/ ); } ) ).jsonValue(); - let expectedPreviewURL = getUrl( '', `?p=${ postId }&preview=true` ); + const expectedPreviewURL = getUrl( '', `?p=${ postId }&preview=true` ); expect( previewPage.url() ).toBe( expectedPreviewURL ); // Title in preview should match input. @@ -97,16 +97,6 @@ describe( 'Preview', () => { // Preview for published post (no unsaved changes) directs to canonical URL for post. await editorPage.bringToFront(); await publishPost(); - // Wait until the publish panel is closed - await Promise.all( [ - editorPage.waitForFunction( () => ! document.querySelector( '.editor-post-publish-panel' ) ), - editorPage.click( '.editor-post-publish-panel__header button' ), - ] ); - expectedPreviewURL = await editorPage.$eval( '.components-notice.is-success a', ( node ) => node.href ); - - await editorPage.bringToFront(); - await waitForPreviewNavigation( previewPage ); - expect( previewPage.url() ).toBe( expectedPreviewURL ); // Return to editor to change title. await editorPage.bringToFront(); diff --git a/test/e2e/specs/publish-panel.test.js b/test/e2e/specs/publish-panel.test.js index 9a3a6eaee63e5b..27842682577c78 100644 --- a/test/e2e/specs/publish-panel.test.js +++ b/test/e2e/specs/publish-panel.test.js @@ -52,7 +52,7 @@ describe( 'PostPublishPanel', () => { it( 'should retain focus within the panel', async () => { await page.type( '.editor-post-title__input', 'E2E Test Post' ); await openPublishPanel(); - await pressWithModifier( 'Shift', 'Tab' ); + await pressWithModifier( 'shift', 'Tab' ); const focusedElementClassList = await page.$eval( ':focus', ( focusedElement ) => { return Object.values( focusedElement.classList ); diff --git a/test/e2e/specs/reusable-blocks.test.js b/test/e2e/specs/reusable-blocks.test.js index 06e5a6e3df8ee4..3dc3d32afa6c16 100644 --- a/test/e2e/specs/reusable-blocks.test.js +++ b/test/e2e/specs/reusable-blocks.test.js @@ -7,7 +7,6 @@ import { pressWithModifier, searchForBlock, getEditedPostContent, - META_KEY, } from '../support/utils'; function waitForAndAcceptDialog() { @@ -189,8 +188,13 @@ describe( 'Reusable Blocks', () => { // Delete the block and accept the confirmation dialog await page.click( 'button[aria-label="More options"]' ); - const convertButton = await page.waitForXPath( '//button[text()="Remove from Reusable Blocks"]' ); - await Promise.all( [ waitForAndAcceptDialog(), convertButton.click() ] ); + const deleteButton = await page.waitForXPath( '//button[text()="Remove from Reusable Blocks"]' ); + await Promise.all( [ waitForAndAcceptDialog(), deleteButton.click() ] ); + + // Wait for deletion to finish + await page.waitForXPath( + '//*[contains(@class, "components-notice") and contains(@class, "is-success")]/*[text()="Block deleted."]' + ); // Check that we have an empty post again expect( await getEditedPostContent() ).toBe( '' ); @@ -215,8 +219,8 @@ describe( 'Reusable Blocks', () => { await page.keyboard.type( 'Second paragraph' ); // Select all the blocks - await pressWithModifier( META_KEY, 'a' ); - await pressWithModifier( META_KEY, 'a' ); + await pressWithModifier( 'primary', 'a' ); + await pressWithModifier( 'primary', 'a' ); // Trigger isTyping = false await page.mouse.move( 200, 300, { steps: 10 } ); diff --git a/test/e2e/specs/rich-text.test.js b/test/e2e/specs/rich-text.test.js index 9203ec32c303dd..dca248c8289314 100644 --- a/test/e2e/specs/rich-text.test.js +++ b/test/e2e/specs/rich-text.test.js @@ -7,8 +7,6 @@ import { insertBlock, clickBlockAppender, pressWithModifier, - META_KEY, - ACCESS_MODIFIER_KEYS, } from '../support/utils'; describe( 'RichText', () => { @@ -31,8 +29,8 @@ describe( 'RichText', () => { it( 'should apply formatting with access shortcut', async () => { await clickBlockAppender(); await page.keyboard.type( 'test' ); - await pressWithModifier( META_KEY, 'a' ); - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'd' ); + await pressWithModifier( 'primary', 'a' ); + await pressWithModifier( 'access', 'd' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -40,8 +38,8 @@ describe( 'RichText', () => { it( 'should apply formatting with primary shortcut', async () => { await clickBlockAppender(); await page.keyboard.type( 'test' ); - await pressWithModifier( META_KEY, 'a' ); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'a' ); + await pressWithModifier( 'primary', 'b' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -50,12 +48,23 @@ describe( 'RichText', () => { await clickBlockAppender(); await page.keyboard.type( 'Some ' ); // All following characters should now be bold. - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); await page.keyboard.type( 'bold' ); // All following characters should no longer be bold. - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); await page.keyboard.type( '.' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should transform backtick to code', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'A `backtick`' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/test/e2e/specs/shortcut-help.test.js b/test/e2e/specs/shortcut-help.test.js index b7f7ea7491fd22..ea69639fe36229 100644 --- a/test/e2e/specs/shortcut-help.test.js +++ b/test/e2e/specs/shortcut-help.test.js @@ -6,7 +6,6 @@ import { clickOnMoreMenuItem, clickOnCloseModalButton, pressWithModifier, - ACCESS_MODIFIER_KEYS, } from '../support/utils'; describe( 'keyboard shortcut help modal', () => { @@ -27,13 +26,13 @@ describe( 'keyboard shortcut help modal', () => { } ); it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => { - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + await pressWithModifier( 'access', 'h' ); const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); expect( shortcutHelpModalElements ).toHaveLength( 1 ); } ); it( 'closes the shortcut help modal when the shortcut key (access+h) is pressed again', async () => { - await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' ); + await pressWithModifier( 'access', 'h' ); const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' ); expect( shortcutHelpModalElements ).toHaveLength( 0 ); } ); diff --git a/test/e2e/specs/sidebar.test.js b/test/e2e/specs/sidebar.test.js index 237c81cdba2d9f..3b5c9b92716673 100644 --- a/test/e2e/specs/sidebar.test.js +++ b/test/e2e/specs/sidebar.test.js @@ -2,8 +2,10 @@ * Internal dependencies */ import { + findSidebarPanelWithTitle, newPost, observeFocusLoss, + openDocumentSettingsSidebar, pressWithModifier, setViewport, } from '../support/utils'; @@ -12,12 +14,12 @@ const SIDEBAR_SELECTOR = '.edit-post-sidebar'; const ACTIVE_SIDEBAR_TAB_SELECTOR = '.edit-post-sidebar__panel-tab.is-active'; const ACTIVE_SIDEBAR_BUTTON_TEXT = 'Document'; -describe( 'Publishing', () => { +describe( 'Sidebar', () => { beforeAll( () => { observeFocusLoss(); } ); - it( 'Should have sidebar visible at the start with document sidebar active on desktop', async () => { + it( 'should have sidebar visible at the start with document sidebar active on desktop', async () => { await setViewport( 'large' ); await newPost(); const { nodesCount, content, height, width } = await page.$$eval( ACTIVE_SIDEBAR_TAB_SELECTOR, ( nodes ) => { @@ -41,14 +43,14 @@ describe( 'Publishing', () => { expect( height ).toBeGreaterThan( 10 ); } ); - it( 'Should have the sidebar closed by default on mobile', async () => { + it( 'should have the sidebar closed by default on mobile', async () => { await setViewport( 'small' ); await newPost(); const sidebar = await page.$( SIDEBAR_SELECTOR ); expect( sidebar ).toBeNull(); } ); - it( 'Should close the sidebar when resizing from desktop to mobile', async () => { + it( 'should close the sidebar when resizing from desktop to mobile', async () => { await setViewport( 'large' ); await newPost(); @@ -62,7 +64,7 @@ describe( 'Publishing', () => { expect( sidebarsMobile ).toHaveLength( 0 ); } ); - it( 'Should reopen sidebar the sidebar when resizing from mobile to desktop if the sidebar was closed automatically', async () => { + it( 'should reopen sidebar the sidebar when resizing from mobile to desktop if the sidebar was closed automatically', async () => { await setViewport( 'large' ); await newPost(); await setViewport( 'small' ); @@ -76,14 +78,14 @@ describe( 'Publishing', () => { expect( sidebarsDesktop ).toHaveLength( 1 ); } ); - it( 'Should preserve tab order while changing active tab', async () => { + it( 'should preserve tab order while changing active tab', async () => { await newPost(); // Region navigate to Sidebar. - await pressWithModifier( 'Control', '`' ); - await pressWithModifier( 'Control', '`' ); - await pressWithModifier( 'Control', '`' ); - await pressWithModifier( 'Control', '`' ); + await pressWithModifier( 'ctrl', '`' ); + await pressWithModifier( 'ctrl', '`' ); + await pressWithModifier( 'ctrl', '`' ); + await pressWithModifier( 'ctrl', '`' ); // Tab lands at first (presumed selected) option "Document". await page.keyboard.press( 'Tab' ); @@ -102,4 +104,32 @@ describe( 'Publishing', () => { ) ); expect( isActiveBlockTab ).toBe( true ); } ); + + it( 'should be possible to programmatically remove Document Settings panels', async () => { + await newPost(); + + await openDocumentSettingsSidebar(); + + expect( await findSidebarPanelWithTitle( 'Categories' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Tags' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Featured Image' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Excerpt' ) ).toBeDefined(); + expect( await findSidebarPanelWithTitle( 'Discussion' ) ).toBeDefined(); + + await page.evaluate( () => { + const { removeEditorPanel } = wp.data.dispatch( 'core/edit-post' ); + + removeEditorPanel( 'taxonomy-panel-category' ); + removeEditorPanel( 'taxonomy-panel-post_tag' ); + removeEditorPanel( 'featured-image' ); + removeEditorPanel( 'post-excerpt' ); + removeEditorPanel( 'discussion-panel' ); + } ); + + expect( await findSidebarPanelWithTitle( 'Categories' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Tags' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Featured Image' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Excerpt' ) ).toBeUndefined(); + expect( await findSidebarPanelWithTitle( 'Discussion' ) ).toBeUndefined(); + } ); } ); diff --git a/test/e2e/specs/splitting-merging.test.js b/test/e2e/specs/splitting-merging.test.js index d059fbf42ea63d..f6456dfa1e3429 100644 --- a/test/e2e/specs/splitting-merging.test.js +++ b/test/e2e/specs/splitting-merging.test.js @@ -7,7 +7,6 @@ import { getEditedPostContent, pressTimes, pressWithModifier, - META_KEY, } from '../support/utils'; describe( 'splitting and merging blocks', () => { @@ -44,7 +43,7 @@ describe( 'splitting and merging blocks', () => { await page.keyboard.down( 'Shift' ); await pressTimes( 'ArrowRight', 5 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); // Collapse selection, still within inline boundary. await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); @@ -57,7 +56,7 @@ describe( 'splitting and merging blocks', () => { // Regression Test: Caret should reset to end of inline boundary when // backspacing to delete second paragraph. await insertBlock( 'Paragraph' ); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); await page.keyboard.type( 'Foo' ); await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Backspace' ); @@ -129,7 +128,7 @@ describe( 'splitting and merging blocks', () => { await page.keyboard.down( 'Shift' ); await pressTimes( 'ArrowLeft', 3 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Enter' ); await page.keyboard.press( 'Enter' ); diff --git a/test/e2e/specs/templates.test.js b/test/e2e/specs/templates.test.js index 4becad767d74b5..9b5d22080e0d42 100644 --- a/test/e2e/specs/templates.test.js +++ b/test/e2e/specs/templates.test.js @@ -2,7 +2,6 @@ * Internal dependencies */ import { - META_KEY, newPost, getEditedPostContent, saveDraft, @@ -49,7 +48,7 @@ describe( 'templates', () => { // re-added after saving and reloading the editor. await page.type( '.editor-post-title__input', 'My Empty Book' ); await page.keyboard.press( 'ArrowDown' ); - await pressWithModifier( META_KEY, 'A' ); + await pressWithModifier( 'primary', 'A' ); await page.keyboard.press( 'Backspace' ); await saveDraft(); await page.reload(); diff --git a/test/e2e/specs/undo.test.js b/test/e2e/specs/undo.test.js index cf671065a4375f..fe67a73dfb680a 100644 --- a/test/e2e/specs/undo.test.js +++ b/test/e2e/specs/undo.test.js @@ -6,14 +6,41 @@ import { getEditedPostContent, newPost, pressWithModifier, - META_KEY, } from '../support/utils'; describe( 'undo', () => { - beforeAll( async () => { + beforeEach( async () => { await newPost(); } ); + it( 'should undo typing after a pause', async () => { + await clickBlockAppender(); + + await page.keyboard.type( 'before pause' ); + await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) ); + await page.keyboard.type( ' after pause' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should undo typing after non input change', async () => { + await clickBlockAppender(); + + await page.keyboard.type( 'before keyboard ' ); + await pressWithModifier( 'primary', 'b' ); + await page.keyboard.type( 'after keyboard' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await pressWithModifier( 'primary', 'z' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + it( 'Should undo to expected level intervals', async () => { await clickBlockAppender(); @@ -25,12 +52,12 @@ describe( 'undo', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); - await pressWithModifier( META_KEY, 'z' ); // Undo 3rd paragraph text. - await pressWithModifier( META_KEY, 'z' ); // Undo 3rd block. - await pressWithModifier( META_KEY, 'z' ); // Undo 2nd paragraph text. - await pressWithModifier( META_KEY, 'z' ); // Undo 2nd block. - await pressWithModifier( META_KEY, 'z' ); // Undo 1st paragraph text. - await pressWithModifier( META_KEY, 'z' ); // Undo 1st block. + await pressWithModifier( 'primary', 'z' ); // Undo 3rd paragraph text. + await pressWithModifier( 'primary', 'z' ); // Undo 3rd block. + await pressWithModifier( 'primary', 'z' ); // Undo 2nd paragraph text. + await pressWithModifier( 'primary', 'z' ); // Undo 2nd block. + await pressWithModifier( 'primary', 'z' ); // Undo 1st paragraph text. + await pressWithModifier( 'primary', 'z' ); // Undo 1st block. expect( await getEditedPostContent() ).toBe( '' ); // After undoing every action, there should be no more undo history. diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js index 0da4e2f29ad8af..bcad1f3d55bb0c 100644 --- a/test/e2e/specs/writing-flow.test.js +++ b/test/e2e/specs/writing-flow.test.js @@ -7,7 +7,6 @@ import { newPost, pressTimes, pressWithModifier, - META_KEY, } from '../support/utils'; describe( 'adding blocks', () => { @@ -89,7 +88,7 @@ describe( 'adding blocks', () => { await page.keyboard.down( 'Shift' ); await pressTimes( 'ArrowLeft', 6 ); await page.keyboard.up( 'Shift' ); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); // Arrow left from selected bold should collapse to before the inline // boundary. Arrow once more to traverse into first paragraph. @@ -146,7 +145,7 @@ describe( 'adding blocks', () => { // Ensure no zero-width space character. Notably, this can occur when // save occurs while at an inline boundary edge. await clickBlockAppender(); - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); expect( await getEditedPostContent() ).toMatchSnapshot(); // Backspace to remove the content in this block, resetting it. @@ -154,7 +153,7 @@ describe( 'adding blocks', () => { // Ensure no data-mce-selected. Notably, this can occur when content // is saved while typing within an inline boundary. - await pressWithModifier( META_KEY, 'b' ); + await pressWithModifier( 'primary', 'b' ); await page.keyboard.type( 'Inside' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -162,14 +161,14 @@ describe( 'adding blocks', () => { it( 'should insert line break at end', async () => { await clickBlockAppender(); await page.keyboard.type( 'a' ); - await pressWithModifier( 'Shift', 'Enter' ); + await pressWithModifier( 'shift', 'Enter' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); it( 'should insert line break at end and continue writing', async () => { await clickBlockAppender(); await page.keyboard.type( 'a' ); - await pressWithModifier( 'Shift', 'Enter' ); + await pressWithModifier( 'shift', 'Enter' ); await page.keyboard.type( 'b' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -178,7 +177,7 @@ describe( 'adding blocks', () => { await clickBlockAppender(); await page.keyboard.type( 'ab' ); await page.keyboard.press( 'ArrowLeft' ); - await pressWithModifier( 'Shift', 'Enter' ); + await pressWithModifier( 'shift', 'Enter' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -186,13 +185,13 @@ describe( 'adding blocks', () => { await clickBlockAppender(); await page.keyboard.type( 'a' ); await page.keyboard.press( 'ArrowLeft' ); - await pressWithModifier( 'Shift', 'Enter' ); + await pressWithModifier( 'shift', 'Enter' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); it( 'should insert line break in empty container', async () => { await clickBlockAppender(); - await pressWithModifier( 'Shift', 'Enter' ); + await pressWithModifier( 'shift', 'Enter' ); expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -210,7 +209,7 @@ describe( 'adding blocks', () => { expect( isInTitle ).toBe( true ); // Should remain in title upon modifier + ArrowDown: - await pressWithModifier( META_KEY, 'ArrowDown' ); + await pressWithModifier( 'primary', 'ArrowDown' ); isInTitle = await page.evaluate( () => ( !! document.activeElement.closest( '.editor-post-title' ) ) ); @@ -224,42 +223,58 @@ describe( 'adding blocks', () => { expect( isInBlock ).toBe( true ); } ); - it( 'should not delete trailing spaces when deleting a word with backspace', async () => { + it( 'should not delete surrounding space when deleting a word with Backspace', async () => { await clickBlockAppender(); - await page.keyboard.type( '1 2 3 4' ); + await page.keyboard.type( '1 2 3' ); + await pressTimes( 'ArrowLeft', ' 3'.length ); await page.keyboard.press( 'Backspace' ); - await page.keyboard.type( '4' ); - const blockText = await page.evaluate( () => document.activeElement.textContent ); - expect( blockText ).toBe( '1 2 3 4' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.type( '2' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); } ); - it( 'should not delete trailing spaces when deleting a word with alt + backspace', async () => { + it( 'should not delete surrounding space when deleting a word with Alt+Backspace', async () => { await clickBlockAppender(); - await page.keyboard.type( 'alpha beta gamma delta' ); + await page.keyboard.type( 'alpha beta gamma' ); + await pressTimes( 'ArrowLeft', ' gamma'.length ); + if ( process.platform === 'darwin' ) { - await pressWithModifier( 'Alt', 'Backspace' ); + await pressWithModifier( 'alt', 'Backspace' ); } else { - await pressWithModifier( META_KEY, 'Backspace' ); + await pressWithModifier( 'primary', 'Backspace' ); } - await page.keyboard.type( 'delta' ); - const blockText = await page.evaluate( () => document.activeElement.textContent ); - expect( blockText ).toBe( 'alpha beta gamma delta' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.type( 'beta' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'should not delete surrounding space when deleting a selected word', async () => { + await clickBlockAppender(); + await page.keyboard.type( 'alpha beta gamma' ); + await pressTimes( 'ArrowLeft', ' gamma'.length ); + await page.keyboard.down( 'Shift' ); + await pressTimes( 'ArrowLeft', 'beta'.length ); + await page.keyboard.up( 'Shift' ); + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + await page.keyboard.type( 'beta' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); } ); it( 'should create valid paragraph blocks when rapidly pressing Enter', async () => { await clickBlockAppender(); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Enter' ); + await pressTimes( 'Enter', 10 ); + // Check that none of the paragraph blocks have <br> in them. - const postContent = await getEditedPostContent(); - expect( postContent.indexOf( 'br' ) ).toBe( -1 ); + expect( await getEditedPostContent() ).toMatchSnapshot(); } ); } ); diff --git a/test/e2e/support/utils.js b/test/e2e/support/utils.js deleted file mode 100644 index 4b6fbefabcc368..00000000000000 --- a/test/e2e/support/utils.js +++ /dev/null @@ -1,625 +0,0 @@ -/** - * Node dependencies - */ -import { join } from 'path'; -import { URL } from 'url'; - -/** - * External dependencies - */ -import { times, castArray } from 'lodash'; -import fetch from 'node-fetch'; - -/** - * WordPress dependencies - */ -import { addQueryArgs } from '@wordpress/url'; - -const WP_ADMIN_USER = { - username: 'admin', - password: 'password', -}; - -const { - WP_BASE_URL = 'http://localhost:8889', - WP_USERNAME = WP_ADMIN_USER.username, - WP_PASSWORD = WP_ADMIN_USER.password, -} = process.env; - -/** - * Platform-specific meta key. - * - * @see pressWithModifier - * - * @type {string} - */ -export const META_KEY = process.platform === 'darwin' ? 'Meta' : 'Control'; - -/** - * Platform-specific modifier for the access key chord. - * - * @see pressWithModifier - * - * @type {string} - */ -export const ACCESS_MODIFIER_KEYS = process.platform === 'darwin' ? [ 'Control', 'Alt' ] : [ 'Shift', 'Alt' ]; - -/** - * Regular expression matching zero-width space characters. - * - * @type {RegExp} - */ -const REGEXP_ZWSP = /[\u200B\u200C\u200D\uFEFF]/; - -/** - * Given an array of functions, each returning a promise, performs all - * promises in sequence (waterfall) order. - * - * @param {Function[]} sequence Array of promise creators. - * - * @return {Promise} Promise resolving once all in the sequence complete. - */ -async function promiseSequence( sequence ) { - return sequence.reduce( - ( current, next ) => current.then( next ), - Promise.resolve() - ); -} - -export function getUrl( WPPath, query = '' ) { - const url = new URL( WP_BASE_URL ); - - url.pathname = join( url.pathname, WPPath ); - url.search = query; - - return url.href; -} - -function isWPPath( WPPath, query = '' ) { - const currentUrl = new URL( page.url() ); - - currentUrl.search = query; - - return getUrl( WPPath ) === currentUrl.href; -} - -async function goToWPPath( WPPath, query ) { - await page.goto( getUrl( WPPath, query ) ); -} - -async function login( username = WP_USERNAME, password = WP_PASSWORD ) { - await page.focus( '#user_login' ); - await pressWithModifier( META_KEY, 'a' ); - await page.type( '#user_login', username ); - await page.focus( '#user_pass' ); - await pressWithModifier( META_KEY, 'a' ); - await page.type( '#user_pass', password ); - - await Promise.all( [ - page.waitForNavigation(), - page.click( '#wp-submit' ), - ] ); -} - -/** - * Switches the current user to the admin user (if the user - * running the test is not already the admin user). - */ -export async function switchToAdminUser() { - if ( WP_USERNAME === WP_ADMIN_USER.username ) { - return; - } - await goToWPPath( 'wp-login.php' ); - await login( WP_ADMIN_USER.username, WP_ADMIN_USER.password ); -} - -/** - * Switches the current user to whichever user we should be - * running the tests as (if we're not already that user). - */ -export async function switchToTestUser() { - if ( WP_USERNAME === WP_ADMIN_USER.username ) { - return; - } - await goToWPPath( 'wp-login.php' ); - await login(); -} - -export async function visitAdmin( adminPath, query ) { - await goToWPPath( join( 'wp-admin', adminPath ), query ); - - if ( isWPPath( 'wp-login.php' ) ) { - await login(); - return visitAdmin( adminPath, query ); - } -} - -export async function newPost( { - postType, - title, - content, - excerpt, - enableTips = false, -} = {} ) { - const query = addQueryArgs( '', { - post_type: postType, - post_title: title, - content, - excerpt, - } ).slice( 1 ); - await visitAdmin( 'post-new.php', query ); - - await page.evaluate( ( _enableTips ) => { - const action = _enableTips ? 'enableTips' : 'disableTips'; - wp.data.dispatch( 'core/nux' )[ action ](); - }, enableTips ); - - if ( enableTips ) { - await page.reload(); - } -} - -/** - * Toggles the screen option with the given label. - * - * @param {string} label The label of the screen option, e.g. 'Show Tips'. - * @param {?boolean} shouldBeChecked If true, turns the option on. If false, off. If - * undefined, the option will be toggled. - */ -export async function toggleOption( label, shouldBeChecked = undefined ) { - await clickOnMoreMenuItem( 'Options' ); - const [ handle ] = await page.$x( `//label[contains(text(), "${ label }")]` ); - - const isChecked = await page.evaluate( ( element ) => element.control.checked, handle ); - if ( isChecked !== shouldBeChecked ) { - await handle.click(); - } - - await page.click( 'button[aria-label="Close dialog"]' ); -} - -export async function arePrePublishChecksEnabled( ) { - return page.evaluate( () => window.wp.data.select( 'core/editor' ).isPublishSidebarEnabled() ); -} - -export async function enablePrePublishChecks( ) { - await toggleOption( 'Enable Pre-publish Checks', true ); -} - -export async function disablePrePublishChecks( ) { - await toggleOption( 'Enable Pre-publish Checks', false ); -} - -export async function setViewport( type ) { - const allowedDimensions = { - large: { width: 960, height: 700 }, - small: { width: 600, height: 700 }, - }; - const currentDimension = allowedDimensions[ type ]; - await page.setViewport( currentDimension ); - await waitForPageDimensions( currentDimension.width, currentDimension.height ); -} - -/** - * Function that waits until the page viewport has the required dimensions. - * It is being used to address a problem where after using setViewport the execution may continue, - * without the new dimensions being applied. - * https://github.com/GoogleChrome/puppeteer/issues/1751 - * - * @param {number} width Width of the window. - * @param {height} height Height of the window. - */ -export async function waitForPageDimensions( width, height ) { - await page.mainFrame().waitForFunction( - `window.innerWidth === ${ width } && window.innerHeight === ${ height }` - ); -} - -export async function switchToEditor( mode ) { - await page.click( '.edit-post-more-menu [aria-label="Show more tools & options"]' ); - const [ button ] = await page.$x( `//button[contains(text(), '${ mode } Editor')]` ); - await button.click( 'button' ); -} - -/** - * Returns a promise which resolves with the edited post content (HTML string). - * - * @return {Promise} Promise resolving with post content markup. - */ -export async function getEditedPostContent() { - const content = await page.evaluate( () => { - const { select } = window.wp.data; - return select( 'core/editor' ).getEditedPostContent(); - } ); - - // Globally guard against zero-width characters. - if ( REGEXP_ZWSP.test( content ) ) { - throw new Error( 'Unexpected zero-width space character in editor content.' ); - } - - return content; -} - -/** - * Verifies that the edit post sidebar is opened, and if it is not, opens it. - * - * @return {Promise} Promise resolving once the edit post sidebar is opened. - */ -export async function ensureSidebarOpened() { - // This try/catch flow relies on the fact that `page.$eval` throws an error - // if the element matching the given selector does not exist. Thus, if an - // error is thrown, it can be inferred that the sidebar is not opened. - try { - return page.$eval( '.edit-post-sidebar', () => {} ); - } catch ( error ) { - return page.click( '.edit-post-header__settings [aria-label="Settings"]' ); - } -} - -/** - * Clicks the default block appender. - */ -export async function clickBlockAppender() { - await page.click( '.editor-default-block-appender__content' ); -} - -/** - * Search for block in the global inserter - * - * @param {string} searchTerm The text to search the inserter for. - */ -export async function searchForBlock( searchTerm ) { - await page.click( '.edit-post-header [aria-label="Add block"]' ); - // Waiting here is necessary because sometimes the inserter takes more time to - // render than Puppeteer takes to complete the 'click' action - await page.waitForSelector( '.editor-inserter__menu' ); - await page.keyboard.type( searchTerm ); -} - -/** - * Opens the inserter, searches for the given term, then selects the first - * result that appears. - * - * @param {string} searchTerm The text to search the inserter for. - * @param {string} panelName The inserter panel to open (if it's closed by default). - */ -export async function insertBlock( searchTerm, panelName = null ) { - await searchForBlock( searchTerm ); - if ( panelName ) { - const panelButton = ( await page.$x( `//button[contains(text(), '${ panelName }')]` ) )[ 0 ]; - await panelButton.click(); - } - await page.click( `button[aria-label="${ searchTerm }"]` ); -} - -export async function convertBlock( name ) { - await page.mouse.move( 200, 300, { steps: 10 } ); - await page.mouse.move( 250, 350, { steps: 10 } ); - await page.click( '.editor-block-switcher__toggle' ); - await page.click( `.editor-block-types-list__item[aria-label="${ name }"]` ); -} - -/** - * Performs a key press with modifier (Shift, Control, Meta, Mod), where "Mod" - * is normalized to platform-specific modifier (Meta in MacOS, else Control). - * - * @param {string|Array} modifiers Modifier key or array of modifier keys. - * @param {string} key Key to press while modifier held. - */ -export async function pressWithModifier( modifiers, key ) { - const modifierKeys = castArray( modifiers ); - - await Promise.all( - modifierKeys.map( async ( modifier ) => page.keyboard.down( modifier ) ) - ); - - await page.keyboard.press( key ); - - await Promise.all( - modifierKeys.map( async ( modifier ) => page.keyboard.up( modifier ) ) - ); -} - -/** - * Clicks on More Menu item, searches for the button with the text provided and clicks it. - * - * @param {string} buttonLabel The label to search the button for. - */ -export async function clickOnMoreMenuItem( buttonLabel ) { - await expect( page ).toClick( '.edit-post-more-menu [aria-label="Show more tools & options"]' ); - await page.click( `.edit-post-more-menu__content button[aria-label="${ buttonLabel }"]` ); -} - -/** - * Opens the publish panel. - */ -export async function openPublishPanel() { - await page.click( '.editor-post-publish-panel__toggle' ); - - // Disable reason: Wait for the animation to complete, since otherwise the - // click attempt may occur at the wrong point. - // eslint-disable-next-line no-restricted-syntax - await page.waitFor( 100 ); -} - -/** - * Publishes the post, resolving once the request is complete (once a notice - * is displayed). - * - * @return {Promise} Promise resolving when publish is complete. - */ -export async function publishPost() { - await openPublishPanel(); - - // Publish the post - await page.click( '.editor-post-publish-button' ); - - // A success notice should show up - return page.waitForSelector( '.components-notice.is-success' ); -} - -/** - * Publishes the post without the pre-publish checks, - * resolving once the request is complete (once a notice is displayed). - * - * @return {Promise} Promise resolving when publish is complete. - */ -export async function publishPostWithoutPrePublishChecks() { - await page.click( '.editor-post-publish-button' ); - return page.waitForSelector( '.components-notice.is-success' ); -} - -/** - * Saves the post as a draft, resolving once the request is complete (once the - * "Saved" indicator is displayed). - * - * @return {Promise} Promise resolving when draft save is complete. - */ -export async function saveDraft() { - await page.click( '.editor-post-save-draft' ); - return page.waitForSelector( '.editor-post-saved-state.is-saved' ); -} - -/** - * Given the clientId of a block, selects the block on the editor. - * - * @param {string} clientId Identified of the block. - */ -export async function selectBlockByClientId( clientId ) { - await page.evaluate( ( id ) => { - wp.data.dispatch( 'core/editor' ).selectBlock( id ); - }, clientId ); -} - -/** - * Clicks on the button in the header which opens Document Settings sidebar when it is closed. - */ -export async function openDocumentSettingsSidebar() { - const openButton = await page.$( '.edit-post-header__settings button[aria-label="Settings"][aria-expanded="false"]' ); - - if ( openButton ) { - await page.click( openButton ); - } -} - -/** - * Presses the given keyboard key a number of times in sequence. - * - * @param {string} key Key to press. - * @param {number} count Number of times to press. - * - * @return {Promise} Promise resolving when key presses complete. - */ -export async function pressTimes( key, count ) { - return promiseSequence( times( count, () => () => page.keyboard.press( key ) ) ); -} - -export async function clearLocalStorage() { - await page.evaluate( () => window.localStorage.clear() ); -} - -/** - * Callback which automatically accepts dialog. - * - * @param {puppeteer.Dialog} dialog Dialog object dispatched by page via the 'dialog' event. - */ -async function acceptPageDialog( dialog ) { - await dialog.accept(); -} - -/** - * Enables even listener which accepts a page dialog which - * may appear when navigating away from Gutenberg. - */ -export function enablePageDialogAccept() { - page.on( 'dialog', acceptPageDialog ); -} - -/** - * Click on the close button of an open modal. - * - * @param {?string} modalClassName Class name for the modal to close - */ -export async function clickOnCloseModalButton( modalClassName ) { - let closeButtonClassName = '.components-modal__header .components-icon-button'; - - if ( modalClassName ) { - closeButtonClassName = `${ modalClassName } ${ closeButtonClassName }`; - } - - const closeButton = await page.$( closeButtonClassName ); - - if ( closeButton ) { - await page.click( closeButtonClassName ); - } -} - -/** - * Sets code editor content - * @param {string} content New code editor content. - * - * @return {Promise} Promise resolving with an array containing all blocks in the document. - */ -export async function setPostContent( content ) { - return await page.evaluate( ( _content ) => { - const { dispatch } = window.wp.data; - const blocks = wp.blocks.parse( _content ); - dispatch( 'core/editor' ).resetBlocks( blocks ); - }, content ); -} - -/** - * Returns an array with all blocks; Equivalent to calling wp.data.select( 'core/editor' ).getBlocks(); - * - * @return {Promise} Promise resolving with an array containing all blocks in the document. - */ -export async function getAllBlocks() { - return await page.evaluate( () => { - const { select } = window.wp.data; - return select( 'core/editor' ).getBlocks(); - } ); -} - -/** - * Binds to the document on page load which throws an error if a `focusout` - * event occurs without a related target (i.e. focus loss). - */ -export function observeFocusLoss() { - page.on( 'load', () => { - page.evaluate( () => { - document.body.addEventListener( 'focusout', ( event ) => { - if ( ! event.relatedTarget ) { - throw new Error( 'Unexpected focus loss' ); - } - } ); - } ); - } ); -} - -/** - * Creates a function to determine if a request is embedding a certain URL. - * - * @param {string} url The URL to check against a request. - * @return {function} Function that determines if a request is for the embed API, embedding a specific URL. - */ -export function isEmbedding( url ) { - return ( request ) => matchURL( 'oembed%2F1.0%2Fproxy' )( request ) && parameterEquals( 'url', url )( request ); -} - -/** - * Respond to a request with a JSON response. - * - * @param {string} mockResponse The mock object to wrap in a JSON response. - * @return {Promise} Promise that responds to a request with the mock JSON response. - */ -export function JSONResponse( mockResponse ) { - return async ( request ) => request.respond( getJSONResponse( mockResponse ) ); -} - -/** - * Creates a function to determine if a request is calling a URL with the substring present. - * - * @param {string} substring The substring to check for. - * @return {function} Function that determines if a request's URL contains substring. - */ -export function matchURL( substring ) { - return ( request ) => -1 !== request.url().indexOf( substring ); -} - -/** - * Creates a function to determine if a request has a parameter with a certain value. - * - * @param {string} parameterName The query parameter to check. - * @param {string} value The value to check for. - * @return {function} Function that determines if a request's query parameter is the specified value. - */ -export function parameterEquals( parameterName, value ) { - return ( request ) => { - const url = request.url(); - const match = new RegExp( `.*${ parameterName }=([^&]+).*` ).exec( url ); - if ( ! match ) { - return false; - } - return value === decodeURIComponent( match[ 1 ] ); - }; -} - -/** - * Get a JSON response for the passed in object, for use with `request.respond`. - * - * @param {Object} obj Object to seralise for response. - * @return {Object} Response for use with `request.respond`. - */ -export function getJSONResponse( obj ) { - return { - content: 'application/json', - body: JSON.stringify( obj ), - }; -} - -/** - * Mocks a request with the supplied mock object, or allows it to run with an optional transform, based on the - * deserialised JSON response for the request. - * - * @param {function} mockCheck function that returns true if the request should be mocked. - * @param {Object} mock A mock object to wrap in a JSON response, if the request should be mocked. - * @param {function|undefined} responseObjectTransform An optional function that transforms the response's object before the response is used. - * @return {Promise} Promise that uses `mockCheck` to see if a request should be mocked with `mock`, and optionally transforms the response with `responseObjectTransform`. - */ -export function mockOrTransform( mockCheck, mock, responseObjectTransform = ( obj ) => obj ) { - return async ( request ) => { - // Because we can't get the responses to requests and modify them on the fly, - // we have to make our own request and get the response, then apply the - // optional transform to the json encoded object. - const response = await fetch( - request.url(), - { - headers: request.headers(), - method: request.method(), - body: request.postData(), - } - ); - const responseObject = await response.json(); - if ( mockCheck( responseObject ) ) { - request.respond( getJSONResponse( mock ) ); - } else { - request.respond( getJSONResponse( responseObjectTransform( responseObject ) ) ); - } - }; -} - -/** - * Sets up mock checks and responses. Accepts a list of mock settings with the following properties: - * - match: function to check if a request should be mocked. - * - onRequestMatch: async function to respond to the request. - * - * Example: - * const MOCK_RESPONSES = [ - * { - * match: isEmbedding( 'https://wordpress.org/gutenberg/handbook/' ), - * onRequestMatch: JSONResponse( MOCK_BAD_WORDPRESS_RESPONSE ), - * }, - * { - * match: isEmbedding( 'https://wordpress.org/gutenberg/handbook/block-api/attributes/' ), - * onRequestMatch: JSONResponse( MOCK_EMBED_WORDPRESS_SUCCESS_RESPONSE ), - * } - * ]; - * setUpResponseMocking( MOCK_RESPONSES ); - * - * If none of the mock settings match the request, the request is allowed to continue. - * - * @param {Array} mocks Array of mock settings. - */ -export async function setUpResponseMocking( mocks ) { - await page.setRequestInterception( true ); - page.on( 'request', async ( request ) => { - for ( let i = 0; i < mocks.length; i++ ) { - const mock = mocks[ i ]; - if ( mock.match( request ) ) { - await mock.onRequestMatch( request ); - return; - } - } - request.continue(); - } ); -} diff --git a/test/e2e/support/utils/accept-page-dialog.js b/test/e2e/support/utils/accept-page-dialog.js new file mode 100644 index 00000000000000..d10b40bf4f94ec --- /dev/null +++ b/test/e2e/support/utils/accept-page-dialog.js @@ -0,0 +1,8 @@ +/** + * Callback which automatically accepts dialog. + * + * @param {puppeteer.Dialog} dialog Dialog object dispatched by page via the 'dialog' event. + */ +export async function acceptPageDialog( dialog ) { + await dialog.accept(); +} diff --git a/test/e2e/support/utils/are-pre-publish-checks-enabled.js b/test/e2e/support/utils/are-pre-publish-checks-enabled.js new file mode 100644 index 00000000000000..fbccb04b54b2bb --- /dev/null +++ b/test/e2e/support/utils/are-pre-publish-checks-enabled.js @@ -0,0 +1,5 @@ +export async function arePrePublishChecksEnabled() { + return page.evaluate( () => + window.wp.data.select( 'core/editor' ).isPublishSidebarEnabled() + ); +} diff --git a/test/e2e/support/utils/clear-local-storage.js b/test/e2e/support/utils/clear-local-storage.js new file mode 100644 index 00000000000000..2770f7ce740d02 --- /dev/null +++ b/test/e2e/support/utils/clear-local-storage.js @@ -0,0 +1,3 @@ +export async function clearLocalStorage() { + await page.evaluate( () => window.localStorage.clear() ); +} diff --git a/test/e2e/support/utils/click-block-appender.js b/test/e2e/support/utils/click-block-appender.js new file mode 100644 index 00000000000000..8bade1f9bae33c --- /dev/null +++ b/test/e2e/support/utils/click-block-appender.js @@ -0,0 +1,6 @@ +/** + * Clicks the default block appender. + */ +export async function clickBlockAppender() { + await page.click( '.editor-default-block-appender__content' ); +} diff --git a/test/e2e/support/utils/click-on-close-modal-button.js b/test/e2e/support/utils/click-on-close-modal-button.js new file mode 100644 index 00000000000000..3e057260f76fdf --- /dev/null +++ b/test/e2e/support/utils/click-on-close-modal-button.js @@ -0,0 +1,19 @@ +/** + * Click on the close button of an open modal. + * + * @param {?string} modalClassName Class name for the modal to close + */ +export async function clickOnCloseModalButton( modalClassName ) { + let closeButtonClassName = + '.components-modal__header .components-icon-button'; + + if ( modalClassName ) { + closeButtonClassName = `${ modalClassName } ${ closeButtonClassName }`; + } + + const closeButton = await page.$( closeButtonClassName ); + + if ( closeButton ) { + await page.click( closeButtonClassName ); + } +} diff --git a/test/e2e/support/utils/click-on-more-menu-item.js b/test/e2e/support/utils/click-on-more-menu-item.js new file mode 100644 index 00000000000000..7b1da2d44e9f3a --- /dev/null +++ b/test/e2e/support/utils/click-on-more-menu-item.js @@ -0,0 +1,14 @@ + +/** + * Clicks on More Menu item, searches for the button with the text provided and clicks it. + * + * @param {string} buttonLabel The label to search the button for. + */ +export async function clickOnMoreMenuItem( buttonLabel ) { + await expect( page ).toClick( + '.edit-post-more-menu [aria-label="Show more tools & options"]' + ); + await page.click( + `.edit-post-more-menu__content button[aria-label="${ buttonLabel }"]` + ); +} diff --git a/test/e2e/support/utils/config.js b/test/e2e/support/utils/config.js new file mode 100644 index 00000000000000..f098b78c528791 --- /dev/null +++ b/test/e2e/support/utils/config.js @@ -0,0 +1,17 @@ +const WP_ADMIN_USER = { + username: 'admin', + password: 'password', +}; + +const { + WP_USERNAME = WP_ADMIN_USER.username, + WP_PASSWORD = WP_ADMIN_USER.password, + WP_BASE_URL = 'http://localhost:8889', +} = process.env; + +export { + WP_ADMIN_USER, + WP_USERNAME, + WP_PASSWORD, + WP_BASE_URL, +}; diff --git a/test/e2e/support/utils/convert-block.js b/test/e2e/support/utils/convert-block.js new file mode 100644 index 00000000000000..8d2033fd7cfc01 --- /dev/null +++ b/test/e2e/support/utils/convert-block.js @@ -0,0 +1,6 @@ +export async function convertBlock( name ) { + await page.mouse.move( 200, 300, { steps: 10 } ); + await page.mouse.move( 250, 350, { steps: 10 } ); + await page.click( '.editor-block-switcher__toggle' ); + await page.click( `.editor-block-types-list__item[aria-label="${ name }"]` ); +} diff --git a/test/e2e/support/utils/disable-pre-publish-checks.js b/test/e2e/support/utils/disable-pre-publish-checks.js new file mode 100644 index 00000000000000..93f676c796bc9f --- /dev/null +++ b/test/e2e/support/utils/disable-pre-publish-checks.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { toggleOption } from './toggle-option'; + +export async function disablePrePublishChecks() { + await toggleOption( 'Enable Pre-publish Checks', false ); +} diff --git a/test/e2e/support/utils/enable-page-dialog-accept.js b/test/e2e/support/utils/enable-page-dialog-accept.js new file mode 100644 index 00000000000000..c419de917ac987 --- /dev/null +++ b/test/e2e/support/utils/enable-page-dialog-accept.js @@ -0,0 +1,12 @@ +/** + * Internal dependencies + */ +import { acceptPageDialog } from './accept-page-dialog'; + +/** + * Enables even listener which accepts a page dialog which + * may appear when navigating away from Gutenberg. + */ +export function enablePageDialogAccept() { + page.on( 'dialog', acceptPageDialog ); +} diff --git a/test/e2e/support/utils/enable-pre-publish-checks.js b/test/e2e/support/utils/enable-pre-publish-checks.js new file mode 100644 index 00000000000000..83069b40f9b1b0 --- /dev/null +++ b/test/e2e/support/utils/enable-pre-publish-checks.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { toggleOption } from './toggle-option'; + +export async function enablePrePublishChecks() { + await toggleOption( 'Enable Pre-publish Checks', true ); +} diff --git a/test/e2e/support/utils/ensure-sidebar-opened.js b/test/e2e/support/utils/ensure-sidebar-opened.js new file mode 100644 index 00000000000000..977a4325105637 --- /dev/null +++ b/test/e2e/support/utils/ensure-sidebar-opened.js @@ -0,0 +1,15 @@ +/** + * Verifies that the edit post sidebar is opened, and if it is not, opens it. + * + * @return {Promise} Promise resolving once the edit post sidebar is opened. + */ +export async function ensureSidebarOpened() { + // This try/catch flow relies on the fact that `page.$eval` throws an error + // if the element matching the given selector does not exist. Thus, if an + // error is thrown, it can be inferred that the sidebar is not opened. + try { + return page.$eval( '.edit-post-sidebar', () => {} ); + } catch ( error ) { + return page.click( '.edit-post-header__settings [aria-label="Settings"]' ); + } +} diff --git a/test/e2e/support/utils/find-sidebar-panel-with-title.js b/test/e2e/support/utils/find-sidebar-panel-with-title.js new file mode 100644 index 00000000000000..fae3f33b53910a --- /dev/null +++ b/test/e2e/support/utils/find-sidebar-panel-with-title.js @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { first } from 'lodash'; + +/** + * Finds a sidebar panel with the provided title. + * + * @param {string} panelTitle The name of sidebar panel. + * + * @return {?ElementHandle} Object that represents an in-page DOM element. + */ +export async function findSidebarPanelWithTitle( panelTitle ) { + return first( await page.$x( `//div[@class="edit-post-sidebar"]//button[@class="components-button components-panel__body-toggle"][contains(text(),"${ panelTitle }")]` ) ); +} diff --git a/test/e2e/support/utils/get-all-blocks.js b/test/e2e/support/utils/get-all-blocks.js new file mode 100644 index 00000000000000..679baab96a5593 --- /dev/null +++ b/test/e2e/support/utils/get-all-blocks.js @@ -0,0 +1,11 @@ +/** + * Returns an array with all blocks; Equivalent to calling wp.data.select( 'core/editor' ).getBlocks(); + * + * @return {Promise} Promise resolving with an array containing all blocks in the document. + */ +export async function getAllBlocks() { + return await page.evaluate( () => { + const { select } = window.wp.data; + return select( 'core/editor' ).getBlocks(); + } ); +} diff --git a/test/e2e/support/utils/get-edited-post-content.js b/test/e2e/support/utils/get-edited-post-content.js new file mode 100644 index 00000000000000..2352eb6c519b5b --- /dev/null +++ b/test/e2e/support/utils/get-edited-post-content.js @@ -0,0 +1,25 @@ +/** + * Regular expression matching zero-width space characters. + * + * @type {RegExp} + */ +const REGEXP_ZWSP = /[\u200B\u200C\u200D\uFEFF]/; + +/** + * Returns a promise which resolves with the edited post content (HTML string). + * + * @return {Promise} Promise resolving with post content markup. + */ +export async function getEditedPostContent() { + const content = await page.evaluate( () => { + const { select } = window.wp.data; + return select( 'core/editor' ).getEditedPostContent(); + } ); + + // Globally guard against zero-width characters. + if ( REGEXP_ZWSP.test( content ) ) { + throw new Error( 'Unexpected zero-width space character in editor content.' ); + } + + return content; +} diff --git a/test/e2e/support/utils/get-json-response.js b/test/e2e/support/utils/get-json-response.js new file mode 100644 index 00000000000000..4d8581129de799 --- /dev/null +++ b/test/e2e/support/utils/get-json-response.js @@ -0,0 +1,12 @@ +/** + * Get a JSON response for the passed in object, for use with `request.respond`. + * + * @param {Object} obj Object to seralise for response. + * @return {Object} Response for use with `request.respond`. + */ +export function getJSONResponse( obj ) { + return { + content: 'application/json', + body: JSON.stringify( obj ), + }; +} diff --git a/test/e2e/support/utils/get-url.js b/test/e2e/support/utils/get-url.js new file mode 100644 index 00000000000000..edd5801036978a --- /dev/null +++ b/test/e2e/support/utils/get-url.js @@ -0,0 +1,16 @@ +/** + * Node dependencies + */ +import { join } from 'path'; +import { URL } from 'url'; + +import { WP_BASE_URL } from './config'; + +export function getUrl( WPPath, query = '' ) { + const url = new URL( WP_BASE_URL ); + + url.pathname = join( url.pathname, WPPath ); + url.search = query; + + return url.href; +} diff --git a/test/e2e/support/utils/go-to-wp-path.js b/test/e2e/support/utils/go-to-wp-path.js new file mode 100644 index 00000000000000..69c4bbeb7dba5b --- /dev/null +++ b/test/e2e/support/utils/go-to-wp-path.js @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { getUrl } from './get-url'; + +export async function goToWPPath( WPPath, query ) { + await page.goto( getUrl( WPPath, query ) ); +} diff --git a/test/e2e/support/utils/index.js b/test/e2e/support/utils/index.js new file mode 100644 index 00000000000000..a3f52f91ad79d4 --- /dev/null +++ b/test/e2e/support/utils/index.js @@ -0,0 +1,39 @@ +export { arePrePublishChecksEnabled } from './are-pre-publish-checks-enabled'; +export { clearLocalStorage } from './clear-local-storage'; +export { clickBlockAppender } from './click-block-appender'; +export { clickOnCloseModalButton } from './click-on-close-modal-button'; +export { clickOnMoreMenuItem } from './click-on-more-menu-item'; +export { convertBlock } from './convert-block'; +export { disablePrePublishChecks } from './disable-pre-publish-checks'; +export { enablePageDialogAccept } from './enable-page-dialog-accept'; +export { enablePrePublishChecks } from './enable-pre-publish-checks'; +export { ensureSidebarOpened } from './ensure-sidebar-opened'; +export { findSidebarPanelWithTitle } from './find-sidebar-panel-with-title'; +export { getAllBlocks } from './get-all-blocks'; +export { getEditedPostContent } from './get-edited-post-content'; +export { getUrl } from './get-url'; +export { insertBlock } from './insert-block'; +export { isEmbedding } from './is-embedding'; +export { JSONResponse } from './json-response'; +export { matchURL } from './match-url'; +export { mockOrTransform } from './mock-or-transform'; +export { newPost } from './new-post'; +export { observeFocusLoss } from './observe-focus-loss'; +export { openDocumentSettingsSidebar } from './open-document-settings-sidebar'; +export { openPublishPanel } from './open-publish-panel'; +export { pressTimes } from './press-times'; +export { pressWithModifier } from './press-with-modifier'; +export { publishPost } from './publish-post'; +export { publishPostWithoutPrePublishChecks } from './publish-post-without-pre-publish-checks'; +export { saveDraft } from './save-draft'; +export { searchForBlock } from './search-for-block'; +export { selectBlockByClientId } from './select-block-by-client-id'; +export { setPostContent } from './set-post-content'; +export { setViewport } from './set-viewport'; +export { setUpResponseMocking } from './set-up-response-mocking'; +export { switchToAdminUser } from './switch-to-admin-user'; +export { switchToEditor } from './switch-to-editor'; +export { switchToTestUser } from './switch-to-test-user'; +export { toggleOption } from './toggle-option'; +export { visitAdmin } from './visit-admin'; +export { waitForPageDimensions } from './wait-for-page-dimensions'; diff --git a/test/e2e/support/utils/insert-block.js b/test/e2e/support/utils/insert-block.js new file mode 100644 index 00000000000000..f32cb2da1aba87 --- /dev/null +++ b/test/e2e/support/utils/insert-block.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { searchForBlock } from './search-for-block'; + +/** + * Opens the inserter, searches for the given term, then selects the first + * result that appears. + * + * @param {string} searchTerm The text to search the inserter for. + * @param {string} panelName The inserter panel to open (if it's closed by default). + */ +export async function insertBlock( searchTerm, panelName = null ) { + await searchForBlock( searchTerm ); + if ( panelName ) { + const panelButton = ( await page.$x( + `//button[contains(text(), '${ panelName }')]` + ) )[ 0 ]; + await panelButton.click(); + } + await page.click( `button[aria-label="${ searchTerm }"]` ); +} diff --git a/test/e2e/support/utils/is-embedding.js b/test/e2e/support/utils/is-embedding.js new file mode 100644 index 00000000000000..a3b9b158102497 --- /dev/null +++ b/test/e2e/support/utils/is-embedding.js @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { matchURL } from './match-url'; +import { parameterEquals } from './parameter-equals'; + +/** + * Creates a function to determine if a request is embedding a certain URL. + * + * @param {string} url The URL to check against a request. + * @return {function} Function that determines if a request is for the embed API, embedding a specific URL. + */ +export function isEmbedding( url ) { + return ( request ) => + matchURL( 'oembed%2F1.0%2Fproxy' )( request ) && + parameterEquals( 'url', url )( request ); +} diff --git a/test/e2e/support/utils/is-wp-path.js b/test/e2e/support/utils/is-wp-path.js new file mode 100644 index 00000000000000..ecd1695fb52b66 --- /dev/null +++ b/test/e2e/support/utils/is-wp-path.js @@ -0,0 +1,17 @@ +/** + * Node dependencies + */ +import { URL } from 'url'; + +/** + * Internal dependencies + */ +import { getUrl } from './get-url'; + +export function isWPPath( WPPath, query = '' ) { + const currentUrl = new URL( page.url() ); + + currentUrl.search = query; + + return getUrl( WPPath ) === currentUrl.href; +} diff --git a/test/e2e/support/utils/json-response.js b/test/e2e/support/utils/json-response.js new file mode 100644 index 00000000000000..0e3dc5e256ba65 --- /dev/null +++ b/test/e2e/support/utils/json-response.js @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { getJSONResponse } from './get-json-response'; + +/** + * Respond to a request with a JSON response. + * + * @param {string} mockResponse The mock object to wrap in a JSON response. + * @return {Promise} Promise that responds to a request with the mock JSON response. + */ +export function JSONResponse( mockResponse ) { + return async ( request ) => request.respond( getJSONResponse( mockResponse ) ); +} diff --git a/test/e2e/support/utils/login.js b/test/e2e/support/utils/login.js new file mode 100644 index 00000000000000..19b3e99d727271 --- /dev/null +++ b/test/e2e/support/utils/login.js @@ -0,0 +1,17 @@ + +/** + * Internal dependencies + */ +import { WP_USERNAME, WP_PASSWORD } from './config'; +import { pressWithModifier } from './press-with-modifier'; + +export async function login( username = WP_USERNAME, password = WP_PASSWORD ) { + await page.focus( '#user_login' ); + await pressWithModifier( 'primary', 'a' ); + await page.type( '#user_login', username ); + await page.focus( '#user_pass' ); + await pressWithModifier( 'primary', 'a' ); + await page.type( '#user_pass', password ); + + await Promise.all( [ page.waitForNavigation(), page.click( '#wp-submit' ) ] ); +} diff --git a/test/e2e/support/utils/match-url.js b/test/e2e/support/utils/match-url.js new file mode 100644 index 00000000000000..c7f2bda330b8e1 --- /dev/null +++ b/test/e2e/support/utils/match-url.js @@ -0,0 +1,9 @@ +/** + * Creates a function to determine if a request is calling a URL with the substring present. + * + * @param {string} substring The substring to check for. + * @return {function} Function that determines if a request's URL contains substring. + */ +export function matchURL( substring ) { + return ( request ) => -1 !== request.url().indexOf( substring ); +} diff --git a/test/e2e/support/utils/mock-or-transform.js b/test/e2e/support/utils/mock-or-transform.js new file mode 100644 index 00000000000000..4a97a4d41e9bcd --- /dev/null +++ b/test/e2e/support/utils/mock-or-transform.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import fetch from 'node-fetch'; + +/** + * Internal dependencies + */ +import { getJSONResponse } from './get-json-response'; + +/** + * Mocks a request with the supplied mock object, or allows it to run with an optional transform, based on the + * deserialised JSON response for the request. + * + * @param {function} mockCheck function that returns true if the request should be mocked. + * @param {Object} mock A mock object to wrap in a JSON response, if the request should be mocked. + * @param {function|undefined} responseObjectTransform An optional function that transforms the response's object before the response is used. + * @return {Promise} Promise that uses `mockCheck` to see if a request should be mocked with `mock`, and optionally transforms the response with `responseObjectTransform`. + */ +export function mockOrTransform( + mockCheck, + mock, + responseObjectTransform = ( obj ) => obj +) { + return async ( request ) => { + // Because we can't get the responses to requests and modify them on the fly, + // we have to make our own request and get the response, then apply the + // optional transform to the json encoded object. + const response = await fetch( request.url(), { + headers: request.headers(), + method: request.method(), + body: request.postData(), + } ); + const responseObject = await response.json(); + if ( mockCheck( responseObject ) ) { + request.respond( getJSONResponse( mock ) ); + } else { + request.respond( getJSONResponse( responseObjectTransform( responseObject ) ) ); + } + }; +} diff --git a/test/e2e/support/utils/new-post.js b/test/e2e/support/utils/new-post.js new file mode 100644 index 00000000000000..cdc229fa73f73f --- /dev/null +++ b/test/e2e/support/utils/new-post.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { visitAdmin } from './visit-admin'; + +/** + * Creates new post. + * + * @param {Object} obj Object to create new post, along with tips enabling option. + */ +export async function newPost( { + postType, + title, + content, + excerpt, + enableTips = false, +} = {} ) { + const query = addQueryArgs( '', { + post_type: postType, + post_title: title, + content, + excerpt, + } ).slice( 1 ); + await visitAdmin( 'post-new.php', query ); + + await page.evaluate( ( _enableTips ) => { + const action = _enableTips ? 'enableTips' : 'disableTips'; + wp.data.dispatch( 'core/nux' )[ action ](); + }, enableTips ); + + if ( enableTips ) { + await page.reload(); + } +} diff --git a/test/e2e/support/utils/observe-focus-loss.js b/test/e2e/support/utils/observe-focus-loss.js new file mode 100644 index 00000000000000..13e541de27a8fe --- /dev/null +++ b/test/e2e/support/utils/observe-focus-loss.js @@ -0,0 +1,15 @@ +/** + * Binds to the document on page load which throws an error if a `focusout` + * event occurs without a related target (i.e. focus loss). + */ +export function observeFocusLoss() { + page.on( 'load', () => { + page.evaluate( () => { + document.body.addEventListener( 'focusout', ( event ) => { + if ( ! event.relatedTarget ) { + throw new Error( 'Unexpected focus loss' ); + } + } ); + } ); + } ); +} diff --git a/test/e2e/support/utils/open-document-settings-sidebar.js b/test/e2e/support/utils/open-document-settings-sidebar.js new file mode 100644 index 00000000000000..ab6f797a4bacca --- /dev/null +++ b/test/e2e/support/utils/open-document-settings-sidebar.js @@ -0,0 +1,12 @@ +/** + * Clicks on the button in the header which opens Document Settings sidebar when it is closed. + */ +export async function openDocumentSettingsSidebar() { + const openButton = await page.$( + '.edit-post-header__settings button[aria-label="Settings"][aria-expanded="false"]' + ); + + if ( openButton ) { + await openButton.click(); + } +} diff --git a/test/e2e/support/utils/open-publish-panel.js b/test/e2e/support/utils/open-publish-panel.js new file mode 100644 index 00000000000000..ec2441c9abda9a --- /dev/null +++ b/test/e2e/support/utils/open-publish-panel.js @@ -0,0 +1,11 @@ +/** + * Opens the publish panel. + */ +export async function openPublishPanel() { + await page.click( '.editor-post-publish-panel__toggle' ); + + // Disable reason: Wait for the animation to complete, since otherwise the + // click attempt may occur at the wrong point. + // eslint-disable-next-line no-restricted-syntax + await page.waitFor( 100 ); +} diff --git a/test/e2e/support/utils/parameter-equals.js b/test/e2e/support/utils/parameter-equals.js new file mode 100644 index 00000000000000..85c46566afc30f --- /dev/null +++ b/test/e2e/support/utils/parameter-equals.js @@ -0,0 +1,17 @@ +/** + * Creates a function to determine if a request has a parameter with a certain value. + * + * @param {string} parameterName The query parameter to check. + * @param {string} value The value to check for. + * @return {function} Function that determines if a request's query parameter is the specified value. + */ +export function parameterEquals( parameterName, value ) { + return ( request ) => { + const url = request.url(); + const match = new RegExp( `.*${ parameterName }=([^&]+).*` ).exec( url ); + if ( ! match ) { + return false; + } + return value === decodeURIComponent( match[ 1 ] ); + }; +} diff --git a/test/e2e/support/utils/press-times.js b/test/e2e/support/utils/press-times.js new file mode 100644 index 00000000000000..2e406fd31a0248 --- /dev/null +++ b/test/e2e/support/utils/press-times.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { times } from 'lodash'; + +/** + * Internal dependencies + */ +import { promiseSequence } from './promise-sequence'; + +/** + * Presses the given keyboard key a number of times in sequence. + * + * @param {string} key Key to press. + * @param {number} count Number of times to press. + * + * @return {Promise} Promise resolving when key presses complete. + */ +export async function pressTimes( key, count ) { + return promiseSequence( times( count, () => () => page.keyboard.press( key ) ) ); +} diff --git a/test/e2e/support/utils/press-with-modifier.js b/test/e2e/support/utils/press-with-modifier.js new file mode 100644 index 00000000000000..ec274dfd39766b --- /dev/null +++ b/test/e2e/support/utils/press-with-modifier.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { capitalize } from 'lodash'; + +/** + * WordPress dependencies + */ +import { modifiers, SHIFT, ALT, CTRL } from '@wordpress/keycodes'; + +/** + * Performs a key press with modifier (Shift, Control, Meta, Alt), where each modifier + * is normalized to platform-specific modifier. + * + * @param {string} modifier Modifier key. + * @param {string} key Key to press while modifier held. + */ +export async function pressWithModifier( modifier, key ) { + const isAppleOS = () => process.platform === 'darwin'; + const overWrittenModifiers = { + ...modifiers, + shiftAlt: ( _isApple ) => _isApple() ? [ SHIFT, ALT ] : [ SHIFT, CTRL ], + }; + const mappedModifiers = overWrittenModifiers[ modifier ]( isAppleOS ); + const ctrlSwap = ( mod ) => mod === CTRL ? 'control' : mod; + + await Promise.all( + mappedModifiers.map( async ( mod ) => { + const capitalizedMod = capitalize( ctrlSwap( mod ) ); + return page.keyboard.down( capitalizedMod ); + } ) + ); + + await page.keyboard.press( key ); + + await Promise.all( + mappedModifiers.map( async ( mod ) => { + const capitalizedMod = capitalize( ctrlSwap( mod ) ); + return page.keyboard.up( capitalizedMod ); + } ) + ); +} diff --git a/test/e2e/support/utils/promise-sequence.js b/test/e2e/support/utils/promise-sequence.js new file mode 100644 index 00000000000000..b8e77ba9eb19fd --- /dev/null +++ b/test/e2e/support/utils/promise-sequence.js @@ -0,0 +1,14 @@ +/** + * Given an array of functions, each returning a promise, performs all + * promises in sequence (waterfall) order. + * + * @param {Function[]} sequence Array of promise creators. + * + * @return {Promise} Promise resolving once all in the sequence complete. + */ +export async function promiseSequence( sequence ) { + return sequence.reduce( + ( current, next ) => current.then( next ), + Promise.resolve() + ); +} diff --git a/test/e2e/support/utils/publish-post-without-pre-publish-checks.js b/test/e2e/support/utils/publish-post-without-pre-publish-checks.js new file mode 100644 index 00000000000000..b2b550ad0a840c --- /dev/null +++ b/test/e2e/support/utils/publish-post-without-pre-publish-checks.js @@ -0,0 +1,10 @@ +/** + * Publishes the post without the pre-publish checks, + * resolving once the request is complete (once a notice is displayed). + * + * @return {Promise} Promise resolving when publish is complete. + */ +export async function publishPostWithoutPrePublishChecks() { + await page.click( '.editor-post-publish-button' ); + return page.waitForSelector( '.components-notice.is-success' ); +} diff --git a/test/e2e/support/utils/publish-post.js b/test/e2e/support/utils/publish-post.js new file mode 100644 index 00000000000000..106e6c2f9abfb9 --- /dev/null +++ b/test/e2e/support/utils/publish-post.js @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import { openPublishPanel } from './open-publish-panel'; + +/** + * Publishes the post, resolving once the request is complete (once a notice + * is displayed). + * + * @return {Promise} Promise resolving when publish is complete. + */ +export async function publishPost() { + await openPublishPanel(); + + // Publish the post + await page.click( '.editor-post-publish-button' ); + + // A success notice should show up + return page.waitForSelector( '.components-notice.is-success' ); +} diff --git a/test/e2e/support/utils/save-draft.js b/test/e2e/support/utils/save-draft.js new file mode 100644 index 00000000000000..aa914c2e577e31 --- /dev/null +++ b/test/e2e/support/utils/save-draft.js @@ -0,0 +1,10 @@ +/** + * Saves the post as a draft, resolving once the request is complete (once the + * "Saved" indicator is displayed). + * + * @return {Promise} Promise resolving when draft save is complete. + */ +export async function saveDraft() { + await page.click( '.editor-post-save-draft' ); + return page.waitForSelector( '.editor-post-saved-state.is-saved' ); +} diff --git a/test/e2e/support/utils/search-for-block.js b/test/e2e/support/utils/search-for-block.js new file mode 100644 index 00000000000000..e96b92839221ac --- /dev/null +++ b/test/e2e/support/utils/search-for-block.js @@ -0,0 +1,12 @@ +/** + * Search for block in the global inserter + * + * @param {string} searchTerm The text to search the inserter for. + */ +export async function searchForBlock( searchTerm ) { + await page.click( '.edit-post-header [aria-label="Add block"]' ); + // Waiting here is necessary because sometimes the inserter takes more time to + // render than Puppeteer takes to complete the 'click' action + await page.waitForSelector( '.editor-inserter__menu' ); + await page.keyboard.type( searchTerm ); +} diff --git a/test/e2e/support/utils/select-block-by-client-id.js b/test/e2e/support/utils/select-block-by-client-id.js new file mode 100644 index 00000000000000..cf7e4624d621fc --- /dev/null +++ b/test/e2e/support/utils/select-block-by-client-id.js @@ -0,0 +1,10 @@ +/** + * Given the clientId of a block, selects the block on the editor. + * + * @param {string} clientId Identified of the block. + */ +export async function selectBlockByClientId( clientId ) { + await page.evaluate( ( id ) => { + wp.data.dispatch( 'core/editor' ).selectBlock( id ); + }, clientId ); +} diff --git a/test/e2e/support/utils/set-post-content.js b/test/e2e/support/utils/set-post-content.js new file mode 100644 index 00000000000000..62398b646fc532 --- /dev/null +++ b/test/e2e/support/utils/set-post-content.js @@ -0,0 +1,13 @@ +/** + * Sets code editor content + * @param {string} content New code editor content. + * + * @return {Promise} Promise resolving with an array containing all blocks in the document. + */ +export async function setPostContent( content ) { + return await page.evaluate( ( _content ) => { + const { dispatch } = window.wp.data; + const blocks = wp.blocks.parse( _content ); + dispatch( 'core/editor' ).resetBlocks( blocks ); + }, content ); +} diff --git a/test/e2e/support/utils/set-up-response-mocking.js b/test/e2e/support/utils/set-up-response-mocking.js new file mode 100644 index 00000000000000..4384d634953203 --- /dev/null +++ b/test/e2e/support/utils/set-up-response-mocking.js @@ -0,0 +1,35 @@ +/** + * Sets up mock checks and responses. Accepts a list of mock settings with the following properties: + * - match: function to check if a request should be mocked. + * - onRequestMatch: async function to respond to the request. + * + * Example: + * const MOCK_RESPONSES = [ + * { + * match: isEmbedding( 'https://wordpress.org/gutenberg/handbook/' ), + * onRequestMatch: JSONResponse( MOCK_BAD_WORDPRESS_RESPONSE ), + * }, + * { + * match: isEmbedding( 'https://wordpress.org/gutenberg/handbook/block-api/attributes/' ), + * onRequestMatch: JSONResponse( MOCK_EMBED_WORDPRESS_SUCCESS_RESPONSE ), + * } + * ]; + * setUpResponseMocking( MOCK_RESPONSES ); + * + * If none of the mock settings match the request, the request is allowed to continue. + * + * @param {Array} mocks Array of mock settings. + */ +export async function setUpResponseMocking( mocks ) { + await page.setRequestInterception( true ); + page.on( 'request', async ( request ) => { + for ( let i = 0; i < mocks.length; i++ ) { + const mock = mocks[ i ]; + if ( mock.match( request ) ) { + await mock.onRequestMatch( request ); + return; + } + } + request.continue(); + } ); +} diff --git a/test/e2e/support/utils/set-viewport.js b/test/e2e/support/utils/set-viewport.js new file mode 100644 index 00000000000000..75de557e795bdf --- /dev/null +++ b/test/e2e/support/utils/set-viewport.js @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { waitForPageDimensions } from './wait-for-page-dimensions'; + +export async function setViewport( type ) { + const allowedDimensions = { + large: { width: 960, height: 700 }, + small: { width: 600, height: 700 }, + }; + const currentDimension = allowedDimensions[ type ]; + await page.setViewport( currentDimension ); + await waitForPageDimensions( currentDimension.width, currentDimension.height ); +} diff --git a/test/e2e/support/utils/switch-to-admin-user.js b/test/e2e/support/utils/switch-to-admin-user.js new file mode 100644 index 00000000000000..a98401ee762970 --- /dev/null +++ b/test/e2e/support/utils/switch-to-admin-user.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import { goToWPPath } from './go-to-wp-path'; +import { login } from './login'; +import { WP_USERNAME, WP_ADMIN_USER } from './config'; + +/** + * Switches the current user to the admin user (if the user + * running the test is not already the admin user). + */ +export async function switchToAdminUser() { + if ( WP_USERNAME === WP_ADMIN_USER.username ) { + return; + } + await goToWPPath( 'wp-login.php' ); + await login( WP_ADMIN_USER.username, WP_ADMIN_USER.password ); +} diff --git a/test/e2e/support/utils/switch-to-editor.js b/test/e2e/support/utils/switch-to-editor.js new file mode 100644 index 00000000000000..2e73a5c9bc4af7 --- /dev/null +++ b/test/e2e/support/utils/switch-to-editor.js @@ -0,0 +1,9 @@ +export async function switchToEditor( mode ) { + await page.click( + '.edit-post-more-menu [aria-label="Show more tools & options"]' + ); + const [ button ] = await page.$x( + `//button[contains(text(), '${ mode } Editor')]` + ); + await button.click( 'button' ); +} diff --git a/test/e2e/support/utils/switch-to-test-user.js b/test/e2e/support/utils/switch-to-test-user.js new file mode 100644 index 00000000000000..3eccb9db03924b --- /dev/null +++ b/test/e2e/support/utils/switch-to-test-user.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import { goToWPPath } from './go-to-wp-path'; +import { login } from './login'; +import { WP_USERNAME, WP_ADMIN_USER } from './config'; + +/** + * Switches the current user to whichever user we should be + * running the tests as (if we're not already that user). + */ +export async function switchToTestUser() { + if ( WP_USERNAME === WP_ADMIN_USER.username ) { + return; + } + await goToWPPath( 'wp-login.php' ); + await login(); +} diff --git a/test/e2e/support/utils/toggle-option.js b/test/e2e/support/utils/toggle-option.js new file mode 100644 index 00000000000000..8da5a8122d27f3 --- /dev/null +++ b/test/e2e/support/utils/toggle-option.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { clickOnMoreMenuItem } from './click-on-more-menu-item'; + +/** + * Toggles the screen option with the given label. + * + * @param {string} label The label of the screen option, e.g. 'Show Tips'. + * @param {?boolean} shouldBeChecked If true, turns the option on. If false, off. If + * undefined, the option will be toggled. + */ +export async function toggleOption( label, shouldBeChecked = undefined ) { + await clickOnMoreMenuItem( 'Options' ); + const [ handle ] = await page.$x( `//label[contains(text(), "${ label }")]` ); + + const isChecked = await page.evaluate( + ( element ) => element.control.checked, + handle + ); + if ( isChecked !== shouldBeChecked ) { + await handle.click(); + } + + await page.click( 'button[aria-label="Close dialog"]' ); +} diff --git a/test/e2e/support/utils/visit-admin.js b/test/e2e/support/utils/visit-admin.js new file mode 100644 index 00000000000000..ac744cca3b94a4 --- /dev/null +++ b/test/e2e/support/utils/visit-admin.js @@ -0,0 +1,20 @@ +/** + * Node dependencies + */ +import { join } from 'path'; + +/** + * Internal dependencies + */ +import { goToWPPath } from './go-to-wp-path'; +import { isWPPath } from './is-wp-path'; +import { login } from './login'; + +export async function visitAdmin( adminPath, query ) { + await goToWPPath( join( 'wp-admin', adminPath ), query ); + + if ( isWPPath( 'wp-login.php' ) ) { + await login(); + return visitAdmin( adminPath, query ); + } +} diff --git a/test/e2e/support/utils/wait-for-page-dimensions.js b/test/e2e/support/utils/wait-for-page-dimensions.js new file mode 100644 index 00000000000000..023fc43522d662 --- /dev/null +++ b/test/e2e/support/utils/wait-for-page-dimensions.js @@ -0,0 +1,16 @@ +/** + * Function that waits until the page viewport has the required dimensions. + * It is being used to address a problem where after using setViewport the execution may continue, + * without the new dimensions being applied. + * https://github.com/GoogleChrome/puppeteer/issues/1751 + * + * @param {number} width Width of the window. + * @param {height} height Height of the window. + */ +export async function waitForPageDimensions( width, height ) { + await page + .mainFrame() + .waitForFunction( + `window.innerWidth === ${ width } && window.innerHeight === ${ height }` + ); +} diff --git a/test/e2e/test-plugins/inner-blocks-templates/index.js b/test/e2e/test-plugins/inner-blocks-templates/index.js index 2c478968dea90b..0922fe106cecf2 100644 --- a/test/e2e/test-plugins/inner-blocks-templates/index.js +++ b/test/e2e/test-plugins/inner-blocks-templates/index.js @@ -10,6 +10,13 @@ } ], ]; + var TEMPLATE_PARAGRAPH_PLACEHOLDER = [ + [ 'core/paragraph', { + fontSize: 'large', + placeholder: 'Content…', + } ], + ]; + var save = function() { return el( InnerBlocks.Content ); }; @@ -48,4 +55,21 @@ save, } ); + + registerBlockType( 'test/test-inner-blocks-paragraph-placeholder', { + title: 'Test Inner Blocks Paragraph Placeholder', + icon: 'cart', + category: 'common', + + edit: function( props ) { + return el( + InnerBlocks, + { + template: TEMPLATE_PARAGRAPH_PLACEHOLDER, + } + ); + }, + + save, + } ); } )(); diff --git a/test/e2e/test-plugins/plugins-api.php b/test/e2e/test-plugins/plugins-api.php index d219eab684f955..64a8cb23644480 100644 --- a/test/e2e/test-plugins/plugins-api.php +++ b/test/e2e/test-plugins/plugins-api.php @@ -50,3 +50,21 @@ filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/sidebar.js' ), true ); + +wp_enqueue_script( + 'gutenberg-test-annotations-sidebar', + plugins_url( 'plugins-api/annotations-sidebar.js', __FILE__ ), + array( + 'wp-components', + 'wp-compose', + 'wp-data', + 'wp-edit-post', + 'wp-editor', + 'wp-element', + 'wp-i18n', + 'wp-plugins', + 'wp-annotations', + ), + filemtime( plugin_dir_path( __FILE__ ) . 'plugins-api/annotations-sidebar.js' ), + true +); diff --git a/test/e2e/test-plugins/plugins-api/annotations-sidebar.js b/test/e2e/test-plugins/plugins-api/annotations-sidebar.js new file mode 100644 index 00000000000000..5dfa0e6a52888b --- /dev/null +++ b/test/e2e/test-plugins/plugins-api/annotations-sidebar.js @@ -0,0 +1,121 @@ +( function() { + var Button = wp.components.Button; + var PanelBody = wp.components.PanelBody; + var PanelRow = wp.components.PanelRow; + var compose = wp.compose.compose; + var withDispatch = wp.data.withDispatch; + var withSelect = wp.data.withSelect; + var select = wp.data.select; + var dispatch = wp.data.dispatch; + var PlainText = wp.editor.PlainText; + var Fragment = wp.element.Fragment; + var el = wp.element.createElement; + var Component = wp.element.Component; + var __ = wp.i18n.__; + var registerPlugin = wp.plugins.registerPlugin; + var PluginSidebar = wp.editPost.PluginSidebar; + var PluginSidebarMoreMenuItem = wp.editPost.PluginSidebarMoreMenuItem; + + class SidebarContents extends Component { + constructor( props ) { + super( props ); + + this.state = { + start: 0, + end: 0, + } + } + + render() { + return el( + PanelBody, + {}, + el( + 'input', + { + type: 'number', + id: 'annotations-tests-range-start', + onChange: ( reactEvent ) => { + this.setState( { + start: reactEvent.target.value, + } ); + }, + value: this.state.start, + } + ), + el( + 'input', + { + type: 'number', + id: 'annotations-tests-range-end', + onChange: ( reactEvent ) => { + this.setState( { + end: reactEvent.target.value, + } ); + }, + value: this.state.end, + } + ), + el( + Button, + { + isPrimary: true, + onClick: () => { + dispatch( 'core/annotations' ).__experimentalAddAnnotation( { + source: 'e2e-tests', + blockClientId: select( 'core/editor' ).getBlockOrder()[ 0 ], + richTextIdentifier: 'content', + range: { + start: parseInt( this.state.start, 10 ), + end: parseInt( this.state.end, 10 ), + }, + } ); + }, + }, + __( 'Add annotation' ) + ), + el( + Button, + { + isPrimary: true, + onClick: () => { + dispatch( 'core/annotations' ).__experimentalRemoveAnnotationsBySource( 'e2e-tests' ); + } + }, + + __( 'Remove annotations' ) + ) + ); + } + } + + function AnnotationsSidebar() { + return el( + Fragment, + {}, + el( + PluginSidebar, + { + name: 'annotations-sidebar', + title: __( 'Annotations Sidebar' ) + }, + el( + SidebarContents, + {} + ) + ), + el( + PluginSidebarMoreMenuItem, + { + target: 'annotations-sidebar' + }, + __( 'Annotations Sidebar' ) + ) + ); + } + + registerPlugin( 'annotations-sidebar', { + icon: 'text', + render: AnnotationsSidebar + } ); +} )(); diff --git a/test/e2e/test-plugins/plugins-api/sidebar.js b/test/e2e/test-plugins/plugins-api/sidebar.js index c97d29c754f23a..22038c81c4c928 100644 --- a/test/e2e/test-plugins/plugins-api/sidebar.js +++ b/test/e2e/test-plugins/plugins-api/sidebar.js @@ -50,24 +50,6 @@ }, __( 'Reset' ) ) - ), - el( - Button, - { - isPrimary: true, - onClick: () => { - dispatch( 'core/annotations' ).__experimentalAddAnnotation( { - source: 'e2e-tests', - blockClientId: select( 'core/editor' ).getBlockOrder()[ 0 ], - richTextIdentifier: 'content', - range: { - start: 9, - end: 13, - }, - } ); - }, - }, - __( 'Add annotation' ) ) ); } diff --git a/test/integration/__snapshots__/blocks-raw-handling.spec.js.snap b/test/integration/__snapshots__/blocks-raw-handling.spec.js.snap index 84028b7c5f539b..320b201a836e3c 100644 --- a/test/integration/__snapshots__/blocks-raw-handling.spec.js.snap +++ b/test/integration/__snapshots__/blocks-raw-handling.spec.js.snap @@ -29,7 +29,28 @@ exports[`Blocks raw handling rawHandler should convert HTML post to blocks with <h3>Shortcode</h3> <!-- /wp:heading --> -<!-- wp:gallery {\\"columns\\":3,\\"linkTo\\":\\"attachment\\"} --> +<!-- wp:gallery {\\"ids\\":[1],\\"columns\\":3,\\"linkTo\\":\\"attachment\\"} --> <ul class=\\"wp-block-gallery columns-3 is-cropped\\"><li class=\\"blocks-gallery-item\\"><figure><img data-id=\\"1\\" class=\\"wp-image-1\\"/></figure></li></ul> -<!-- /wp:gallery -->" +<!-- /wp:gallery --> + +<!-- wp:html --> +<dl> + <dt>Term</dt> + <dd> + Description. + </dd> +</dl> +<!-- /wp:html --> + +<!-- wp:list {\\"ordered\\":true} --> +<ol><li>Item</li></ol> +<!-- /wp:list --> + +<!-- wp:quote --> +<blockquote class=\\"wp-block-quote\\"><p>Text.</p></blockquote> +<!-- /wp:quote --> + +<!-- wp:html --> +<blockquote><h1>Heading</h1><p>Text.</p></blockquote> +<!-- /wp:html -->" `; diff --git a/test/integration/fixtures/wordpress-convert.html b/test/integration/fixtures/wordpress-convert.html index 1cb01568c37059..0fe1de21dc6621 100644 --- a/test/integration/fixtures/wordpress-convert.html +++ b/test/integration/fixtures/wordpress-convert.html @@ -6,3 +6,33 @@ <h3>More tag</h3> <p><!--more--></p> <h3>Shortcode</h3> <p>[gallery ids="1"]</p> + +<!-- HTML tags without block equivalent --> + +<dl> + <dt>Term</dt> + <dd> + Description. + </dd> +</dl> + +<!-- Invalid list --> + +<ol> + <ol> + <li>Item</li> + </ol> +</ol> + +<!-- Quote with paragraphs --> + +<blockquote> + <p>Text.</p> +</blockquote> + +<!-- Quote with more than paragraphs --> + +<blockquote> + <h1>Heading</h1> + <p>Text.</p> +</blockquote> diff --git a/test/integration/fixtures/wordpress-out.html b/test/integration/fixtures/wordpress-out.html index b1d5977395c22f..e179380c983daa 100644 --- a/test/integration/fixtures/wordpress-out.html +++ b/test/integration/fixtures/wordpress-out.html @@ -18,6 +18,6 @@ <h3>More tag</h3> <h3>Shortcode</h3> <!-- /wp:heading --> -<!-- wp:gallery {"columns":3,"linkTo":"attachment"} --> +<!-- wp:gallery {"ids":[1],"columns":3,"linkTo":"attachment"} --> <ul class="wp-block-gallery columns-3 is-cropped"><li class="blocks-gallery-item"><figure><img data-id="1" class="wp-image-1"/></figure></li></ul> <!-- /wp:gallery --> diff --git a/test/integration/full-content/fixtures/core__audio.parsed.json b/test/integration/full-content/fixtures/core__audio.parsed.json index 6b0acbd0c4a1fb..8024c293a5498f 100644 --- a/test/integration/full-content/fixtures/core__audio.parsed.json +++ b/test/integration/full-content/fixtures/core__audio.parsed.json @@ -6,13 +6,17 @@ }, "innerBlocks": [], "innerHTML": "\n<figure class=\"wp-block-audio alignright\">\n <audio controls=\"\" src=\"https://media.simplecast.com/episodes/audio/80564/draft-podcast-51-livePublish2.mp3\"></audio>\n</figure>\n", - "innerContent": [ "\n<figure class=\"wp-block-audio alignright\">\n <audio controls=\"\" src=\"https://media.simplecast.com/episodes/audio/80564/draft-podcast-51-livePublish2.mp3\"></audio>\n</figure>\n" ] + "innerContent": [ + "\n<figure class=\"wp-block-audio alignright\">\n <audio controls=\"\" src=\"https://media.simplecast.com/episodes/audio/80564/draft-podcast-51-livePublish2.mp3\"></audio>\n</figure>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__block.parsed.json b/test/integration/full-content/fixtures/core__block.parsed.json index 33c4d86f6c6c7c..4dda73b389eeb1 100644 --- a/test/integration/full-content/fixtures/core__block.parsed.json +++ b/test/integration/full-content/fixtures/core__block.parsed.json @@ -13,6 +13,8 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__button__center.parsed.json b/test/integration/full-content/fixtures/core__button__center.parsed.json index 352767e1c1a8f3..45ed5130cbf15e 100644 --- a/test/integration/full-content/fixtures/core__button__center.parsed.json +++ b/test/integration/full-content/fixtures/core__button__center.parsed.json @@ -6,13 +6,17 @@ }, "innerBlocks": [], "innerHTML": "\n<div class=\"wp-block-button aligncenter\"><a class=\"wp-block-button__link\" href=\"https://github.com/WordPress/gutenberg\">Help build Gutenberg</a></div>\n", - "innerContent": [ "\n<div class=\"wp-block-button aligncenter\"><a class=\"wp-block-button__link\" href=\"https://github.com/WordPress/gutenberg\">Help build Gutenberg</a></div>\n" ] + "innerContent": [ + "\n<div class=\"wp-block-button aligncenter\"><a class=\"wp-block-button__link\" href=\"https://github.com/WordPress/gutenberg\">Help build Gutenberg</a></div>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__categories.parsed.json b/test/integration/full-content/fixtures/core__categories.parsed.json index 60d03d7bc50625..102791b85219e0 100644 --- a/test/integration/full-content/fixtures/core__categories.parsed.json +++ b/test/integration/full-content/fixtures/core__categories.parsed.json @@ -15,6 +15,8 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__code.parsed.json b/test/integration/full-content/fixtures/core__code.parsed.json index d9bf0a215e82b8..4aa3fbe8c40d47 100644 --- a/test/integration/full-content/fixtures/core__code.parsed.json +++ b/test/integration/full-content/fixtures/core__code.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<pre class=\"wp-block-code\"><code>export default function MyButton() {\n\treturn &lt;Button&gt;Click Me!&lt;/Button&gt;;\n}</code></pre>\n", - "innerContent": [ "\n<pre class=\"wp-block-code\"><code>export default function MyButton() {\n\treturn &lt;Button&gt;Click Me!&lt;/Button&gt;;\n}</code></pre>\n" ] + "innerContent": [ + "\n<pre class=\"wp-block-code\"><code>export default function MyButton() {\n\treturn &lt;Button&gt;Click Me!&lt;/Button&gt;;\n}</code></pre>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__column.parsed.json b/test/integration/full-content/fixtures/core__column.parsed.json index 10f1e1a07cf05b..91b566bf7e5c42 100644 --- a/test/integration/full-content/fixtures/core__column.parsed.json +++ b/test/integration/full-content/fixtures/core__column.parsed.json @@ -8,24 +8,36 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n\t<p>Column One, Paragraph One</p>\n\t", - "innerContent": [ "\n\t<p>Column One, Paragraph One</p>\n\t" ] + "innerContent": [ + "\n\t<p>Column One, Paragraph One</p>\n\t" + ] }, { "blockName": "core/paragraph", "attrs": {}, "innerBlocks": [], "innerHTML": "\n\t<p>Column One, Paragraph Two</p>\n\t", - "innerContent": [ "\n\t<p>Column One, Paragraph Two</p>\n\t" ] + "innerContent": [ + "\n\t<p>Column One, Paragraph Two</p>\n\t" + ] } ], "innerHTML": "\n<div class=\"wp-block-column\">\n\t\n\t\n</div>\n", - "innerContent": [ "\n<div class=\"wp-block-column\">\n\t", null, "\n\t", null, "\n</div>\n" ] + "innerContent": [ + "\n<div class=\"wp-block-column\">\n\t", + null, + "\n\t", + null, + "\n</div>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__gallery.html b/test/integration/full-content/fixtures/core__gallery.html index 5e48c7e66351fb..a5842fd7dd581b 100644 --- a/test/integration/full-content/fixtures/core__gallery.html +++ b/test/integration/full-content/fixtures/core__gallery.html @@ -1,4 +1,4 @@ -<!-- wp:core/gallery --> +<!-- wp:core/gallery {"ids":[null,null]} --> <ul class="wp-block-gallery columns-2 is-cropped"> <li class="blocks-gallery-item"> <figure> diff --git a/test/integration/full-content/fixtures/core__gallery.json b/test/integration/full-content/fixtures/core__gallery.json index 8cfcc26d3b37a0..1e87ee42c8702f 100644 --- a/test/integration/full-content/fixtures/core__gallery.json +++ b/test/integration/full-content/fixtures/core__gallery.json @@ -16,6 +16,10 @@ "caption": "" } ], + "ids": [ + null, + null + ], "imageCrop": true, "linkTo": "none" }, diff --git a/test/integration/full-content/fixtures/core__gallery.parsed.json b/test/integration/full-content/fixtures/core__gallery.parsed.json index fc5c9e17d6890b..e070b1f70b5f63 100644 --- a/test/integration/full-content/fixtures/core__gallery.parsed.json +++ b/test/integration/full-content/fixtures/core__gallery.parsed.json @@ -1,7 +1,12 @@ [ { "blockName": "core/gallery", - "attrs": {}, + "attrs": { + "ids": [ + null, + null + ] + }, "innerBlocks": [], "innerHTML": "\n<ul class=\"wp-block-gallery columns-2 is-cropped\">\n\t<li class=\"blocks-gallery-item\">\n\t\t<figure>\n\t\t\t<img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"title\" />\n\t\t</figure>\n\t</li>\n\t<li class=\"blocks-gallery-item\">\n\t\t<figure>\n\t\t\t<img src=\"http://google.com/hi.png\" alt=\"title\" />\n\t\t</figure>\n\t</li>\n</ul>\n", "innerContent": [ diff --git a/test/integration/full-content/fixtures/core__gallery.serialized.html b/test/integration/full-content/fixtures/core__gallery.serialized.html index 5bf6ce819f9767..55a1e6bab03770 100644 --- a/test/integration/full-content/fixtures/core__gallery.serialized.html +++ b/test/integration/full-content/fixtures/core__gallery.serialized.html @@ -1,3 +1,3 @@ -<!-- wp:gallery --> +<!-- wp:gallery {"ids":[null,null]} --> <ul class="wp-block-gallery columns-2 is-cropped"><li class="blocks-gallery-item"><figure><img src="https://cldup.com/uuUqE_dXzy.jpg" alt="title"/></figure></li><li class="blocks-gallery-item"><figure><img src="http://google.com/hi.png" alt="title"/></figure></li></ul> <!-- /wp:gallery --> diff --git a/test/integration/full-content/fixtures/core__gallery__columns.html b/test/integration/full-content/fixtures/core__gallery__columns.html index cf1f1bb43fa3f4..6493c70309dad2 100644 --- a/test/integration/full-content/fixtures/core__gallery__columns.html +++ b/test/integration/full-content/fixtures/core__gallery__columns.html @@ -1,4 +1,4 @@ -<!-- wp:core/gallery {"columns":1} --> +<!-- wp:core/gallery {"ids":[null,null],"columns":1} --> <ul class="wp-block-gallery columns-1 is-cropped"> <li class="blocks-gallery-item"> <figure> diff --git a/test/integration/full-content/fixtures/core__gallery__columns.json b/test/integration/full-content/fixtures/core__gallery__columns.json index b3daaa05f6e8a0..d96473e9d8d041 100644 --- a/test/integration/full-content/fixtures/core__gallery__columns.json +++ b/test/integration/full-content/fixtures/core__gallery__columns.json @@ -16,6 +16,10 @@ "caption": "" } ], + "ids": [ + null, + null + ], "columns": 1, "imageCrop": true, "linkTo": "none" diff --git a/test/integration/full-content/fixtures/core__gallery__columns.parsed.json b/test/integration/full-content/fixtures/core__gallery__columns.parsed.json index 6f6e4b856d7ece..6a2b71ac242f19 100644 --- a/test/integration/full-content/fixtures/core__gallery__columns.parsed.json +++ b/test/integration/full-content/fixtures/core__gallery__columns.parsed.json @@ -2,6 +2,10 @@ { "blockName": "core/gallery", "attrs": { + "ids": [ + null, + null + ], "columns": 1 }, "innerBlocks": [], diff --git a/test/integration/full-content/fixtures/core__gallery__columns.serialized.html b/test/integration/full-content/fixtures/core__gallery__columns.serialized.html index 183e484ec42b6d..7c5c369cc4199b 100644 --- a/test/integration/full-content/fixtures/core__gallery__columns.serialized.html +++ b/test/integration/full-content/fixtures/core__gallery__columns.serialized.html @@ -1,3 +1,3 @@ -<!-- wp:gallery {"columns":1} --> +<!-- wp:gallery {"ids":[null,null],"columns":1} --> <ul class="wp-block-gallery columns-1 is-cropped"><li class="blocks-gallery-item"><figure><img src="https://cldup.com/uuUqE_dXzy.jpg" alt="title"/></figure></li><li class="blocks-gallery-item"><figure><img src="http://google.com/hi.png" alt="title"/></figure></li></ul> <!-- /wp:gallery --> diff --git a/test/integration/full-content/fixtures/core__image__custom-link-class.html b/test/integration/full-content/fixtures/core__image__custom-link-class.html new file mode 100644 index 00000000000000..57cc46c9c39bbe --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-class.html @@ -0,0 +1,3 @@ +<!-- wp:core/image {"linkDestination":"custom"} --> +<figure class="wp-block-image"><a class="custom-link" href="https://wordpress.org/"><img src="https://cldup.com/uuUqE_dXzy.jpg" alt="" /></a></figure> +<!-- /wp:core/image --> diff --git a/test/integration/full-content/fixtures/core__image__custom-link-class.json b/test/integration/full-content/fixtures/core__image__custom-link-class.json new file mode 100644 index 00000000000000..d47fe4162c9c62 --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-class.json @@ -0,0 +1,17 @@ +[ + { + "clientId": "_clientId_0", + "name": "core/image", + "isValid": true, + "attributes": { + "url": "https://cldup.com/uuUqE_dXzy.jpg", + "alt": "", + "caption": "", + "href": "https://wordpress.org/", + "linkClass": "custom-link", + "linkDestination": "custom" + }, + "innerBlocks": [], + "originalContent": "<figure class=\"wp-block-image\"><a class=\"custom-link\" href=\"https://wordpress.org/\"><img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"\" /></a></figure>" + } +] diff --git a/test/integration/full-content/fixtures/core__image__custom-link-class.parsed.json b/test/integration/full-content/fixtures/core__image__custom-link-class.parsed.json new file mode 100644 index 00000000000000..c69b53dcc08aa9 --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-class.parsed.json @@ -0,0 +1,22 @@ +[ + { + "blockName": "core/image", + "attrs": { + "linkDestination": "custom" + }, + "innerBlocks": [], + "innerHTML": "\n<figure class=\"wp-block-image\"><a class=\"custom-link\" href=\"https://wordpress.org/\"><img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"\" /></a></figure>\n", + "innerContent": [ + "\n<figure class=\"wp-block-image\"><a class=\"custom-link\" href=\"https://wordpress.org/\"><img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"\" /></a></figure>\n" + ] + }, + { + "blockName": null, + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n", + "innerContent": [ + "\n" + ] + } +] diff --git a/test/integration/full-content/fixtures/core__image__custom-link-class.serialized.html b/test/integration/full-content/fixtures/core__image__custom-link-class.serialized.html new file mode 100644 index 00000000000000..9ac1f7a7a914e9 --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-class.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"linkDestination":"custom"} --> +<figure class="wp-block-image"><a class="custom-link" href="https://wordpress.org/"><img src="https://cldup.com/uuUqE_dXzy.jpg" alt=""/></a></figure> +<!-- /wp:image --> diff --git a/test/integration/full-content/fixtures/core__image__custom-link-rel.html b/test/integration/full-content/fixtures/core__image__custom-link-rel.html new file mode 100644 index 00000000000000..3424ed3fff3d70 --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-rel.html @@ -0,0 +1,3 @@ +<!-- wp:core/image {"linkDestination":"custom"} --> +<figure class="wp-block-image"><a href="https://wordpress.org/" rel="external"><img src="https://cldup.com/uuUqE_dXzy.jpg" alt="" /></a></figure> +<!-- /wp:core/image --> diff --git a/test/integration/full-content/fixtures/core__image__custom-link-rel.json b/test/integration/full-content/fixtures/core__image__custom-link-rel.json new file mode 100644 index 00000000000000..6000da69608e62 --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-rel.json @@ -0,0 +1,17 @@ +[ + { + "clientId": "_clientId_0", + "name": "core/image", + "isValid": true, + "attributes": { + "url": "https://cldup.com/uuUqE_dXzy.jpg", + "alt": "", + "caption": "", + "href": "https://wordpress.org/", + "rel": "external", + "linkDestination": "custom" + }, + "innerBlocks": [], + "originalContent": "<figure class=\"wp-block-image\"><a href=\"https://wordpress.org/\" rel=\"external\"><img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"\" /></a></figure>" + } +] diff --git a/test/integration/full-content/fixtures/core__image__custom-link-rel.parsed.json b/test/integration/full-content/fixtures/core__image__custom-link-rel.parsed.json new file mode 100644 index 00000000000000..91649db09a595f --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-rel.parsed.json @@ -0,0 +1,22 @@ +[ + { + "blockName": "core/image", + "attrs": { + "linkDestination": "custom" + }, + "innerBlocks": [], + "innerHTML": "\n<figure class=\"wp-block-image\"><a href=\"https://wordpress.org/\" rel=\"external\"><img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"\" /></a></figure>\n", + "innerContent": [ + "\n<figure class=\"wp-block-image\"><a href=\"https://wordpress.org/\" rel=\"external\"><img src=\"https://cldup.com/uuUqE_dXzy.jpg\" alt=\"\" /></a></figure>\n" + ] + }, + { + "blockName": null, + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n", + "innerContent": [ + "\n" + ] + } +] diff --git a/test/integration/full-content/fixtures/core__image__custom-link-rel.serialized.html b/test/integration/full-content/fixtures/core__image__custom-link-rel.serialized.html new file mode 100644 index 00000000000000..92702548c11d9d --- /dev/null +++ b/test/integration/full-content/fixtures/core__image__custom-link-rel.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:image {"linkDestination":"custom"} --> +<figure class="wp-block-image"><a href="https://wordpress.org/" rel="external"><img src="https://cldup.com/uuUqE_dXzy.jpg" alt=""/></a></figure> +<!-- /wp:image --> diff --git a/test/integration/full-content/fixtures/core__media-text.html b/test/integration/full-content/fixtures/core__media-text.html index 63d279b9db438c..ec516a98eb94df 100644 --- a/test/integration/full-content/fixtures/core__media-text.html +++ b/test/integration/full-content/fixtures/core__media-text.html @@ -1,7 +1,7 @@ <!-- wp:media-text {"mediaId":17985,"mediaType":"image"} --> <div class="wp-block-media-text alignwide"> <figure class="wp-block-media-text__media"> - <img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt=""/> + <img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt="" class="wp-image-17985"/> </figure> <div class="wp-block-media-text__content"> <!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} --> diff --git a/test/integration/full-content/fixtures/core__media-text.json b/test/integration/full-content/fixtures/core__media-text.json index 2b892048ecdde3..09e4f0c6f68f38 100644 --- a/test/integration/full-content/fixtures/core__media-text.json +++ b/test/integration/full-content/fixtures/core__media-text.json @@ -28,6 +28,6 @@ "originalContent": "<p class=\"has-large-font-size\">My Content</p>" } ], - "originalContent": "<div class=\"wp-block-media-text alignwide\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>" + "originalContent": "<div class=\"wp-block-media-text alignwide\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"\" class=\"wp-image-17985\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>" } ] diff --git a/test/integration/full-content/fixtures/core__media-text.parsed.json b/test/integration/full-content/fixtures/core__media-text.parsed.json index ec0bf62e613174..7c758d02aa73a7 100644 --- a/test/integration/full-content/fixtures/core__media-text.parsed.json +++ b/test/integration/full-content/fixtures/core__media-text.parsed.json @@ -19,9 +19,9 @@ ] } ], - "innerHTML": "\n<div class=\"wp-block-media-text alignwide\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>\n", + "innerHTML": "\n<div class=\"wp-block-media-text alignwide\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"\" class=\"wp-image-17985\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>\n", "innerContent": [ - "\n<div class=\"wp-block-media-text alignwide\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t", + "\n<div class=\"wp-block-media-text alignwide\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"\" class=\"wp-image-17985\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t", null, "\n\t</div>\n</div>\n" ] diff --git a/test/integration/full-content/fixtures/core__media-text.serialized.html b/test/integration/full-content/fixtures/core__media-text.serialized.html index 5e70ba8e96eaa3..51d3cce84b74e6 100644 --- a/test/integration/full-content/fixtures/core__media-text.serialized.html +++ b/test/integration/full-content/fixtures/core__media-text.serialized.html @@ -1,5 +1,5 @@ <!-- wp:media-text {"mediaId":17985,"mediaType":"image"} --> -<div class="wp-block-media-text alignwide"><figure class="wp-block-media-text__media"><img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt=""/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} --> +<div class="wp-block-media-text alignwide"><figure class="wp-block-media-text__media"><img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt="" class="wp-image-17985"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} --> <p class="has-large-font-size">My Content</p> <!-- /wp:paragraph --></div></div> <!-- /wp:media-text --> diff --git a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.html b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.html index 66e424e3b148a3..0c383e4537a57b 100644 --- a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.html +++ b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.html @@ -1,7 +1,7 @@ <!-- wp:media-text {"align":"none","mediaId":17985,"mediaType":"image"} --> <div class="wp-block-media-text"> <figure class="wp-block-media-text__media"> - <img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt="my alt"/> + <img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt="my alt" class="wp-image-17985" /> </figure> <div class="wp-block-media-text__content"> <!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} --> diff --git a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.json b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.json index 548f2dbd28f18d..0efacb35ee5b02 100644 --- a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.json +++ b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.json @@ -28,6 +28,6 @@ "originalContent": "<p class=\"has-large-font-size\">Content</p>" } ], - "originalContent": "<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"my alt\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>" + "originalContent": "<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"my alt\" class=\"wp-image-17985\" />\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>" } ] diff --git a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.parsed.json b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.parsed.json index e078f9c6ae589d..9068878ecf502e 100644 --- a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.parsed.json +++ b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.parsed.json @@ -20,9 +20,9 @@ ] } ], - "innerHTML": "\n<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"my alt\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>\n", + "innerHTML": "\n<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"my alt\" class=\"wp-image-17985\" />\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t\n\t</div>\n</div>\n", "innerContent": [ - "\n<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"my alt\"/>\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t", + "\n<div class=\"wp-block-media-text\">\n\t<figure class=\"wp-block-media-text__media\">\n\t\t<img src=\"http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg\" alt=\"my alt\" class=\"wp-image-17985\" />\n\t</figure>\n\t<div class=\"wp-block-media-text__content\">\n\t\t", null, "\n\t</div>\n</div>\n" ] diff --git a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.serialized.html b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.serialized.html index 3a28587c29bafc..9e00e7ce93ca1a 100644 --- a/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.serialized.html +++ b/test/integration/full-content/fixtures/core__media-text__image-alt-no-align.serialized.html @@ -1,5 +1,5 @@ <!-- wp:media-text {"align":"none","mediaId":17985,"mediaType":"image"} --> -<div class="wp-block-media-text"><figure class="wp-block-media-text__media"><img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt="my alt"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} --> +<div class="wp-block-media-text"><figure class="wp-block-media-text__media"><img src="http://localhost/wp-content/uploads/2018/09/1600px-Mount_Everest_as_seen_from_Drukair2_PLW_edit.jpg" alt="my alt" class="wp-image-17985"/></figure><div class="wp-block-media-text__content"><!-- wp:paragraph {"placeholder":"Content…","fontSize":"large"} --> <p class="has-large-font-size">Content</p> <!-- /wp:paragraph --></div></div> <!-- /wp:media-text --> diff --git a/test/integration/full-content/fixtures/core__missing.parsed.json b/test/integration/full-content/fixtures/core__missing.parsed.json index 85ab1542c72a3b..59d7fb99eb59d4 100644 --- a/test/integration/full-content/fixtures/core__missing.parsed.json +++ b/test/integration/full-content/fixtures/core__missing.parsed.json @@ -7,13 +7,17 @@ }, "innerBlocks": [], "innerHTML": "\n<p>Testing missing block with some</p>\n<div class=\"wp-some-class\">\n\tHTML <span style=\"color: red;\">content</span>\n</div>\n", - "innerContent": [ "\n<p>Testing missing block with some</p>\n<div class=\"wp-some-class\">\n\tHTML <span style=\"color: red;\">content</span>\n</div>\n" ] + "innerContent": [ + "\n<p>Testing missing block with some</p>\n<div class=\"wp-some-class\">\n\tHTML <span style=\"color: red;\">content</span>\n</div>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__more.parsed.json b/test/integration/full-content/fixtures/core__more.parsed.json index b806b0c0af8dba..624431c0413032 100644 --- a/test/integration/full-content/fixtures/core__more.parsed.json +++ b/test/integration/full-content/fixtures/core__more.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<!--more-->\n", - "innerContent": [ "\n<!--more-->\n" ] + "innerContent": [ + "\n<!--more-->\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__more__custom-text-teaser.parsed.json b/test/integration/full-content/fixtures/core__more__custom-text-teaser.parsed.json index e3096b9d7cdbb2..c58996ee1ef1c3 100644 --- a/test/integration/full-content/fixtures/core__more__custom-text-teaser.parsed.json +++ b/test/integration/full-content/fixtures/core__more__custom-text-teaser.parsed.json @@ -7,13 +7,17 @@ }, "innerBlocks": [], "innerHTML": "\n<!--more Continue Reading-->\n<!--noteaser-->\n", - "innerContent": [ "\n<!--more Continue Reading-->\n<!--noteaser-->\n" ] + "innerContent": [ + "\n<!--more Continue Reading-->\n<!--noteaser-->\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__nextpage.parsed.json b/test/integration/full-content/fixtures/core__nextpage.parsed.json index ff3f2703bf69ae..600c2fa3c7a43a 100644 --- a/test/integration/full-content/fixtures/core__nextpage.parsed.json +++ b/test/integration/full-content/fixtures/core__nextpage.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<!--nextpage-->\n", - "innerContent": [ "\n<!--nextpage-->\n" ] + "innerContent": [ + "\n<!--nextpage-->\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__paragraph__align-right.parsed.json b/test/integration/full-content/fixtures/core__paragraph__align-right.parsed.json index e0c2ab7be237bc..a8f850f47f72b5 100644 --- a/test/integration/full-content/fixtures/core__paragraph__align-right.parsed.json +++ b/test/integration/full-content/fixtures/core__paragraph__align-right.parsed.json @@ -6,13 +6,17 @@ }, "innerBlocks": [], "innerHTML": "\n<p style=\"text-align:right;\">... like this one, which is separate from the above and right aligned.</p>\n", - "innerContent": [ "\n<p style=\"text-align:right;\">... like this one, which is separate from the above and right aligned.</p>\n" ] + "innerContent": [ + "\n<p style=\"text-align:right;\">... like this one, which is separate from the above and right aligned.</p>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__paragraph__deprecated.parsed.json b/test/integration/full-content/fixtures/core__paragraph__deprecated.parsed.json index e6b914e24e1110..523743bab9e465 100644 --- a/test/integration/full-content/fixtures/core__paragraph__deprecated.parsed.json +++ b/test/integration/full-content/fixtures/core__paragraph__deprecated.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\nUnwrapped is <em>still</em> valid.\n", - "innerContent": [ "\nUnwrapped is <em>still</em> valid.\n" ] + "innerContent": [ + "\nUnwrapped is <em>still</em> valid.\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__preformatted.parsed.json b/test/integration/full-content/fixtures/core__preformatted.parsed.json index c78497076e90d2..0eb6c9a5b30bc6 100644 --- a/test/integration/full-content/fixtures/core__preformatted.parsed.json +++ b/test/integration/full-content/fixtures/core__preformatted.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<pre class=\"wp-block-preformatted\">Some <em>preformatted</em> text...<br>And more!</pre>\n", - "innerContent": [ "\n<pre class=\"wp-block-preformatted\">Some <em>preformatted</em> text...<br>And more!</pre>\n" ] + "innerContent": [ + "\n<pre class=\"wp-block-preformatted\">Some <em>preformatted</em> text...<br>And more!</pre>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__pullquote.parsed.json b/test/integration/full-content/fixtures/core__pullquote.parsed.json index 033b311fa5a190..1126f70a2ed1d6 100644 --- a/test/integration/full-content/fixtures/core__pullquote.parsed.json +++ b/test/integration/full-content/fixtures/core__pullquote.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<figure class=\"wp-block-pullquote\">\n <blockquote>\n <p>Testing pullquote block...</p><cite>...with a caption</cite>\n </blockquote>\n</figure>\n", - "innerContent": [ "\n<figure class=\"wp-block-pullquote\">\n <blockquote>\n <p>Testing pullquote block...</p><cite>...with a caption</cite>\n </blockquote>\n</figure>\n" ] + "innerContent": [ + "\n<figure class=\"wp-block-pullquote\">\n <blockquote>\n <p>Testing pullquote block...</p><cite>...with a caption</cite>\n </blockquote>\n</figure>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.parsed.json b/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.parsed.json index fe8abfce70a364..c025cf8cae24e7 100644 --- a/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.parsed.json +++ b/test/integration/full-content/fixtures/core__pullquote__multi-paragraph.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<figure class=\"wp-block-pullquote\">\n <blockquote>\n <p>Paragraph <strong>one</strong></p>\n <p>Paragraph two</p>\n <cite>by whomever</cite>\n\t</blockquote>\n</figure>\n", - "innerContent": [ "\n<figure class=\"wp-block-pullquote\">\n <blockquote>\n <p>Paragraph <strong>one</strong></p>\n <p>Paragraph two</p>\n <cite>by whomever</cite>\n\t</blockquote>\n</figure>\n" ] + "innerContent": [ + "\n<figure class=\"wp-block-pullquote\">\n <blockquote>\n <p>Paragraph <strong>one</strong></p>\n <p>Paragraph two</p>\n <cite>by whomever</cite>\n\t</blockquote>\n</figure>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__quote__style-1.parsed.json b/test/integration/full-content/fixtures/core__quote__style-1.parsed.json index 6a873438f17316..c86fe92fb11d8f 100644 --- a/test/integration/full-content/fixtures/core__quote__style-1.parsed.json +++ b/test/integration/full-content/fixtures/core__quote__style-1.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<blockquote class=\"wp-block-quote\"><p>The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery.</p><cite>Matt Mullenweg, 2017</cite></blockquote>\n", - "innerContent": [ "\n<blockquote class=\"wp-block-quote\"><p>The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery.</p><cite>Matt Mullenweg, 2017</cite></blockquote>\n" ] + "innerContent": [ + "\n<blockquote class=\"wp-block-quote\"><p>The editor will endeavour to create a new page and post building experience that makes writing rich posts effortless, and has “blocks” to make it easy what today might take shortcodes, custom HTML, or “mystery meat” embed discovery.</p><cite>Matt Mullenweg, 2017</cite></blockquote>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__quote__style-2.parsed.json b/test/integration/full-content/fixtures/core__quote__style-2.parsed.json index 6470afbc17a2e1..5543ac33cfd6c1 100644 --- a/test/integration/full-content/fixtures/core__quote__style-2.parsed.json +++ b/test/integration/full-content/fixtures/core__quote__style-2.parsed.json @@ -6,13 +6,17 @@ }, "innerBlocks": [], "innerHTML": "\n<blockquote class=\"wp-block-quote is-style-large\"><p>There is no greater agony than bearing an untold story inside you.</p><cite>Maya Angelou</cite></blockquote>\n", - "innerContent": [ "\n<blockquote class=\"wp-block-quote is-style-large\"><p>There is no greater agony than bearing an untold story inside you.</p><cite>Maya Angelou</cite></blockquote>\n" ] + "innerContent": [ + "\n<blockquote class=\"wp-block-quote is-style-large\"><p>There is no greater agony than bearing an untold story inside you.</p><cite>Maya Angelou</cite></blockquote>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__separator.parsed.json b/test/integration/full-content/fixtures/core__separator.parsed.json index 48a8e742c35b05..cb5714d4df31b3 100644 --- a/test/integration/full-content/fixtures/core__separator.parsed.json +++ b/test/integration/full-content/fixtures/core__separator.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<hr class=\"wp-block-separator\" />\n", - "innerContent": [ "\n<hr class=\"wp-block-separator\" />\n" ] + "innerContent": [ + "\n<hr class=\"wp-block-separator\" />\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__shortcode.parsed.json b/test/integration/full-content/fixtures/core__shortcode.parsed.json index b875770f15a452..85ae209c2a1d37 100644 --- a/test/integration/full-content/fixtures/core__shortcode.parsed.json +++ b/test/integration/full-content/fixtures/core__shortcode.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n[gallery ids=\"238,338\"]\n", - "innerContent": [ "\n[gallery ids=\"238,338\"]\n" ] + "innerContent": [ + "\n[gallery ids=\"238,338\"]\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__spacer.parsed.json b/test/integration/full-content/fixtures/core__spacer.parsed.json index c3c0938df5b9da..68591dd47de553 100644 --- a/test/integration/full-content/fixtures/core__spacer.parsed.json +++ b/test/integration/full-content/fixtures/core__spacer.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n", - "innerContent": [ "\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n" ] + "innerContent": [ + "\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__subhead.parsed.json b/test/integration/full-content/fixtures/core__subhead.parsed.json index d88b9ee4c90b69..c0dbef54853747 100644 --- a/test/integration/full-content/fixtures/core__subhead.parsed.json +++ b/test/integration/full-content/fixtures/core__subhead.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<p class=\"wp-block-subhead\">This is a <em>subhead</em>.</p>\n", - "innerContent": [ "\n<p class=\"wp-block-subhead\">This is a <em>subhead</em>.</p>\n" ] + "innerContent": [ + "\n<p class=\"wp-block-subhead\">This is a <em>subhead</em>.</p>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__table.parsed.json b/test/integration/full-content/fixtures/core__table.parsed.json index 7a2d91003f4373..5462dae3908a8c 100644 --- a/test/integration/full-content/fixtures/core__table.parsed.json +++ b/test/integration/full-content/fixtures/core__table.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<table class=\"wp-block-table\"><thead><tr><th>Version</th><th>Musician</th><th>Date</th></tr></thead><tbody><tr><td><a href=\"https://wordpress.org/news/2003/05/wordpress-now-available/\">.70</a></td><td>No musician chosen.</td><td>May 27, 2003</td></tr><tr><td><a href=\"https://wordpress.org/news/2004/01/wordpress-10/\">1.0</a></td><td>Miles Davis</td><td>January 3, 2004</td></tr><tr><td>Lots of versions skipped, see <a href=\"https://codex.wordpress.org/WordPress_Versions\">the full list</a></td><td>&hellip;</td><td>&hellip;</td></tr><tr><td><a href=\"https://wordpress.org/news/2015/12/clifford/\">4.4</a></td><td>Clifford Brown</td><td>December 8, 2015</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/04/coleman/\">4.5</a></td><td>Coleman Hawkins</td><td>April 12, 2016</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/08/pepper/\">4.6</a></td><td>Pepper Adams</td><td>August 16, 2016</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/12/vaughan/\">4.7</a></td><td>Sarah Vaughan</td><td>December 6, 2016</td></tr></tbody></table>\n", - "innerContent": [ "\n<table class=\"wp-block-table\"><thead><tr><th>Version</th><th>Musician</th><th>Date</th></tr></thead><tbody><tr><td><a href=\"https://wordpress.org/news/2003/05/wordpress-now-available/\">.70</a></td><td>No musician chosen.</td><td>May 27, 2003</td></tr><tr><td><a href=\"https://wordpress.org/news/2004/01/wordpress-10/\">1.0</a></td><td>Miles Davis</td><td>January 3, 2004</td></tr><tr><td>Lots of versions skipped, see <a href=\"https://codex.wordpress.org/WordPress_Versions\">the full list</a></td><td>&hellip;</td><td>&hellip;</td></tr><tr><td><a href=\"https://wordpress.org/news/2015/12/clifford/\">4.4</a></td><td>Clifford Brown</td><td>December 8, 2015</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/04/coleman/\">4.5</a></td><td>Coleman Hawkins</td><td>April 12, 2016</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/08/pepper/\">4.6</a></td><td>Pepper Adams</td><td>August 16, 2016</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/12/vaughan/\">4.7</a></td><td>Sarah Vaughan</td><td>December 6, 2016</td></tr></tbody></table>\n" ] + "innerContent": [ + "\n<table class=\"wp-block-table\"><thead><tr><th>Version</th><th>Musician</th><th>Date</th></tr></thead><tbody><tr><td><a href=\"https://wordpress.org/news/2003/05/wordpress-now-available/\">.70</a></td><td>No musician chosen.</td><td>May 27, 2003</td></tr><tr><td><a href=\"https://wordpress.org/news/2004/01/wordpress-10/\">1.0</a></td><td>Miles Davis</td><td>January 3, 2004</td></tr><tr><td>Lots of versions skipped, see <a href=\"https://codex.wordpress.org/WordPress_Versions\">the full list</a></td><td>&hellip;</td><td>&hellip;</td></tr><tr><td><a href=\"https://wordpress.org/news/2015/12/clifford/\">4.4</a></td><td>Clifford Brown</td><td>December 8, 2015</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/04/coleman/\">4.5</a></td><td>Coleman Hawkins</td><td>April 12, 2016</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/08/pepper/\">4.6</a></td><td>Pepper Adams</td><td>August 16, 2016</td></tr><tr><td><a href=\"https://wordpress.org/news/2016/12/vaughan/\">4.7</a></td><td>Sarah Vaughan</td><td>December 6, 2016</td></tr></tbody></table>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__text-columns.parsed.json b/test/integration/full-content/fixtures/core__text-columns.parsed.json index 1a7db4e09ed3a3..b00a69b7e3f546 100644 --- a/test/integration/full-content/fixtures/core__text-columns.parsed.json +++ b/test/integration/full-content/fixtures/core__text-columns.parsed.json @@ -6,13 +6,17 @@ }, "innerBlocks": [], "innerHTML": "\n<div class=\"wp-block-text-columns aligncenter columns-2\">\n <div class=\"wp-block-column\">\n <p>One</p>\n </div>\n <div class=\"wp-block-column\">\n <p>Two</p>\n </div>\n</div>\n", - "innerContent": [ "\n<div class=\"wp-block-text-columns aligncenter columns-2\">\n <div class=\"wp-block-column\">\n <p>One</p>\n </div>\n <div class=\"wp-block-column\">\n <p>Two</p>\n </div>\n</div>\n" ] + "innerContent": [ + "\n<div class=\"wp-block-text-columns aligncenter columns-2\">\n <div class=\"wp-block-column\">\n <p>One</p>\n </div>\n <div class=\"wp-block-column\">\n <p>Two</p>\n </div>\n</div>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__text__converts-to-paragraph.parsed.json b/test/integration/full-content/fixtures/core__text__converts-to-paragraph.parsed.json index 75a5ca1140907a..1e472910342844 100644 --- a/test/integration/full-content/fixtures/core__text__converts-to-paragraph.parsed.json +++ b/test/integration/full-content/fixtures/core__text__converts-to-paragraph.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<p>This is an old-style text block. Changed to <code>paragraph</code> in #2135.</p>\n", - "innerContent": [ "\n<p>This is an old-style text block. Changed to <code>paragraph</code> in #2135.</p>\n" ] + "innerContent": [ + "\n<p>This is an old-style text block. Changed to <code>paragraph</code> in #2135.</p>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__verse.parsed.json b/test/integration/full-content/fixtures/core__verse.parsed.json index 4cccc9383a50cb..2fbff3b1b326ed 100644 --- a/test/integration/full-content/fixtures/core__verse.parsed.json +++ b/test/integration/full-content/fixtures/core__verse.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<pre class=\"wp-block-verse\">A <em>verse</em>…<br>And more!</pre>\n", - "innerContent": [ "\n<pre class=\"wp-block-verse\">A <em>verse</em>…<br>And more!</pre>\n" ] + "innerContent": [ + "\n<pre class=\"wp-block-verse\">A <em>verse</em>…<br>And more!</pre>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/integration/full-content/fixtures/core__video.parsed.json b/test/integration/full-content/fixtures/core__video.parsed.json index e9be9d8a2ea2c0..5fd5505e395f76 100644 --- a/test/integration/full-content/fixtures/core__video.parsed.json +++ b/test/integration/full-content/fixtures/core__video.parsed.json @@ -4,13 +4,17 @@ "attrs": {}, "innerBlocks": [], "innerHTML": "\n<figure class=\"wp-block-video\"><video controls src=\"https://awesome-fake.video/file.mp4\"></video></figure>\n", - "innerContent": [ "\n<figure class=\"wp-block-video\"><video controls src=\"https://awesome-fake.video/file.mp4\"></video></figure>\n" ] + "innerContent": [ + "\n<figure class=\"wp-block-video\"><video controls src=\"https://awesome-fake.video/file.mp4\"></video></figure>\n" + ] }, { "blockName": null, "attrs": {}, "innerBlocks": [], "innerHTML": "\n", - "innerContent": [ "\n" ] + "innerContent": [ + "\n" + ] } ] diff --git a/test/unit/jest.config.json b/test/unit/jest.config.json index 6060917fb7ff21..ccd22b33408f36 100644 --- a/test/unit/jest.config.json +++ b/test/unit/jest.config.json @@ -14,5 +14,8 @@ "/test/e2e", "<rootDir>/.*/build/", "<rootDir>/.*/build-module/" + ], + "transformIgnorePatterns": [ + "node_modules/(?!(simple-html-tokenizer)/)" ] } diff --git a/webpack.config.js b/webpack.config.js index afd5c17c28c937..7d9877d7c15e3e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -16,6 +16,13 @@ const CustomTemplatedPathPlugin = require( '@wordpress/custom-templated-path-web const LibraryExportDefaultPlugin = require( '@wordpress/library-export-default-webpack-plugin' ); const { BundleAnalyzerPlugin } = require( 'webpack-bundle-analyzer' ); +/** + * Internal dependencies + */ +const { dependencies } = require( './package' ); + +const WORDPRESS_NAMESPACE = '@wordpress/'; + /** * Given a string, returns a new string with dash separators converted to * camelCase equivalent. This is not as aggressive as `_.camelCase` in @@ -33,46 +40,9 @@ function camelCaseDash( string ) { ); } -const gutenbergPackages = [ - 'a11y', - 'annotations', - 'api-fetch', - 'autop', - 'blob', - 'blocks', - 'block-library', - 'block-serialization-default-parser', - 'block-serialization-spec-parser', - 'components', - 'compose', - 'core-data', - 'data', - 'date', - 'deprecated', - 'dom', - 'dom-ready', - 'edit-post', - 'editor', - 'element', - 'escape-html', - 'format-library', - 'hooks', - 'html-entities', - 'i18n', - 'is-shallow-equal', - 'keycodes', - 'list-reusable-blocks', - 'notices', - 'nux', - 'plugins', - 'redux-routine', - 'rich-text', - 'shortcode', - 'token-list', - 'url', - 'viewport', - 'wordcount', -]; +const gutenbergPackages = Object.keys( dependencies ) + .filter( ( packageName ) => packageName.startsWith( WORDPRESS_NAMESPACE ) ) + .map( ( packageName ) => packageName.replace( WORDPRESS_NAMESPACE, '' ) ); const externals = { react: 'React', @@ -85,7 +55,7 @@ const externals = { }; gutenbergPackages.forEach( ( name ) => { - externals[ `@wordpress/${ name }` ] = { + externals[ WORDPRESS_NAMESPACE + name ] = { this: [ 'wp', camelCaseDash( name ) ], }; } ); @@ -167,6 +137,7 @@ const config = { 'deprecated', 'dom-ready', 'redux-routine', + 'token-list', ].map( camelCaseDash ) ), new CopyWebpackPlugin( gutenbergPackages.map( ( packageName ) => ( { @@ -177,7 +148,11 @@ const config = { if ( config.mode === 'production' ) { return postcss( [ require( 'cssnano' )( { - preset: 'default', + preset: [ 'default', { + discardComments: { + removeAll: true, + }, + } ], } ), ] ) .process( content, { from: 'src/app.css', to: 'dest/app.css' } )