링크 노드 타입

카테고리: 포맷팅 • 뷰 연동 노트를 포함한 상세 구현 가이드

스키마 정의

링크 마크 타입의 스키마 정의:

{
  link: {
    attrs: {
      href: { default: '' },
      title: { default: '' }
    }
  }
}

모델 표현

모델 표현 예제:

{
  type: 'text',
  text: '여기를 클릭하세요',
  marks: [{
    type: 'link',
    attrs: { href: 'https://example.com', title: '예제' }
  }]
}

HTML 직렬화

모델을 HTML로 변환:

function serializeLinkMark(text, mark) {
  const href = escapeHtml(mark.attrs?.href || '');
  const title = mark.attrs?.title ? 
    ' title="' + escapeHtml(mark.attrs.title) + '"' : '';
  return '<a href="' + href + '"' + title + '>' + text + '</a>';
}

HTML 역직렬화

HTML을 모델로 파싱:

function extractLinkMark(element) {
  if (element.tagName === 'A' && element.hasAttribute('href')) {
    return {
      type: 'link',
      attrs: {
        href: element.getAttribute('href') || '',
        title: element.getAttribute('title') || ''
      }
    };
  }
  return null;
}

뷰 연동

뷰 연동 노트: 이 노드 타입을 뷰 레이어에서 구현할 때 contenteditable 동작, 선택 처리, 이벤트 관리에 특히 주의하세요.

뷰 연동 코드:

// 링크 생성/편집
function createLink(url, text) {
  return {
    type: 'text',
    text: text,
    marks: [{
      type: 'link',
      attrs: { href: url }
    }]
  };
}

// 링크 URL 업데이트
function updateLinkUrl(linkMark, newUrl) {
  return {
    ...linkMark,
    attrs: { ...linkMark.attrs, href: newUrl }
  };
}

// 링크 클릭 처리
function handleLinkClick(e) {
  const link = e.target.closest('a');
  if (link && e.ctrlKey || e.metaKey) {
    // 기본 동작 허용 (새 탭에서 열기)
    return;
  }
  e.preventDefault();
  // 에디터에서 링크 네비게이션 처리
}

일반적인 문제

일반적인 함정: 이 노드 타입을 구현할 때 자주 발생하는 문제들입니다. 구현 전에 주의 깊게 검토하세요.

일반적인 문제 및 해결 방법:

// 문제: 빈 href 링크
// 해결: 링크 생성 전 href 검증
function validateLink(link) {
  if (!link.attrs.href || link.attrs.href.trim() === '') {
    return false;
  }
  return true;
}

// 문제: 다른 마크와 함께 링크
// 해결: 링크는 볼드, 이탤릭 등과 공존할 수 있음
// 하지만 코드 마크는 링크를 배제함
function canApplyLinkWithMarks(marks) {
  return !marks.some(m => m.type === 'code');
}

// 문제: 상대 URL vs 절대 URL
// 해결: URL 정규화
function normalizeUrl(url) {
  if (url.startsWith('http://') || url.startsWith('https://')) {
    return url;
  }
  if (url.startsWith('/')) {
    return url; // 루트에 대한 상대 경로
  }
  return 'https://' + url; // 외부로 가정
}

// 문제: 링크 보안 (XSS)
// 해결: href 정화
function sanitizeUrl(url) {
  // javascript:, data: 등 제거
  if (url.startsWith('javascript:') || url.startsWith('data:')) {
    return '#';
  }
  return url;
}

구현

완전한 구현 예제:

class LinkMark {
  constructor(attrs) {
    this.type = 'link';
    this.attrs = {
      href: attrs?.href || '',
      title: attrs?.title || ''
    };
  }
  
  toDOM() {
    const attrs = {};
    if (this.attrs.href) attrs.href = this.attrs.href;
    if (this.attrs.title) attrs.title = this.attrs.title;
    return ['a', attrs, 0];
  }
  
  static fromDOM(element) {
    if (element.tagName === 'A' && element.hasAttribute('href')) {
      return new LinkMark({
        href: element.getAttribute('href') || '',
        title: element.getAttribute('title') || ''
      });
    }
    return null;
  }
}