feat(editor): add geolocation support for content editing

- Add geolocation Tiptap extension for embedding maps
- Create GeolocationExtended component for rendering map embeds
- Create GeolocationPlaceholder for editor insertion
- Add GeoCard component for displaying location data
- Support latitude, longitude, zoom level, and optional labels
- Enable location-based content in albums and posts

Allows editors to embed interactive maps with specific locations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-24 01:12:34 +01:00
parent b548807d88
commit 8627b1d574
8 changed files with 1674 additions and 0 deletions

View file

@ -0,0 +1,273 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import type { GeoLocation } from '@prisma/client'
interface Props {
location: GeoLocation
height?: number
interactive?: boolean
showPopup?: boolean
class?: string
}
let {
location,
height = 400,
interactive = true,
showPopup = true,
class: className = ''
}: Props = $props()
let mapContainer: HTMLDivElement
let map: any
let marker: any
let leaflet: any
// Load Leaflet dynamically
async function loadLeaflet() {
if (typeof window === 'undefined') return
// Check if already loaded
if (window.L) {
leaflet = window.L
return
}
// Load Leaflet CSS
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='
link.crossOrigin = ''
document.head.appendChild(link)
// Load Leaflet JS
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo='
script.crossOrigin = ''
await new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
leaflet = window.L
}
// Initialize map
async function initMap() {
if (!mapContainer || !leaflet) return
// Create map
map = leaflet.map(mapContainer, {
center: [location.latitude, location.longitude],
zoom: 15,
scrollWheelZoom: interactive,
dragging: interactive,
touchZoom: interactive,
doubleClickZoom: interactive,
boxZoom: interactive,
keyboard: interactive,
zoomControl: interactive
})
// Add tile layer (using OpenStreetMap)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
})
.addTo(map)
// Create custom marker icon if color is specified
let markerOptions = {}
if (location.markerColor) {
const markerIcon = leaflet.divIcon({
className: 'custom-marker',
html: `<div style="background-color: ${location.markerColor}; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
})
markerOptions = { icon: markerIcon }
}
// Add marker
marker = leaflet.marker([location.latitude, location.longitude], markerOptions).addTo(map)
// Add popup if enabled
if (showPopup && (location.title || location.description)) {
const popupContent = `
<div class="location-popup">
${location.title ? `<h3>${location.title}</h3>` : ''}
${location.description ? `<p>${location.description}</p>` : ''}
</div>
`
marker.bindPopup(popupContent, {
autoPan: true,
keepInView: true
})
// Open popup by default on non-interactive maps
if (!interactive) {
marker.openPopup()
}
}
}
// Cleanup
function cleanup() {
if (map) {
map.remove()
map = null
}
marker = null
}
onMount(async () => {
try {
await loadLeaflet()
await initMap()
} catch (error) {
console.error('Failed to load map:', error)
}
})
onDestroy(() => {
cleanup()
})
// Reinitialize if location changes
$effect(() => {
if (map && location) {
cleanup()
initMap()
}
})
</script>
<div class="geo-card {className}">
<div
bind:this={mapContainer}
class="map-container"
style="height: {height}px"
role="img"
aria-label="Map showing {location.title ||
'location'} at coordinates {location.latitude}, {location.longitude}"
>
<noscript>
<div class="map-fallback">
<div class="fallback-content">
<h3>{location.title}</h3>
{#if location.description}
<p>{location.description}</p>
{/if}
<p class="coordinates">
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
</p>
<a
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=15/{location.latitude}/{location.longitude}"
target="_blank"
rel="noopener noreferrer"
>
View on OpenStreetMap
</a>
</div>
</div>
</noscript>
</div>
</div>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.geo-card {
width: 100%;
border-radius: $image-corner-radius;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.map-container {
width: 100%;
position: relative;
background: $grey-95;
:global(.leaflet-container) {
font-family: inherit;
}
:global(.location-popup h3) {
margin: 0 0 $unit-half;
font-size: 1rem;
font-weight: 600;
color: $grey-10;
}
:global(.location-popup p) {
margin: 0;
font-size: 0.875rem;
color: $grey-30;
line-height: 1.4;
}
:global(.leaflet-popup-content-wrapper) {
border-radius: $corner-radius-md;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.leaflet-popup-content) {
margin: $unit-2x;
}
}
.map-fallback {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: $grey-95;
padding: $unit-3x;
text-align: center;
}
.fallback-content {
h3 {
margin: 0 0 $unit;
font-size: 1.25rem;
color: $grey-10;
}
p {
margin: 0 0 $unit;
color: $grey-40;
line-height: 1.5;
}
.coordinates {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.875rem;
color: $grey-60;
margin-bottom: $unit-2x;
}
a {
color: $red-60;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
/* Global styles for Leaflet */
:global(.leaflet-control-attribution) {
font-size: 0.75rem;
background: rgba(255, 255, 255, 0.9) !important;
}
</style>

View file

@ -0,0 +1,80 @@
import { Node, mergeAttributes, type NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
export interface GeolocationExtendedOptions {
HTMLAttributes: Record<string, any>
}
export const GeolocationExtended = (
component: Component<NodeViewProps>
): Node<GeolocationExtendedOptions> =>
Node.create<GeolocationExtendedOptions>({
name: 'geolocation',
addOptions() {
return {
HTMLAttributes: {}
}
},
group: 'block',
atom: true,
draggable: true,
addAttributes() {
return {
latitude: {
default: null,
parseHTML: (element) => parseFloat(element.getAttribute('data-latitude') || '0'),
renderHTML: (attributes) => ({
'data-latitude': attributes.latitude
})
},
longitude: {
default: null,
parseHTML: (element) => parseFloat(element.getAttribute('data-longitude') || '0'),
renderHTML: (attributes) => ({
'data-longitude': attributes.longitude
})
},
title: {
default: null,
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes) => ({
'data-title': attributes.title
})
},
description: {
default: null,
parseHTML: (element) => element.getAttribute('data-description'),
renderHTML: (attributes) => ({
'data-description': attributes.description
})
},
markerColor: {
default: '#ef4444',
parseHTML: (element) => element.getAttribute('data-marker-color') || '#ef4444',
renderHTML: (attributes) => ({
'data-marker-color': attributes.markerColor
})
},
zoom: {
default: 15,
parseHTML: (element) => parseInt(element.getAttribute('data-zoom') || '15'),
renderHTML: (attributes) => ({
'data-zoom': attributes.zoom
})
}
}
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name })
]
},
addNodeView() {
return SvelteNodeViewRenderer(component)
}
})

View file

@ -0,0 +1,55 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
import type { Component } from 'svelte'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
export interface GeolocationPlaceholderOptions {
HTMLAttributes: Record<string, object>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
geolocationPlaceholder: {
/**
* Inserts a geolocation placeholder
*/
insertGeolocationPlaceholder: () => ReturnType
}
}
}
export const GeolocationPlaceholder = (
component: Component<NodeViewProps>
): Node<GeolocationPlaceholderOptions> =>
Node.create<GeolocationPlaceholderOptions>({
name: 'geolocation-placeholder',
addOptions() {
return {
HTMLAttributes: {}
}
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)]
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component)
},
addCommands() {
return {
insertGeolocationPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'geolocation-placeholder'
})
}
}
}
})

View file

@ -0,0 +1,352 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import type { NodeViewProps } from '@tiptap/core'
interface Props extends NodeViewProps {}
let { node, updateAttributes }: Props = $props()
let mapContainer: HTMLDivElement
let map: any
let marker: any
let leaflet: any
let isEditing = $state(false)
// Extract attributes
const latitude = $derived(node.attrs.latitude)
const longitude = $derived(node.attrs.longitude)
const title = $derived(node.attrs.title)
const description = $derived(node.attrs.description)
const markerColor = $derived(node.attrs.markerColor || '#ef4444')
const zoom = $derived(node.attrs.zoom || 15)
// Load Leaflet dynamically
async function loadLeaflet() {
if (typeof window === 'undefined') return
// Check if already loaded
if (window.L) {
leaflet = window.L
return
}
// Load Leaflet CSS
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='
link.crossOrigin = ''
document.head.appendChild(link)
// Load Leaflet JS
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo='
script.crossOrigin = ''
await new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
leaflet = window.L
}
// Initialize map
async function initMap() {
if (!mapContainer || !leaflet || !latitude || !longitude) return
// Create map
map = leaflet.map(mapContainer, {
center: [latitude, longitude],
zoom: zoom,
scrollWheelZoom: false, // Disabled by default in editor
dragging: !isEditing,
touchZoom: !isEditing,
doubleClickZoom: !isEditing,
boxZoom: false,
keyboard: false,
zoomControl: true
})
// Add tile layer (using OpenStreetMap)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
})
.addTo(map)
// Create custom marker icon if color is specified
let markerOptions = {}
if (markerColor) {
const markerIcon = leaflet.divIcon({
className: 'custom-marker',
html: `<div style="background-color: ${markerColor}; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15]
})
markerOptions = { icon: markerIcon }
}
// Add marker
marker = leaflet.marker([latitude, longitude], markerOptions).addTo(map)
// Add popup if title or description exists
if (title || description) {
const popupContent = `
<div class="location-popup">
${title ? `<h3>${title}</h3>` : ''}
${description ? `<p>${description}</p>` : ''}
</div>
`
marker.bindPopup(popupContent, {
autoPan: true,
keepInView: true
})
}
}
// Cleanup
function cleanup() {
if (map) {
map.remove()
map = null
}
marker = null
}
// Handle edit mode
function toggleEdit() {
isEditing = !isEditing
if (map) {
map.dragging[isEditing ? 'disable' : 'enable']()
map.touchZoom[isEditing ? 'disable' : 'enable']()
map.doubleClickZoom[isEditing ? 'disable' : 'enable']()
map.scrollWheelZoom[isEditing ? 'disable' : 'enable']()
}
}
onMount(async () => {
try {
await loadLeaflet()
await initMap()
} catch (error) {
console.error('Failed to load map:', error)
}
})
onDestroy(() => {
cleanup()
})
// Reinitialize if attributes change
$effect(() => {
if (map && (latitude || longitude)) {
cleanup()
initMap()
}
})
</script>
<div class="geolocation-node" data-drag-handle>
<div class="geolocation-header">
<div class="header-info">
<span class="icon">📍</span>
{#if title}
<span class="title">{title}</span>
{:else}
<span class="coordinates">{latitude?.toFixed(4)}, {longitude?.toFixed(4)}</span>
{/if}
</div>
<div class="header-actions">
<button
class="action-button"
onclick={toggleEdit}
title={isEditing ? 'Enable map interaction' : 'Disable map interaction'}
>
{isEditing ? '🔒' : '🔓'}
</button>
</div>
</div>
<div
bind:this={mapContainer}
class="map-container"
class:editing={isEditing}
role="img"
aria-label="Map showing {title || 'location'} at coordinates {latitude}, {longitude}"
>
<noscript>
<div class="map-fallback">
<div class="fallback-content">
{#if title}
<h3>{title}</h3>
{/if}
{#if description}
<p>{description}</p>
{/if}
<p class="coordinates">
{latitude?.toFixed(6)}, {longitude?.toFixed(6)}
</p>
<a
href="https://www.openstreetmap.org/?mlat={latitude}&mlon={longitude}#map={zoom}/{latitude}/{longitude}"
target="_blank"
rel="noopener noreferrer"
>
View on OpenStreetMap
</a>
</div>
</div>
</noscript>
</div>
</div>
<style lang="scss">
@import '$styles/variables.scss';
.geolocation-node {
margin: $unit-2x 0;
border: 1px solid $grey-90;
border-radius: $corner-radius-md;
overflow: hidden;
background: white;
}
.geolocation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $unit $unit-2x;
background: $grey-95;
border-bottom: 1px solid $grey-90;
font-size: 0.875rem;
}
.header-info {
display: flex;
align-items: center;
gap: $unit;
.icon {
font-size: 1rem;
}
.title {
font-weight: 500;
color: $grey-10;
}
.coordinates {
font-family: 'SF Mono', Monaco, monospace;
color: $grey-40;
font-size: 0.75rem;
}
}
.action-button {
padding: 4px 8px;
background: transparent;
border: 1px solid $grey-80;
border-radius: $corner-radius-sm;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
&:hover {
background: white;
border-color: $grey-60;
}
}
.map-container {
height: 400px;
width: 100%;
position: relative;
background: $grey-95;
&.editing {
opacity: 0.8;
pointer-events: none;
}
:global(.leaflet-container) {
font-family: inherit;
}
:global(.location-popup) {
h3 {
margin: 0 0 $unit-half;
font-size: 1rem;
font-weight: 600;
color: $grey-10;
}
p {
margin: 0;
font-size: 0.875rem;
color: $grey-30;
line-height: 1.4;
}
}
:global(.leaflet-popup-content-wrapper) {
border-radius: $corner-radius-md;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.leaflet-popup-content) {
margin: $unit-2x;
}
}
.map-fallback {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: $grey-95;
padding: $unit-3x;
text-align: center;
}
.fallback-content {
h3 {
margin: 0 0 $unit;
font-size: 1.25rem;
color: $grey-10;
}
p {
margin: 0 0 $unit;
color: $grey-40;
line-height: 1.5;
}
.coordinates {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.875rem;
color: $grey-60;
margin-bottom: $unit-2x;
}
a {
color: $red-60;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
/* Ensure Leaflet attribution is styled properly */
:global(.leaflet-control-attribution) {
font-size: 0.75rem;
background: rgba(255, 255, 255, 0.9) !important;
}
</style>

View file

@ -0,0 +1,425 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { Readable } from 'svelte/store'
import type { Editor } from '@tiptap/core'
import Button from '$lib/components/admin/Button.svelte'
import Input from '$lib/components/admin/Input.svelte'
import Textarea from '$lib/components/admin/Textarea.svelte'
const editor = getContext<Readable<Editor>>('editor')
let showForm = $state(false)
let title = $state('')
let description = $state('')
let latitude = $state('')
let longitude = $state('')
let markerColor = $state('#ef4444')
let zoom = $state(15)
// Map picker state
let showMapPicker = $state(false)
let mapContainer: HTMLDivElement
let pickerMap: any
let pickerMarker: any
let leaflet: any
// Load Leaflet for map picker
async function loadLeaflet() {
if (typeof window === 'undefined') return
if (window.L) {
leaflet = window.L
return
}
// Load CSS
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
// Load JS
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
await new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
leaflet = window.L
}
// Initialize map picker
async function initMapPicker() {
if (!mapContainer || !leaflet) return
// Default to San Francisco if no coordinates
const lat = latitude ? parseFloat(latitude) : 37.7749
const lng = longitude ? parseFloat(longitude) : -122.4194
pickerMap = leaflet.map(mapContainer, {
center: [lat, lng],
zoom: zoom
})
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
})
.addTo(pickerMap)
// Add marker
pickerMarker = leaflet
.marker([lat, lng], {
draggable: true
})
.addTo(pickerMap)
// Update coordinates on marker drag
pickerMarker.on('dragend', (e: any) => {
const position = e.target.getLatLng()
latitude = position.lat.toFixed(6)
longitude = position.lng.toFixed(6)
})
// Update marker on map click
pickerMap.on('click', (e: any) => {
pickerMarker.setLatLng(e.latlng)
latitude = e.latlng.lat.toFixed(6)
longitude = e.latlng.lng.toFixed(6)
})
// Update zoom on change
pickerMap.on('zoomend', () => {
zoom = pickerMap.getZoom()
})
}
// Open map picker
async function openMapPicker() {
showMapPicker = true
await loadLeaflet()
// Wait for DOM update
setTimeout(() => {
initMapPicker()
}, 100)
}
// Close map picker
function closeMapPicker() {
if (pickerMap) {
pickerMap.remove()
pickerMap = null
}
pickerMarker = null
showMapPicker = false
}
function handleInsert() {
const lat = parseFloat(latitude)
const lng = parseFloat(longitude)
if (isNaN(lat) || isNaN(lng)) {
alert('Please enter valid coordinates')
return
}
$editor
.chain()
.focus()
.setGeolocation({
latitude: lat,
longitude: lng,
title: title || undefined,
description: description || undefined,
markerColor,
zoom
})
.run()
}
function handleQuickLocation(lat: number, lng: number, name: string) {
$editor
.chain()
.focus()
.setGeolocation({
latitude: lat,
longitude: lng,
title: name,
zoom: 13
})
.run()
}
</script>
<div class="geolocation-placeholder">
{#if !showForm}
<div class="placeholder-content">
<div class="icon">📍</div>
<p class="label">Add a location</p>
<div class="actions">
<Button variant="secondary" buttonSize="small" onclick={() => (showForm = true)}>
Custom Location
</Button>
<div class="quick-locations">
<button
class="quick-location"
onclick={() => handleQuickLocation(37.7749, -122.4194, 'San Francisco')}
>
San Francisco
</button>
<button
class="quick-location"
onclick={() => handleQuickLocation(40.7128, -74.006, 'New York')}
>
New York
</button>
<button
class="quick-location"
onclick={() => handleQuickLocation(51.5074, -0.1278, 'London')}
>
London
</button>
</div>
</div>
</div>
{:else}
<div class="form-content">
<h3>Add Location</h3>
<div class="form-fields">
<Input label="Title (optional)" bind:value={title} placeholder="Location name" />
<Textarea
label="Description (optional)"
bind:value={description}
placeholder="About this location"
rows={2}
/>
<div class="coordinates-row">
<Input
label="Latitude"
bind:value={latitude}
placeholder="37.7749"
type="number"
step="0.000001"
required
/>
<Input
label="Longitude"
bind:value={longitude}
placeholder="-122.4194"
type="number"
step="0.000001"
required
/>
<Button variant="secondary" onclick={openMapPicker} buttonSize="small">📍 Pick</Button>
</div>
<div class="options-row">
<label class="color-label">
Marker Color
<input type="color" bind:value={markerColor} class="color-input" />
</label>
<label class="zoom-label">
Zoom Level
<input type="range" bind:value={zoom} min="1" max="20" class="zoom-input" />
<span>{zoom}</span>
</label>
</div>
</div>
{#if showMapPicker}
<div class="map-picker-modal">
<div class="map-picker-content">
<div class="map-picker-header">
<h4>Click to set location</h4>
<Button variant="ghost" buttonSize="small" onclick={closeMapPicker}>Close</Button>
</div>
<div bind:this={mapContainer} class="map-picker-container"></div>
</div>
</div>
{/if}
<div class="form-actions">
<Button variant="ghost" onclick={() => (showForm = false)}>Cancel</Button>
<Button variant="primary" onclick={handleInsert} disabled={!latitude || !longitude}>
Insert Location
</Button>
</div>
</div>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.geolocation-placeholder {
margin: $unit-2x 0;
background: $grey-95;
border: 2px dashed $grey-80;
border-radius: $corner-radius-md;
overflow: hidden;
}
.placeholder-content {
padding: $unit-3x;
text-align: center;
.icon {
font-size: 2rem;
margin-bottom: $unit;
}
.label {
margin: 0 0 $unit-2x;
color: $grey-40;
font-size: 0.875rem;
}
.actions {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
}
.quick-locations {
display: flex;
gap: $unit-half;
flex-wrap: wrap;
justify-content: center;
margin-top: $unit;
}
.quick-location {
padding: 4px 12px;
background: white;
border: 1px solid $grey-80;
border-radius: $corner-radius-sm;
font-size: 0.75rem;
color: $grey-30;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: $grey-60;
color: $grey-10;
}
}
}
.form-content {
padding: $unit-3x;
h3 {
margin: 0 0 $unit-3x;
font-size: 1rem;
font-weight: 600;
color: $grey-10;
}
}
.form-fields {
display: flex;
flex-direction: column;
gap: $unit-2x;
margin-bottom: $unit-3x;
}
.coordinates-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: $unit;
align-items: flex-end;
}
.options-row {
display: grid;
grid-template-columns: auto 1fr;
gap: $unit-3x;
align-items: center;
}
.color-label,
.zoom-label {
display: flex;
align-items: center;
gap: $unit;
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
}
.color-input {
width: 40px;
height: 24px;
padding: 0;
border: 1px solid $grey-80;
border-radius: $corner-radius-sm;
cursor: pointer;
}
.zoom-input {
width: 100px;
}
.zoom-label span {
min-width: 24px;
text-align: right;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
color: $grey-40;
}
.map-picker-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: $unit-3x;
}
.map-picker-content {
background: white;
border-radius: $corner-radius-md;
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.map-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $unit-2x $unit-3x;
border-bottom: 1px solid $grey-90;
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: $grey-10;
}
}
.map-picker-container {
height: 500px;
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: $unit;
}
</style>

View file

@ -0,0 +1,122 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import GeolocationPlaceholder from './geolocation-placeholder.svelte'
import GeolocationExtended from './geolocation-extended.svelte'
export interface GeolocationOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
geolocation: {
/**
* Set a geolocation node
*/
setGeolocation: (options: {
latitude: number
longitude: number
title?: string
description?: string
markerColor?: string
zoom?: number
}) => ReturnType
}
}
}
export const Geolocation = Node.create<GeolocationOptions>({
name: 'geolocation',
group: 'block',
atom: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {}
}
},
addAttributes() {
return {
latitude: {
default: null,
parseHTML: (element) => parseFloat(element.getAttribute('data-latitude') || '0'),
renderHTML: (attributes) => ({
'data-latitude': attributes.latitude
})
},
longitude: {
default: null,
parseHTML: (element) => parseFloat(element.getAttribute('data-longitude') || '0'),
renderHTML: (attributes) => ({
'data-longitude': attributes.longitude
})
},
title: {
default: null,
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes) => {
if (!attributes.title) return {}
return { 'data-title': attributes.title }
}
},
description: {
default: null,
parseHTML: (element) => element.getAttribute('data-description'),
renderHTML: (attributes) => {
if (!attributes.description) return {}
return { 'data-description': attributes.description }
}
},
markerColor: {
default: '#ef4444',
parseHTML: (element) => element.getAttribute('data-marker-color') || '#ef4444',
renderHTML: (attributes) => ({
'data-marker-color': attributes.markerColor
})
},
zoom: {
default: 15,
parseHTML: (element) => parseInt(element.getAttribute('data-zoom') || '15'),
renderHTML: (attributes) => ({
'data-zoom': attributes.zoom
})
}
}
},
parseHTML() {
return [
{
tag: 'div[data-type="geolocation"]'
}
]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'geolocation' })]
},
addNodeView() {
return SvelteNodeViewRenderer(GeolocationExtended, {
placeholder: GeolocationPlaceholder
})
},
addCommands() {
return {
setGeolocation:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options
})
}
}
}
})

View file

@ -0,0 +1,112 @@
<script lang="ts">
import { type NodeViewProps } from '@tiptap/core'
import { NodeViewWrapper } from 'svelte-tiptap'
import { onMount } from 'svelte'
import type L from 'leaflet'
interface Props extends NodeViewProps {}
let { node, selected }: Props = $props()
let mapContainer: HTMLDivElement
let map: L.Map | null = null
let leaflet: typeof L
const latitude = node.attrs.latitude as number
const longitude = node.attrs.longitude as number
const title = node.attrs.title as string
const description = node.attrs.description as string
const markerColor = node.attrs.markerColor as string
const zoom = (node.attrs.zoom as number) || 15
onMount(async () => {
// Dynamically import Leaflet
leaflet = (await import('leaflet')).default
await import('leaflet/dist/leaflet.css')
// Initialize map
map = leaflet.map(mapContainer).setView([latitude, longitude], zoom)
// Add tile layer
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
})
.addTo(map)
// Create custom icon with color
const icon = leaflet.divIcon({
html: `<div style="background-color: ${markerColor}; width: 25px; height: 25px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [25, 25],
iconAnchor: [12, 25],
popupAnchor: [0, -25],
className: 'custom-marker'
})
// Add marker
const marker = leaflet.marker([latitude, longitude], { icon }).addTo(map)
// Add popup if title or description exists
if (title || description) {
const popupContent = `
<div class="map-popup">
${title ? `<h4>${title}</h4>` : ''}
${description ? `<p>${description}</p>` : ''}
</div>
`
marker.bindPopup(popupContent)
}
return () => {
map?.remove()
}
})
</script>
<NodeViewWrapper>
<div class="geolocation-node" class:selected>
<div bind:this={mapContainer} class="map-container"></div>
</div>
</NodeViewWrapper>
<style lang="scss">
:global(.leaflet-container) {
font-family: inherit;
}
:global(.custom-marker) {
background: transparent;
border: none;
}
:global(.map-popup) {
h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
p {
margin: 0;
font-size: 14px;
color: #4b5563;
}
}
.geolocation-node {
margin: 16px 0;
border-radius: 8px;
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s;
&.selected {
border-color: #3b82f6;
}
}
.map-container {
width: 100%;
height: 400px;
background: #f3f4f6;
}
</style>

View file

@ -0,0 +1,255 @@
<script lang="ts">
import { type Editor, type NodeViewProps } from '@tiptap/core'
import { NodeViewWrapper } from 'svelte-tiptap'
import MapPin from 'lucide-svelte/icons/map-pin'
interface Props extends NodeViewProps {}
let { node, editor, getPos, updateAttributes, deleteNode }: Props = $props()
let latitude = $state<number | null>(null)
let longitude = $state<number | null>(null)
let title = $state('')
let description = $state('')
let markerColor = $state('#ef4444')
let isConfigured = $state(false)
function handleSubmit() {
if (!latitude || !longitude || !title) {
alert('Please fill in all required fields')
return
}
// Replace this placeholder with the actual geolocation node
const pos = getPos()
if (typeof pos === 'number') {
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + node.nodeSize })
.insertContent({
type: 'geolocation',
attrs: {
latitude,
longitude,
title,
description,
markerColor
}
})
.run()
}
}
function handleCancel() {
deleteNode()
}
</script>
<NodeViewWrapper>
<div class="geolocation-placeholder">
<div class="icon">
<MapPin size={24} />
</div>
{#if !isConfigured}
<div class="content">
<h3>Add Location</h3>
<p>Add a map with a location marker</p>
<button class="configure-btn" onclick={() => (isConfigured = true)}>
Configure Location
</button>
</div>
{:else}
<div class="form">
<h3>Configure Location</h3>
<div class="form-group">
<label for="latitude">Latitude <span class="required">*</span></label>
<input
id="latitude"
type="number"
step="0.000001"
bind:value={latitude}
placeholder="e.g., 37.7749"
required
/>
</div>
<div class="form-group">
<label for="longitude">Longitude <span class="required">*</span></label>
<input
id="longitude"
type="number"
step="0.000001"
bind:value={longitude}
placeholder="e.g., -122.4194"
required
/>
</div>
<div class="form-group">
<label for="title">Title <span class="required">*</span></label>
<input id="title" type="text" bind:value={title} placeholder="Location name" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
bind:value={description}
placeholder="Optional description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="markerColor">Marker Color</label>
<input id="markerColor" type="color" bind:value={markerColor} />
</div>
<div class="actions">
<button class="cancel-btn" onclick={handleCancel}>Cancel</button>
<button class="submit-btn" onclick={handleSubmit}>Add Location</button>
</div>
</div>
{/if}
</div>
</NodeViewWrapper>
<style lang="scss">
.geolocation-placeholder {
background: #f8f9fa;
border: 2px dashed #e0e0e0;
border-radius: 8px;
padding: 24px;
margin: 16px 0;
text-align: center;
}
.icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #6b7280;
}
.content {
h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
p {
margin: 0 0 16px;
color: #6b7280;
}
}
.configure-btn {
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #2563eb;
}
}
.form {
text-align: left;
max-width: 400px;
margin: 0 auto;
h3 {
margin: 0 0 20px;
font-size: 18px;
font-weight: 600;
color: #1f2937;
text-align: center;
}
}
.form-group {
margin-bottom: 16px;
label {
display: block;
margin-bottom: 4px;
font-size: 14px;
font-weight: 500;
color: #374151;
}
.required {
color: #ef4444;
}
input,
textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
input[type='color'] {
width: 60px;
height: 36px;
padding: 4px;
cursor: pointer;
}
}
.actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
.cancel-btn,
.submit-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.cancel-btn {
background: #f3f4f6;
color: #374151;
&:hover {
background: #e5e7eb;
}
}
.submit-btn {
background: #3b82f6;
color: white;
&:hover {
background: #2563eb;
}
}
</style>