diff options
| -rw-r--r-- | addon/index.mjs | 5 | ||||
| -rw-r--r-- | src/Layout.mjs | 179 | ||||
| -rw-r--r-- | src/css/dumbymap.css | 56 | ||||
| -rw-r--r-- | src/dumbymap.mjs | 15 | ||||
| -rw-r--r-- | src/editor.mjs | 2 |
5 files changed, 155 insertions, 102 deletions
diff --git a/addon/index.mjs b/addon/index.mjs index b5d71ba..77716b6 100644 --- a/addon/index.mjs +++ b/addon/index.mjs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | const url = new URL(window.location) | 1 | const url = new URL(window.location) |
| 2 | const use = url.searchParams.get('use') | ||
| 2 | if (url.host === 'www.ptt.cc') { | 3 | if (url.host === 'www.ptt.cc') { |
| 3 | const content = document.querySelector('#main-content') | 4 | const content = document.querySelector('#main-content') |
| 4 | Array.from(content.childNodes) | 5 | Array.from(content.childNodes) |
| @@ -22,7 +23,7 @@ const addBlocks = blockSelector | |||
| 22 | : undefined | 23 | : undefined |
| 23 | 24 | ||
| 24 | const simpleRender = window.mapclay.renderWith(config => ({ | 25 | const simpleRender = window.mapclay.renderWith(config => ({ |
| 25 | use: 'Leaflet', | 26 | use: use ?? 'Leaflet', |
| 26 | width: '100%', | 27 | width: '100%', |
| 27 | height: '200px', | 28 | height: '200px', |
| 28 | XYZ: 'https://tile.openstreetmap.jp/styles/osm-bright/512/{z}/{x}/{y}.png', | 29 | XYZ: 'https://tile.openstreetmap.jp/styles/osm-bright/512/{z}/{x}/{y}.png', |
| @@ -36,7 +37,7 @@ const simpleRender = window.mapclay.renderWith(config => ({ | |||
| 36 | window.generateMaps(document.querySelector('main') ?? document.body, { | 37 | window.generateMaps(document.querySelector('main') ?? document.body, { |
| 37 | crs: url.searchParams.get('crs') ?? 'EPSG:4326', | 38 | crs: url.searchParams.get('crs') ?? 'EPSG:4326', |
| 38 | addBlocks, | 39 | addBlocks, |
| 39 | initialLayout: '', | 40 | initialLayout: 'sticky', |
| 40 | render: simpleRender, | 41 | render: simpleRender, |
| 41 | autoMap: true, | 42 | autoMap: true, |
| 42 | }) | 43 | }) |
diff --git a/src/Layout.mjs b/src/Layout.mjs index 485b513..f79c70e 100644 --- a/src/Layout.mjs +++ b/src/Layout.mjs | |||
| @@ -86,6 +86,83 @@ export class SideBySide extends Layout { | |||
| 86 | } | 86 | } |
| 87 | 87 | ||
| 88 | /** | 88 | /** |
| 89 | * addDraggable. | ||
| 90 | * | ||
| 91 | * @param {HTMLElement} element | ||
| 92 | */ | ||
| 93 | const addDraggable = (element, { snap, left, top } = {}) => { | ||
| 94 | element.classList.add('draggable-block') | ||
| 95 | |||
| 96 | // Make sure current element always on top | ||
| 97 | const siblings = Array.from( | ||
| 98 | element.parentElement?.querySelectorAll(':scope > *') ?? [], | ||
| 99 | ) | ||
| 100 | let popTimer = null | ||
| 101 | const onmouseover = () => { | ||
| 102 | popTimer = setTimeout(() => { | ||
| 103 | siblings.forEach(e => e.style.removeProperty('z-index')) | ||
| 104 | element.style.zIndex = '9001' | ||
| 105 | }, 200) | ||
| 106 | } | ||
| 107 | const onmouseout = () => { | ||
| 108 | clearTimeout(popTimer) | ||
| 109 | } | ||
| 110 | element.addEventListener('mouseover', onmouseover) | ||
| 111 | element.addEventListener('mouseout', onmouseout) | ||
| 112 | |||
| 113 | // Add draggable part | ||
| 114 | const draggablePart = document.createElement('div') | ||
| 115 | element.appendChild(draggablePart) | ||
| 116 | draggablePart.className = 'draggable-part' | ||
| 117 | draggablePart.innerHTML = '<div class="handle">\u2630</div>' | ||
| 118 | |||
| 119 | // Add draggable instance | ||
| 120 | const draggable = new PlainDraggable(element, { | ||
| 121 | left, | ||
| 122 | top, | ||
| 123 | handle: draggablePart, | ||
| 124 | snap, | ||
| 125 | }) | ||
| 126 | |||
| 127 | // FIXME use pure CSS to hide utils | ||
| 128 | const utils = element.querySelector('.utils') | ||
| 129 | draggable.onDragStart = () => { | ||
| 130 | if (utils) utils.style.display = 'none' | ||
| 131 | element.classList.add('drag') | ||
| 132 | } | ||
| 133 | |||
| 134 | draggable.onDragEnd = () => { | ||
| 135 | if (utils) utils.style = '' | ||
| 136 | element.classList.remove('drag') | ||
| 137 | element.style.zIndex = '9000' | ||
| 138 | } | ||
| 139 | |||
| 140 | // Reposition draggable instance when resized | ||
| 141 | const resizeObserver = new window.ResizeObserver(() => { | ||
| 142 | draggable?.position() | ||
| 143 | }) | ||
| 144 | resizeObserver.observe(element) | ||
| 145 | |||
| 146 | // Callback for remove | ||
| 147 | onRemove(element, () => { | ||
| 148 | resizeObserver.disconnect() | ||
| 149 | }) | ||
| 150 | |||
| 151 | new window.MutationObserver(() => { | ||
| 152 | if (!element.classList.contains('draggable-block') && draggable) { | ||
| 153 | element.removeEventListener('mouseover', onmouseover) | ||
| 154 | element.removeEventListener('mouseout', onmouseout) | ||
| 155 | resizeObserver.disconnect() | ||
| 156 | } | ||
| 157 | }).observe(element, { | ||
| 158 | attributes: true, | ||
| 159 | attributeFilter: ['class'], | ||
| 160 | }) | ||
| 161 | |||
| 162 | return draggable | ||
| 163 | } | ||
| 164 | |||
| 165 | /** | ||
| 89 | * Overlay Layout, Showcase occupies viewport, and HTML content becomes draggable blocks | 166 | * Overlay Layout, Showcase occupies viewport, and HTML content becomes draggable blocks |
| 90 | * | 167 | * |
| 91 | * @extends {Layout} | 168 | * @extends {Layout} |
| @@ -103,70 +180,6 @@ export class Overlay extends Layout { | |||
| 103 | } | 180 | } |
| 104 | 181 | ||
| 105 | /** | 182 | /** |
| 106 | * addDraggable. | ||
| 107 | * | ||
| 108 | * @param {HTMLElement} element | ||
| 109 | */ | ||
| 110 | addDraggable = element => { | ||
| 111 | // Make sure current element always on top | ||
| 112 | const siblings = Array.from( | ||
| 113 | element.parentElement?.querySelectorAll(':scope > *') ?? [], | ||
| 114 | ) | ||
| 115 | let popTimer = null | ||
| 116 | element.onmouseover = () => { | ||
| 117 | popTimer = setTimeout(() => { | ||
| 118 | siblings.forEach(e => e.style.removeProperty('z-index')) | ||
| 119 | element.style.zIndex = '9001' | ||
| 120 | }, 200) | ||
| 121 | } | ||
| 122 | element.onmouseout = () => { | ||
| 123 | clearTimeout(popTimer) | ||
| 124 | } | ||
| 125 | |||
| 126 | // Add draggable part | ||
| 127 | const draggablePart = document.createElement('div') | ||
| 128 | element.appendChild(draggablePart) | ||
| 129 | draggablePart.className = 'draggable-part' | ||
| 130 | draggablePart.innerHTML = '<div class="handle">\u2630</div>' | ||
| 131 | |||
| 132 | // Add draggable instance | ||
| 133 | const { left, top } = element.getBoundingClientRect() | ||
| 134 | const draggable = new PlainDraggable(element, { | ||
| 135 | top, | ||
| 136 | left, | ||
| 137 | handle: draggablePart, | ||
| 138 | snap: { x: { step: 20 }, y: { step: 20 } }, | ||
| 139 | }) | ||
| 140 | |||
| 141 | // FIXME use pure CSS to hide utils | ||
| 142 | const utils = element.querySelector('.utils') | ||
| 143 | draggable.onDragStart = () => { | ||
| 144 | utils.style.display = 'none' | ||
| 145 | element.classList.add('drag') | ||
| 146 | } | ||
| 147 | |||
| 148 | draggable.onDragEnd = () => { | ||
| 149 | utils.style = '' | ||
| 150 | element.classList.remove('drag') | ||
| 151 | element.style.zIndex = '9000' | ||
| 152 | } | ||
| 153 | |||
| 154 | // Reposition draggable instance when resized | ||
| 155 | new window.ResizeObserver(() => { | ||
| 156 | try { | ||
| 157 | draggable.position() | ||
| 158 | } catch (err) { | ||
| 159 | console.warn(err) | ||
| 160 | } | ||
| 161 | }).observe(element) | ||
| 162 | |||
| 163 | // Callback for remove | ||
| 164 | onRemove(element, () => { | ||
| 165 | draggable.remove() | ||
| 166 | }) | ||
| 167 | } | ||
| 168 | |||
| 169 | /** | ||
| 170 | * enterHandler. | 183 | * enterHandler. |
| 171 | * | 184 | * |
| 172 | * @param {HTMLElement} options.hemlHolder - Parent element for block | 185 | * @param {HTMLElement} options.hemlHolder - Parent element for block |
| @@ -182,7 +195,7 @@ export class Overlay extends Layout { | |||
| 182 | } | 195 | } |
| 183 | 196 | ||
| 184 | // Create draggable blocks and set each position by previous one | 197 | // Create draggable blocks and set each position by previous one |
| 185 | let [left, top] = [20, 20] | 198 | const [left, top] = [20, 20] |
| 186 | blocks.forEach(block => { | 199 | blocks.forEach(block => { |
| 187 | const originLeft = Number(block.dataset.left) | 200 | const originLeft = Number(block.dataset.left) |
| 188 | const originTop = Number(block.dataset.top) | 201 | const originTop = Number(block.dataset.top) |
| @@ -210,8 +223,8 @@ export class Overlay extends Layout { | |||
| 210 | wrapper.style.left = left + 'px' | 223 | wrapper.style.left = left + 'px' |
| 211 | wrapper.style.top = top + 'px' | 224 | wrapper.style.top = top + 'px' |
| 212 | htmlHolder.appendChild(wrapper) | 225 | htmlHolder.appendChild(wrapper) |
| 213 | const { width } = wrapper.getBoundingClientRect() | 226 | const rect = wrapper.getBoundingClientRect() |
| 214 | left += width + 30 | 227 | left += rect.width + 30 |
| 215 | if (left > window.innerWidth) { | 228 | if (left > window.innerWidth) { |
| 216 | top += 200 | 229 | top += 200 |
| 217 | left = left % window.innerWidth | 230 | left = left % window.innerWidth |
| @@ -222,7 +235,14 @@ export class Overlay extends Layout { | |||
| 222 | wrapper, | 235 | wrapper, |
| 223 | { left: originLeft, top: originTop }, | 236 | { left: originLeft, top: originTop }, |
| 224 | { resume: true, duration: 300 }, | 237 | { resume: true, duration: 300 }, |
| 225 | ).finished.finally(() => this.addDraggable(wrapper)) | 238 | ).finished.finally(() => addDraggable(wrapper, { |
| 239 | left: rect.left, | ||
| 240 | top: rect.top, | ||
| 241 | snap: { | ||
| 242 | x: { step: 20 }, | ||
| 243 | y: { step: 20 }, | ||
| 244 | }, | ||
| 245 | })) | ||
| 226 | 246 | ||
| 227 | // Trivial case: | 247 | // Trivial case: |
| 228 | // This hack make sure utils remains at the same place even when wrapper resized | 248 | // This hack make sure utils remains at the same place even when wrapper resized |
| @@ -271,3 +291,26 @@ export class Overlay extends Layout { | |||
| 271 | blocks.forEach(resumeFromDraggable) | 291 | blocks.forEach(resumeFromDraggable) |
| 272 | } | 292 | } |
| 273 | } | 293 | } |
| 294 | |||
| 295 | /** | ||
| 296 | * Sticky Layout, Showcase is draggable and stick to viewport | ||
| 297 | * | ||
| 298 | * @extends {Layout} | ||
| 299 | */ | ||
| 300 | export class Sticky extends Layout { | ||
| 301 | draggable = document.createElement('div') | ||
| 302 | |||
| 303 | enterHandler = ({ showcase }) => { | ||
| 304 | showcase.replaceWith(this.draggable) | ||
| 305 | this.draggable.appendChild(showcase) | ||
| 306 | this.draggableInstance = addDraggable(this.draggable) | ||
| 307 | const rect = this.draggable.getBoundingClientRect() | ||
| 308 | this.draggable.style.cssText = `left: ${window.innerWidth - rect.width - 20}px; top: ${window.innerHeight - rect.height - 20}px;` | ||
| 309 | } | ||
| 310 | |||
| 311 | leaveHandler = ({ showcase }) => { | ||
| 312 | this.draggableInstance?.remove() | ||
| 313 | this.draggable.replaceWith(showcase) | ||
| 314 | this.draggable.querySelectorAll(':scope > :not(.mapclay)').forEach(e => e.remove()) | ||
| 315 | } | ||
| 316 | } | ||
diff --git a/src/css/dumbymap.css b/src/css/dumbymap.css index c0bb1a9..cc161fa 100644 --- a/src/css/dumbymap.css +++ b/src/css/dumbymap.css | |||
| @@ -141,7 +141,7 @@ root { | |||
| 141 | background-repeat: no-repeat; | 141 | background-repeat: no-repeat; |
| 142 | background-position: right 2px top 2px; | 142 | background-position: right 2px top 2px; |
| 143 | 143 | ||
| 144 | color: #555; | 144 | color: #555 !important; |
| 145 | background-size: 12px 12px; | 145 | background-size: 12px 12px; |
| 146 | 146 | ||
| 147 | &.geolink { | 147 | &.geolink { |
| @@ -166,7 +166,7 @@ root { | |||
| 166 | } | 166 | } |
| 167 | } | 167 | } |
| 168 | 168 | ||
| 169 | .Dumby:not([data-layout=""]) .dumby-block { | 169 | .DumbyMap.Dumby[data-layout] .dumby-block { |
| 170 | padding: 1rem 1rem 1rem 2rem; | 170 | padding: 1rem 1rem 1rem 2rem; |
| 171 | 171 | ||
| 172 | position: relative; | 172 | position: relative; |
| @@ -495,7 +495,7 @@ root { | |||
| 495 | } | 495 | } |
| 496 | } | 496 | } |
| 497 | 497 | ||
| 498 | .Dumby[data-layout='normal'] { | 498 | .DumbyMap.Dumby[data-layout='normal'] { |
| 499 | max-width: 60em; | 499 | max-width: 60em; |
| 500 | 500 | ||
| 501 | &::after { | 501 | &::after { |
| @@ -595,6 +595,35 @@ root { | |||
| 595 | } | 595 | } |
| 596 | } | 596 | } |
| 597 | 597 | ||
| 598 | .Dumby[data-layout='sticky'] { | ||
| 599 | .SemanticHtml { | ||
| 600 | max-width: 60em; | ||
| 601 | margin: 0 auto; | ||
| 602 | } | ||
| 603 | |||
| 604 | .draggable-block { | ||
| 605 | position: fixed; | ||
| 606 | |||
| 607 | &::before { | ||
| 608 | display: none; | ||
| 609 | } | ||
| 610 | } | ||
| 611 | |||
| 612 | .Showcase { | ||
| 613 | display: block; | ||
| 614 | overflow: hidden; | ||
| 615 | width: 20vw; | ||
| 616 | min-width: 10vw; | ||
| 617 | height: 200px; | ||
| 618 | min-height: 100px; | ||
| 619 | resize: both; | ||
| 620 | |||
| 621 | .mapclay { | ||
| 622 | border-radius: 4px !important; | ||
| 623 | } | ||
| 624 | } | ||
| 625 | } | ||
| 626 | |||
| 598 | .utils { | 627 | .utils { |
| 599 | display: flex; | 628 | display: flex; |
| 600 | padding-block: 1rem; | 629 | padding-block: 1rem; |
| @@ -642,6 +671,7 @@ root { | |||
| 642 | .draggable-block { | 671 | .draggable-block { |
| 643 | overflow: visible; | 672 | overflow: visible; |
| 644 | width: fit-content; | 673 | width: fit-content; |
| 674 | height: fit-content; | ||
| 645 | 675 | ||
| 646 | position: absolute; | 676 | position: absolute; |
| 647 | 677 | ||
| @@ -659,7 +689,7 @@ root { | |||
| 659 | opacity: 0; | 689 | opacity: 0; |
| 660 | pointer-events: auto; | 690 | pointer-events: auto; |
| 661 | 691 | ||
| 662 | &:has(.dumby-block.focus) { | 692 | &:has(.dumby-block.focus, .mapclay.focus) { |
| 663 | visibility: visible; | 693 | visibility: visible; |
| 664 | opacity: 1; | 694 | opacity: 1; |
| 665 | } | 695 | } |
| @@ -730,7 +760,7 @@ root { | |||
| 730 | position: absolute; | 760 | position: absolute; |
| 731 | left: 0; | 761 | left: 0; |
| 732 | top: 0; | 762 | top: 0; |
| 733 | z-index: 1; | 763 | z-index: 2; |
| 734 | border-top-left-radius: 0.3rem; | 764 | border-top-left-radius: 0.3rem; |
| 735 | border-top-right-radius: 0.3rem; | 765 | border-top-right-radius: 0.3rem; |
| 736 | } | 766 | } |
| @@ -782,22 +812,6 @@ root { | |||
| 782 | font-weight: bold; | 812 | font-weight: bold; |
| 783 | } | 813 | } |
| 784 | 814 | ||
| 785 | .Dumby[data-layout='sticky'] { | ||
| 786 | max-width: 80em; | ||
| 787 | |||
| 788 | .Showcase { | ||
| 789 | display: block; | ||
| 790 | width: 20vw; | ||
| 791 | height: 40vh; | ||
| 792 | |||
| 793 | position: absolute; | ||
| 794 | right: 20px; | ||
| 795 | bottom: 20px; | ||
| 796 | |||
| 797 | background: red; | ||
| 798 | } | ||
| 799 | } | ||
| 800 | |||
| 801 | .dumby-block details { | 815 | .dumby-block details { |
| 802 | display: block; | 816 | display: block; |
| 803 | width: fit-content; | 817 | width: fit-content; |
diff --git a/src/dumbymap.mjs b/src/dumbymap.mjs index b612367..13f4574 100644 --- a/src/dumbymap.mjs +++ b/src/dumbymap.mjs | |||
| @@ -5,7 +5,7 @@ import MarkdownItFrontMatter from 'markdown-it-front-matter' | |||
| 5 | import MarkdownItInjectLinenumbers from 'markdown-it-inject-linenumbers' | 5 | import MarkdownItInjectLinenumbers from 'markdown-it-inject-linenumbers' |
| 6 | import * as mapclay from 'mapclay' | 6 | import * as mapclay from 'mapclay' |
| 7 | import { replaceTextNodes, onRemove, animateRectTransition, throttle, shiftByWindow } from './utils' | 7 | import { replaceTextNodes, onRemove, animateRectTransition, throttle, shiftByWindow } from './utils' |
| 8 | import { Layout, SideBySide, Overlay } from './Layout' | 8 | import { Layout, SideBySide, Overlay, Sticky } from './Layout' |
| 9 | import * as utils from './dumbyUtils' | 9 | import * as utils from './dumbyUtils' |
| 10 | import * as menuItem from './MenuItem' | 10 | import * as menuItem from './MenuItem' |
| 11 | import PlainModal from 'plain-modal' | 11 | import PlainModal from 'plain-modal' |
| @@ -23,6 +23,7 @@ const defaultLayouts = [ | |||
| 23 | new Layout({ name: 'normal' }), | 23 | new Layout({ name: 'normal' }), |
| 24 | new SideBySide({ name: 'side-by-side' }), | 24 | new SideBySide({ name: 'side-by-side' }), |
| 25 | new Overlay({ name: 'overlay' }), | 25 | new Overlay({ name: 'overlay' }), |
| 26 | new Sticky({ name: 'sticky' }), | ||
| 26 | ] | 27 | ] |
| 27 | 28 | ||
| 28 | /** Cache across every dumbymap generation */ | 29 | /** Cache across every dumbymap generation */ |
| @@ -169,13 +170,12 @@ export const generateMaps = (container, { | |||
| 169 | /** Prepare Contaner */ | 170 | /** Prepare Contaner */ |
| 170 | container.classList.add('Dumby') | 171 | container.classList.add('Dumby') |
| 171 | delete container.dataset.layout | 172 | delete container.dataset.layout |
| 172 | container.dataset.layout = initialLayout ?? defaultLayouts[0].name | ||
| 173 | 173 | ||
| 174 | /** Prepare Semantic HTML part and blocks of contents inside */ | 174 | /** Prepare Semantic HTML part and blocks of contents inside */ |
| 175 | const htmlHolder = container.querySelector('.SemanticHtml') ?? | 175 | const htmlHolder = container.querySelector('.SemanticHtml') ?? |
| 176 | Array.from(container.children).find(e => e.id?.includes('main') || e.className.includes('main')) ?? | 176 | Array.from(container.children).find(e => e.id?.includes('main') || e.className.includes('main')) ?? |
| 177 | Array.from(container.children).sort((a, b) => a.textContent.length < b.textContent.length).at(0) | 177 | Array.from(container.children).sort((a, b) => a.textContent.length < b.textContent.length).at(0) |
| 178 | htmlHolder.classList.add('.SemanticHtml') | 178 | htmlHolder.classList.add('SemanticHtml') |
| 179 | 179 | ||
| 180 | const blocks = addBlocks(htmlHolder) | 180 | const blocks = addBlocks(htmlHolder) |
| 181 | blocks.forEach(b => { | 181 | blocks.forEach(b => { |
| @@ -280,13 +280,7 @@ export const generateMaps = (container, { | |||
| 280 | const mutation = mutations.at(-1) | 280 | const mutation = mutations.at(-1) |
| 281 | const target = mutation.target | 281 | const target = mutation.target |
| 282 | const focus = target.classList.contains('focus') | 282 | const focus = target.classList.contains('focus') |
| 283 | const shouldBeInShowcase = | 283 | const shouldBeInShowcase = focus && showcase.checkVisibility() |
| 284 | focus && | ||
| 285 | showcase.checkVisibility({ | ||
| 286 | contentVisibilityAuto: true, | ||
| 287 | opacityProperty: true, | ||
| 288 | visibilityProperty: true, | ||
| 289 | }) | ||
| 290 | 284 | ||
| 291 | if (focus) { | 285 | if (focus) { |
| 292 | dumbymap.utils | 286 | dumbymap.utils |
| @@ -393,6 +387,7 @@ export const generateMaps = (container, { | |||
| 393 | }) | 387 | }) |
| 394 | 388 | ||
| 395 | onRemove(htmlHolder, () => layoutObserver.disconnect()) | 389 | onRemove(htmlHolder, () => layoutObserver.disconnect()) |
| 390 | container.dataset.layout = initialLayout ?? defaultLayouts[0].name | ||
| 396 | 391 | ||
| 397 | /** | 392 | /** |
| 398 | * afterMapRendered. callback of each map rendered | 393 | * afterMapRendered. callback of each map rendered |
diff --git a/src/editor.mjs b/src/editor.mjs index d56ad4b..2854373 100644 --- a/src/editor.mjs +++ b/src/editor.mjs | |||
| @@ -24,7 +24,7 @@ const initialLayout = pageParams.get('layout') | |||
| 24 | /** Variables: dumbymap and editor **/ | 24 | /** Variables: dumbymap and editor **/ |
| 25 | const context = document.querySelector('[data-mode]') | 25 | const context = document.querySelector('[data-mode]') |
| 26 | const textArea = document.querySelector('.editor textarea') | 26 | const textArea = document.querySelector('.editor textarea') |
| 27 | const dumbyContainer = document.querySelector('.DumbyMap') | 27 | const dumbyContainer = document.querySelector('.Dumby') |
| 28 | dumbyContainer.dataset.scrollLine = '' | 28 | dumbyContainer.dataset.scrollLine = '' |
| 29 | /** Watch: DumbyMap */ | 29 | /** Watch: DumbyMap */ |
| 30 | new window.MutationObserver(mutations => { | 30 | new window.MutationObserver(mutations => { |