Skip to content

Commit bbda5ec

Browse files
Update link annotations; open links with anchor popover (#13)
1 parent 28b2adf commit bbda5ec

9 files changed

+165
-36
lines changed

src/lib/EntrySession.svelte.js

+71-26
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ export default class EntrySession {
3333
get_selected_block_path() {
3434
let sel = this.selection;
3535
if (sel?.type === 'container') {
36-
let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
37-
let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
36+
const { start, end } = this.get_selection_range();
3837
if (start + 1 === end) {
3938
return [...sel.path, start];
4039
}
@@ -88,10 +87,7 @@ export default class EntrySession {
8887
active_annotation(annotation_type) {
8988
if (this.selection?.type !== 'text') return null;
9089

91-
const [start, end] = [
92-
Math.min(this.selection.anchor_offset, this.selection.focus_offset),
93-
Math.max(this.selection.anchor_offset, this.selection.focus_offset)
94-
];
90+
const { start, end } = this.get_selection_range();
9591
const annotated_text = this.get(this.selection.path);
9692
const annotations = annotated_text[1];
9793

@@ -132,10 +128,7 @@ export default class EntrySession {
132128
if (this.selection?.type !== 'container') return;
133129
const container = this.get(this.selection.path); // container is an array of blocks
134130

135-
const [start, end] = [
136-
Math.min(this.selection.anchor_offset, this.selection.focus_offset),
137-
Math.max(this.selection.anchor_offset, this.selection.focus_offset)
138-
];
131+
const { start, end } = this.get_selection_range();
139132

140133
if (this.selection.anchor_offset !== this.selection.focus_offset) {
141134
// If selection is not collapsed, collapse it to the right or the left
@@ -168,22 +161,67 @@ export default class EntrySession {
168161

169162
annotate_text(annotation_type, annotation_data) {
170163
if (this.selection.type !== 'text') return;
171-
// You can not annotate text if the selection is collapsed.
172-
if (this.selection.focus_offset === this.selection.anchor_offset) return;
173164

174-
const [start, end] = [
175-
Math.min(this.selection.anchor_offset, this.selection.focus_offset),
176-
Math.max(this.selection.anchor_offset, this.selection.focus_offset)
177-
];
165+
const { start, end } = this.get_selection_range();
166+
178167
const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path)));
179168
const annotations = annotated_text[1];
180169
const existing_annotations = this.active_annotation();
181170

171+
172+
// Special annotation type handling should probably be done in a separate function.
173+
// The goal is to keep the core logic simple and allow developer to extend and pick only what they need.
174+
// It could also be abstracted to not check for type (e.g. "link") but for a special attribute
175+
// e.g. "zero-range-updatable" for annotations that are updatable without a range selection change.
176+
177+
178+
// Special handling for links when there's no selection range
179+
// Links should be updatable by just clicking on them without a range selection
180+
if (annotation_type === 'link' && start === end && existing_annotations) {
181+
182+
// Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf)
183+
const index = annotations.findIndex(anno =>
184+
anno[0] === existing_annotations[0] &&
185+
anno[1] === existing_annotations[1] &&
186+
anno[2] === existing_annotations[2]
187+
);
188+
// const index = annotations.indexOf(existing_annotations);
189+
190+
if (index !== -1) {
191+
if (annotation_data.href === '') {
192+
// Remove the annotation if the href is empty
193+
annotations.splice(index, 1);
194+
} else {
195+
annotations[index] = [
196+
existing_annotations[0],
197+
existing_annotations[1],
198+
'link',
199+
{ ...existing_annotations[3], ...annotation_data }
200+
];
201+
}
202+
203+
this.set(this.selection.path, annotated_text);
204+
return;
205+
}
206+
}
207+
208+
// Regular annotation handling
209+
if (start === end) {
210+
// For non-link annotations: You can not annotate text if the selection is collapsed.
211+
return;
212+
}
213+
182214
if (existing_annotations) {
183215
// If there's an existing annotation of the same type, remove it
184216
if (existing_annotations[2] === annotation_type) {
185-
const index = annotations.indexOf(existing_annotations);
186-
annotations.splice(index, 1);
217+
const index = annotations.findIndex(anno =>
218+
anno[0] === existing_annotations[0] &&
219+
anno[1] === existing_annotations[1] &&
220+
anno[2] === existing_annotations[2]
221+
);
222+
if (index !== -1) {
223+
annotations.splice(index, 1);
224+
}
187225
} else {
188226
// If there's an annotation of a different type, don't add a new one
189227
return;
@@ -281,10 +319,7 @@ export default class EntrySession {
281319
if (this.selection.type !== 'text') return;
282320

283321
const annotated_text = structuredClone($state.snapshot(this.get(this.selection.path)));
284-
const [start, end] = [
285-
Math.min(this.selection.anchor_offset, this.selection.focus_offset),
286-
Math.max(this.selection.anchor_offset, this.selection.focus_offset)
287-
];
322+
const { start, end } = this.get_selection_range();
288323

289324
// Transform the plain text string.
290325
annotated_text[0] = annotated_text[0].slice(0, start) + replaced_text + annotated_text[0].slice(end);
@@ -426,10 +461,7 @@ export default class EntrySession {
426461

427462
const path = this.selection.path;
428463
const container = [...this.get(path)];
429-
const [start, end] = [
430-
Math.min(this.selection.anchor_offset, this.selection.focus_offset),
431-
Math.max(this.selection.anchor_offset, this.selection.focus_offset)
432-
];
464+
const { start, end } = this.get_selection_range();
433465

434466
const is_moving_up = direction === 'up';
435467
const offset = is_moving_up ? -1 : 1;
@@ -458,4 +490,17 @@ export default class EntrySession {
458490
move_down() {
459491
this.move('down');
460492
}
493+
494+
get_selection_range() {
495+
if (!this.selection) return null;
496+
497+
const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
498+
const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
499+
500+
return {
501+
start,
502+
end,
503+
length: end - start
504+
};
505+
}
461506
}

src/lib/Svedit.svelte

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import { setContext } from 'svelte';
3+
import Icon from '$lib/Icon.svelte';
34
45
let {
56
entry_session,
@@ -12,6 +13,7 @@
1213
let is_mouse_down = $state(false);
1314
let container_selection_paths = $derived(get_container_selection_paths());
1415
let container_cursor_info = $derived(get_container_cursor_info());
16+
let text_selection_info = $derived(get_text_selection_info());
1517
1618
function get_container_selection_paths() {
1719
const paths = [];
@@ -54,6 +56,29 @@
5456
}
5557
}
5658
59+
function get_text_selection_info() {
60+
const sel = entry_session.selection;
61+
if (!sel || sel.type !== 'text') return null;
62+
63+
const active_annotation = entry_session.active_annotation();
64+
if (active_annotation && active_annotation[2] === 'link') {
65+
const annotated_text = entry_session.get(sel.path);
66+
const annotation_index = annotated_text[1].indexOf(active_annotation);
67+
return {
68+
path: sel.path,
69+
annotation: active_annotation,
70+
annotation_index: annotation_index
71+
};
72+
}
73+
return null;
74+
}
75+
76+
function open_link() {
77+
if (text_selection_info?.annotation?.[3]?.href) {
78+
window.open(text_selection_info.annotation[3].href, '_blank');
79+
}
80+
}
81+
5782
setContext("svedit", {
5883
get entry_session() {
5984
return entry_session;
@@ -658,6 +683,15 @@
658683
style="position-anchor: --{container_cursor_info.path.join('-')};"
659684
></div>
660685
{/if}
686+
687+
{#if text_selection_info}
688+
<div
689+
class="text-selection-overlay"
690+
style="position-anchor: --{text_selection_info.path.join('-') + '-' + text_selection_info.annotation_index};"
691+
>
692+
<button onclick={open_link} class="small"><Icon name="external-link" /></button>
693+
</div>
694+
{/if}
661695
</div>
662696
</div>
663697
@@ -725,4 +759,19 @@
725759
/* div.hide-selection :global(::selection) {
726760
background: transparent;
727761
} */
728-
</style>
762+
763+
.text-selection-overlay {
764+
position: absolute;
765+
top: anchor(top);
766+
left: anchor(right);
767+
pointer-events: auto;
768+
transform: translateX(var(--s-1)) translateY(-12px);
769+
z-index: 10;
770+
}
771+
772+
.text-selection-overlay button {
773+
color: var(--primary-text-color);
774+
--icon-color: var(--primary-text-color);
775+
box-shadow: var(--shadow-2);
776+
}
777+
</style>

src/lib/Text.svelte

+7-4
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// Sort annotations by start_offset
1212
const sorted_annotations = $state.snapshot(annotations).sort((a, b) => a[0] - b[0]);
1313
14-
for (let annotation of sorted_annotations) {
14+
for (let [index, annotation] of sorted_annotations.entries()) {
1515
// Add text before the annotation
1616
if (annotation[0] > last_index) {
1717
fragments.push(text.slice(last_index, annotation[0]));
@@ -22,6 +22,7 @@
2222
fragments.push({
2323
type: annotation[2],
2424
content: annotated_content,
25+
annotation_index: index,
2526
...annotation[3]
2627
});
2728
@@ -38,6 +39,7 @@
3839
3940
let fragments = $derived(render_annotated_text(svedit.entry_session.get(path)[0], svedit.entry_session.get(path)[1]));
4041
let plain_text = $derived(svedit.entry_session.get(path)[0]);
42+
4143
</script>
4244

4345

@@ -46,19 +48,20 @@
4648
contenteditable="true"
4749
data-type="text"
4850
data-path={path.join('.')}
51+
style="anchor-name: --{path.join('-')};"
4952
class={css_class}
5053
><!--
5154
--><!-- Zero-width space for empty text --><!--
5255
-->{#if plain_text.length === 0}&#8203;{/if}<!--
53-
-->{#each fragments as fragment}<!--
56+
-->{#each fragments as fragment, index}<!--
5457
-->{#if typeof fragment === 'string'}<!--
5558
-->{fragment}<!--
5659
-->{:else if fragment.type === 'emphasis'}<!--
5760
--><em>{fragment.content}</em><!--
5861
-->{:else if fragment.type === 'strong'}<!--
5962
--><strong>{fragment.content}</strong><!--
6063
-->{:else if fragment.type === 'link'}<!--
61-
--><a href={fragment.href}>{fragment.content}</a><!--
64+
--><a onclick={(e)=> {e.preventDefault()}} style="anchor-name: --{path.join('-') + '-' + fragment.annotation_index};" href={fragment.href}>{fragment.content}</a><!--
6265
-->{:else}<!--
6366
-->{fragment.content}<!--
6467
-->{/if}<!--
@@ -77,4 +80,4 @@
7780
cursor: text;
7881
}
7982
}
80-
</style>
83+
</style>

src/lib/TextToolBar.svelte

+14-4
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,19 @@
3939
}
4040
4141
function insert_link() {
42-
entry_session.annotate_text('link', {
43-
href: window.prompt('Enter the URL')
44-
});
42+
// if the user cancels the prompt it will use the previous link
43+
const current_link = entry_session.active_annotation()?.[2] === 'link'
44+
? entry_session.active_annotation()[3].href
45+
: '';
46+
47+
const new_url = window.prompt('Enter the URL', current_link);
48+
49+
// Update if the user didn't cancel the prompt
50+
if (new_url !== null) {
51+
entry_session.annotate_text('link', {
52+
href: new_url // Pass the new_url directly, even if it's an empty string
53+
});
54+
}
4555
}
4656
4757
</script>
@@ -147,7 +157,7 @@
147157
transform: translateY(-50%);
148158
left: var(--s-4);
149159
border-radius: 9999px;
150-
box-shadow: 0 0 1px oklch(0 0 0 / 0.3), 0 0 2px oklch(0 0 0 / 0.1), 0 0 10px oklch(0 0 0 / 0.05);
160+
box-shadow: var(--shadow-2);
151161
display: flex;
152162
z-index: 50;
153163
flex-direction: column;

src/lib/styles/shadows.css

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:root {
2+
--shadow-2: 0 0 1px oklch(0 0 0 / 0.3), 0 0 2px oklch(0 0 0 / 0.1), 0 0 10px oklch(0 0 0 / 0.05);
3+
}

src/lib/styles/typography.css

+14
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ em, i, .italic {
9797

9898
button {
9999
border-radius: 9999px;
100+
font-size: calc(var(--base-size) / var(--scale-ratio));
101+
background-color: var(--canvas-fill-color);
102+
font-weight: 400;
100103
padding-inline: var(--s-3);
104+
min-height: 44px;
105+
display: flex;
106+
align-items: center;
107+
justify-content: center;
108+
transition: background-color 0.1s ease-in-out;
101109
&:focus, &:focus-visible {
102110
outline: none;
103111
}
@@ -108,6 +116,12 @@ button {
108116
opacity: 0.4;
109117
cursor: not-allowed;
110118
}
119+
&:not(:disabled):hover {
120+
background-color: oklch(from var(--canvas-fill-color) calc(l - 0.05) c h);
121+
}
122+
&.small {
123+
min-height: 36px;
124+
}
111125
}
112126

113127
.flex-column, .flex-row {

src/routes/+layout.svelte

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import '$lib/styles/typography.css';
44
import '$lib/styles/colors.css';
55
import '$lib/styles/spacing.css';
6+
import '$lib/styles/shadows.css';
67
78
let { children } = $props();
89
</script>

src/routes/+page.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
body: [
1717
{ 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.', []] },
1818
{ 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"}]]] },
19-
{ 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.', []] },
19+
{ 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 at the bottom of the page has a container of list items.', []] },
2020
{ 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.', []] },
2121
{ type: 'story', layout: 1, image: '/images/svelte-logo.svg', title: ['Made for Svelte 5', []], description: ['Integrate with your Svelte application. Use it as a template and copy and paste Svedit.svelte to build your custom rich content editor.', [ [20, 26, "link", {"href": "https://svelte.dev/"}], [80, 93, "emphasis", null] ]] },
2222
{ type: 'story', layout: 2, image: '/images/extendable.svg', title: ['Alpha version', []], description: ['Expect bugs. Expect missing features. Expect the need for more work on your part to make this work for your use case.\n\nFind below a list of known issues we\'ll be working to get fixed next:', []] },

static/icons/external-link.svg

+4
Loading

0 commit comments

Comments
 (0)