Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

<!-- Challenge: Show the toolbar when the virtual keyboard is visible.-->
<!-- Solution: enable the VirtualKeyboard: overlaysContent property -->
<!-- Caveat: This only works in Chrome (Android) so far -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" spellcheck="false">
Expand Down
1 change: 1 addition & 0 deletions src/lib/ListBlock.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
data-type="block"
data-index={path.at(-1)}
style="anchor-name: --{path.join('-')};"
class='p-10 max-w-screen-md mx-auto'
>
<Container class="list" path={[...path, 'items']}>
<!-- NOTE: We only allow list items inside list -->
Expand Down
55 changes: 35 additions & 20 deletions src/lib/StoryBlock.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
</script>

<div
class="story-block layout-{block.layout} p-2"
class="story-block layout-{block.layout} max-w-screen-lg mx-auto w-full"
data-path={path.join('.')}
data-type="block"
data-index={path.at(-1)}
style="anchor-name: --{path.join('-')};"
>
<div class='non-text-content' contenteditable="false">
<div
class='non-text-content'
contenteditable="false"
>
<!-- svelte-ignore a11y_img_redundant_alt -->
<img src={block.image} alt="Random image" />
</div>
Expand All @@ -30,18 +33,28 @@

<style>
.story-block {
container-type: inline-size;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(30ch, 100%), 1fr));
gap: var(--s-10);
img {
width: 100%;
height: auto;
/* Don't crop SVGs */
&[src*=".svg"] {
object-fit: contain;
object-position: center;
}
grid-template-columns: 1fr;
/* Apply padding on the sides of the block, but only on devices that need it, e.g. iPhone with notch */
/* Learn more about this technique here: https://kulturbanause.de/blog/websites-fuer-das-iphone-x-optimieren-weisse-balken-entfernen-viewport-anpassen-safe-area-festlegen/ */
padding-inline-start: max(var(--s-10), env(safe-area-inset-left, 0px));
padding-inline-end: max(var(--s-10), env(safe-area-inset-right, 0px));
padding-block-start: max(var(--s-10), env(safe-area-inset-top, 0px));
padding-block-end: max(var(--s-10), env(safe-area-inset-bottom, 0px));
@media (min-width: 680px) {
grid-template-columns: 1fr 1fr;
}
/* gap: var(--s-10); */
}
.story-block img {
width: 100%;
height: auto;
}
/* Don't crop SVGs */
.story-block img[src*=".svg"] {
object-fit: contain;
object-position: center;
}

.non-text-content {
Expand All @@ -50,8 +63,8 @@
display: flex;
align-items: center;
justify-content: center;
min-width: 30ch;
@media (max-width: 768px) {
min-width: 340px;
@media (max-width: 680px) {
min-width: 100%;
}
}
Expand All @@ -61,12 +74,14 @@
align-items: center;
}

.story-block.layout-2 > div:first-child {
order: 2;
}

.story-block.layout-2 > div:last-child {
order: 1;
@container (min-width: 680px) {
/* on mobile display image on top of text */
.story-block.layout-2 > div:first-child {
order: 2;
}
.story-block.layout-2 > div:last-child {
order: 1;
}
}

.story-block.layout-3 > div:first-child {
Expand Down
88 changes: 82 additions & 6 deletions src/lib/TextToolBar.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,66 @@
<script>
import { fly } from 'svelte/transition';
import Icon from '$lib/Icon.svelte';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';

let {
entry_session,
} = $props();
let { entry_session } = $props();

// Visual Viewport handling:
// Position the toolbar above the soft keyboard
// This prevents the keyboard from overlapping the toolbar on mobile devices
//
// Issue with current approach:
// When changing scroll direction, browsers (e.g., Safari) initially adjust the visual viewport
// instead of scrolling the page. This continues until the viewport shift equals the difference
// between the initial scroll position and the screen height minus the visual viewport height.
//
// During this adjustment period, the toolbar's position appears incorrect:
// - When scrolling up: Not an issue, as the toolbar is temporarily hidden below the keyboard.
// - When scrolling down: The toolbar appears above the content, away from the visual viewport's
// bottom edge. This creates a risk of users accidentally tapping toolbar buttons while scrolling.
//
// The toolbar's position only normalizes once actual page scrolling begins.
//
// Workarounds:
// - checking the scroll direction and temporarily hiding the toolbar could be a workaround. But there seems to be also a delay…
// Using this new API (not yet supported): navigator.virtualKeyboard.addEventListener("geometrychange", () => {})
//
// Related resources to this open issue:
// https://developer.chrome.com/docs/web-platform/virtual-keyboard
// https://ishadeed.com/article/virtual-keyboard-api/
// https://developer.chrome.com/blog/viewport-resize-behavior
// https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/VirtualKeyboardAPI/explainer.md
// https://www.bram.us/2021/09/13/prevent-items-from-being-hidden-underneath-the-virtual-keyboard-by-means-of-the-virtualkeyboard-api/
// https://blog.opendigerati.com/the-eccentric-ways-of-ios-safari-with-the-keyboard-b5aa3f34228d
//
// Bug report for Safari:
// https://bugs.webkit.org/show_bug.cgi?id=230225


function updateViewport() {
if (browser) {
const viewport_height = window.visualViewport?.height || window.innerHeight;
const viewport_offset = window.visualViewport?.offsetTop || 0;
const toolbar_top_position = viewport_height + viewport_offset - 60; // 44 (toolbar height) + 16 (bottom offset)
document.documentElement.style.setProperty('--toolbar-top-position', `${toolbar_top_position}px`);
}
}

onMount(() => {
if (browser) {
updateViewport();
window.visualViewport?.addEventListener('resize', updateViewport);
window.visualViewport?.addEventListener('scroll', updateViewport);
}
});

onDestroy(() => {
if (browser) {
window.visualViewport?.removeEventListener('resize', updateViewport);
window.visualViewport?.removeEventListener('scroll', updateViewport);
}
});

const layout_options = [
{ value: 1, label: 'Image left', icon: 'image-left' },
Expand Down Expand Up @@ -47,7 +103,11 @@
</script>


<div class="editor-toolbar p-1" in:fly={{ duration: 100, y: 5 }} out:fly={{ duration: 100, y: 5 }}>
<div
class="editor-toolbar p-1"
in:fly={{ duration: 100, y: 5 }}
out:fly={{ duration: 100, y: 5 }}
>
{#if entry_session.selection?.type === 'container'}
<button
title='Move up'
Expand Down Expand Up @@ -138,7 +198,19 @@
</div>

<style>
/* only use the javascript visual viewport placement technique when the contenteditable is focused */
@media (max-width: 768px) {
:global(body:has(:is([contenteditable="true"]):focus)) {
.editor-toolbar {
top: var(--toolbar-top-position);
bottom: auto;
}
}
}

.editor-toolbar {
--toolbar-height: 44px;
--toolbar-bottom-offset: var(--s-4);
color: var(--primary-text-color);
background-color: var(--canvas-fill-color);
width: fit-content;
Expand All @@ -152,21 +224,25 @@
z-index: 50;
flex-direction: column;
align-items: center;
transition: all 0.1s ease-in-out 200ms;
overflow-y: hidden;

@media (max-width: 768px) {
top: auto;
bottom: var(--s-4);
/* alternative css only approach for Chromium / Android */
bottom: max(calc(env(keyboard-inset-height, 0px) + var(--toolbar-bottom-offset)), var(--toolbar-bottom-offset));
left: 50%;
transform: translateX(-50%);
flex-direction: row;
max-width: calc(100vw - 2 * var(--s-4));
height: var(--toolbar-height);
overflow-x: auto;
scrollbar-width: thin;
}

button {
height: 100%;
min-height: 44px;
min-height: var(--toolbar-height);
--icon-color: var(--primary-text-color);
position: relative;
&.active {
Expand Down
22 changes: 21 additions & 1 deletion src/lib/styles/spacing.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
.m-8 { --m: var(--s-8) } .ms-8 { --ms: var(--s-8); } .me-8 { --me: var(--s-8); } .mbs-8 { --mbs: var(--s-8); } .mbe-8 { --mbe: var(--s-8); } .mx-8 { --mx: var(--s-8); } .my-8 { --my: var(--s-8); }
.m-9 { --m: var(--s-9) } .ms-9 { --ms: var(--s-9); } .me-9 { --me: var(--s-9); } .mbs-9 { --mbs: var(--s-9); } .mbe-9 { --mbe: var(--s-9); } .mx-9 { --mx: var(--s-9); } .my-9 { --my: var(--s-9); }
.m-10 { --m: var(--s-10)} .ms-10 { --ms: var(--s-10);} .me-10 { --me: var(--s-10); } .mbs-10 { --mbs: var(--s-10); } .mbe-10 { --mbe: var(--s-10); } .mx-10 { --mx: var(--s-10); } .my-10 { --my: var(--s-10); }
.m-auto { margin: auto; }
.mx-auto { margin-inline: auto; }
.my-auto { margin-block: auto; }
.ms-auto { margin-inline-start: auto; }
.me-auto { margin-inline-end: auto; }
.mbs-auto { margin-block-start: auto; }
.mbe-auto { margin-block-end: auto; }


/* for less than ~10 spacing values, we use the following approach */
Expand Down Expand Up @@ -87,4 +94,17 @@

.gap-05, .gap-1, .gap-2, .gap-3, .gap-4, .gap-5, .gap-6, .gap-7, .gap-8, .gap-9, .gap-10 { gap: var(--g); }
.gap-x-05, .gap-x-1, .gap-x-2, .gap-x-3, .gap-x-4, .gap-x-5, .gap-x-6, .gap-x-7, .gap-x-8, .gap-x-9, .gap-x-10 { column-gap: var(--gx); }
.gap-y-05, .gap-y-1, .gap-y-2, .gap-y-3, .gap-y-4, .gap-y-5, .gap-y-6, .gap-y-7, .gap-y-8, .gap-y-9, .gap-y-10 { row-gap: var(--gy); }
.gap-y-05, .gap-y-1, .gap-y-2, .gap-y-3, .gap-y-4, .gap-y-5, .gap-y-6, .gap-y-7, .gap-y-8, .gap-y-9, .gap-y-10 { row-gap: var(--gy); }



/* Max-Width */
.max-w-prose { max-width: 65ch; }
.max-w-screen-sm { max-width: 640px; }
.max-w-screen-md { max-width: 768px; }
.max-w-screen-lg { max-width: 1024px; }
.max-w-screen-xl { max-width: 1280px; }
.max-w-screen-2xl { max-width: 1536px; }

/* Width */
.w-full { width: 100%; }
38 changes: 14 additions & 24 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
let entry_session = new EntrySession({
type: 'page',
title: ['Svedit', []],
subtitle: ['A template for building rich content editors with Svelte 5', [
subtitle: ['A template for building rich content editors with Svelte 5', [
[24, 44, 'emphasis']
]],
body: [
{ type: 'story', layout: 1, image: '/images/editable.svg', title: ['Visual in-place editing', []], description: ['Model your content in JSON, render it with Svelte components, and edit content directly in the layout. You only have to follow a couple of rules to make this work.', []] },
{ type: 'story', layout: 1, image: '/images/editable.svg', title: ['Visual inplace editing', []], description: ['Model your content in JSON, render it with Svelte components, and edit content directly in the layout. You only have to follow a couple of rules to make this work.', []] },
{ type: 'story', layout: 2, image: '/images/lightweight.svg', title: ['Minimal viable editor', []], description: ["The reference implementation uses only about 1000 lines of code. That means you'll be able to serve editable web pages, removing the need for a separate Content Management System.", [[100,118, "link", { "href": "https://editable.website"}]]] },
{ type: 'story', layout: 1, image: '/images/nested-blocks-illustration.svg', title: ['Nested blocks', []], description: ['A block can embed a container of other blocks. For instance the list block below has a container of list items.', []] },
{ type: 'story', layout: 2, image: '/images/container-cursors.svg', title: ['Container cursors', []], description: ['They work just like text cursors, but instead of a character position in a string they address a block position in a container.\n\nTry it by selecting a few blocks, then press ↑ or ↓. Press ↵ to insert a new block or ⌫ to delete the block before the cursor.', []] },
Expand All @@ -24,7 +24,7 @@
type: 'list',
list_style: 'decimal-leading-zero',
items: [
{ type: 'list_item', description: ['Images can not yet be selected and changed. We\'ll solve this by making any non-text property selectable on the canvas, and show a popover (e.g. an image selector, or a math formula editor) to make changes, which will then be reflected in the canvas display immediately.' , []] },
{ type: 'list_item', description: ['Images can not yet be selected and changed. We\'ll solve this by making any nontext property selectable on the canvas, and show a popover (e.g. an image selector, or a math formula editor) to make changes, which will then be reflected in the canvas display immediately.' , []] },
{ type: 'list_item', description: ['Container selections inside nested blocks (e.g. list items in this list) do not work reliably yet.', []] },
{ type: 'list_item', description: ['Only the latest Chrome is supported at the moment as we rely on CSS Anchor Positioning for overlays.', []] },
{ type: 'list_item', description: ['Full mobile support is considered in our design, but not yet implemented.', []] },
Expand All @@ -38,15 +38,16 @@
<title>Svedit - A rich content editor for Svelte 5</title>
</svelte:head>

<div class="demo-wrapper pbs-10">
<div class="demo-wrapper">
<TextToolBar {entry_session} />


<Svedit {entry_session} editable={true} class='flex-column gap-y-10'>
<Text path={['title']} class='heading1' />
<Text path={['subtitle']} class='heading3' />
<Svedit {entry_session} editable={true} class='flex-column'>
<div class='flex-column gap-y-10 p-10 max-w-screen-lg mx-auto w-full'>
<Text path={['title']} class='heading1' />
<Text path={['subtitle']} class='heading3' />
</div>
<!-- NOTE: non-editable island must have contenteditable="false" and contain some text content, otherwise invalid selections occur. -->
<div contenteditable="false" style="background: #eee; opacity: 0.5; padding: 20px;">
<div contenteditable="false" style="background: #eee; opacity: 0.5;" class='p-10 max-w-screen-lg mx-auto'>
<div><div>In this example the title and subtitle above are editable, but this piece of content here is not. Below is a container of Story and List blocks:</div></div>
</div>
<Container class="body flex-column gap-y-10" path={['body']}>
Expand All @@ -60,10 +61,11 @@
{/if}
{/snippet}
</Container>
</Svedit>
</Svedit>

<hr/>

<div class='flex-column gap-y-2 my-10'>
<div class='flex-column gap-y-2 my-10 w-full max-w-screen-lg mx-auto'>
<p>Selection:</p>
<pre class='debug-info p-4'>{JSON.stringify(entry_session.selection || {}, null, ' ')}</pre>
<p>Entry:</p>
Expand All @@ -73,19 +75,7 @@

<style>
.demo-wrapper {
padding-inline: var(--s-10);
max-width: 100ch;
margin-inline: auto;
background: var(--canvas-fill-color);

/* We want a two column layout for the block container. */
/* :global(.body) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
width: 100%;
} */

/* no paddings or margins here on the body, so Blocks can use the full width (edge to edge layouts) */
}
.debug-info {
text-wrap: wrap;
Expand Down