Milkdown / milkdown

🍼 Plugin driven WYSIWYG markdown editor framework.
https://milkdown.dev
MIT License
8.85k stars 390 forks source link

[Bug] MilkdownError: Context "collabServiceCtx" not found, do you forget to inject it? #845

Closed zerodayz closed 1 year ago

zerodayz commented 1 year ago

Initial checklist

Affected packages and versions

6.5.4

Link to runnable example

No response

Steps to reproduce

I am setting up milkdown editor in Vue3 with composition API and script setup with collaborative plugin:

<template>
    <VueEditor :editor=editor />
</template>
<script setup>
import {Editor, rootCtx, defaultValueCtx, ThemeColor, ThemeFont, themeManagerCtx, commandsCtx} from "@milkdown/core";
import { createDropdownItem, defaultActions, slash, slashPlugin } from '@milkdown/plugin-slash';

import { TextSelection } from '@milkdown/prose/state'
import { setBlockType } from '@milkdown/prose/commands'
import { nord } from '@milkdown/theme-nord';
import { VueEditor, useEditor } from "@milkdown/vue";
import { commonmark } from "@milkdown/preset-commonmark";
import { emoji } from "@milkdown/plugin-emoji";
import { clipboard } from '@milkdown/plugin-clipboard';
import { indent, indentPlugin } from '@milkdown/plugin-indent';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { menu, menuPlugin } from '@milkdown/plugin-menu';
import { cursor } from '@milkdown/plugin-cursor';
import { history } from '@milkdown/plugin-history';
import { trailing } from '@milkdown/plugin-trailing';
import { tooltip } from '@milkdown/plugin-tooltip';
import { gfm } from '@milkdown/preset-gfm';
import { collaborative, collabServiceCtx } from '@milkdown/plugin-collaborative';
import { WebsocketProvider } from "y-websocket";
import { Doc } from "yjs";

import {useI18n} from "vue-i18n";

const extendedNord = nord.override((emotion, manager) => {
  manager.set(ThemeColor, ([key, opacity]) => {
    switch (key) {
      case 'primary':
        return `var(--theme-font-color)`;
      case 'secondary':
        return `rgba(210, 210, 210, 0.3)`;
      case 'neutral':
        return `var(--theme-font-color)`
      case 'solid':
        return `var(--theme-font-color)`;
      case 'shadow':
      case 'line':
      case 'surface':
      case 'background':
        return `rgba(72, 72, 72, var(--transparency))`;
    }
  });
  manager.set(ThemeFont, (key) => {
    if (key === 'typography') return '"stratum-1-web",sans-serif';

    return 'monospace';
  });
});

const mySlash = slash.configure(slashPlugin, {
  config: (ctx) => {
    return ({ content, isTopLevel }) => {
      if (!isTopLevel) return null;

      if (!content) {
        return { placeholder: t('type_slash_commands_label') };
      }

      const mapActions = (action) => {
        const { id = '' } = action;
        switch (id) {
          case 'h1':
            action.dom = createDropdownItem(ctx.get(themeManagerCtx), 'Large Heading', 'h1');
            return action;
          case 'h2':
            action.dom = createDropdownItem(ctx.get(themeManagerCtx), 'Medium Heading', 'h2');
            return action;
          // others ids
          default:
            return action;
        }
      };

      if (content.startsWith('/')) {
        return content === '/'
          ? {
            placeholder: t('type_to_filter_label'),
            actions: defaultActions(ctx).map(mapActions),
          }
          : {
            actions: defaultActions(ctx, content).map(mapActions),
          };
      }

      return null;
    };
  },
});

const hasMark = (state, type) => {
  if (!type)
    return false
  const { from, $from, to, empty } = state.selection
  if (empty)
    return !!type.isInSet(state.storedMarks || $from.marks())

  return state.doc.rangeHasMark(from, to, type)
}

const doc = new Doc();
const wsProvider = new WebsocketProvider('wss://websockets:3002', "milkdown", doc)

const { editor } = useEditor((root) =>
  Editor.make()
    .config((ctx) => {
      ctx.set(rootCtx, root);
      ctx.set(defaultValueCtx, "");
      ctx.get(listenerCtx).markdownUpdated((ctx, markdown, prevMarkdown) => {
        editor_data.value = markdown;
      });
    })
    .use(extendedNord)
    .use(emoji)
    .use(mySlash)
    .use(clipboard)
    .use(
      indent.configure(indentPlugin, {
        type: 'space', // available values: 'tab', 'space',
        size: 4,
      }),
    )
    .use(commonmark)
    .use(listener)
    .use(
      menu.configure(menuPlugin, {
      config: [
        [
          {
            type: 'button',
            icon: 'undo',
            key: 'Undo',
          },
          {
            type: 'button',
            icon: 'redo',
            key: 'Redo',
          },
        ],
        [
          {
            type: 'select',
            text: 'Heading',
            options: [
              { id: '1', text: 'Large Heading' },
              { id: '2', text: 'Medium Heading' },
              { id: '3', text: 'Small Heading' },
              { id: '0', text: 'Plain Text' },
            ],
            disabled: (view) => {
              const { state } = view
              const heading = state.schema.nodes.heading
              if (!heading)
                return true
              const setToHeading = (level) => setBlockType(heading, { level })(state)
              return (
                !(view.state.selection instanceof TextSelection)
                || !(setToHeading(1) || setToHeading(2) || setToHeading(3))
              )
            },
            onSelect: id => (Number(id) ? ['TurnIntoHeading', Number(id)] : ['TurnIntoText', null]),
          },
        ],
        [
          {
            type: 'button',
            icon: 'bold',
            key: 'ToggleBold',
            active: view => hasMark(view.state, view.state.schema.marks.strong),
            disabled: view => !view.state.schema.marks.strong,
          },
          {
            type: 'button',
            icon: 'italic',
            key: 'ToggleItalic',
            active: view => hasMark(view.state, view.state.schema.marks.em),
            disabled: view => !view.state.schema.marks.em,
          },
          {
            type: 'button',
            icon: 'strikeThrough',
            key: 'ToggleStrikeThrough',
            active: view => hasMark(view.state, view.state.schema.marks.strike_through),
            disabled: view => !view.state.schema.marks.strike_through,
          },
        ],
        [
          {
            type: 'button',
            icon: 'link',
            key: 'ToggleLink',
            active: view => hasMark(view.state, view.state.schema.marks.link),
          },
          {
            type: 'button',
            icon: 'code',
            key: 'TurnIntoCodeFence',
          },
        ],
        [
          {
            type: 'button',
            icon: 'divider',
            key: 'InsertHr',
          },
        ],
      ],
    }),)
    .use(cursor)
    .use(history)
    .use(trailing)
    .use(tooltip)
    .use(gfm)
    .use(collaborative)
    .action((ctx) => {
      const collabService = ctx.get(collabServiceCtx);
      collabService
        .bindDoc(doc)
        .setAwareness(wsProvider.awareness)
        .connect()
    })
);
</script>

Did I forget to setup the collabServiceCtx in any way? Websockets for the wsProvider works fine, as I can see connection established.

Expected behavior

Show the editor window with collaborative plugin enabled.

Actual behavior

Doesn't show the editor window.

Runtime

Safari

OS

macOS

Build and bundle tools

Other (please specify in steps to reproduce)

Saul-Mirone commented 1 year ago

You should call the action after editor load finished.

const { editor, getInstance, getDom, loading } = useEditor(/* creator */);
effect(() => {
    if (!loading) {
        const editor = getInstance();
        editor.action(ctx => {
        // connect
        })
    }
})