maradotwebp_obsidian-avatar/src/components/AvatarView.svelte
2025-06-25 20:39:06 +02:00

231 lines
4.8 KiB
Svelte

<script lang="ts">
import {
App,
MarkdownPostProcessorContext,
MarkdownRenderer,
MarkdownView,
TFile,
} from "obsidian";
import Fab from "./Fab.svelte";
import ObsidianIcon from "./ObsidianIcon.svelte";
import type { SetState, State } from "../core/stateProviders";
import { SelectImageModal } from "./SelectImageModal";
import { AvatarPlugin } from "../plugin";
import { onMount } from "svelte";
interface AvatarViewState {
image?: string;
/**
* The side on which the image will be displayed.
*
* @default "left"
*/
side?: "left" | "right";
/**
* The size of the avatar image.
*
* small: 180x180
* medium: 240x240
* large: 300x300
*
* @default "medium"
*/
size?: "small" | "medium" | "large" | number;
/**
* Alignment of the avatar image within the container.
*
* Corresponds to the `object-position` CSS property.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/object-position
*
* @default "50% 50%"
*/
objectPosition?: string;
description: string;
}
export let app: App;
export let plugin: AvatarPlugin;
export let ctx: MarkdownPostProcessorContext;
export let state: State<AvatarViewState>;
export let setState: SetState<AvatarViewState>;
let initialDescription = state?.description;
let hoverOnImage: boolean = false;
let editMode: boolean = false;
let descriptionEditEl: HTMLElement;
let descriptionPreviewEl: HTMLElement;
let inSourceMode = false;
$: fallbackImage = `https://ui-avatars.com/api/?name=${ctx?.sourcePath.split("/").at(-1) ?? "::"}&size=240`;
$: avatarSize = state.size
? typeof state.size === "string"
? {
small: 180,
medium: 240,
large: 300,
}[state.size]
: state.size
: 240;
$: objectPosition = state.objectPosition;
onMount(() => {
inSourceMode = isSourceMode();
let x = app.workspace.on("layout-change", () => {
inSourceMode = isSourceMode();
});
return () => app.workspace.offref(x);
});
function isSourceMode() {
const view = app.workspace.getActiveViewOfType(MarkdownView);
return view?.getMode?.() === "source";
}
function enterEditMode() {
if (inSourceMode && !editMode) {
editMode = true;
queueMicrotask(() => {
descriptionEditEl.focus();
});
}
}
function updateDescription(evt: MouseEvent) {
editMode = false;
initialDescription = state.description;
setState((fm) => {
fm.description = state.description;
});
evt.stopPropagation();
}
function updateImage() {
if (inSourceMode) {
new SelectImageModal(app, (path) => {
setState((fm) => {
fm.image = path;
});
}).open();
}
}
function normalizeImgPath(src: string): string {
const file = app.vault.getAbstractFileByPath(src);
if (file && file instanceof TFile) {
return app.vault.getResourcePath(file);
}
return src;
}
$: if (descriptionPreviewEl && plugin && state && state?.description) {
descriptionPreviewEl.innerHTML = "";
MarkdownRenderer.renderMarkdown(
state.description,
descriptionPreviewEl,
ctx?.sourcePath ?? "",
plugin,
);
}
</script>
<div class="avatar-view flex" class:reverse={state?.side === "right"}>
<div
class="avatar-container relative"
on:click={updateImage}
on:mouseenter={() => (hoverOnImage = true)}
on:mouseleave={() => (hoverOnImage = false)}
>
<img
class="avatar"
alt="Avatar"
style:width={`${avatarSize}px`}
style:height={`${avatarSize}px`}
style:object-position={objectPosition}
src={state.image ? normalizeImgPath(state?.image) : fallbackImage}
/>
{#if inSourceMode && hoverOnImage}
<Fab>
<ObsidianIcon id="edit"></ObsidianIcon>
</Fab>
{/if}
</div>
<div
class="description relative"
on:click={enterEditMode}
class:editmode={!!editMode}
>
<textarea
class="textarea"
bind:this={descriptionEditEl}
hidden={!editMode}
placeholder="Write your story..."
bind:value={state.description}
></textarea>
<span
class="avatar-plugin--md with-placeholder"
hidden={editMode}
bind:this={descriptionPreviewEl}
data-placeholder="Write your story..."
></span>
{#if editMode}
<Fab on:click={updateDescription}>
<ObsidianIcon id="save"></ObsidianIcon>
</Fab>
{/if}
</div>
</div>
<style>
.flex {
display: flex;
gap: 1.4em;
flex-wrap: wrap;
justify-content: center;
}
.flex.reverse {
flex-direction: row-reverse;
}
@media (min-width: 992px) {
.flex {
flex-wrap: nowrap;
}
}
.relative {
position: relative;
}
.avatar-container {
flex: 0 0 auto;
}
.avatar {
object-fit: cover;
border-radius: 6px;
}
.description {
flex: 1 1 auto;
word-break: break-word;
border-radius: 6px;
}
.description.editmode {
padding: 6px;
}
.textarea {
width: 100%;
height: 100%;
resize: none;
}
.with-placeholder:empty:before {
content: attr(data-placeholder);
color: var(--text-faint);
font-style: italic;
}
</style>