The picker listed it as `soon` beside Image. It is not soon any more — the set, its ordering and the carousel that draws it were already built on the site, so the block is a reference to one and the admin's whole job is choosing which. `GalleryPicker` chooses a SET and nothing else. Editing what is IN the set stays under Galleries: a block that could also rearrange its own pictures would be a second place to do one job, and the two would disagree. No thumbnails — every stored path names an original a browser cannot display, and the plugin has no way to address a rendition, so the name and the count identify it instead. Saved on choose, like a video and for the same reason: picking one thing is one act with one outcome, so there is nothing to draft and nothing to cancel out of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
567 lines
22 KiB
TypeScript
567 lines
22 KiB
TypeScript
/**
|
|
* The admin's own chrome, drawn INSIDE the rendered page.
|
|
*
|
|
* ── What this is, and where it deliberately does not live ──
|
|
*
|
|
* Nothing here exists in the website's source. The site renders a page; this puts the builder's
|
|
* controls on top of it, from the plugin, at runtime. So the site never carries a line of code that
|
|
* only an editor would ever see, and the builder can change without the site being rebuilt.
|
|
*
|
|
* It is possible because the preview frame is SAME ORIGIN — it comes through the platform's dev proxy
|
|
* on Officer's own host — so `iframe.contentDocument` is readable and writable. That single fact is
|
|
* what the whole page builder rests on.
|
|
*
|
|
* ── What the SITE has to provide, and it is only this ──
|
|
*
|
|
* data-page-empty the placeholder shown when a page has no sections
|
|
* data-section-id which row a band came from
|
|
* data-column a column within a band
|
|
* data-content-column the wrapper that holds the page to its measure
|
|
*
|
|
* Three attributes and no behaviour. Markers rather than classes, so they cannot be mistaken for
|
|
* styling, cannot be purged by a build that has never heard of this file, and read as what they are:
|
|
* handles for something else.
|
|
*
|
|
* ── Plain DOM, not React ──
|
|
*
|
|
* React could portal into another document, and it would mean running a reconciler against a document
|
|
* that reloads under it whenever the dev server rebuilds. These are a handful of elements with click
|
|
* handlers; building them by hand costs a few lines, and a reload becomes a document that no longer
|
|
* exists rather than a tree React still thinks it owns.
|
|
*
|
|
* ── Styles are inline, on purpose ──
|
|
*
|
|
* The frame has the SITE's stylesheet, which is Tailwind built by scanning the site's own templates.
|
|
* A class this file invents was never scanned and does not exist there, so it would silently do
|
|
* nothing.
|
|
*/
|
|
|
|
/** Everything injected carries this, so a re-injection can find and remove what the last one left. */
|
|
const MARK = 'data-fofos-admin-canvas';
|
|
|
|
/** The editor's own colour. Deliberately not the site's — this is chrome, and it should not blend in. */
|
|
const ACCENT = '#3b82f6';
|
|
|
|
/**
|
|
* A component, as the canvas needs it.
|
|
*
|
|
* `content` is carried even though nothing in this file draws it: the panel reads it back when somebody
|
|
* presses Edit, and it arrived with the section. Fetching it again on every Edit would be a request to
|
|
* be told something already in hand.
|
|
*/
|
|
export type CanvasComponent = {
|
|
id: number;
|
|
column: number;
|
|
kind: string;
|
|
/*
|
|
One optional field per kind that the panel reads back. Not `Record<string, unknown>`: every reader
|
|
would then need a cast, and the point of naming them is that the compiler knows `free-text` holds
|
|
`html` and `gallery` holds `galleryId`.
|
|
*/
|
|
content?: { html?: string; galleryId?: number | null };
|
|
padding?: Record<string, { x?: number; y?: number }>;
|
|
};
|
|
export type CanvasSection = {
|
|
id: number;
|
|
name: string;
|
|
layout: 'one' | 'two';
|
|
padding?: Record<string, { x?: number; y?: number }>;
|
|
components: CanvasComponent[];
|
|
};
|
|
|
|
export type Canvas = {
|
|
sections: CanvasSection[];
|
|
onAddSection: () => void;
|
|
onDeleteSection: (section: CanvasSection) => void;
|
|
/** Somebody wants to change the space around a band or around one thing in it. */
|
|
onSpaceSection: (section: CanvasSection) => void;
|
|
onSpaceComponent: (component: CanvasComponent) => void;
|
|
/** A column wants something in it. */
|
|
onAddComponent: (section: CanvasSection, column: number) => void;
|
|
/** Something already in a column was pressed. */
|
|
onEditComponent: (component: CanvasComponent) => void;
|
|
onDeleteComponent: (component: CanvasComponent) => void;
|
|
};
|
|
|
|
/**
|
|
* Where the page's content goes.
|
|
*
|
|
* `<main>` because it is what the site's Layout renders and what the word means — the page minus the
|
|
* furniture around it.
|
|
*/
|
|
const contentRoot = (document: Document): HTMLElement | null => document.querySelector('main');
|
|
|
|
const style = (element: HTMLElement, rules: string[]) => {
|
|
element.style.cssText = rules.join(';');
|
|
};
|
|
|
|
/**
|
|
* Draw the builder's controls into a rendered page.
|
|
*
|
|
* Returns a cleanup. It runs before the next injection and on unmount — see `onDocument` in
|
|
* SitePreview — so a reload never leaves two sets of controls behind.
|
|
*/
|
|
export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
|
const root = contentRoot(document);
|
|
if (!root) return () => {};
|
|
|
|
// Anything a previous injection left. Cheap insurance: the cleanup should have run, and if a reload
|
|
// raced it, a second Add section button is exactly the sort of thing nobody reports as a bug.
|
|
// Across the whole document, not just `main`: the measure guides are appended to body so they can be
|
|
// `fixed` without a positioned ancestor interfering.
|
|
document.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
|
|
|
/** Everything to undo, in the order it was done. */
|
|
const undo: (() => void)[] = [];
|
|
|
|
/*
|
|
A stylesheet, for the one thing inline styles cannot express: `:hover`.
|
|
|
|
The component tools sat permanently at the top-right of whatever they belonged to, on top of the
|
|
text. On a paragraph that fills its column there is nowhere for them to go that is not on top of
|
|
something — so they are hidden until the component is hovered, which is when they are wanted and
|
|
the only time they are.
|
|
|
|
The outline goes the same way. An editor should show the page, with the handles arriving under the
|
|
cursor; an editor that outlines everything all the time is showing you the editor.
|
|
*/
|
|
const sheet = document.createElement('style');
|
|
sheet.setAttribute(MARK, 'stylesheet');
|
|
sheet.textContent = `
|
|
/*
|
|
EVERYTHING belonging to a section appears when that section is hovered.
|
|
|
|
Its outline, its name, its shape, its Delete, the outlines of its columns and the button that adds
|
|
to them. A page with four bands was four labelled boxes at rest, and the page underneath was the
|
|
one thing you could not see — which is what an editor is for.
|
|
|
|
Opacity rather than display, so nothing moves when it appears: the controls hold their space
|
|
whether or not they are visible, and the page does not reflow under the cursor.
|
|
*/
|
|
[data-section-id] {
|
|
outline: 1px solid transparent;
|
|
outline-offset: -1px;
|
|
transition: outline-color .12s;
|
|
}
|
|
[data-section-id]:hover { outline-color: ${ACCENT}; }
|
|
|
|
[${MARK}="section-bar"] { opacity: 0; transition: opacity .12s; }
|
|
[data-section-id]:hover [${MARK}="section-bar"] { opacity: 1; }
|
|
/* Keyboard reaches them too: focus inside a band is the same intent as hovering it. */
|
|
[data-section-id]:focus-within [${MARK}="section-bar"] { opacity: 1; }
|
|
|
|
[data-column] {
|
|
outline: 1px dashed transparent;
|
|
outline-offset: -5px;
|
|
transition: outline-color .12s;
|
|
}
|
|
[data-section-id]:hover [data-column] {
|
|
outline-color: color-mix(in srgb, ${ACCENT} 45%, transparent);
|
|
}
|
|
|
|
[${MARK}="add-component"] { opacity: 0; transition: opacity .12s; }
|
|
[data-section-id]:hover [${MARK}="add-component"] { opacity: 1; }
|
|
[${MARK}="add-component"]:focus { opacity: 1; }
|
|
|
|
/*
|
|
A COMPONENT's own handles need their own hover, not the section's. A band with three paragraphs
|
|
would otherwise show three sets of Edit and Delete at once, and none of them would say which.
|
|
*/
|
|
[data-fofos-component] { outline: 1px dotted transparent; transition: outline-color .12s; }
|
|
[data-fofos-component]:hover { outline-color: color-mix(in srgb, ${ACCENT} 55%, transparent); }
|
|
[${MARK}="component-tools"] { opacity: 0; transition: opacity .12s; }
|
|
[data-fofos-component]:hover > [${MARK}="component-tools"] { opacity: 1; }
|
|
[${MARK}="component-tools"]:focus-within { opacity: 1; }
|
|
`;
|
|
document.head.append(sheet);
|
|
undo.push(() => sheet.remove());
|
|
|
|
/*
|
|
The site's empty-page placeholder, hidden while the builder draws its own controls in that space.
|
|
|
|
Found by its marker rather than by position. Hidden rather than removed, so the cleanup puts it
|
|
back — a page whose last section is deleted shows it again without a reload.
|
|
*/
|
|
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
|
if (placeholder) {
|
|
placeholder.style.display = 'none';
|
|
undo.push(() => {
|
|
placeholder.style.display = '';
|
|
});
|
|
}
|
|
|
|
/*
|
|
Every section, outlined and named.
|
|
|
|
This is the whole reason the decoration exists: an EMPTY section renders as nothing, which is
|
|
correct on the public site and useless in an editor. Somebody who has just chosen "two columns"
|
|
needs to see two columns, not a blank page they have to take on trust.
|
|
*/
|
|
for (const section of canvas.sections) {
|
|
const element = root.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
|
|
if (!element) continue;
|
|
|
|
const previousPosition = element.style.position;
|
|
const previousMargin = element.style.margin;
|
|
|
|
// `relative` so the label and the controls can be placed against this band rather than the page.
|
|
element.style.position = 'relative';
|
|
// Room for the outline and the label above it, so two bands in a row do not touch. Kept inline
|
|
// because it is layout and must hold whether or not anything is hovered — the outline itself is in
|
|
// the stylesheet, INSET, so a band filling the column never draws past the measure.
|
|
element.style.margin = '2.5rem 0';
|
|
|
|
undo.push(() => {
|
|
element.style.position = previousPosition;
|
|
element.style.margin = previousMargin;
|
|
});
|
|
|
|
const bar = document.createElement('div');
|
|
bar.setAttribute(MARK, 'section-bar');
|
|
style(bar, [
|
|
'position:absolute',
|
|
'top:-1.75rem',
|
|
'left:0',
|
|
'right:0',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'gap:0.5rem',
|
|
'font-size:0.6875rem',
|
|
`color:${ACCENT}`,
|
|
// The page's own font would make this look like content. It is not content.
|
|
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
]);
|
|
|
|
const label = document.createElement('span');
|
|
label.textContent = section.name;
|
|
style(label, ['font-weight:600']);
|
|
|
|
const shape = document.createElement('span');
|
|
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
|
|
style(shape, ['opacity:0.7']);
|
|
|
|
/*
|
|
Spacing before Delete, and both on the right.
|
|
|
|
The order is how often they are wanted, not how they read: spacing is an ordinary adjustment and
|
|
deleting a band is not, so the destructive one sits furthest from where the hand arrives.
|
|
*/
|
|
const actions: HTMLElement[] = [];
|
|
for (const [text, run, first] of [
|
|
['Spacing', () => canvas.onSpaceSection(section), true],
|
|
['Delete', () => canvas.onDeleteSection(section), false],
|
|
] as const) {
|
|
const action = document.createElement('button');
|
|
action.type = 'button';
|
|
action.textContent = text;
|
|
style(action, [
|
|
first ? 'margin-left:auto' : '',
|
|
'cursor:pointer',
|
|
'background:none',
|
|
'border:0',
|
|
'padding:0',
|
|
'font:inherit',
|
|
'color:inherit',
|
|
'opacity:0.7',
|
|
'text-decoration:underline',
|
|
]);
|
|
action.addEventListener('click', run);
|
|
undo.push(() => action.removeEventListener('click', run));
|
|
actions.push(action);
|
|
}
|
|
|
|
bar.append(label, shape, ...actions);
|
|
element.append(bar);
|
|
undo.push(() => bar.remove());
|
|
|
|
/*
|
|
The columns, outlined too.
|
|
|
|
An empty column has height and nothing else, so a two-column band and a one-column band look
|
|
identical until something is in them — which is exactly when it is too late to notice you chose
|
|
the wrong one.
|
|
*/
|
|
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
|
|
/*
|
|
Every component in this column gets a handle.
|
|
|
|
Drawn ON the rendered component rather than replacing it — an editor that swaps content for a
|
|
placeholder is showing you the editor, not the page. Free text with nothing written in it
|
|
renders as nothing, so the outline is the only thing saying it is there at all.
|
|
*/
|
|
const mine = section.components.filter((component) => component.column === columnIndex);
|
|
for (const component of mine) {
|
|
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
|
|
if (!node) continue;
|
|
|
|
const wasPosition = node.style.position;
|
|
node.style.position = 'relative';
|
|
// The outline and the tools' visibility are the stylesheet's, so they can depend on :hover.
|
|
// The ID, not an empty marker: the panel writes a draft into this node as somebody types, and
|
|
// has to be able to find the one being edited.
|
|
node.setAttribute('data-fofos-component', String(component.id));
|
|
undo.push(() => {
|
|
node.style.position = wasPosition;
|
|
node.removeAttribute('data-fofos-component');
|
|
});
|
|
|
|
const tools = document.createElement('div');
|
|
tools.setAttribute(MARK, 'component-tools');
|
|
style(tools, [
|
|
'position:absolute',
|
|
// Above its own top-right corner rather than inside it, so even while visible it sits in the
|
|
// gap between components instead of over the first line of this one.
|
|
'top:-1.25rem',
|
|
'right:0',
|
|
'display:flex',
|
|
'gap:0.5rem',
|
|
'padding:0 0.25rem',
|
|
'font-size:0.625rem',
|
|
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
`color:${ACCENT}`,
|
|
'background:var(--site-bg, #fff)',
|
|
// Above the page, below the section bar, and never a target when invisible.
|
|
'z-index:5',
|
|
]);
|
|
|
|
for (const [label, run] of [
|
|
['Edit', () => canvas.onEditComponent(component)],
|
|
['Spacing', () => canvas.onSpaceComponent(component)],
|
|
['Delete', () => canvas.onDeleteComponent(component)],
|
|
] as const) {
|
|
const action = document.createElement('button');
|
|
action.type = 'button';
|
|
action.textContent = label;
|
|
style(action, [
|
|
'cursor:pointer',
|
|
'background:none',
|
|
'border:0',
|
|
'padding:0',
|
|
'font:inherit',
|
|
'color:inherit',
|
|
'text-decoration:underline',
|
|
]);
|
|
action.addEventListener('click', run);
|
|
undo.push(() => action.removeEventListener('click', run));
|
|
tools.append(action);
|
|
}
|
|
|
|
node.append(tools);
|
|
undo.push(() => tools.remove());
|
|
}
|
|
|
|
/*
|
|
And a way to put something in it.
|
|
|
|
One per column, always at the bottom, so a column that already has three paragraphs still has
|
|
somewhere obvious to add a fourth. Quieter than Add section — this is the inner loop, and a
|
|
button as loud as the one that adds a whole band would make every column shout.
|
|
*/
|
|
const add = document.createElement('button');
|
|
add.setAttribute(MARK, 'add-component');
|
|
add.type = 'button';
|
|
add.textContent = '+ Add component';
|
|
style(add, [
|
|
'display:block',
|
|
'width:100%',
|
|
'margin-top:0.75rem',
|
|
'padding:0.5rem',
|
|
'cursor:pointer',
|
|
'background:none',
|
|
`border:1px dashed color-mix(in srgb, ${ACCENT} 40%, transparent)`,
|
|
'border-radius:0.375rem',
|
|
`color:${ACCENT}`,
|
|
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
'font-size:0.75rem',
|
|
]);
|
|
const onAdd = () => canvas.onAddComponent(section, columnIndex);
|
|
add.addEventListener('click', onAdd);
|
|
columnElement.append(add);
|
|
undo.push(() => {
|
|
add.removeEventListener('click', onAdd);
|
|
add.remove();
|
|
});
|
|
});
|
|
}
|
|
|
|
// The measure. Drawn for anything that shows a page — see paintMeasure.
|
|
undo.push(paintMeasure(document));
|
|
|
|
// ── Add section, always last ─────────────────────────────────────────────────────────────────
|
|
/*
|
|
Add section goes inside the CONTENT COLUMN, not `main`.
|
|
|
|
`main` is the full width of the page; the column is what holds it to its measure. Appended to main,
|
|
the drop zone ran edge to edge and straight past the guides — describing a place a section could go
|
|
that no section will ever occupy.
|
|
|
|
Falls back to `main` for a page that opts out of the measure (`full` in Layout), where the column
|
|
genuinely is the page.
|
|
*/
|
|
const zoneParent = root.querySelector<HTMLElement>('[data-content-column]') ?? root;
|
|
|
|
const zone = document.createElement('div');
|
|
zone.setAttribute(MARK, 'add-section');
|
|
style(zone, [
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:center',
|
|
// Tall when the page is empty, modest once there is something to add to — the zone should not
|
|
// dwarf the content it sits under.
|
|
canvas.sections.length ? 'min-height:8rem' : 'min-height:40vh',
|
|
'margin:2.5rem 0',
|
|
'border:2px dashed color-mix(in srgb, currentColor 25%, transparent)',
|
|
'border-radius:0.75rem',
|
|
]);
|
|
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = '+ Add section';
|
|
style(button, [
|
|
'cursor:pointer',
|
|
'padding:0.75rem 1.5rem',
|
|
'border-radius:0.5rem',
|
|
// The page's own colours, inverted, so the control belongs to whatever theme the site is wearing.
|
|
'background:var(--site-fg, #000)',
|
|
'color:var(--site-bg, #fff)',
|
|
'border:0',
|
|
'font:inherit',
|
|
'font-size:0.875rem',
|
|
]);
|
|
button.addEventListener('click', canvas.onAddSection);
|
|
|
|
zone.append(button);
|
|
zoneParent.append(zone);
|
|
undo.push(() => {
|
|
button.removeEventListener('click', canvas.onAddSection);
|
|
zone.remove();
|
|
});
|
|
|
|
return () => {
|
|
// Reversed, so a style put back is put back over the value that replaced it rather than under.
|
|
for (const step of undo.reverse()) step();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The content column's edges, as two lines and a width.
|
|
*
|
|
* ── Why this is on its own ──
|
|
*
|
|
* Every preview wants it and only one of them wants the rest. The page builder draws sections, columns
|
|
* and a drop zone; the Layout section draws none of those — its bands are code and nothing there can be
|
|
* rearranged — but it has exactly the same question about where the measure ends.
|
|
*
|
|
* ── Why it needs saying at all ──
|
|
*
|
|
* The page is held to `max-w-7xl` and centred, so on a wide screen the content stops well short of the
|
|
* window and nothing marks where. That is invisible until something is placed against the edge and
|
|
* turns out to be in the wrong place, which is the moment it becomes expensive.
|
|
*
|
|
* Returns a cleanup, like everything else injected here.
|
|
*/
|
|
export function paintMeasure(document: Document): () => void {
|
|
const root = document.querySelector<HTMLElement>('main');
|
|
const column = root?.querySelector<HTMLElement>('[data-content-column]');
|
|
if (!column) return () => {};
|
|
|
|
const guides: HTMLElement[] = [];
|
|
|
|
const place = () => {
|
|
for (const guide of guides) guide.remove();
|
|
guides.length = 0;
|
|
|
|
const box = column.getBoundingClientRect();
|
|
// Below the frame's own width there is no measure to show — the column simply IS the page, and two
|
|
// lines against the edges would be noise claiming to be information.
|
|
if (box.width >= (document.documentElement.clientWidth || 0) - 1) return;
|
|
|
|
for (const [side, x] of [
|
|
['left', box.left],
|
|
['right', box.right],
|
|
] as const) {
|
|
const guide = document.createElement('div');
|
|
guide.setAttribute(MARK, `measure-${side}`);
|
|
style(guide, [
|
|
// `fixed`, so they stay while the page scrolls. A guide that scrolls away is a guide for the
|
|
// top of the page only.
|
|
'position:fixed',
|
|
'top:0',
|
|
'bottom:0',
|
|
`left:${Math.round(x)}px`,
|
|
'width:1px',
|
|
`background:color-mix(in srgb, ${ACCENT} 35%, transparent)`,
|
|
// It must not swallow a click meant for the page underneath it.
|
|
'pointer-events:none',
|
|
'z-index:2147483000',
|
|
]);
|
|
document.body.append(guide);
|
|
guides.push(guide);
|
|
}
|
|
|
|
const label = document.createElement('div');
|
|
label.setAttribute(MARK, 'measure-label');
|
|
label.textContent = `${Math.round(box.width)}px`;
|
|
style(label, [
|
|
'position:fixed',
|
|
'top:0.25rem',
|
|
`left:${Math.round(box.left + 4)}px`,
|
|
'font-size:0.625rem',
|
|
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
`color:${ACCENT}`,
|
|
'opacity:0.8',
|
|
'pointer-events:none',
|
|
'z-index:2147483000',
|
|
]);
|
|
document.body.append(label);
|
|
guides.push(label);
|
|
};
|
|
|
|
place();
|
|
// The frame's width changes when the device does, and a remount is not the only way that happens.
|
|
const frameWindow = document.defaultView;
|
|
frameWindow?.addEventListener('resize', place);
|
|
|
|
return () => {
|
|
frameWindow?.removeEventListener('resize', place);
|
|
for (const guide of guides) guide.remove();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The site's gutter, applied to a rendered page before it is saved.
|
|
*
|
|
* ── Why this can be classes and not variables ──
|
|
*
|
|
* Colours and typefaces go into the frame as CSS custom properties, because that is what the page reads
|
|
* them as. A gutter is Tailwind classes — `px-4 md:px-8` — because only a class can carry a breakpoint,
|
|
* and there is no variable to set instead.
|
|
*
|
|
* That works here for one reason: the classes are in the SITE's stylesheet, which the frame already
|
|
* has. The site safelists every step (`@source inline` in styles.css), so any value this writes is one
|
|
* the frame can already render. A class invented here would silently do nothing.
|
|
*
|
|
* ── Only gutter classes are touched ──
|
|
*
|
|
* The elements carrying `data-gutter` hold the gutter and nothing else that looks like it: a section's
|
|
* own padding lands on the <section>, and the band padding on <header>. So removing everything matching
|
|
* `px-N` from these three is safe, where doing it to an arbitrary element would not be.
|
|
*/
|
|
export function applyGutter(document: Document, classes: string): () => void {
|
|
const targets = [...document.querySelectorAll<HTMLElement>('[data-gutter]')];
|
|
const before = targets.map((element) => element.className);
|
|
|
|
const GUTTER = /^(?:md:|lg:)?px-\d+$/;
|
|
|
|
for (const element of targets) {
|
|
const kept = element.className.split(/\s+/).filter((name) => name && !GUTTER.test(name));
|
|
element.className = [...kept, ...classes.split(/\s+/).filter(Boolean)].join(' ');
|
|
}
|
|
|
|
return () => {
|
|
targets.forEach((element, index) => {
|
|
element.className = before[index]!;
|
|
});
|
|
};
|
|
}
|