@seorii/tiptap 0.0.7 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import { Extension } from '@tiptap/core';
2
+ export declare const Command: Extension<any, any>;
3
+ declare const _default: Extension<any, any>;
4
+ export default _default;
@@ -0,0 +1,27 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import Suggestion from '@tiptap/suggestion';
3
+ import suggestion from "./suggest";
4
+ export const Command = Extension.create({
5
+ name: 'slash',
6
+ addOptions() {
7
+ return {
8
+ suggestion: {
9
+ char: '/',
10
+ command: ({ editor, range, props }) => {
11
+ props.command({ editor, range });
12
+ }
13
+ }
14
+ };
15
+ },
16
+ addProseMirrorPlugins() {
17
+ return [
18
+ Suggestion({
19
+ editor: this.editor,
20
+ ...this.options.suggestion
21
+ })
22
+ ];
23
+ }
24
+ });
25
+ export default Command.configure({
26
+ suggestion
27
+ });
@@ -0,0 +1,13 @@
1
+ /// <reference types="svelte" />
2
+ export declare const slashVisible: import("svelte/store").Writable<boolean>;
3
+ export declare const slashItems: import("svelte/store").Writable<never[]>;
4
+ export declare const slashLocaltion: import("svelte/store").Writable<{
5
+ x: number;
6
+ y: number;
7
+ height: number;
8
+ }>;
9
+ export declare const slashProps: import("svelte/store").Writable<{
10
+ editor: null;
11
+ range: null;
12
+ }>;
13
+ export declare const slashDetail: import("svelte/store").Writable<null>;
@@ -0,0 +1,6 @@
1
+ import { writable } from 'svelte/store';
2
+ export const slashVisible = writable(false);
3
+ export const slashItems = writable([]);
4
+ export const slashLocaltion = writable({ x: 0, y: 0, height: 0 });
5
+ export const slashProps = writable({ editor: null, range: null });
6
+ export const slashDetail = writable(null);
@@ -0,0 +1,23 @@
1
+ declare const _default: {
2
+ items: ({ query }: {
3
+ query: any;
4
+ }) => {
5
+ section: string;
6
+ list: {
7
+ icon: string;
8
+ title: string;
9
+ subtitle: string;
10
+ command: ({ editor, range }: {
11
+ editor: any;
12
+ range: any;
13
+ }) => void;
14
+ }[];
15
+ }[];
16
+ render: () => {
17
+ onStart: (props: any) => void;
18
+ onUpdate(props: any): void;
19
+ onKeyDown(props: any): true | undefined;
20
+ onExit(): void;
21
+ };
22
+ };
23
+ export default _default;
@@ -0,0 +1,126 @@
1
+ import { slashVisible, slashItems, slashLocaltion, slashProps, slashDetail } from './stores';
2
+ export default {
3
+ items: ({ query }) => {
4
+ const raw = [
5
+ {
6
+ section: '텍스트', list: [
7
+ {
8
+ icon: 'title',
9
+ title: '제목 1',
10
+ subtitle: '큰 제목',
11
+ command: ({ editor, range }) => {
12
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run();
13
+ }
14
+ },
15
+ {
16
+ icon: 'title',
17
+ title: '제목 2',
18
+ subtitle: '좀 더 작은 제목',
19
+ command: ({ editor, range }) => {
20
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run();
21
+ }
22
+ },
23
+ {
24
+ icon: 'title',
25
+ title: '제목 3',
26
+ subtitle: '적당히 큰 제목',
27
+ command: ({ editor, range }) => {
28
+ editor.chain().focus().deleteRange(range).setNode('heading', { level: 3 }).run();
29
+ }
30
+ },
31
+ {
32
+ icon: 'format_list_bulleted',
33
+ title: '리스트',
34
+ subtitle: '순서 없는 리스트',
35
+ command: ({ editor, range }) => {
36
+ editor.commands.deleteRange(range);
37
+ editor.commands.toggleBulletList();
38
+ }
39
+ },
40
+ {
41
+ icon: 'format_list_numbered',
42
+ title: '숫자 리스트',
43
+ subtitle: '1, 2, 3, 4',
44
+ command: ({ editor, range }) => {
45
+ editor.commands.deleteRange(range);
46
+ editor.commands.toggleOrderedList();
47
+ }
48
+ }
49
+ ]
50
+ },
51
+ {
52
+ section: '블록', list: [
53
+ {
54
+ icon: 'code',
55
+ title: '코드 블록',
56
+ subtitle: '하이라이팅되는 코드 블록',
57
+ command: ({ editor, range }) => {
58
+ editor.chain().focus().deleteRange(range).setNode('codeBlock').run();
59
+ }
60
+ },
61
+ {
62
+ icon: 'functions',
63
+ title: '수식 블록',
64
+ subtitle: '가운데로 정렬된 큰 수식',
65
+ command: ({ editor, range }) => {
66
+ const { to } = range;
67
+ editor.chain().focus().deleteRange(range).setNode('math_display').focus().run();
68
+ }
69
+ },
70
+ {
71
+ icon: 'table_chart',
72
+ title: '테이블',
73
+ subtitle: '표',
74
+ command: ({ editor, range }) => {
75
+ editor.chain().focus().insertTable({ rows: 2, cols: 3 }).run();
76
+ }
77
+ },
78
+ {
79
+ icon: 'iframe',
80
+ title: '프레임',
81
+ subtitle: '다른 웹사이트 삽입',
82
+ command: ({ editor, range }) => {
83
+ slashDetail.set('iframe');
84
+ }
85
+ },
86
+ {
87
+ icon: 'youtube_activity',
88
+ title: '유튜브',
89
+ subtitle: '유튜브 임베드',
90
+ command: ({ editor, range }) => {
91
+ slashDetail.set('youtube');
92
+ }
93
+ }
94
+ ]
95
+ }
96
+ ];
97
+ const filtered = raw.map(({ section, list }) => ({ section, list: list.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())) })).filter(({ list }) => list.length > 0);
98
+ return filtered;
99
+ },
100
+ render: () => {
101
+ return {
102
+ onStart: (props) => {
103
+ let editor = props.editor;
104
+ let range = props.range;
105
+ let location = props.clientRect();
106
+ slashProps.set({ editor, range });
107
+ slashVisible.set(true);
108
+ slashLocaltion.set({ x: location.x, y: location.y, height: location.height });
109
+ slashItems.set(props.items);
110
+ slashDetail.set(null);
111
+ },
112
+ onUpdate(props) {
113
+ slashItems.set(props.items);
114
+ },
115
+ onKeyDown(props) {
116
+ if (props.event.key === 'Escape') {
117
+ slashVisible.set(false);
118
+ return true;
119
+ }
120
+ },
121
+ onExit() {
122
+ slashVisible.set(false);
123
+ }
124
+ };
125
+ }
126
+ };
@@ -1,4 +1,4 @@
1
- import { isColumnSelected, isTableSelected } from "./util";
1
+ import { isColumnSelected, isRowSelected, isTableSelected } from "./util";
2
2
  import { TableMap } from "prosemirror-tables";
3
3
  export const deleteTable = ({ editor }) => {
4
4
  const { selection } = editor.state;
@@ -14,7 +14,7 @@ export const deleteTable = ({ editor }) => {
14
14
  }
15
15
  }
16
16
  for (let i = height - 1; i >= 0; i--) {
17
- if (isColumnSelected(i)(selection)) {
17
+ if (isRowSelected(i)(selection)) {
18
18
  editor.commands.deleteRow();
19
19
  return true;
20
20
  }
@@ -1,7 +1,6 @@
1
1
  import type { Node, ResolvedPos } from 'prosemirror-model';
2
- import type { Selection, Transaction } from 'prosemirror-state';
2
+ import type { EditorState, Selection, Transaction } from 'prosemirror-state';
3
3
  import { CellSelection, type TableRect } from 'prosemirror-tables';
4
- import type { EditorState } from "prosemirror-state";
5
4
  export declare const isRectSelected: (rect: any) => (selection: CellSelection) => boolean;
6
5
  export declare const findTable: (selection: Selection) => {
7
6
  pos: number;
@@ -10,6 +9,7 @@ export declare const findTable: (selection: Selection) => {
10
9
  node: Node;
11
10
  } | undefined;
12
11
  export declare const isCellSelection: (selection: any) => boolean;
12
+ export declare const isTableAnySelected: (selection: any) => false | number[];
13
13
  export declare const isColumnSelected: (columnIndex: number) => (selection: any) => boolean;
14
14
  export declare const isRowSelected: (rowIndex: number) => (selection: any) => boolean;
15
15
  export declare const isTableSelected: (selection: any) => boolean;
@@ -16,6 +16,14 @@ export const findTable = (selection) => findParentNode((node) => node.type.spec.
16
16
  export const isCellSelection = (selection) => {
17
17
  return selection instanceof CellSelection;
18
18
  };
19
+ export const isTableAnySelected = (selection) => {
20
+ if (isCellSelection(selection)) {
21
+ const map = TableMap.get(selection.$anchorCell.node(-1));
22
+ const start = selection.$anchorCell.start(-1);
23
+ return map.cellsInRect(map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start));
24
+ }
25
+ return false;
26
+ };
19
27
  export const isColumnSelected = (columnIndex) => (selection) => {
20
28
  if (isCellSelection(selection)) {
21
29
  const map = TableMap.get(selection.$anchorCell.node(-1));
@@ -0,0 +1,15 @@
1
+ import { Node } from "@tiptap/core";
2
+ export interface VideoPlayerOptions {
3
+ HTMLAttributes: Record<string, any>;
4
+ }
5
+ declare module "@tiptap/core" {
6
+ interface Commands<ReturnType> {
7
+ videoPlayer: {
8
+ insertVideoPlayer: (options: {
9
+ url: string;
10
+ }) => ReturnType;
11
+ };
12
+ }
13
+ }
14
+ declare const _default: Node<VideoPlayerOptions, any>;
15
+ export default _default;
@@ -0,0 +1,100 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core";
2
+ import { Plugin, PluginKey } from "prosemirror-state";
3
+ const youtubeRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:m\.)?(?:youtu(?:be)?\.com\/(?:v\/|embed\/|watch(?:\/|\?v=))|youtu\.be\/)((?:\w|-){11})(?:\S+)?$/;
4
+ const youtubeExtractId = (url) => {
5
+ const match = youtubeRegExp.exec(url.trim());
6
+ return match ? match[1] : false;
7
+ };
8
+ const videoPlayerStaticAttributes = { nocookie: true };
9
+ export default Node.create({
10
+ name: "lite-youtube",
11
+ content: "",
12
+ marks: "",
13
+ group: "block",
14
+ draggable: true,
15
+ addAttributes() {
16
+ return {
17
+ videoid: {
18
+ default: null,
19
+ },
20
+ provider: {
21
+ default: "youtube",
22
+ },
23
+ };
24
+ },
25
+ parseHTML() {
26
+ return [
27
+ {
28
+ tag: "lite-youtube",
29
+ },
30
+ ];
31
+ },
32
+ renderHTML({ HTMLAttributes }) {
33
+ return [
34
+ "lite-youtube",
35
+ mergeAttributes(videoPlayerStaticAttributes, this.options.HTMLAttributes, HTMLAttributes),
36
+ ];
37
+ },
38
+ addCommands() {
39
+ return {
40
+ insertVideoPlayer: (options) => ({ chain, editor }) => {
41
+ const { url } = options;
42
+ const videoid = youtubeExtractId(url);
43
+ if (videoid) {
44
+ const { selection } = editor.state;
45
+ const pos = selection.$head;
46
+ return chain().insertContentAt(pos.before(), [{
47
+ type: this.name,
48
+ attrs: { videoid, provider: "youtube" },
49
+ }]).run();
50
+ }
51
+ return false;
52
+ },
53
+ };
54
+ },
55
+ addProseMirrorPlugins() {
56
+ return [
57
+ new Plugin({
58
+ key: new PluginKey("handlePasteVideoURL"),
59
+ props: {
60
+ handlePaste: (view, _event, slice) => {
61
+ if (slice.content.childCount !== 1)
62
+ return false;
63
+ const { state } = view;
64
+ const { selection } = state;
65
+ const { empty } = selection;
66
+ if (!empty)
67
+ return false;
68
+ const pos = selection.$head;
69
+ const node = pos.node();
70
+ if (node.content.size > 0)
71
+ return false;
72
+ let textContent = "", href = "";
73
+ slice.content.forEach((node) => {
74
+ textContent += node.textContent;
75
+ });
76
+ textContent = textContent.trim();
77
+ let videoid = youtubeExtractId(textContent);
78
+ if (!videoid)
79
+ for (const mark of slice?.content?.content?.[0]?.marks) {
80
+ if (mark.attrs.href) {
81
+ const id = youtubeExtractId(mark.attrs.href);
82
+ if (id) {
83
+ videoid = id;
84
+ break;
85
+ }
86
+ }
87
+ }
88
+ if (!videoid)
89
+ return false;
90
+ this.editor.chain().insertContentAt(pos.before(), [{
91
+ type: this.name,
92
+ attrs: { videoid, provider: "youtube" },
93
+ }]).run();
94
+ return true;
95
+ },
96
+ },
97
+ }),
98
+ ];
99
+ },
100
+ });
@@ -0,0 +1,135 @@
1
+ <script>import { BubbleMenu } from "svelte-tiptap";
2
+ import { getContext, tick } from "svelte";
3
+ import 'tippy.js/animations/shift-away-subtle.css';
4
+ import ToolbarButton from "./ToolbarButton.svelte";
5
+ import { isTableAnySelected } from "../plugin/table/util";
6
+ import deleteTable from "../plugin/table/deleteTable";
7
+ import setMath from "./setMath";
8
+ import { Button, Icon, IconButton, Input, List, OneLine, Tooltip } from "nunui";
9
+ const tiptap = getContext('editor');
10
+ let link = false, href = '', sel = '', _sel = '';
11
+ $: selection = $tiptap?.state?.selection;
12
+ $: table = isTableAnySelected(selection);
13
+ $: sel = selection?.from + '-' + selection?.to;
14
+ $: if ($tiptap && sel !== _sel) {
15
+ _sel = sel;
16
+ link = false;
17
+ href = $tiptap.getAttributes('link').href;
18
+ }
19
+ $: if ($tiptap) {
20
+ if (href)
21
+ $tiptap.chain().setLink({ href }).run();
22
+ else
23
+ $tiptap.chain().unsetLink().run();
24
+ }
25
+ </script>
26
+
27
+
28
+ {#if $tiptap}
29
+ <BubbleMenu editor={$tiptap} updateDelay={50}
30
+ tippyOptions={{moveTransition: 'transform 0.2s cubic-bezier(1,.5,0,.85)', animation:'shift-away-subtle', duration: [200, 50]}}>
31
+ {#if $$slots.default}
32
+ <slot/>
33
+ {:else}
34
+ <main>
35
+ {#if link}
36
+ <div class="link">
37
+ <p>
38
+ <Icon icon="link"/>
39
+ 링크
40
+ </p>
41
+ <Input placeholder="url" fullWidth bind:value={href} autofocus/>
42
+ <div>
43
+ <Button tabindex="0" transparent small on:click={() => {
44
+ href = ''
45
+ $tiptap.chain().focus().unsetLink().run()
46
+ tick().then(() => link = false)
47
+ }}>삭제
48
+ </Button>
49
+ <Button tabindex="0" transparent small on:click={() => link = false}>닫기</Button>
50
+ </div>
51
+ </div>
52
+ {:else if table}
53
+ {#if table.length > 1}
54
+ <ToolbarButton icon="cell_merge" handler={() => $tiptap.commands.mergeCells()}/>
55
+ {:else}
56
+ <ToolbarButton icon="splitscreen_left" handler={() => $tiptap.commands.splitCell()}/>
57
+ {/if}
58
+ <ToolbarButton icon="keyboard_double_arrow_left"
59
+ handler={() => $tiptap.chain().focus().addColumnBefore().run()}/>
60
+ <ToolbarButton icon="keyboard_double_arrow_right"
61
+ handler={() => $tiptap.chain().focus().addColumnAfter().run()}/>
62
+ <ToolbarButton icon="keyboard_double_arrow_up"
63
+ handler={() => $tiptap.chain().focus().addRowBefore().run()}/>
64
+ <ToolbarButton icon="keyboard_double_arrow_down"
65
+ handler={() => $tiptap.chain().focus().addRowAfter().run()}/>
66
+ <ToolbarButton icon="close" handler={() => deleteTable({editor: $tiptap})}/>
67
+ {:else}
68
+ <Tooltip bottom left xstack width="160px">
69
+ <IconButton size="1.2em" icon="format_align_left" slot="target"/>
70
+ <div style="margin: -6px;font-size: 0.6em">
71
+ <List>
72
+ <OneLine icon="format_align_left" title="왼쪽 정렬"
73
+ on:click={() => $tiptap.chain().focus().setTextAlign('left').run()}
74
+ active={$tiptap.isActive({ textAlign: 'left' })}/>
75
+ <OneLine icon="format_align_center" title="가운데 정렬"
76
+ on:click={() => $tiptap.chain().focus().setTextAlign('center').run()}
77
+ active={$tiptap.isActive({ textAlign: 'center' })}/>
78
+ <OneLine icon="format_align_right" title="오른쪽 정렬"
79
+ on:click={() => $tiptap.chain().focus().setTextAlign('right').run()}
80
+ active={$tiptap.isActive({ textAlign: 'right' })}/>
81
+ <OneLine icon="format_align_justify" title="양쪽 정렬"
82
+ on:click={() => $tiptap.chain().focus().setTextAlign('justify').run()}
83
+ active={$tiptap.isActive({ textAlign: 'justify' })}/>
84
+ </List>
85
+ </div>
86
+ </Tooltip>
87
+ <ToolbarButton icon="format_bold" prop="bold"/>
88
+ <ToolbarButton icon="format_italic" prop="italic"/>
89
+ <ToolbarButton icon="format_strikethrough" prop="strike"/>
90
+ <ToolbarButton icon="format_underlined" prop="underline"/>
91
+ <ToolbarButton icon="superscript" prop="superscript"/>
92
+ <ToolbarButton icon="subscript" prop="subscript"/>
93
+ <ToolbarButton icon="functions" handler={() => setMath($tiptap)}/>
94
+ <ToolbarButton icon="code" prop="code"/>
95
+ <ToolbarButton icon="link" prop="link" handler={() => link = true}/>
96
+ {/if}
97
+ </main>
98
+ {/if}
99
+ </BubbleMenu>
100
+ {/if}
101
+
102
+ <style>main {
103
+ box-shadow: var(--shadow);
104
+ background: var(--surface, #fff);
105
+ color: var(--on-surface, #000);
106
+ padding: 8px;
107
+ border-radius: 4px;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ font-size: 1.2em;
112
+ }
113
+ main > :global(*) {
114
+ margin: 0 2px;
115
+ }
116
+ main > :global(*):first-child {
117
+ margin-left: 0;
118
+ }
119
+ main > :global(*):last-child {
120
+ margin-right: 0;
121
+ }
122
+
123
+ .link {
124
+ display: flex;
125
+ flex-direction: column;
126
+ font-size: 0.7em;
127
+ }
128
+ .link p {
129
+ margin: 0 0 0.6em 0;
130
+ }
131
+ .link div {
132
+ margin-top: 0.6em;
133
+ display: flex;
134
+ justify-content: flex-end;
135
+ }</style>
@@ -0,0 +1,17 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ import 'tippy.js/animations/shift-away-subtle.css';
3
+ declare const __propDef: {
4
+ props: Record<string, never>;
5
+ events: {
6
+ [evt: string]: CustomEvent<any>;
7
+ };
8
+ slots: {
9
+ default: {};
10
+ };
11
+ };
12
+ export type BubbleProps = typeof __propDef.props;
13
+ export type BubbleEvents = typeof __propDef.events;
14
+ export type BubbleSlots = typeof __propDef.slots;
15
+ export default class Bubble extends SvelteComponentTyped<BubbleProps, BubbleEvents, BubbleSlots> {
16
+ }
17
+ export {};
@@ -0,0 +1,150 @@
1
+ <script>import { Button, IconButton, Input, List, TwoLine } from "nunui";
2
+ import { getContext } from "svelte";
3
+ import { slashVisible, slashItems, slashLocaltion, slashProps, slashDetail } from '../plugin/command/stores';
4
+ import { fly, slide } from "svelte/transition";
5
+ import { quartOut } from "svelte/easing";
6
+ const tiptap = getContext('editor');
7
+ export let selectedIndex = 0;
8
+ let height = 0, elements = [];
9
+ let iframe = '';
10
+ $: if ($slashVisible) {
11
+ iframe = '';
12
+ }
13
+ </script>
14
+
15
+ <svelte:window bind:innerHeight={height}/>
16
+
17
+ {#if $slashVisible}
18
+ <div class="scrim" on:click={() => $slashVisible = false}/>
19
+ <main style="left: {$slashLocaltion.x}px; top: {$slashLocaltion.y + $slashLocaltion.height + 384 > height
20
+ ? $slashLocaltion.y - $slashLocaltion.height - 384
21
+ : $slashLocaltion.y + $slashLocaltion.height}px;" transition:fly={{y: 10, duration: 200, easing: quartOut}}>
22
+ {#if $slashDetail === 'iframe'}
23
+ <div class="detail">
24
+ <header>
25
+ <IconButton icon="arrow_back" on:click={() => $slashDetail = ''}/>
26
+ <div class="title">iframe</div>
27
+ </header>
28
+ <Input placeholder="url" fullWidth bind:value={iframe} autofocus
29
+ on:submit={() => $tiptap.commands.insertContent({type: 'iframe', attrs: {src: iframe}})}/>
30
+ <footer>
31
+ <Button tabindex="0" transparent small on:click={() => {
32
+ iframe = ''
33
+ $slashDetail = ''
34
+ }}>취소
35
+ </Button>
36
+ <Button tabindex="0" transparent small
37
+ on:click={() => $tiptap.commands.insertContent({type: 'iframe', attrs: {src: iframe}})}>삽입
38
+ </Button>
39
+ </footer>
40
+ </div>
41
+ {:else if $slashDetail === 'youtube'}
42
+ <div class="detail">
43
+ <header>
44
+ <IconButton icon="arrow_back" on:click={() => $slashDetail = ''}/>
45
+ <div class="title">youtube</div>
46
+ </header>
47
+ <Input placeholder="url" fullWidth bind:value={iframe} autofocus
48
+ on:submit={() => $tiptap.commands.insertVideoPlayer({url: iframe})}/>
49
+ <footer>
50
+ <Button tabindex="0" transparent small on:click={() => {
51
+ iframe = ''
52
+ $slashDetail = ''
53
+ }}>취소
54
+ </Button>
55
+ <Button tabindex="0" transparent small
56
+ on:click={() => $tiptap.commands.insertVideoPlayer({url: iframe})}>삽입
57
+ </Button>
58
+ </footer>
59
+ </div>
60
+ {:else}
61
+ <div class="list">
62
+ <List>
63
+ {#each $slashItems as {section, list}(section)}
64
+ <div class="section" transition:slide={{duration: 400, easing: quartOut}}>{section}</div>
65
+ <div transition:slide={{duration: 400, easing: quartOut}}>
66
+ {#each list || [] as {title, subtitle, icon, command, section}, i(title)}
67
+ <div transition:slide={{duration: 400, easing: quartOut}}>
68
+ <TwoLine on:mouseenter={() => (selectedIndex = i)} on:click={() => {
69
+ command?.($slashProps);
70
+ setTimeout(() => $tiptap.commands.focus());
71
+ }} bind:this={elements[i]} {icon} {title} subtitle={subtitle || ''}/>
72
+ </div>
73
+ {/each}
74
+ </div>
75
+ {/each}
76
+ {#if !$slashItems.length}
77
+ <div class="section" transition:slide={{duration: 400, easing: quartOut}}>결과 없음</div>
78
+ {/if}
79
+ </List>
80
+ </div>
81
+ {/if}
82
+ </main>
83
+ {/if}
84
+
85
+ <style>.scrim {
86
+ position: fixed;
87
+ top: 0;
88
+ left: 0;
89
+ width: 100%;
90
+ height: 100%;
91
+ cursor: default;
92
+ z-index: 0;
93
+ }
94
+
95
+ main {
96
+ position: fixed;
97
+ background: var(--surface, #fff);
98
+ width: 220px;
99
+ border-radius: 4px;
100
+ overflow-y: scroll;
101
+ z-index: 10;
102
+ box-shadow: var(--shadow);
103
+ }
104
+
105
+ .section {
106
+ padding: 8px 16px;
107
+ font-size: 0.8em;
108
+ font-weight: 300;
109
+ color: var(--on-surface, #000);
110
+ opacity: 0.8;
111
+ }
112
+
113
+ .list {
114
+ color: var(--primary-dark7);
115
+ }
116
+ .list :global(.title) {
117
+ font-size: 0.7em !important;
118
+ font-weight: 300 !important;
119
+ margin-bottom: 4px;
120
+ }
121
+ .list :global(.subtitle) {
122
+ font-size: 0.8em !important;
123
+ font-weight: 300 !important;
124
+ color: var(--primary-dark1);
125
+ }
126
+
127
+ .detail {
128
+ font-size: 0.8em;
129
+ padding: 8px;
130
+ display: flex;
131
+ flex-direction: column;
132
+ }
133
+
134
+ header {
135
+ display: flex;
136
+ align-items: center;
137
+ margin-bottom: 6px;
138
+ }
139
+ header > :global(*) {
140
+ margin-right: 8px;
141
+ }
142
+ header > :global(*):last-child {
143
+ margin-right: 0;
144
+ }
145
+
146
+ footer {
147
+ margin-top: 0.6em;
148
+ display: flex;
149
+ justify-content: flex-end;
150
+ }</style>
@@ -0,0 +1,16 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: {
4
+ selectedIndex?: number | undefined;
5
+ };
6
+ events: {
7
+ [evt: string]: CustomEvent<any>;
8
+ };
9
+ slots: {};
10
+ };
11
+ export type CommandProps = typeof __propDef.props;
12
+ export type CommandEvents = typeof __propDef.events;
13
+ export type CommandSlots = typeof __propDef.slots;
14
+ export default class Command extends SvelteComponentTyped<CommandProps, CommandEvents, CommandSlots> {
15
+ }
16
+ export {};
@@ -0,0 +1,54 @@
1
+ <script>import { BubbleMenu, FloatingMenu } from "svelte-tiptap";
2
+ import { getContext } from "svelte";
3
+ import { Button, IconButton, List, OneLine, Tooltip } from "nunui";
4
+ import ToolbarButton from "./ToolbarButton.svelte";
5
+ import setMath from "./setMath";
6
+ const tiptap = getContext('editor');
7
+ </script>
8
+
9
+ {#if $tiptap}
10
+ <FloatingMenu editor={$tiptap}
11
+ tippyOptions={{animation:'fade', duration: [200, 50]}}>
12
+ <main on:mousedown={() => setTimeout(() => $tiptap.commands.focus())}>
13
+ <span>
14
+ /로 명령어 입력. 또는
15
+ </span>
16
+ <Tooltip bottom left xstack width="160px">
17
+ <IconButton size="1.2em" icon="text_fields" slot="target"/>
18
+ <div style="margin: -6px">
19
+ <List>
20
+ <OneLine icon="counter_1" title="제목 1" on:click={() => $tiptap.commands.setHeading({level: 1})}/>
21
+ <OneLine icon="counter_2" title="제목 2" on:click={() => $tiptap.commands.setHeading({level: 2})}/>
22
+ <OneLine icon="counter_3" title="제목 3" on:click={() => $tiptap.commands.setHeading({level: 3})}/>
23
+ <OneLine icon="segment" title="본문" on:click={() => $tiptap.commands.setParagraph()}/>
24
+ </List>
25
+ </div>
26
+ </Tooltip>
27
+ <ToolbarButton icon="format_bold" prop="bold"/>
28
+ <ToolbarButton icon="format_italic" prop="italic"/>
29
+ <ToolbarButton icon="format_strikethrough" prop="strike"/>
30
+ <ToolbarButton icon="format_underlined" prop="underline"/>
31
+ <ToolbarButton icon="functions" handler={() => {
32
+ const end = $tiptap.state.selection.$to.pos;
33
+ $tiptap.chain().focus().insertContent({
34
+ type: 'math_inline',
35
+ }).insertContent(' ').run();
36
+ }}/>
37
+ <ToolbarButton icon="code" prop="code"/>
38
+ </main>
39
+ </FloatingMenu>
40
+ {/if}
41
+
42
+ <style>span {
43
+ opacity: 0.6;
44
+ }
45
+
46
+ main {
47
+ display: flex;
48
+ flex-wrap: wrap;
49
+ width: max-content;
50
+ align-items: center;
51
+ }
52
+ main > :global(*) {
53
+ margin-right: 4px;
54
+ }</style>
@@ -0,0 +1,14 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: Record<string, never>;
4
+ events: {
5
+ [evt: string]: CustomEvent<any>;
6
+ };
7
+ slots: {};
8
+ };
9
+ export type FloatingProps = typeof __propDef.props;
10
+ export type FloatingEvents = typeof __propDef.events;
11
+ export type FloatingSlots = typeof __propDef.slots;
12
+ export default class Floating extends SvelteComponentTyped<FloatingProps, FloatingEvents, FloatingSlots> {
13
+ }
14
+ export {};
@@ -1,34 +1,38 @@
1
1
  <script>import { browser } from "$app/environment";
2
- import { onMount, setContext } from 'svelte';
3
- import { Card, Input } from "nunui";
2
+ import { beforeUpdate, onMount, setContext } from 'svelte';
4
3
  import { writable } from "svelte/store";
5
4
  import sanitizeHtml from 'sanitize-html';
6
5
  import "@seorii/prosemirror-math/style.css";
7
- const san = (body, force = false) => (editor || force) ? body : sanitizeHtml(body, {
8
- allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'math-inline', 'math-node', 'iframe', 'tiptap-file']),
6
+ import Bubble from "./Bubble.svelte";
7
+ import Floating from "./Floating.svelte";
8
+ import Command from "./Command.svelte";
9
+ import { slashItems, slashProps, slashVisible } from "../plugin/command/stores";
10
+ const san = (body) => sanitizeHtml(body, {
11
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'math-inline', 'math-node', 'iframe', 'tiptap-file', 'lite-youtube']),
9
12
  allowedStyles: '*', allowedAttributes: {
10
13
  '*': ['style', 'class'],
11
14
  a: ['href', 'name', 'target'],
12
15
  img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],
13
16
  iframe: ['src', 'width', 'height', 'frameborder', 'allowfullscreen'],
14
- 'tiptap-file': ['id']
17
+ th: ['colwidth', 'colspan', 'rowspan'],
18
+ td: ['colwidth', 'colspan', 'rowspan'],
19
+ 'tiptap-file': ['id'],
20
+ 'lite-youtube': ['videoid', 'params', 'nocookie', 'title', 'provider'],
15
21
  },
16
22
  });
17
23
  export let body = '', editor = false, style = '', ref = null, options = {};
18
24
  const tiptap = setContext('editor', writable(null));
19
- let element, _body = san(body, true), fullscreen = false, html = false, mounted = false;
25
+ let element, fullscreen = false, mounted = false, last = '';
20
26
  $: ref = $tiptap;
21
- $: _san = san(body);
22
- $: if (_san !== _body && $tiptap)
23
- $tiptap?.commands.setContent(_body = _san);
24
27
  $: $tiptap && $tiptap.setEditable(editor);
25
- if (browser)
28
+ if (browser) {
26
29
  onMount(() => {
30
+ body = last = san(body);
27
31
  mounted = true;
28
- import('./tiptap').then(({ default: tt }) => {
32
+ Promise.all([import('./tiptap'), import("@justinribeiro/lite-youtube")]).then(([{ default: tt }]) => {
29
33
  if (!mounted)
30
34
  return;
31
- $tiptap = tt(element, _body, {
35
+ $tiptap = tt(element, body, {
32
36
  editable: editor,
33
37
  onTransaction: () => $tiptap = $tiptap,
34
38
  ...options,
@@ -37,7 +41,7 @@ if (browser)
37
41
  let content = tiptap.getHTML(), json = tiptap.getJSON().content;
38
42
  if (Array.isArray(json) && json.length === 1 && !json[0].hasOwnProperty("content"))
39
43
  content = null;
40
- _body = body = editor ? content : body;
44
+ body = last = content;
41
45
  });
42
46
  });
43
47
  return () => {
@@ -45,43 +49,88 @@ if (browser)
45
49
  $tiptap?.destroy?.();
46
50
  };
47
51
  });
52
+ beforeUpdate(() => {
53
+ if (last === body)
54
+ return;
55
+ body = san(body);
56
+ $tiptap?.commands?.setContent?.(body);
57
+ });
58
+ }
59
+ let selectedIndex = 0;
60
+ $: selectedIndex = $slashVisible ? selectedIndex : 0;
61
+ function handleKeydown(event) {
62
+ if (!$slashVisible)
63
+ return;
64
+ if (event.key === 'ArrowUp') {
65
+ event.preventDefault();
66
+ selectedIndex = (selectedIndex + $slashItems.length - 1) % $slashItems.length;
67
+ return true;
68
+ }
69
+ if (event.key === 'ArrowDown') {
70
+ event.preventDefault();
71
+ selectedIndex = (selectedIndex + 1) % $slashItems.length;
72
+ return true;
73
+ }
74
+ if (event.key === 'Enter') {
75
+ event.preventDefault();
76
+ selectItem(selectedIndex);
77
+ return true;
78
+ }
79
+ return false;
80
+ }
81
+ function selectItem(index) {
82
+ const item = $slashItems[index];
83
+ if (item) {
84
+ let range = $slashProps.range;
85
+ item.command({ editor, range });
86
+ }
87
+ }
48
88
  </script>
49
89
 
50
- <Card outlined={editor}
51
- style="max-width:100%;margin-top:0;{editor ? '' : 'box-shadow:none;padding:0;border-radius:0;'}{style}">
52
- <main class:fullscreen class:editor>
53
- <div class="wrapper">
54
- <div bind:this={element} class:hide={html} class="target"></div>
55
- {#if !$tiptap}
56
- 로드 중...
57
- {/if}
58
- <div class:hide={!html}>
59
- <Input multiline fullWidth placeholder="HTML" bind:value={body}/>
60
- </div>
61
- </div>
62
- </main>
63
- </Card>
90
+ <main class:fullscreen class:editor>
91
+ <div class="wrapper">
92
+ <div bind:this={element} class="target" on:keydown|capture={handleKeydown}></div>
93
+ {#if !$tiptap}
94
+ 로드 중...
95
+ {/if}
96
+ </div>
97
+ {#if editor}
98
+ <Command {selectedIndex}/>
99
+ <Floating/>
100
+ {#if $$slots.bubble}
101
+ <Bubble>
102
+ <slot name="bubble"/>
103
+ </Bubble>
104
+ {:else}
105
+ <Bubble/>
106
+ {/if}
107
+ {/if}
108
+ </main>
109
+
64
110
 
65
111
  <style>main {
66
112
  position: relative;
67
113
  overscroll-behavior: none;
114
+ --shadow: 0 1px 2px rgba(127, 127, 127, 0.07),
115
+ 0 2px 4px rgba(127, 127, 127, 0.07),
116
+ 0 4px 8px rgba(127, 127, 127, 0.07),
117
+ 0 8px 16px rgba(127, 127, 127, 0.07),
118
+ 0 16px 32px rgba(127, 127, 127, 0.07),
119
+ 0 32px 64px rgba(127, 127, 127, 0.07);
68
120
  }
69
121
  main.fullscreen {
70
- z-index: 9999999;
122
+ z-index: 999999999;
71
123
  position: fixed;
72
124
  top: 0;
73
125
  left: 0;
126
+ right: 0;
127
+ bottom: 0;
74
128
  background: var(--surface);
75
129
  padding: 82px 12px 12px 12px;
76
- width: calc(100% - 24px);
77
- height: calc(100% - 94px);
78
130
  }
79
131
  main .wrapper {
80
132
  position: relative;
81
133
  }
82
- main .wrapper .hide {
83
- display: none;
84
- }
85
134
 
86
135
  .target > :global(div) > :global(*:first-child) {
87
136
  margin-top: 0 !important;
@@ -95,7 +144,7 @@ main .wrapper .hide {
95
144
  filter: drop-shadow(0 0 0.75rem var(--primary-light13));
96
145
  }
97
146
 
98
- .editor .iframe-wrapper.ProseMirror-selectednode {
147
+ .editor :global(.iframe-wrapper.ProseMirror-selectednode) {
99
148
  outline: 3px solid var(--primary);
100
149
  }
101
150
 
@@ -11,7 +11,9 @@ declare const __propDef: {
11
11
  events: {
12
12
  [evt: string]: CustomEvent<any>;
13
13
  };
14
- slots: {};
14
+ slots: {
15
+ bubble: {};
16
+ };
15
17
  };
16
18
  export type TipTapProps = typeof __propDef.props;
17
19
  export type TipTapEvents = typeof __propDef.events;
@@ -0,0 +1,26 @@
1
+ <script>import { getContext } from "svelte";
2
+ import { Button, IconButton } from "nunui";
3
+ const editor = getContext("editor");
4
+ export let prop = '', attrs = '', label = '', icon = '', methodName = 'toggle' + prop.charAt(0).toUpperCase() + prop.slice(1), tooltip, handler;
5
+ $: isActive = () => {
6
+ return editor && prop && $editor.isActive(prop, attrs);
7
+ };
8
+ function toggle() {
9
+ if (!$editor)
10
+ return;
11
+ //$editor.chain().focus().clearNodes().run()
12
+ if (handler)
13
+ return handler();
14
+ setTimeout(() => $editor.chain().focus()[methodName](attrs)?.run(), 0);
15
+ }
16
+ </script>
17
+
18
+ {#if icon}
19
+ <IconButton size="1.2em" {icon} active={isActive()} on:click={toggle} tooltip={tooltip} tabindex="0"/>
20
+ {:else}
21
+ <Button outlined={!isActive()} on:click={handler || toggle} small {...$$restProps}>
22
+ {label}
23
+ <slot/>
24
+ </Button>
25
+ {/if}
26
+
@@ -0,0 +1,25 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ declare const __propDef: {
3
+ props: {
4
+ [x: string]: any;
5
+ prop?: string | undefined;
6
+ attrs?: string | undefined;
7
+ label?: string | undefined;
8
+ icon?: string | undefined;
9
+ methodName?: string | undefined;
10
+ tooltip: any;
11
+ handler: any;
12
+ };
13
+ events: {
14
+ [evt: string]: CustomEvent<any>;
15
+ };
16
+ slots: {
17
+ default: {};
18
+ };
19
+ };
20
+ export type ToolbarButtonProps = typeof __propDef.props;
21
+ export type ToolbarButtonEvents = typeof __propDef.events;
22
+ export type ToolbarButtonSlots = typeof __propDef.slots;
23
+ export default class ToolbarButton extends SvelteComponentTyped<ToolbarButtonProps, ToolbarButtonEvents, ToolbarButtonSlots> {
24
+ }
25
+ export {};
@@ -0,0 +1 @@
1
+ export default function setMath(tiptap: any): void;
@@ -0,0 +1,14 @@
1
+ export default function setMath(tiptap) {
2
+ const { selection } = tiptap.state;
3
+ tiptap.chain().command(({ state, tr }) => state.doc.nodesBetween(selection.from, selection.to, (node, position) => {
4
+ if (!node.isTextblock || selection.from === selection.to)
5
+ return;
6
+ const startPosition = Math.max(position + 1, selection.from);
7
+ const endPosition = Math.min(position + node.nodeSize, selection.to);
8
+ const substringFrom = Math.max(0, selection.from - position - 1);
9
+ const substringTo = Math.max(0, selection.to - position - 1);
10
+ const updatedText = node.textContent.substring(substringFrom, substringTo);
11
+ const newNode = state.schema.nodes.math_inline.create(null, state.schema.text(updatedText));
12
+ tr = tr.replaceWith(startPosition, endPosition, newNode);
13
+ })).run();
14
+ }
@@ -1,3 +1,3 @@
1
1
  import { Editor } from "@tiptap/core";
2
- declare const _default: (element: Element, content: string, { placeholder, plugins, ...props }?: any) => Editor;
2
+ declare const _default: (element: Element, content: string, { plugins, ...props }?: any) => Editor;
3
3
  export default _default;
@@ -16,42 +16,50 @@ import tableRow from "../plugin/table/tableRow";
16
16
  import tableCell from "../plugin/table/tableCell";
17
17
  import Superscript from '@tiptap/extension-superscript';
18
18
  import Subscript from "@tiptap/extension-subscript";
19
- import Placeholder from "@tiptap/extension-placeholder";
20
19
  import { Indent } from "../plugin/indent";
21
20
  import { Color } from '@tiptap/extension-color';
22
21
  import TextStyle from '@tiptap/extension-text-style';
23
22
  import Iframe from "../plugin/iframe";
24
23
  // @ts-ignore
25
- import { Katex } from "@seorii/prosemirror-math/tiptap";
26
- export default (element, content, { placeholder = '내용을 입력하세요...', plugins = [], ...props } = {}) => new Editor({
24
+ import { MathInline, MathBlock } from "@seorii/prosemirror-math/tiptap";
25
+ import Youtube from "../plugin/youtube";
26
+ import command from "../plugin/command";
27
+ export default (element, content, { plugins = [], ...props } = {}) => new Editor({
27
28
  element, content, ...props,
28
29
  extensions: [
29
30
  CodeBlockLowlight.configure({ lowlight }),
30
31
  Image,
32
+ Youtube,
31
33
  StarterKit,
32
34
  Underline,
33
35
  Highlight.configure({ multicolor: true }),
34
- Link.configure({ openOnClick: true }),
36
+ Link.configure({
37
+ openOnClick: true, protocols: ['ftp', 'mailto', {
38
+ scheme: 'tel',
39
+ optionalSlashes: true
40
+ }]
41
+ }),
35
42
  TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
36
43
  DropCursor,
37
44
  orderedlist,
45
+ MathInline,
46
+ MathBlock,
38
47
  table,
39
48
  tableHeader,
40
49
  tableRow,
41
50
  tableCell,
42
51
  Superscript,
43
52
  Subscript,
44
- Placeholder.configure({ placeholder }),
45
53
  Indent,
46
54
  Color,
47
55
  TextStyle,
48
- Katex,
49
56
  Iframe,
50
57
  Code.extend({
51
58
  renderHTML({ HTMLAttributes }) {
52
59
  return ['code', mergeAttributes(HTMLAttributes, { class: 'inline' })];
53
60
  }
54
61
  }),
62
+ command,
55
63
  ...plugins,
56
64
  ],
57
65
  });
package/package.json CHANGED
@@ -1,28 +1,33 @@
1
1
  {
2
2
  "name": "@seorii/tiptap",
3
- "version": "0.0.7",
3
+ "version": "0.0.10",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "svelte-kit sync && svelte-package",
7
+ "build-page": "vite build",
7
8
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
8
9
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
9
10
  "lint": "prettier --plugin-search-dir . --check . && eslint .",
10
11
  "format": "prettier --plugin-search-dir . --write .",
11
- "deploy": "svelte-kit sync && svelte-package && npm publish --access public"
12
+ "deploy": "svelte-kit sync && svelte-package && npm publish --access public",
13
+ "page": "npm run build-page && node gh-pages.js"
12
14
  },
13
15
  "devDependencies": {
14
16
  "@sveltejs/adapter-auto": "^2.1.0",
15
- "@sveltejs/kit": "^1.22.3",
17
+ "@sveltejs/adapter-static": "^2.0.3",
18
+ "@sveltejs/kit": "^1.22.4",
16
19
  "@sveltejs/package": "^2.2.0",
20
+ "@types/sanitize-html": "^2.9.0",
17
21
  "@typescript-eslint/eslint-plugin": "^6.2.0",
18
22
  "@typescript-eslint/parser": "^6.2.0",
19
23
  "eslint": "^8.46.0",
20
24
  "eslint-config-prettier": "^8.9.0",
21
25
  "eslint-plugin-svelte3": "^4.0.0",
26
+ "gh-pages": "^5.0.0",
22
27
  "prettier": "^3.0.0",
23
28
  "prettier-plugin-svelte": "^3.0.3",
24
29
  "sass": "^1.64.1",
25
- "svelte": "^4.1.1",
30
+ "svelte": "^4.1.2",
26
31
  "svelte-check": "^3.4.6",
27
32
  "svelte-preprocess": "^5.0.4",
28
33
  "tslib": "^2.6.1",
@@ -31,7 +36,8 @@
31
36
  },
32
37
  "type": "module",
33
38
  "dependencies": {
34
- "@seorii/prosemirror-math": "^0.3.7",
39
+ "@justinribeiro/lite-youtube": "^1.5.0",
40
+ "@seorii/prosemirror-math": "^0.4.2",
35
41
  "@tiptap/core": "^2.0.4",
36
42
  "@tiptap/extension-code": "^2.0.4",
37
43
  "@tiptap/extension-code-block-lowlight": "^2.0.4",
@@ -51,15 +57,20 @@
51
57
  "@tiptap/extension-text-align": "^2.0.4",
52
58
  "@tiptap/extension-text-style": "^2.0.4",
53
59
  "@tiptap/extension-underline": "^2.0.4",
60
+ "@tiptap/pm": "^2.0.4",
54
61
  "@tiptap/starter-kit": "^2.0.4",
62
+ "@tiptap/suggestion": "^2.0.4",
55
63
  "lowlight": "^2.9.0",
56
- "nunui": "^0.0.97",
64
+ "nunui": "^0.0.99",
65
+ "prosemirror-commands": "^1.5.2",
57
66
  "prosemirror-model": "^1.19.3",
58
67
  "prosemirror-state": "^1.4.3",
59
68
  "prosemirror-tables": "^1.3.4",
60
69
  "prosemirror-transform": "^1.7.4",
61
70
  "prosemirror-view": "^1.31.7",
62
- "sanitize-html": "^2.11.0"
71
+ "sanitize-html": "^2.11.0",
72
+ "svelte-tiptap": "^1.1.2",
73
+ "tippy.js": "^6.3.7"
63
74
  },
64
75
  "exports": {
65
76
  ".": {