Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: link preview #10

Open
Aslam97 opened this issue Sep 4, 2024 · 3 comments
Open

feature: link preview #10

Aslam97 opened this issue Sep 4, 2024 · 3 comments
Assignees
Labels

Comments

@Aslam97
Copy link
Owner

Aslam97 commented Sep 4, 2024

No description provided.

@Aslam97 Aslam97 added the enhancement New feature or request label Sep 4, 2024
@Aslam97 Aslam97 self-assigned this Sep 4, 2024
@RaphaelMardine
Copy link

Hey, if you want I can create the feature about preview.
Do you have details about how you want to show it?

@Aslam97
Copy link
Owner Author

Aslam97 commented Oct 10, 2024

@RaphaelMardine Thanks for your interest in the link preview feature. I'm thinking of using a service like Iframely to generate rich link previews, but I'm open to other approaches. The goal is to display metadata (title, description, image) for various link types in a card format, similar to the examples I shared. If you have alternative solutions, I'd be interested to hear your thoughts.

Screenshot 2024-10-11 at 01 46 58

@Aslam97 Aslam97 added feature and removed enhancement New feature or request labels Oct 20, 2024
@slythespacecat
Copy link

slythespacecat commented Feb 3, 2025

I love this project! It's absolutely beautiful! Here's my current implementation for the link preview (although it's missing the menu from the image you linked, which I might add, and add to this comment later):

Link.tsx

import { mergeAttributes, Node } from "@tiptap/core";

export interface LinkPreviewOptions {
  HTMLAttributes: {
    [key: string]: any;
  };
}

type LinkPreviewData = {
  href: string;
  title: string;
  description: string;
  cover: string;
};

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    LinkPreview: {
      /**
       * Add a linkPreview
       */
      setLinkPreview: (data: LinkPreviewData) => ReturnType;
    };
  }
}

const LinkPreview = Node.create({
  name: "linkPreview",

  group: "block",

  atom: true,

  content: "",

  addOptions() {
    return {
      HTMLAttributes: {
        class: "flex flex-row space-x-4 rounded border p-2",
      },
    };
  },

  addAttributes() {
    return {
      href: {
        default: null,
        parseHTML(element) {
          return element.getAttribute("href");
        },
      },
      title: {
        default: "",
        parseHTML(element) {
          return element.firstChild?.childNodes[0].childNodes[0].nodeValue;
        },
        renderHTML() {
          return;
        },
      },
      description: {
        default: "",
        parseHTML(element) {
          return element.firstChild?.childNodes[1].childNodes[0].nodeValue;
        },
        renderHTML() {
          return;
        },
      },
      cover: {
        default: null,
        parseHTML(element) {
          // @ts-ignore
          return element.childNodes[1].src;
        },
        renderHTML() {
          return;
        },
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: `a[data-type="${this.name}"]`,
        priority: 1000,
      },
    ];
  },

  renderHTML({ HTMLAttributes, node }) {
    const { title, description, href, cover } = node.attrs;


    return [
      "div",
      mergeAttributes(this.options.HTMLAttributes, { "data-type": this.name }),
      ["img", { src: cover, alt: title, class: "linkImage" }],
      [
        "div",
        { class: "flex flex-col space-y-2" }, // You can use this class for styling
        ["h2", title],
        ["p", { class: "text-sm" }, description],
        ["a", { href }, "Read more"],
      ],
    ];
  },

  addCommands() {
    return {
      setLinkPreview:
        (data: LinkPreviewData) =>
        ({ tr, dispatch, state }) => {
          const { selection } = tr;
          const nodeType = state.schema.nodes.linkPreview;
          if (!nodeType) {
            return false;
          }

          try {
            const node = nodeType.createAndFill(data);
            if (!node) {
              return false;
            }

            if (dispatch) {
              tr.replaceRangeWith(selection.from, selection.to, node);
            }

            return true;
          } catch (err) {
            console.error("Error creating link preview node:", err);
            return false;
          }
        },
    };
  },
});

export default LinkPreview;

AddLink.tsx (button)

"use client";

import { FC, useState } from "react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/Popover";
import { Link2 } from "lucide-react";
import { Toggle, toggleVariants } from "@/components/ui/toggle";
import { Input } from "@/components/ui/input";
import { Editor } from "@tiptap/core";
import { toast } from "@/hooks/use-toast";
import { z } from "zod";
import { cn } from "@/utils";

interface AddLinkProps {
  editor: Editor;
}

const handleLinkPreview = async (link: string) => {
  try {
    const validator = z.string().url().parse(link);
    if (!validator)
      return toast({
        title: "Something went wrong.",
        description: "Invalid link.",
        variant: "destructive",
      });
    const req = await fetch(`/api/link?url=${encodeURIComponent(link)}`);
    const res = await req.json();
    return res;
  } catch (e) {
    return toast({
      title: "Something went wrong.",
      description: "Could not generate link preview",
      variant: "destructive",
    });
  }
};

const handleToggle = async (link: string, editor: Editor) => {
  try {
    const linkData = await handleLinkPreview(link);
    if (!linkData?.success) return;
    editor.commands.setLinkPreview({
      href: link,
      title: linkData.meta.title,
      description: linkData.meta.description,
      cover: linkData.meta.image.url,
    });
  } catch (e) {
    console.error(e);
  }
  return toast({
    title: "Something went wrong.",
    description: "Could not generate link preview",
    variant: "destructive",
  });
};

const AddLink: FC<AddLinkProps> = ({ editor }) => {
  const [link, setLink] = useState<string>("");
  return (
    <Popover>
      <PopoverTrigger
        className={cn(
          toggleVariants({ variant: "default" }),
          "rounded-bl-none rounded-tl-none",
        )}
      >
        <Link2 className="h-4 w-4" />
      </PopoverTrigger>
      <PopoverContent className="flex flex-col justify-center space-y-2">
        <Input value={link} onChange={(e) => setLink(e.target.value)} />
        <Toggle
          onPressedChange={() => handleToggle(link, editor)}
          className="font-medium"
        >
          Add
        </Toggle>
      </PopoverContent>
    </Popover>
  );
};

export default AddLink;

/api/link

import axios from "axios";
import { NextRequest } from "next/server";

export async function GET(req: NextRequest) {
  const url = new URL(req.url);

  const href = url.searchParams.get("url");

  if (!href) {
    return new Response("Invalid href", { status: 400 });
  }

  const res = await axios.get(href);

  const titleMatch = res.data.match(/<title>(.*?)<\/title>/);
  const title = titleMatch ? titleMatch[1] : "";

  const descriptionMatch = res.data.match(
    /<meta name="description" content="(.*?)"/,
  );
  const description = descriptionMatch ? descriptionMatch[1] : "";

  const imageMatch = res.data.match(
    /<meta property="og:image" content="(.*?)"/,
  );
  const imageUrl = imageMatch ? imageMatch[1] : "";

  return new Response(
    JSON.stringify({
      success: 1,
      meta: {
        title,
        description,
        image: {
          url: imageUrl,
        },
      },
    }),
  );
}

I'm also modifying it to upload the link image to my storage bucket so I don't have to mess with my CORS and allow all. Let me know if should send the updated code. Goes without saying you are absolutely free to modify the code above and add it to this project! (you can also add it unmodified, but I haven't reviewed and properly organized it yet... and after seeing your code, I know this is too messy for you - as it is for me ahahaha)

Best and thank you!

I have also made a very lightweight renderer for my Tiptap editor, so I didn't have to wait for the Static Renderer on v3 and could render my stuff without an Editor instance or injecting HTML onto a div. Let me know if that could be of use to you. It's still quite rudimentary (only a few options done so far, including image but missing the link preview) but could be a starting point for sure

If you have any suggestion or corrections I'd love to hear them of course!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants